도커 트러블슈팅 - 컨테이너 실행환경 디버깅
run, exec, commit 명령어 활용하기

들어가며: 컨테이너 실행환경 디버깅

도커Docker를 사용하다 보면 컨테이너가 제대로 동작하고 있는지 확인해야하는 일이 자주 생깁니다. 개발 과정에서 컨테이너의 상태를 확인하거나, 살아있는 컨테이너의 상태를 확인해야할 경우도 있습니다. 때로는 이미 죽어버린 컨테이너를 해부해봐야할 수도 있습니다. 이 글에선 도커 컨테이너 디버깅 용도로 자주 사용하는 run, exec, commit 명령어들을 소개하고자 합니다.

이 글은 도커 사용법을 어느 정도 이해하고 있다고 가정하고 있습니다. 도커가 처음이신 분들은 다음 글을 추천합니다.

docker run: 도커 기본 중의 기본

docker run은 컨테이너를 실행하는 가장 기본적인 명령어입니다. 이 명령어는 컨테이너를 디버깅하기보다는 이미지를 탐색하는 용도로 사용됩니다. 디버깅을 위한 가장 기본적인 활용법은 -it 옵션을 붙여 셸을 실행하는 방법입니다.

$ docker run -it ubuntu:latest bash
root@a8747c46330d:/#

이 명령어는 ubuntu:latest 이미지에 bash 셸을 실행합니다. 프롬프트가 root@a8747c46330d로 바뀐 지점부터 컨테이너 환경에서 내부를 살펴볼 수 있습니다. 디렉터리를 탐색하거나 파일을 검색해보거나 동작중인 프로세스들도 확인할 수 있습니다.

컨테이너의 파일 탐색 및 추가 사이트 패키지 설치

개인적으로 자주 사용하는 명령어 중 하나는 find입니다. 이 명령어로 컨테이너 전체에서 파일을 찾아보곤 합니다. 컨테이너 환경은 일반 OS보다는 훨씬 작기 때문에 파일 시스템 전체를 대상으로 부담없이 find를 실행해볼 수 있습니다.

root@a8747c46330d:/# find / -name "*lsb-release*"
/etc/lsb-release

필요하다면 apt-get으로 필요한 유틸리티를 설치할 수도 있습니다. 예를 들어 컨테이너의 네트워크 환경에서 DNS 문제를 디버깅해보고 싶다면, nslookup을 설치해 dns 쿼리를 해볼 수 있습니다.

root@a8747c46330d:/# apt-get update
root@a8747c46330d:/# apt-get -y dnsutils
root@a8747c46330d:/# nslookup google.com
Server:         192.168.65.1
Address:        192.168.65.1#53

Non-authoritative answer:
Name:   google.com
Address: 172.217.25.110
Name:   google.com
Address: 2404:6800:4004:809::200e

당연한 이야기입니다만, 리눅스 유틸리티나 패키지 관리자는 리눅스 배포판 별로 다릅니다. 센트OSCentOS 계열이라면 yum을 사용하고 알파인Alpine에서는 apk를 사용합니다.

알파인(alpine)이나 스크래치(scratch) 같은 극단적으로 최소화된 이미지들을 사용할 경우 문제가 생겼을 때 실행환경을 디버깅하는 게 어려울 수 있습니다. 이런 이유로 개인적으로 용량 차이에도 불구하고 ubuntucentos와 같은 좀 더 일반적인 배포판 이미지를 추천하는 편입니다.

셸 명령어 지정하기

도커 이미지에는 기본 명령어(CMD)라는 개념이 있습니다. 예를 들어 ubuntu의 기본 명령어는 bash입니다. 이에 대해서는 docker image inspect로 확인해볼 수 있습니다.*

* 여기선 json 형식의 출력 결과를 탐색하기 위해 JSON 필터링 명령어 jq를 사용하고 있습니다. jq에 대해서는 커맨드라인 JSON 프로세서 jq : 기초 문법과 작동원리를 참고해주세요.

$ docker image inspect ubuntu:latest | jq '.[].ContainerConfig.Cmd'
[
  "/bin/sh",
  "-c",
  "#(nop) ",
  "CMD [\"/bin/bash\"]"
]

따라서 docker run을 실행할 때 bash를 생략해도 bash가 실행됩니다.

$ docker run -it ubuntu
root@9cc74dd97e88:/#

도커 예제들을 보면 bash대신 /bin/bash처럼 전체 경로를 입력하는 경우도 자주 만날 수 있습니다. 이는 패스 설정이 되어있지 않더라도 명령어를 찾기 위한 방어적인 방법이라고 볼 수 있습니다. 앞에서 확인했듯이 반드시 전체경로를 입력할 필요는 없습니다. 이미지에 설정된 $PATH 환경변수 상에 명령어가 있다면 경로는 생략해도 무방합니다. 이 역시 docker image inspect로 확인해볼 수 있습니다.

$ docker image inspect ubuntu:latest | jq '.[].ContainerConfig.Env'
[
  "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
]

이미지에 배시 셸이 없는 경우도 있습니다. 예를 들어 alpine 이미지에는 bash 셸이 없습니다. 이럴 때는 셸을 찾아봅니다.

$ docker run -it alpine sh
$ docker run -it alpine /bin/sh
$ docker run -it alpine

셋 다 결과는 같습니다. sh를 실행해서 디버깅해볼 수 있습니다. sh라도 있는 알파인은 양반이지만, 정말 아무것도 없는 scratch 기반의 이미지는 훨씬 더 디버깅하기가 까다롭습니다.

이미지에 ENTRYPOINT가 설정되어 있는 경우

이미지에 ENTRYPOINT가 설정되어있는 경우 조금 까다롭지만 우회해서 셸을 실행할 수 있습니다. ENTRYPOINT가 설정된 예제 이미지를 만들어보겠습니다.

FROM ubuntu:latest
ENTRYPOINT ls

이미지를 빌드합니다.

$ dokcer build -t ubuntu:ls .

ENTRYPOINT를 사용하면 도커 이미지를 마치 유틸리티처럼 사용할 수 있습니다. 이제 이 이미지의 기본 명령어가 변경된 것을 확인할 수 있습니다.

docker image inspect ubuntu:ls | jq '.[].ContainerConfig.Cmd'
[
  "/bin/sh",
  "-c",
  "#(nop) ",
  "ENTRYPOINT [\"/bin/sh\" \"-c\" \"ls\"]"
]

이 상태에서는 docker run의 마지막 인자로 bash를 넘겨도 셸이 실행되지 않습니다.

$ docker run -it ubuntu:ls bash
bin   dev  home  lib64  mnt  proc  run   srv  tmp  var
boot  etc  lib   media  opt  root  sbin  sys  usr

이런 이미지에서 셸을 실행하고자 한다면, --entrypoint 옵션으로 빈 값을 넘겨주면 됩니다. 다음과 같이 실행하면 셸을 실행할 수 있습니다.

docker run -it --entrypoint '' ubuntu:ls bash
root@25bb74dcd91d:/#

Bash 셸이 정상적으로 실행되었습니다. 이제 컨테이너의 실행환경을 탐색할 수 있습니다.

docker exec: 살아있는 컨테이너에 다른 명령어 실행하기

docker run은 사실 컨테이너보다는 이미지를 검증하고 탐색하기 위한 용도로 사용됩니다. 반면 docker exec 명령어를 사용하면 실제로 실행중인 컨테이너에 또 다른 명령어를 실행할 수 있습니다. exec는 실행중인 컨테이너에서만 사용할 수 있으므로, 먼저 컨테이너를 하나 준비합니다.

$ docker run -d -p 8000:80 --name nginx nginx
80ff0d0c4752d82f55ecafddfac94966644b96df62288ef49d9c03fb1b0157a0

docker ps 명령어를 사용해 nginx 서버가 정상적으로 실행중인지 확인해봅니다.

$ docker ps -l
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                  NAMES
80ff0d0c4752        nginx               "nginx -g 'daemon of…"   2 minutes ago       Up 2 minutes        0.0.0.0:8000->80/tcp   nginx

이 컨테이너의 변경사항을 확인해보겠습니다. docker diff 명령어를 사용하면 컨테이너가 실행된 이후 변경된 파일들을 확인할 수 있습니다.

$ docker diff 80ff0d0c4752
C /run
A /run/nginx.pid
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/proxy_temp
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp

예를 들어 nginx이미지에는 /run/nginx.pid 파일이 존재하지 않습니다.

$ docker run -it nginx:latest bash
root@129128a11ed6:/# cat /run/nginx.pid
cat: /run/nginx.pid: No such file or directory
root@129128a11ed6:/#

하지만 위에서 실행한 nginx 컨테이너에 exec를 사용해 셸을 실행해 탐색해보면 /run/nginx.pid 파일이 존재하는 것을 확인할 수 있습니다.*

* exec 명령어로 셸을 실행할 때도 run과 마찬가지로 -it 옵션이 사용되는 것을 잊지마세요.

$ docker exec -it 80ff0d0c4752 bash
root@80ff0d0c4752:/# cat /run/nginx.pid
1
root@80ff0d0c4752:/#

이를 통해 80ff0d0c4752 컨테이너가 실행중인 상태에서 다른 (셸) 프로세스가 실행된 것을 확인할 수 있습니다. 이 상태에서는 컨테이너가 동작하면서 변경된 사항을 탐색할 수 있습니다. nginx.pid의 값이 1인 게 흥미롭게 느껴질 수도 있습니다. 컨테이너의 PID는 기본적으로 1로 실행됩니다. 이는 PID 네임스페이스가 분리되어있기 때문입니다. 반면에 exec로 실행된 명령어의 pid는 1번이 아닙니다.

echo $$
7

이는 컨테이너에 할당된 PID 네임스페이스를 공유해서 새로운 셸 프로세스가 실행되었기 때문입니다. 여기서 PID 네임스페이스에 대해서 자세히 소개하지는 않습니다만, 더 자세한 동작 원리가 궁금한 분은 다음 글을 참고해주세요.

도커가 처음 나왔을 때는 exec 명령어가 없어서 컨테이너의 상태를 확인하려면, LXC의 유틸리티를 사용하는 등 좀 더 복잡한 방법이 사용되었습니다. exec 명령어는 컨테이너를 사용하는 입장에서는 축복과 같습니다.

docker commit: 종료된 컨테이너도 되살려주는 명령어

docker exec 명령어에는 한 가지 치명적인 문제가 있습니다. 바로 컨테이너가 실행중인 상태에서만 컨테이너 환경에 접근할 수 있다는 점입니다. 안타깝게도 시스템 관리자에게는 살아있는 컨테이너의 실행환경을 탐색하는 경우보다, 죽어있는 컨테이너의 상태를 부검하는 일이 더 중요합니다. 그리고 내부적인 문제로 죽은 컨테이너는 restart로 재실행하더라도 바로 죽어버리는 경우가 많습니다. 따라서 죽어있는 컨테이너에 한해서는 exec 명령어가 무용지물입니다.

이럴 때 고려해볼 수 있는 방법이 docker commit입니다. commit은 컨테이너의 특정 상태를 그대로 이미지로 만들어주는 명령어입니다. 이 명령어는 도커 빌드 과정에서 사용됩니다만, 일반적으로 직접 사용할 일은 거의 없습니다. 이미지 빌드 원리와 commit에 대한 더 자세한 내용은 다음 글을 참고해주세요.

아이디어는 간단합니다. 죽어있는 컨테이너를 docker commit 명령어로 새로운 이미지로 만들고, 이 새로운 이미지에서 docker run으로 셸을 실행해서 컨테이너의 환경을 탐색합니다. 먼저 앞서 만든 nginx 컨테이너를 죽이고, exec 명령어를 사용해보겠습니다.

$ docker kill 80ff0d0c4752
80ff0d0c4752

$ docker exec -it 80ff0d0c4752 bash
Error response from daemon: Container 80ff0d0c4752 is not running

위에서 설명한 대로, 컨테이너가 실행중이 아니라고 에러가 발생합니다. 이 죽은 컨테이너를 커밋해서 이미지로 만들겠습니다.

$ docker commit 80ff0d0c4752 nginx:killed
sha256:67edfba59ed618badfe5dde7105e25473f68cb28f50608aaca3e39bcc824eb20

$ docker images | grep nginx
nginx            killed              67edfba59ed6        32 seconds ago      126MB
...

nginx:killed 이미지가 생성된 것을 확인할 수 있습니다. 이제 docker run으로 셸을 실행해보겠습니다.

docker run -it nginx:killed bash
root@621c34ab41ee:/# cat /run/nginx.pid
1
root@621c34ab41ee:/#

nginx가 실행되다가 강제로 종료되었기 때문에 컨테이너 실행시에 생성된 /run/nginx.pid가 그대로 남아있는 것을 확인할 수 있습니다. 이제 이 셸에서 죽은 컨테이너의 상태를 탐색해볼 수 있습니다.

같이 읽으면 좋은 문서들

다음은 도커 명령어들의 레퍼런스 문서들입니다.

아마존 웹서비스 커맨드라인 인터페이스(AWS CLI) 기초

🗒 기사, 2018-06-25 - 아마존 웹 서비스에서는 공식 커맨드라인 인터페이스 클라이언트 AWSCLI를 제공합니다. AWSCLI를 사용하면 명령줄에서 직접 AWS의 기능을 호출하는 것이 가능합니다. AWSCLI를 설치 및 설정하고 기본적인 사용법에 대해서 알아봅니다.

홈브류(Homebrew)를 사용해 맥OS(macOS)에서 특정 버전의 패키지 설치하기

🗒 기사, 2018-08-21 - 홈브류를 사용하면 맥OS(macOS)에서 쉽게 패키지를 관리할 수 있습니다. 일반적으로 홈브류 패키지는 최신 버전을 제공합니다. 이 글에서는 홈브류를 사용해서 특정 버전의 패키지를 설치하는 방법을 소개합니다.

44bits 첫 번째 밋업 소식: AWS re:Invent 2019 회고

🗒 기사, 2019-12-27 - 지난 12월 18일 44bits의 첫 번째 밋업을 열었습니다. 첫 번째 모임의 주제는 re:Invent 2019 회고였습니다. 리인벤트 참가 후기(raccoony), 11월 이후 AWS 주요 업데이트 정리(nacyot), re:Invent 2019 머신러닝 발표 정리(유경윤) 발표가 진행되었습니다.