개발자가 처음 Docker 접할때 오는 멘붕 몇가지
이번 글은 Docker의 개념 보다는 Docker를 처음 접하게 되었을 때 혼란스러웠던 내용을 정리한 글이다. Docker 컨테이너를 사용하는 용도는 여러가지가 있겠지만 이 글에서는 개발자가 만든 애플리케이션을 Tomcat과 같은 애플리케이션 서버에 탑재해서 배포하는 경우 겪게 되었던 내용이다.
Docker는 Virtual machine이 아니다!
처음 docker를 실행할 때 다음과 같은 명령을 실행하면 우분투 서버가 실행된다고 생각했다. 즉, Virtual machine과 같이 컨테이너 내에 우분투 서버가 실행되는 줄 알았다.
1
$ docker run --name ubuntu_test ubuntu
위 명령을 실행하면 그냥 아무것도 실행하지 않은 것 처럼 아무런 변화가 없다. 다만 다음과 같이 실행되지 않는 docker container를 보는 옵션(-a)을 주고 docker container의 목록을 보면 종료(Exit)되었다고 나타나기는 한다.
1 2
$ docker ps -a d8f31b2635d9 ubuntu "/bin/bash" 19 seconds ago Exited (0) 17 seconds ago ubuntu_test
필자의 경우 여기서 부터 혼란스러웠다. 우분투 image를 실행했는데 왜 아무것도 실행되지 않고 바로 Exit 되었을까? 결론은 Docker의 컨테이너는 Virtual machine과 같이 하나의 온전한 서버를 제공하는 것이 아니라 명령을 실행하는 환경만 제공하고 그 명령을 실행할 뿐이다[1].
위의 예제에서 보면 "docker ps -a" 명령으로 나타난 컨테이너 목록에서 다음과 같은 내용을 볼 수 있다.
/bin/bash 19 seconds ago
이 문구로 유추해보면 우분투 컨테이너를 실행하면 우분투 서버가 실행되는 것이 아니라 "/bin/bash" 가 실행되는 것 뿐이다. 이것이 Virtual machine의 컨테이너와 Docker 컨테이너의 가장 큰 차이점이다. 일반적으로 Linux 서버(Ubuntu or CentOS 등)나 Windows와 같은 운영 체제를 실행한다는 의미에는 많은 것을 내포하고 있다. 대략 다음과 같은 기능들이 실행될 것이다.
- 프로그램 실행 기능
- Memory, CPU 등의 하드웨어 자원을 이용하여 프로그램을 실행할 수 있는 환경 제공
- 네트워크 서비스 제공
- NIC 등을 하드웨어 자원을 인식해서 네트워크 처리가 가능한 환경을 제공
- 키보드, 모니터, 마우스 등과 같은 주변 장치의 입출력에 대한 처리
- 사용자로부터의 입력과 결과를 출력해주는 기능 제공
- 외부에서 접속할 수 있는 환경
- sshd 등과 같은 데몬을 실행하여 서버 외부에서 네트워크를 이용하여 원격에서 접속할 수 있는 기능 제공
그리고 이 모든 것은 사용자가 임의로 전원을 끄기 전에는 지속적으로 동작하는 특징을 가지고 있다. 흔히 Virtual machine 이라고 하는 컨테이너 들은 이런 속성을 가지고 있다. 다만 여러 하드웨어 자원을 Host OS로 부터 할당 받은 것만 사용하도록 되어 있는 것이다.
그러면 컨테이너를 실행(run)하면 bash가 실행되어 prompt가 container의 bash prompt가 나타나야 하는게 정상 아닌가? 왜 Exit 되어 버리는가?
Docker 컨테이너는 단지 명령만 실행하고 그 결과만 보여주는 기능을 수행한다.[1]
즉, 앞의 예제에서는 우분투의 docker image에서 설정된 default 실행 명령[2]인 "/bin/bash" 를 실행하고 그 결과를 출력하고 종료된 것이다. "/bin/bash" 명령은 표준 출력(STDOUT) 또는 표준 에러(STDERR)로 아무것도 출력을 하지 않기 때문에 사용자가 보기에는 실행이 안된 것과 같은 느낌으로 다가 온다. 다음을 실행해보면 무엇을 말하는 것인지 알 수 있다.
1 2 3 4 5
$ docker run --name ubuntu_test ubuntu "env" PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin HOSTNAME=52c48f363166 no_proxy=*.local, 169.254/16 HOME=/root
docker run 명령에서 마지막에 주는 인자 값은 이 컨테이너가 실행할 명령을 전달하는 인자이다. 우분투 컨테이너의 경우 아무런 값을 입력하지 않으면 "/bin/bash"가 실행되고, 인자를 전달하면 그 값이 실행된다. 예제에서는 "env" 명령을 실행하도록 하였는데 "env" 명령은 시스템의 환경 정보를 출력하는 명령이다. 디렉토리 목록을 출력하는 "ls" 명령도 실행할 수 있다.
1 2 3 4 5 6 7 8
$ docker run --name ubuntu_test ubuntu "ls" bin boot dev etc home lib ...
docker 컨테이너를 실행한 다음 동일한 명령을 실행하면 다음과 같은 에러가 나타나는데 이것은 docker run 명령이 "create" 와 "start" 명령을 한번에 실행시키는 명령이기 때문에 create 시 이미 동일한 이름의 컨테이너가 존재하기 때문에 발생하는 문제이다. 이 경우 docker rm <id or name>으로 삭제한 후 다시 실행하면 된다.
docker: Error response from daemon: Conflict. The container name "/ubuntu_test" is already in use by container "065b413106d50d69b01077daccc5f7c1c406d98f993c71e6834d6e22318b93e4". You have to remove (or rename) that container to be able to reuse that name. See 'docker run --help'.
아니면 docker run 명령에 "--rm" 옵션을 주어 docker 컨테이너가 종료됨과 동시에 자동으로 삭제되게 할 수 있다. 실제 환경에서는 이 옵션을 사용하기도 하지만 이 글에 있는 예제를 확인하기 위해서라면 --rm 옵션을 주지 않고 실행하는 것을 권장한다.
우분투의 bash shell에서 명령을 실행하려면
위 예제에서 Docker run 명령행이 아닌 우분투 image의 bash shell에서 "ls", "cat" 등과 같은 명령을 실행하려면 어떻게 해야 할까? Docker 컨테이너를 실행할 때 다음 두 옵션을 추가하면 가능한데 대략 다음과 같은 의미이다.
- i : Interactive 모드로 표준입력과 표준출력을 키보드와 화면을 통해 가능하도록 하는 옵션이다.
- t: 텍스트 기반의 터미널(TTY)을 애뮬레이션해주는 옵션이다.
실행은 다음과 같이 한다.
1 2 3 4 5 6 7 8 9 10
$ docker run -it --name ubuntu_test ubuntu # 다음과 같이 ubuntu의 bash shell로 prompt가 나타나고 해당 container 내에서 명령을 실행할 수 있다. root@7a08aa924dcd:/# df -k Filesystem 1K-blocks Used Available Use% Mounted on none 61896484 9147280 49581972 16% / tmpfs 65536 0 65536 0% /dev tmpfs 1023376 0 1023376 0% /sys/fs/cgroup /dev/vda2 61896484 9147280 49581972 16% /etc/hosts shm 65536 0 65536 0% /dev/shm tmpfs 1023376 0 1023376 0% /sys/firmware
이 부분에서 Docker를 처음 접하는 개발자는 또 한번 혼란스러움을 겪을 수 있다. 위 예제와 같이 shell 이 나오고 내가 필요한 명령이 사용 가능하게 되면 마치 우분투 서버가 실행되었다는 착각을 하게 된다. 그리고 해당 shell에서 tomcat이나 rails와 같은 애플리케이션 서버를 설치하고, 실행해본다. 잘 돌아간다. 문제는 이렇게 한 다음 shell에서 "exit" 를 입력하여 shell에서 나오는 순간 컨테이너는 다시 중지된다.
Docker 컨테이너를 백그라운드로 실행하면?
여기까지 진행해보면 이런 생각을 하게 된다.
Docker의 컨테이너도 Host OS의 입장에서 보면 하나의 프로세스이기 때문에 프로세스가 종료(위에서는 shell에서 exit를 입력) 되면 컨테이너가 종료되겠지. 그러면 종료되지 않게 Docker 컨테이너 프로세스를 백그라운드로 실행하면 되지 않을까?
Docker 명령 옵션에 보면 다음과 같은 옵션이 있다.
1 2 3 4 5 6
$ docker help run -d, --detach Run container in background and print container ID $ docker run -d --name ubuntu_test ubuntu 981c0ec37c33631a8625549027ca644bb064d5e00eecb4d890b011d4396dbdb9 $ docker ps -a | grep ubuntu 981c0ec37c33 ubuntu "/bin/bash" 7 seconds ago Exited (0) 6 seconds ago ubuntu_test
"-d" 옵션이 Docker의 컨테이너를 백그라운드 프로세스로 실행하는 옵션이다. 위 예제에서 처럼 실행하면 컨테이너 ID가 출력되는데 이 ID나 --name 옵션으로 입력한 이름을 이용하여 컨테이너에 접근할 수 있다. "-d" 옵션을 주고 실행해도 docker의 컨테이너 상태를 확인해보면 "Exited" 상태인 것을 알 수 있다.
왜 이렇게 되는 것일까? docker 의 컨테이너를 실행한다는 것은 Host OS에서 프로세스를 실행하는 것과 동일한 개념이기 때문에 docker 컨테이너에서 실행되는 명령이 계속 실행되고 있는 상황이 아니면 그 명령이 종료됨과 동시에 컨테이너도 종료되기 때문이다. 이제 앞에서 실행한 interactive 모드인 "-it" 옵션을 주어 실행해보자.
1 2 3 4
$ docker run -d -it --name ubuntu_test ubuntu 8e7e71b7b692c7f293593d3f504bacec70e5541bae2021ebc0dbb28d9b8add21 $ docker ps -a | grep ubuntu 8e7e71b7b692 ubuntu "/bin/bash" 2 seconds ago Up 2 seconds ubuntu_test
이렇게 해서 일단 1차 원하는 목적이었던 shell을 백그라운드로 실행하는데는 성공하는 듯 보였다.
일반적으로 Virtual machine 으로 우분투를 실행한 경우, 이 서버에 접속하려면 ssh 와 같은 리모트쉘을 이용하지만 docker 컨테이너의 경우 일반적으로는 sshd를 실행하지 않는다. 대신 Host OS에서 docker attach 명령을 이용하여 컨테이너에 접속할 수 있다. 다음 명령을 이용하여 docker의 컨테이너에 접속할 수 있다.
1 2 3 4 5 6 7
$ docker attach ubuntu_test root@2b206e1f3c07:/# root@2b206e1f3c07:/# ls -al total 72 drwxr-xr-x 34 root root 4096 Dec 17 13:02 . drwxr-xr-x 34 root root 4096 Dec 17 13:02 .. -rwxr-xr-x 1 root root 0 Dec 17 13:02 .dockerenv
하지만 여기서도 여전히 문제가 발생한다. 이 shell에서 "exit" 명령을 이용하여 shell을 나오게 되면 컨테이너도 같이 종료하게 된다.
1 2 3
root@2b206e1f3c07:/# exit $ docker ps -a | grep ubuntu 8e7e71b7b692 ubuntu "/bin/bash" 5 seconds ago Exited (0) 5 seconds ago ubuntu_test
이것은 run -it 옵션과 attach 명령의 내용을 조금만 보면 예측할 수 있다. "-it" 옵션은 컨테이너의 입출력을 interactive 하게 하는 옵션과 TTY 터미널을 애뮬레이션 해주는 옵션이다. 이것을 백그라운드로 실행시킨 것이다. 그리고 attach 명령은 Virtual machine의 리모트쉘 접속과 같은 개념이 아니라 컨테이너의 현재 Host OS shell(local)의 stdout, stderr을 docker 컨테이너에 붙이는 명령인 것 뿐이다.
attach Attach local standard input, output, and error streams to a running container
즉 -it 옵션을 이용하여 interactive 모드로 실행하고 이것을 다시 -d 옵션을 주어 백드라운드로 실행하게 되면 interactive 쉘이 백그라운드로 동작하고 있는 것이다. 여기에 attach 명령으로 접속하여 exit 명령을 실행하면 interactive 쉘(/bin/bash)이 종료되고 이 쉘이 종료되면 결국 docker 컨테이너도 종료하게 되는 것이다.
Docker에 애플리케이션 서버 실행하기
여기까지 확인한 상태에서 필자가 내뱉은 한마디는 "이런 황당한 시츄에이션이! 이런 방식이면 어떻게 애플리케이션 서버를 실행하냐고?" 였다. 하지만 정답은 위에서 이미 다 나와 있었다. Docker의 컨테이너에서 실행되는 명령(위 예제에서는 /bin/bash)을 영원히 실행되게 하면 된다. 예를 들어 다음과 같이 컨테이너를 실행한다.
1 2 3 4
$ docker run -d --name ubuntu_test ubuntu /bin/bash -c "while true; do echo "still live"; sleep 100; done" eb3b9e69b18d826dcc8788fc01930b4c411dabee4cbdfb646af79cb2cfbeacba $ docker ps -a | grep ubuntu eb3b9e69b18d ubuntu "/bin/bash -c 'whi..." 7 seconds ago Up 8 seconds ubuntu_test
위 실행 옵션은 /bin/bash를 실행하면서 -c 옵션에 있는 명령을 실행하게 하는 것이다. -c 에 있는 옵션은 무한 루프를 돌면서 100초마다 한번씩 "still live"를 출력하는 기능을 수행하는 shell script 이다. 이렇게 무한 루프를 도는 명령을 -d 옵션을 이용하여 백드라운드로 실행하기 때문에 컨테이너 실행 후 바로 Host OS의 Prompt로 돌아오지만 컨테이너는 여전히 살아 있는 것을 확인할 수 있다.
이 컨테이너에 이제 attach 해보자. attach 명령을 실행해보면 아무 반응도 없고 100초마다 "still live"만 출력하는 것을 볼 수 있다. 그렇다고 shell prompt가 나타나지도 않는다. 이것은 attach 명령어가 stdout, stderr을 가져오는 것이기 때문에 당연한 것이다. Shell에 접속하기 위해서는 attach가 아닌 exec 명령을 이용해서 컨테이너의 쉘 환경에 접속할 수 있다.
1 2 3 4 5 6 7 8 9 10 11
$ docker help exec Usage: docker exec [OPTIONS] CONTAINER COMMAND [ARG...] Run a command in a running container Options: -i, --interactive Keep STDIN open even if not attached --privileged Give extended privileges to the command -t, --tty Allocate a pseudo-TTY $ docker exec -it eb3b9e69b18d /bin/bash root@eb3b9e69b18d:/# exit $ docker ps -a | grep ubuntu eb3b9e69b18d ubuntu "/bin/bash -c 'whi..." 14 minutes ago Up 14 minutes ubuntu_test
이렇게 접속한 다음 exit 명령을 이용하여 shell을 빠져 나와도 컨테이너는 정상적으로 동작하는 것을 알 수 있다.
지금까지 내용을 종합해보면 Docker의 컨테이너 내에 애플리케이션 서버를 실행하려면 애플리케이션 서버가 무한루프로 동작하게 해야 한다. 하지만 이미 애플리케이션 서버들은 무한루프로 동작하는 프로그램들이다. 따라서 다음 내용만 주의하면 된다.
Docker 컨테이너에서 실행되는 애플리케이션 서버(DB 서버 포함)은 back ground 모드가 아닌 fore ground 모드로 실행해야 한다.
Tomcat의 경우 예를 들면 일반적으로 다음과 같이 실행하여 백드라운드 모드로 동작하게 한다. 이유는 tomcat 서버를 실행시킨 shell이 종료되더라도 tomcat 서버는 정상적으로 계속 동작하게 하기 위해서이다.
1 2
$ cd $CATALINA_HOME/bin; $ ./catalina.sh start;
하지만 docker 환경에서 이렇게 하면 컨테이너가 바로 종료되어 Tomcat 서버가 죽는 것과 동일한 상황을 맞게 된다. 다음과 같이 fore ground로 실행해야 한다.
1 2
$ cd $CATALINA_HOME/bin; $ ./catalina.sh run;
실제 docker 환경에서 tomcat을 실행하는 경우 위 예제와 같이 명령을 직접 입력하지 않고 Dockerfile에 실행 명령을 지정하는데 이때 위와 같이 fore ground 명령을 사용해야 하는 것이다. dockerhub에 있는 공식 tomcat 컨테이너의 image를 만드는 Dockerfile에도 다음과 같이 사용하고 있다.
1 2 3 4 5 6
FROM openjdk:8-jre-alpine ENV CATALINA_HOME /usr/local/tomcat ENV PATH $CATALINA_HOME/bin:$PATH ... EXPOSE 8080 CMD ["catalina.sh", "run"]
대부분의 컨테이너는 최소한의 구성만 있다.
여기까지 진행하면 Docker가 대충 어떻게 돌아가는 지를 이해했으며 원하는 목적을 이루었다. 하지만 이 상황에서 다시 몇가지 사소한 이슈가 발생하였다. 가장 큰 이슈는 컨테이너 내부에 들어가면 애플리케이션 서버를 관리할 수 있는 것이 아무것도 없다는 것이다. Docker의 컨테이너는 대부분 Minimalism을 중요시하기 때문에 딱 필요한 것만 설치한다. 이렇게 작게 만드는 것은 마이크로 서비스 환경에서 하나의 서버에 수십개 이상의 컨테이너가 실행되는 환경을 상상해보면 쉽게 이해할 수 있다.
하지만 컨테이너를 작게 만들다 보니 기본적인 도구조차 없는 경우가 대부분이다. 필자의 경우 기본으로 제공하는 이미지를 사용하지 않고 다음 정도는 설치되어 있는 환경을 구성하여 사용하고 있다.
- JRE 가 아닌 JDK로 구성 이렇게 구성하는 가장 큰 이유는 JRE로 구성하게 되면 자바 프로세스에 대한 모니터닝을 할 수 있는 도구가 없다. 애플리케이션 서버에 성능상 문제가 발생했을 때 jstat, jmap, jstack 등과 같은 명령을 이용하여 jvm의 상태 정보를 조회할 수 있어야 하는데 이런 도구가 없어 불편한 경우가 많다.
- vi 파일을 내용을 확인하기 위해서는 cat, grep 등 기본 명령만으로는 부족해서
- net-tools ifconfig도 없고, netstat 등 네트워크 관련 뭔가 볼 수 있는게 없는데 기본적으로 이런 명령은 되어야 운영 환경에서 문제가 생겼을 때 원인 파악이라도 할 수 있어야 하기 때문이다.
환경설정을 바꾸고 다시 실행하면...
앞에서 설명했듯이 Docker 컨테이너에서 실행되는 애플리케이션 서버는 fore ground로 실행되는데 이렇게 할 경우 귀찮은 문제가 발생한다. 간단한 성능 확인이나 기능 확인 등을 위해 Tomcat 서버의 옵션을 하나 수정한 다음 재시작하게 될 때 문제가 된다. fore ground로 실행되어 있기 때문에 Tomcat 서버가 재 시작되면 해당 컨테이너가 종료된다. 즉, 옵션 하나 바꾸는 것도 컨테이너에서 직접 바꾸면 안되고 Dockerfile을 이용해서 바꾼 후 이미지를 빌드하고, 컨테이너를 시작해야만 적용할 수 있다. 이런 사이클은 개발 단계나 운영 환경에 문제가 발생하여 지속적으로 튜닝을 하면서 상태를 지켜보는 과정이라면 좋은 사이클이라 할 수 없다.
필자가 선택한 방법은 서비스 개발 후 초기 얼마 동안은 다음과 같은 형태로 컨테이너를 구성하였다.
- 애플리케이션 서버는 백그라운드로 실행
- 애플리케이션 서버 실행 후 바로 shell script로 무한 반복 스크립트 실행
즉, 다음과 같은 bootstrap.sh 명령을 실행하도록 하였다.
1 2 3 4 5 6 7
# Run background bin/catalina.sh start # Run script forever while true; do echo "still live"; sleep 600; done
이렇게 하면 설정 변경 후 재시작해도 Docker 컨테이너는 여전히 계속 살아 있고 변경된 옵션에 대해 확인할 수 있다. 이 방법은 서비스 초기 어떤 옵션이 서비스에 맞는지 확인하는 용도로만 사용하고, 정상적인 운영환경에서는 사용하지 않는 것을 권장한다.
프로그램에서 출력하는 로그는 어떻게 해야 하나?
일반적인 환경에서 tomcat등과 같은 애플리케이션 서버의 로그는 파일에 저장하도록 하였다. 로그를 파일에 저장하고 로그 파일의 크기 또는 일자에 따라 파일을 롤링하는 방식을 많이 사용하였는데 이것은 애플리케이션 서버를 백그라운드로 실행하기 때문에 백그라운드로 실행된 상태에서 로그를 확인하기 위해서는 로그를 표준출력으로 보내기 보다는 파일로 저장하는 것이 훨씬 관리하기 편했기 때문이다. 물론 백그라운드로 실행했다고 해서 파일로 저장 못하는 것은 아니지만 사이즈, 크기 등에 따른 롤링 등을 설정하기 위해서는 별도 작업을 해줘야 하는 번거로움이 있다.
그러면 Docker 컨테이너에 애플리케이션 서버를 실행하는 경우 로그는 어떻게 해야 할까? 다음 세가지 방안 정도로 생각할 수 있다.
- 이전과 동일하게 파일로 저장한다.
이것은 컨테이너의 특정 디렉토리에 저장하게 한다는 의미인데 이렇게 할 경우 해당 컨테이너가 삭제되면 로그도 같이 삭제되기 때문에 권장하지 않는다.
- 모든 로그를 표준 출력(STDOUT) 또는 표준 에러(STDERR) 로 출력한다.
Docker 는 컨테이너에서 STDOUT나 STDERR로 출력하는 모든 메시지를 Host OS의 특정 디렉토리에 저장하고 이를 쉽게 조회할 수 있는 명령도 제공한다(docker logs 명령). 따라서 모든 로그를 표준 출력으로 보내면 쉽게 로그에 접근할 수 있게 된다.
이 방식도 문제가 존재하는데 이 로그 파일을 하나의 파일로 관리하게 되면 파일이 너무 커지게 되어 스토리지를 모두 차지하게 되는 문제가 있다. 최근 버전의 Docker에서는 로그 파일을 롤링할 수 있는 기능을 제공하는데 이 방식을 사용할 경우 반드시 이 옵션을 사용하는 것을 권장한다.
- 1번과 같이 파일에 로그를 저장하지만 로그 디렉토리를 Host OS의 볼륨을 이용한다.
이렇게 하면 1번의 로그가 삭제되는 문제와 2번의 롤링 문제를 해결할 수 있지만 컨테이너 생성 시 볼륨을 붙여줘야 한다.
필자의 환경에서는 2번을 사용하고 있다. 스테이징 환경에서는 사이즈나 롤링 설정을 하지 않아 가끔 스테이징 서버의 디스크가 Full 나는 상황이 발생하는데 대부분 로그 문제 때문이다.
맺음말
지금까지 필자가 Docker 를 이용하여 애플리케이션 서버를 구성할 때 부딪혔던 Docker 컨테이너에 대해 잘못 알고 있었던 내용과 몇가지 이슈를 설명하였다. 필자가 속해 있는 개발팀에 Docker 를 처음 접해보는 개발자들이 어떻게 Docker를 이해하는지 유심히 살펴보니 대략 필자와 비슷한 오해와 사용을 하고 있는 것을 관찰할 수 있었다. 그리고 대부분의 Docker 문서에서는 이런 내용들에 대한 설명이 많지 않다. 이 글이 이런 오해를 하는 개발자들에게 조금이나마 도움이 되었으면 하는 바램이다.
각주
[1]물론 Docker 내부적으로는 다른 무엇인가 있겠지만 그런 내용을 설명하기 위한 글이 아니라 이렇더라 정도를 알려주는 것이 목적이기 때문에 이렇게만 설명한다. 이것도 정확한 내용이라기 보다는 필자가 나름대로 추측해서 정리한 것이다. 필자 역시 Docker에 대해 깊게 고민을 하지 않았다.
[2] 이것은 Docker image를 만들때 임의로 지정할 수 있다. 우분투 기본 image가 /bin/bash를 실행하게 만들어졌다.
* 이미지 출처: https://www.docker.com/community-edition