Docker 활용

이 문서는 Docker 활용 방법 및 예제들에 대해 설명하고 있는 문서로, Docker 내부의 기술적인 부분에 대해서는 깊게 설명하지 않고 있으니 참고 바랍니다.

장점? 이걸 써야 하는 이유?

여러 가지 기준이 있을 수 있으나 개인적으로 아래 장점들 때문에 Docker를 사용하고 있다.

  1. Isolation: 내 메인 OS를 더럽(?)히지 않고 격리된 환경을 사용할 수 있기 때문에 다양한 개발 환경을 부담 없이 꾸밀 수 있다. 아주 빠르게.
  2. Migration: 다른 PC에 개발 환경을 새로 구축해야 할 경우 손쉽게 기존 작업 환경을 가져와서 적용하는 것이 가능하다.
  3. Deployment: 여러 설정들을 포함하고 있는 복잡한 애플리케이션을 하나로 묶어서 쉽게 배포할 수 있다.

개념 및 용어

도커는 VirtualBox / VMware처럼 사용자에게 가상화된 환경을 제공해 준다. 하지만 접근 방식이 전혀 다른데, 기존의 방법들은 Guest OS 전체를 가상화하는 방식을 사용하지만 도커는 Host OS에서 제공해주는 다양한 기술(cgroups, namespace, lxc, libcontainer, union file system, …)을 사용해 프로세스를 격리 시키는 방법을 사용한다. 기술적인 자세한 내용은 Docker overview 문서를 참고하길 바란다.

Docker Container

Docker container Virtual machine
Docker container Virtual machine

도커는 Linux, Mac, Windows 환경에서 모두 사용 가능하다. 단, Windows의 경우 도커가 내부적으로 사용하는 기술(Windows의 Hyper-V)이 VirtualBox / VMware와 충돌하기 때문에 도커 사용이 끝난 후 Windows 설정에서 Hyper-V를 Disable 시켜야 VirtualBox / VMware를 다시 사용할 수 한다. (참고: Docker Toolbox를 사용하면 이런 문제를 해결할 수 있지만 공식 사이트에서는 Docker for Windows를 추천하고 있다.)

Docker Image

이미지에 대해 도커 사이트에는 “컨테이너 생성을 위한 명령들이 포함된 읽기 전용 템플릿” 이라고 설명하고 있다. 이해하기 쉽게 설명하면 원하는 서비스 또는 애플리케이션의 운영/동작에 필요한 각종 프로그램과 설정(환경 변수, 시작 시 실행될 커맨드 등)들을 하나로 묶어 놓은 형태라고 보면 된다.

An image is a read-only template with instructions for creating a Docker container. – https://docs.docker.com/engine/docker-overview

읽기 전용인 이유는 이 이미지를 기반으로 여러 컨테이너가 실행될 수 있기 때문이다. 만약 이런 상황에서 베이스 이미지의 파일이 변경된다면 동작 중인 컨테이너에 문제를 일으킬 수 있다.

Sharing Layers

https://docs.docker.com/storage/storagedriver/images/sharing-layers.jpg

이미지는 Dockerfile을 통해 만들어지는데, “A” 이미지를 기반으로 일부 패키지를 수정/추가 후 “B” 이미지를 만들 수도 있고, 잘 만들어진 이미지를 Docker Hub에 올려서 공유할 수도 있다.

Often, an image is based on another image, with some additional customization. For example, you may build an image which is based on the ubuntu image, but installs the Apache web server and your application, as well as the configuration details needed to make your application run. You might create your own images or you might only use those created by others and published in a registry. To build your own image, you create a Dockerfile with a simple syntax for defining the steps needed to create the image and run it. Each instruction in a Dockerfile creates a layer in the image. When you change the Dockerfile and rebuild the image, only those layers which have changed are rebuilt. This is part of what makes images so lightweight, small, and fast, when compared to other virtualization technologies. – https://docs.docker.com/engine/docker-overview

이미지는 내부적으로 레이어들로 구성된 파일 시스템을 사용한다. 파일 시스템에 수정/추가가 필요할 경우, 새로운 레이어가 생기고 변경 사항은 그 레이어에 저장된다. 컨테이너로 실행 시에는 이미지의 모든 레이어가 하나로 합쳐진 것처럼 보이는데 뒤에 나올 Union mount가 이것을 가능하게 한다.

예를 들어, ubuntu 이미지에 A라는 애플리케이션을 추가해서 새로운 “myimage”를 생성했을 경우, 이 이미지는 부모 이미지가 가지고 있던 레이어(ubuntu 이미지는 5개 레이어로 구성되어 있다.) 위에 새로운 레이어(A 애플리케이션)를 추가해서 총 6개의 레이어로 구성된다.

docker inspect NAME|ID 명령을 통해 실제로 해당 이미지가 어떤 레이어로 구성되어 있는지 확인해 볼 수 있는데, 아래 결과를 보면 ubuntu:xenial 이미지가 여러 개의 레이어로 구성되어 있음을 확인할 수 있다.

$ docker inspect ubuntu:xenial
...
"RootFS": {
  "Type": "layers",
  "Layers": [
    "sha256:833649a3e04c96faf218d8082b3533fa0674664f4b361c93cad91cf97222b733",
    "sha256:a6a01ad8b53fac9c52a907f40b70a6c61fe305db83a63ae83c970e7be1029d86",
    "sha256:d2bb1fc88136e1c5d59909e7704a9eb6671ea7aa4a0d4272f3e2689fc31a6bd1",
    "sha256:2bbb3cec611d9e3c4016eba00c9f87c51c7d03e54ccd9f12f8f04ac369f5243d",
    "sha256:8600ee70176b569d0776833f106d239d56043cb854a5edbb74aff6c5e8d4782d"
  ]
},
...

레이어를 이용한 이미지는 배포시에도 대단히 효율적인데, 사용자가 이미 ubuntu 이미지를 가지고 있을 경우 “myimage”를 다운로드할 때 새로 추가된 레이어만 다운로드하게 된다.

$ docker pull webispy/artik_devenv_ux64
Using default tag: latest
latest: Pulling from webispy/artik_devenv_ux64
22dc81ace0ea: Already exists
1a8b3c87dba3: Already exists
91390a1c435a: Already exists
07844b14977e: Already exists
b78396653dae: Already exists
d166aa0d5f6d: Already exists
596325feaa3f: Downloading [===>                            ]   16.4MB/232.3MB

Docker Container

이미지를 실행한 상태로 기존 VirualBox / VMware와 달리 컨테이너의 생성 및 실행은 순식간에 이뤄진다. (VirtualBox / VMware의 경우 ubuntu.iso를 이용해 설치 과정을 끝내야 ubuntu 환경을 사용할 수 있지만, 도커의 경우에는 ubuntu 도커 이미지만 다운로드하면 바로 ubuntu 환경을 실행할 수 있다.)

A container is a runnable instance of an image. – https://docs.docker.com/engine/docker-overview

예를 들어, 현재 내 PC에 Fedora OS가 설치되어 있는데 특정 모듈의 개발을 위해 ubuntu 16.04 (xenial) 환경이 필요한 경우, 아래 명령 하나로 수 초안에 바로 ubuntu 환경을 만들 수 있다.

$ docker run -it ubuntu:xenial
root@c1e4ccf8f08b:/# 여기서부터는 ubuntu 환경입니다. :)

내 PC에 ubuntu:xenial 도커 이미지가 없을 경우에는 자동으로 Docker Hub에서 이미지를 다운로드해서 실행 된다.

참고로, “-it” 옵션을 앞으로 자주 사용하게 될 텐데, -i는 STDIN 입력을 계속 유지하라는 옵션이고, -t는 가상의 터미널(pseudo-TTY)을 할당하라는 의미이다. 콘솔 없이 바로 프로그램을 실행시키고자 할 경우 아래와 같이 사용할 수도 있다.

$ docker run ubuntu:xenial ls -l /
total 64
drwxr-xr-x   2 root root 4096 Jan 12 21:48 bin
...

또한 도커는 컨테이너를 다시 이미지로 생성할 수도 있다. 예를 들어 ubuntu 이미지 기반으로 생성한 컨테이너 안에서 다양한 작업을 수행한 후 이것을 다시 docker commit 명령을 통해 변경 사항이 포함된 이미지로 만들어서 다른 사람에게 공유할 수 있다.

Union mount / Storage driver

Union mount는 여러 디렉터리들을 합쳐서 하나로 보이게 하는 방법이다. 예를 들어, /var/x를 가지고 있는 파일 시스템과 /tmp/y를 가지고 있는 파일 시스템을 합쳐서 사용자가 /var/x, /tmp/y를 모두 사용 가능하게 해주는 것이다.

union mounting is a way of combining multiple directories into one that appears to contain their combined contents. – https://en.wikipedia.org/wiki/Union_mount

Union Mount

https://ssup2.github.io/theory_analysis/Union_Mount_AUFS_Docker_Image_Layer/

이것을 가능하게 해주는 구현 물로 여러 가지가 있는데, 도커에서는 스토리지 드라이버 설정을 통해 원하는 것을 선택해서 사용할 수 있다. 도커 문서에 보면, Docker CE on Ubuntu 기준으로 aufs, devicemapper, overlay2, overlay, zfs, vfs 등을 사용할 수 있다. 현재 추천하고 있는 것은 overlay2 이다.

Dockerfile

도커 이미지를 생성하기 위해 사용하는 파일이다. 이미지에 포함될 내용 및 컨테이너 생성 시 동작할 명령, 네트워크 포트 설정 등을 전부 이 파일에 기술해 놓고 docker build를 실행하면 원하는 이미지가 생성된다.

Docker Registry

도커 이미지를 저장하고 다운로드하기 위한 저장소(서버)로 Public 또는 Private 하게 운영할 수 있다. 대부분 https://hub.docker.com 저장소를 사용한다.

Docker 시스템 전체 아키텍쳐

Docker architecture

  • PC의 Docker를 관리하는 daemon(DOCKER_HOST), 그리고 daemon을 컨트롤하는 client. 각종 도커 이미지들을 저장하고 있는 서버(Registry)

사용법

설치

APT를 이용한 설치

$ sudo add-apt-repository \
    "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
    $(lsb_release -cs) \
    stable"
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

$ sudo apt-get update
$ sudo apt install docker-ce

FAQ: sudo 없이 사용하기 (Docker 그룹에 추가)

특정 사용자를 docker 그룹에 추가하면 sudo 없이 docker 명령 사용이 가능하다.

$ sudo usermod -aG docker {your-user-name}

FAQ: Proxy 환경

회사 네트워크 사용 등으로 인해 Proxy 설정이 필요할 경우 아래와 같이 http-proxy.conf, https-proxy.conf 파일을 systemd 설정 파일 디렉터리에 생성해 주면 된다.

$ sudo mkdir -p /etc/systemd/system/docker.service.d
$ sudo vi /etc/systemd/system/docker.service.d/http-proxy.conf
[Service]
Environment="HTTP_PROXY=http://x.x.x.x:port"

$ sudo vi /etc/systemd/system/docker.service.d/https-proxy.conf
[Service]
Environment="HTTPS_PROXY=https://x.x.x.x:port"

# systemd에서 설정 정보를 다시 읽도록 요청
$ sudo systemctl daemon-reload

# 도커 서비스 재시작
$ sudo systemctl restart docker

FAQ: DNS 이슈

가끔 DNS가 제대로 동작하지 않을 경우가 있다. 도커 내부 이슈로 보이는데 아래와 같이 기본 DNS로 구글 서버를 하나 추가하고, 실제 DNS 주소도 같이 넣어 놓는다.

# 현재 네트워크의 DNS 주소 얻기
$ nmcli dev show | grep DNS

# 설정파일에 추가하기
$ sudo vi /etc/docker/daemon.json
{
    ...,
    "dns": [ "your_dns_address", "8.8.8.8" ]
}

# 도커 서비스 재시작
$ sudo systemctl restart docker

FAQ: 도커 내부 저장소 위치 옮기기

만약 SSD 등 좀 더 빠른 디스크 위치로 도커에서 관리하는 파일들을 옮기고 싶을 경우 아래 명령을 사용하면 된다.

$ sudo systemctl stop docker

# 기본 저장소 위치에 있는 파일들을 모두 새로운 곳으로 옮긴다. (예: /ssd)
$ sudo mv /var/lib/docker /ssd/

# 설정 파일에 새로운 위치를 등록해 준다.
$ sudo vi /etc/docker/daemon.json
{
    "graph": "/ssd/docker"
}

$ sudo systemctl start docker

FAQ: Storage driver 변경하기

도커 내부적으로 사용하는 파일시스템을 선택할 수 있는데, overlay2가 가장 빠르고 좋다. 혹시 aufs 등으로 설정되어 있으면 아래 가이드를 통해 변경한다. 단, 기존에 받아 놓은 이미지들은 변경 후 새로 받아야 한다.

# 현재 사용중인 Storage driver 확인
$ docker info | grep Storage
Storage Driver: aufs

# overlay2로 변경
$ sudo vi /etc/docker/daemon.json
{
    ...,
    "storage-driver": "overlay2"
}

# 도커 재시작
$ sudo systemctl restart docker

# Storage driver 확인
$ docker info | grep Storage
Storage Driver: overlay2

Docker 명령어들

기본 명령어

docker --help를 통해 사용 가능한 명령어들을 확인할 수 있는데, 자주 쓰는 명령어들은 아래와 같다.

  • pull, push: Registry(Docker Hub)에서 이미지를 다운로드하거나 업로드할 수 있다. 업로드할 경우 이미지 이름이 {your-id}/{image-name} 형태로 되어 있어야 한다.
  • run: 새로운 컨테이너를 만들고 명령을 실행한다.
  • rm: 컨테이너를 지운다.
  • start: 멈춰있는 컨테이너를 시작 시킨다.
  • attach: 실행 중인 컨테이너의 표준 입출력 및 에러 스트림에 연결한다. (콘솔에 연결)
  • build: Dockerfile을 이용해서 이미지를 생성한다.
  • inspect: 컨테이너의 상세한 정보를 볼 수 있다.

이미지 관련 명령어

마찬가지로 docker image --help를 치면 사용할 수 있는 옵션들이 나온다. 자주 쓰는 명령어들에 대해 하나씩 살펴보면 아래와 같다.

  • inspect: 이미지에 대한 상세 정보를 볼 수 있다.
  • load, save: 이미지를 tarball(.tar)로 저장하거나 저장된 tarball을 이미지로 불러올 수 있다. 이미지를 다른 사람에게 공유할 때 유용하다. (외부에 공개하는데 문제가 없다면 Docker Hub에 올리는 것을 추천한다.)
  • ls: 이미지 목록을 출력한다.
  • prune: 사용되지 않는 이미지들을 지운다. 업데이트된 이미지를 다운로드했고, 이전 이미지를 사용하는 컨테이너가 없을 경우 쉽게 한 번에 지울 수 있다. (docker image rm 명령으로도 지울 수 있다.)
  • rm: 이미지를 지운다.
  • tag: 이미지에 새로운 태그(이름)를 달아준다. 이미지를 Registry에 업로드할 경우 이미지 이름을 알맞은 형태로 맞춰줄 때 tag 명령을 사용하면 된다.

Docker 사용 예제

Ubuntu 16.04(xenial) 환경 만들기

Docker hub에서 Ubuntu 이미지를 이미 제공하고 있다. docker pull 커맨드에 이미지 이름(ubuntu)을 적으면 다운로드할 수 있다. 참고로, 이미지 이름 옆에 원하는 버전의 Tag를 같이 적어주면 해당 이미지를 받을 수 있다. 전체 Tag 목록은 https://hub.docker.com/r/library/ubuntu/tags/ 사이트를 통해 확인할 수 있다. Tag가 없으면 기본값으로 ‘:latest’를 사용한다.

$ docker pull ubuntu:xenial

이미지를 다 받았으면, 아래 명령(docker image ls 또는 docker images)으로 이미지 리스트를 확인해 보자.

$ docker image ls
REPOSITORY  TAG       IMAGE ID        CREATED        SIZE
ubuntu      xenial    f975c5035748    1 hours ago    112MB

ubuntu:xenial 이미지가 있는 것을 확인할 수 있다. 이제 아래 명령으로 Ubuntu 컨테이너를 실행하고 그 안으로 들어갈 수 있다. (참고로 “-it”는 터미널 입력을 사용하기 위한 옵션이고, “–rm”은 사용이 끝나면 해당 container를 지우라는 의미이다.)

$ docker run -it --rm ubuntu:xenial

위 명령을 실행하면 뭔가 엄청난 것이 나올 것 같은 기대와는 달리 아래처럼 썰렁하게 prompt만 뜬다.

root@d25b233efecf:/#

보이는 것은 없어 보여도 실제로는 내부적으로 엄청난(?) 과정을 거쳐서 위와 같은 결과가 나온 것이다. 지금부터는 완벽히 격리된 환경에서 명령들이 수행된다. 예로 아래와 같이 ps 명령을 통해 전체 process 목록을 확인해 보면 shell 말고는 아무것도 없는 것을 확인할 수 있다.

root@d25b233efecf:/# ps auxw
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0  18236  3288 pts/0    Ss   05:03   0:00 /bin/bash
root        11  0.0  0.0  34424  2876 pts/0    R+   05:10   0:00 ps auxw

파일시스템도 격리되어 있기 때문에 간단하게 아래 명령을 통해 /home/ 디렉터리의 파일을 조회해 보면 아무것도 없음을 확인할 수 있다.

root@d25b233efecf:~# ls -l /home/
total 0

다른 터미널을 하나 열어서 docker ps로 현재 실행 중인 컨테이너 목록을 보면 아래와 같이 방금 전에 생성한 ubuntu 컨테이너를 확인할 수 있다.

$ docker ps
CONTAINER ID    IMAGE            COMMAND        CREATED            STATUS           PORTS      NAMES
d25b233efecf    ubuntu:xenial    "/bin/bash"    17 minutes ago     Up 17 minutes               youthful_ardinghelli

이제 컨테이너를 종료해 보자. exit 명령으로 쉘에서 빠져나오면 컨테이너가 종료된다. 종료 후 컨테이너 목록을 조회해보면 실행 중인 컨테이너가 없음을 확인할 수 있다.

root@d25b233efecf:~# exit
exit
$ docker ps
CONTAINER ID    IMAGE            COMMAND        CREATED            STATUS           PORTS      NAMES
$

종료된 컨테이너의 재시작

위의 예제에서는 --rm 옵션을 사용했기 때문에 컨테이너가 종료되면 자동으로 삭제된다. 이 옵션 없이 실행하면 삭제되지 않고 종료된 채로 남아 있게 된다.

$ docker run -it ubuntu:xenial
root@28762c8b40ac:/# exit
exit
$ docker ps
CONTAINER ID    IMAGE            COMMAND        CREATED            STATUS           PORTS      NAMES

여기까지는 위와 결과가 같다. 하지만 docker ps -a 옵션을 사용해 전체 컨테이너 목록을 확인해보면 아래와 같이 방금 종료된 컨테이너가 삭제되지 않고 남아있음을 확인할 수 있다.

$ docker ps -a
CONTAINER ID    IMAGE            COMMAND        CREATED            STATUS                    PORTS      NAMES
28762c8b40ac    ubuntu:xenial    "/bin/bash"    4 minutes ago      Exited (0) 4 minutes ago             amazing_benz

이제 아래 명령을 통해 종료된 컨테이너를 재시작하고 그 안으로 들어갈 수 있다.

$ docker restart 28762c8b40ac
28762c8b40ac
$ docker attach 28762c8b40ac
root@28762c8b40ac:/# exit
exit

종료된 채로 남아있는 컨테이너를 삭제하려면 docker rm {container-id} 명령을 사용하면 된다.

$ docker rm 28762c8b40ac
28762c8b40ac
$ docker ps -a
CONTAINER ID    IMAGE            COMMAND        CREATED            STATUS           PORTS      NAMES
$

컨테이너 ID가 외우기 어려운 값이기 때문에 docker restart mybuilder 이런 식으로 ID 대신 미리 설정한 이름을 사용할 수도 있다. 특히 재사용할 컨테이너의 경우에는 미리 이름을 설정해서 생성하는 것이 더 편리하다. 이름 설정 방법은 docker run으로 컨테이너 생성 시 --name {name} 옵션을 이용하면 된다. 이 옵션이 없을 경우 도커에서 마음대로 이름을 할당한다. 위의 예를 보면 “youthful_ardinghelli”, “amazing_benz” 등 이상한 이름들이 자동으로 설정되어 있는 것을 확인할 수 있다.

실행 중인 컨테이너에 명령 실행하기

Ubuntu 이미지의 경우 컨테이너 생성 시 기본으로 실행되는 명령이 /bin/bash이기 때문에 쉘을 통해 기본적으로 명령을 수행할 수 있다. 그러나 다른 이미지의 경우 일반적으로 쉘이 아닌 해당 이미지의 목적에 맞는 명령이 수행된다. 예를 들어 웹 서버용으로 만들어진 이미지의 경우 컨테이너를 생성하면 자동으로 쉘이 아닌 웹 서버가 동작한다. 이런 경우 해당 컨테이너에 명령을 수행하려면 어떻게 해야 할까? 도커는 exec 명령을 지원하고 있다. 아래와 같이 이미 실행 중인 컨테이너에 특정 명령을 수행할 수 있다.

‘hoho’라는 이름을 가진 컨테이너 생성 (/bin/bash가 실행 중)

$ docker run -it --rm --name hoho ubuntu:xenial
root@3ca3ea550ddc:/#

다른 터미널에서 ‘hoho’ 컨테이너에 ls / 명령을 수행

$ docker exec hoho ls /
bin
boot
...

다른 터미널에서 ‘hoho’ 컨테이너에 쉘(/bin/bash)을 추가로 실행 (터미널 입력이 필요하기 때문에 -it 옵션 추가)

$ docker exec -it hoho /bin/bash
root@3ca3ea550ddc:/#

로컬 파일을 컨테이너와 공유하기

-v {local-path}:{container-inside-path} 옵션을 통해 Host와 컨테이너 사이에 파일을 공유할 수 있다. 예를 들어 현재 내 PC의 /home/user/git/test에 있는 파일들을 컨테이너 내부의 /src 디렉터리를 이용해 사용하고자 할 경우 아래와 같이 -v 옵션을 붙이면 된다.

$ ls /home/user/git/test
a.c

$ docker run -it -v /home/user/git/test:/src ubuntu:xenial
root@c1e4ccf8f08b:/# ls /src
a.c

시스템 장치 공유하기 (리눅스)

리눅스에서는 모든 리소스가 파일로 처리되기 때문에 /dev/bus/usb를 공유하면 컨테이너에서도 usb 장치 접근이 가능해진다. 하지만 이 경우 시스템 권한을 필요로 하기 때문에 --privileged 옵션을 같이 사용해야 한다.

$ docker run -it --privileged -v /deb/bus/usb:/dev/bus/usb ubuntu:xenial

컨테이너에서 X11 기반 애플리케이션 사용하기

컨테이너 내부에 있는 X11 기반 애플리케이션을 실행하고 싶을 경우(예: xterm, …) 보안 위험이 있어 추천하지는 않지만 아래와 같이 X11 socket file을 공유하고 DISPLAY 환경 변수를 설정하면 가능하다.(X11은 server/client 구조이기 때문에..)

$ docker run -it --privileged -e DISPLAY=$DISPLAY \
    -v /tmp/.X11-unix:/tmp/.X11-unix \
    ubuntu:xenial
root@c1e4ccf8f08b:/# sudo apt install xterm
root@c1e4ccf8f08b:/# xterm

이미지 만들어서 배포하기

도커는 이미지 생성을 위해 Dockerfile을 사용한다. 이미지에 들어갈 내용을 전부 이 파일에 기술하고 docker build명령을 수행하면 원하는 이미지가 만들어진다. (상세한 내용은 Dockerfile reference 문서에 잘 나와 있다.)

Dockerfile과 이미지 생성

Dockerfile은 아래와 같이 매우 간단한 구조로 되어 있다.

# Comment
INSTRUCTION arguments

INSTRUCTION에 실제로 수행할 명령(RUN, ENV, CMD, …)을 기술하면 되고, 이미지는 docker build 명령을 통해 생성할 수 있다. 아래와 같이 test 디렉터리를 만든 후 Dockerfile을 생성하고 build를 하게 되면 test 디렉터리에 있는 Dockerfile을 참고해서 mytest라는 이미지가 만들어진다.

$ mkdir test

$ vi test/Dockerfile
FROM ubuntu:xenial
RUN touch /root/a

$ docker build test -t mytest
...

$ docker images
REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
mytest       latest   2632869f3c15   8 minutes ago   112MB
ubuntu       xenial   f975c5035748   1 hours ago     112MB

각 INSTRUCTION이 수행될 때마다 그 결과로 파일 시스템에 변경이 생길 수 있는데, 도커는 각각의 변동 사항들을 레이어로 저장한다. 이해하기 쉽게 설명하면 각 INSTRUCTION 마다 GIT Commit이 생성된다고 보면 된다. (일부 INSTRUCTION들은(ENV, ..) 레이어를 생성하지 않는다.)

예를 들어 아래와 같이 a, b, c 파일을 생성하는 3개의 INSTRUCTION들로 구성된 Dockerfile이 있다고 했을 때,

FROM ubuntu:xenial
RUN touch /root/a
RUN touch /root/b
RUN touch /root/c

docker build명령을 통해 mytest라는 이미지를 생성하게 되면

$ docker build test -t mytest
Sending build context to Docker daemon  2.048kB
Step 1/4 : FROM ubuntu:xenial
 ---> f975c5035748
Step 2/4 : RUN touch /root/a
 ---> Running in 3efd221c9151
Removing intermediate container 3efd221c9151
 ---> 44bd6576387d
Step 3/4 : RUN touch /root/b
 ---> Running in 57f30410cd9b
Removing intermediate container 57f30410cd9b
 ---> a13b79f75a67
Step 4/4 : RUN touch /root/c
 ---> Running in 97a4b1303286
Removing intermediate container 97a4b1303286
 ---> 2632869f3c15
Successfully built 2632869f3c15

위처럼 명령이 차례대로 수행되는 것을 확인할 수 있다. 이제, docker image inspect 명령을 통해 실제로 해당 이미지가 어떤 레이어로 구성되어 있는지 확인해 보자.

먼저 mytest의 부모 이미지인 ubuntu:xenial의 경우 아래와 같이 5개의 레이어로 구성되어 있음을 확인할 수 있다.

$ docker image inspect ubuntu:xenial
...
"RootFS": {
    "Type": "layers",
    "Layers": [
        "sha256:a94e0d5a7c404d0e6fa15d8cd4010e69663bd8813b5117fbad71365a73656df9",
        "sha256:88888b9b1b5b7bce5db41267e669e6da63ee95736cb904485f96f29be648bfda",
        "sha256:52f389ea437ebf419d1c9754d0184b57edb45c951666ee86951d9f6afd26035e",
        "sha256:52a7ea2bb533dc2a91614795760a67fb807561e8a588204c4858a300074c082b",
        "sha256:db584c622b50c3b8f9b8b94c270cc5fe235e5f23ec4aacea8ce67a8c16e0fbad"
    ]
},
...

이제, mytest 이미지를 확인해 보면

$ docker image inspect ubuntu:xenial
...
"RootFS": {
    "Type": "layers",
    "Layers": [
        "sha256:a94e0d5a7c404d0e6fa15d8cd4010e69663bd8813b5117fbad71365a73656df9",
        "sha256:88888b9b1b5b7bce5db41267e669e6da63ee95736cb904485f96f29be648bfda",
        "sha256:52f389ea437ebf419d1c9754d0184b57edb45c951666ee86951d9f6afd26035e",
        "sha256:52a7ea2bb533dc2a91614795760a67fb807561e8a588204c4858a300074c082b",
        "sha256:db584c622b50c3b8f9b8b94c270cc5fe235e5f23ec4aacea8ce67a8c16e0fbad",
-->     "sha256:78db838419b3f828f16124ed414d803b64656ef0c5a1c6d11a9adb2aba418719",
-->     "sha256:95994d50f0a68731064c81bce8e9d3529edf6cf1ca19a2f37dc024a1f77b1c0a",
-->     "sha256:91906e9f8bed5b4cadb073da5edd9994aa3bb6171a5e2156c59d84bf006cece5"
    ]
},
...

기본 이미지인 ubuntu가 가지고 있는 5개의 레이어 외에 3개의 레이어가 추가되었음을 확인할 수 있다.

레이어 개념은 상당히 중요하고 유용하데, 이미지 빌드 중간에 실패 후 다시 빌드 할 때 성공한 레이어까지는 캐쉬를 사용해서 바로 넘어가고 실패한 부분부터 빌드를 한다. 예를 들어 아래와 같이 Dockerfile을 작성했을 때,

FROM ubuntu:xenial
RUN touch a
RUN cat b
RUN touch c

b라는 파일이 없이 때문에 이미지 빌드 시 touch a까지 수행되고 cat b는 명령 실행 결과로 실패를 리턴 받아 이미지 빌드가 실패된다. 이제 Dockerfile을 아래와 같이 고쳐서 다시 빌드를 실행하면

FROM ubuntu:xenial
RUN touch a
RUN cat a
RUN touch c

touch a단계는 이미 전에 성공했기 때문에 따로 실행하지 않고 넘어가고 cat a부터 실행하게 된다. 이를 잘 활용하면, 작업에 오래 걸리는 명령들을 최대한 분리시켜(쪼개서), 실패 후 다시 빌드 할 때 시간을 많이 절약할 수 있다. (프로그래밍시 에러 없이 한 번에 코드를 짜기 어려운 것처럼 Dockerfile을 작성할 때도 한 번에 성공하는 경우는 잘 없다. 또한 만들다 보면 중간에 다른 명령이 추가되어야 하는 경우도 있다.)

하지만, 명령을 너무 쪼개면 아래처럼 불필요한 레이어가 생기는 부작용이 발생할 수 있다.

FROM ubuntu:xenial
RUN dd if=/dev/zero of=test.dat bs=5MB count=1
RUN rm test.dat

위 Dockerfile을 빌드 하면 최종 이미지는 몇 MB를 차지할까? Dockerfile 마지막에 test.dat를 지우는 명령이 있기 때문에 실제 크기는 0이고 docker images로 확인했을 때 ubuntu 이미지 크기만큼만 나와야 할 것 같다.

$ docker images
REPOSITORY     TAG       IMAGE ID        CREATED             SIZE
mytest         latest    18c1c4535d5a    4 seconds ago       117MB
ubuntu         xenial    f975c5035748    5 weeks ago         112MB

하지만, 실제 결과를 보니 mytest 이미지는 117MB로 ubuntu 이미지 112MB에 비해 5MB (생성하고 삭제한 test.dat 크기만큼) 늘어나 있다. 왜 그럴까? 그 이유는 각 단계마다 레이어로 저장되기 때문이다. 결국 5MB를 만드는 레이어가 있고, 그 파일을 지우는 레이어가 따로따로 있기 때문에 최종적으로 해당 이미지를 사용해서 컨테이너를 실행했을 때에는 그 파일이 없겠지만, 이미지에는 2개의 레이어가 모두 존재한 채로 저장된다. 이를 해결하려면 아래와 같이 Dockerfile을 작성하면 된다.

FROM ubuntu:xenial
RUN dd if=/dev/zero of=test.dat bs=5MB count=1 \
    && rm test.dat

하나의 INSTRUCTION에 &&를 사용해 여러 개의 명령을 하나로 합쳐 놓는 것이다. 각 명령의 최종 실행 결과만 레이어로 저장되기 때문에 이렇게 할 경우 삭제된 test.dat는 공간을 차지하지 않는다.

$ docker images
REPOSITORY     TAG       IMAGE ID        CREATED             SIZE
mytest         latest    18c1c4535d5a    4 seconds ago       112MB
ubuntu         xenial    f975c5035748    5 weeks ago         112MB

확인 결과 실제로 공간을 차지하지 않았다.

이미지 개발 시에는 최대한 명령들을 분리시켜서 작업의 효율성을 높이고, 최종 배포시에는 이를 다시 합쳐서 최대한 레이어를 줄이는 것을 추천한다. 이제 인터넷에서 많이 찾아볼 수 있는 아래 형태들을 보면 왜 그렇게 작성했는지 이해할 수 있을 것이다.

FROM ubuntu:xenial
RUN apt-get update \
    && apt-get install .... \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

Docker build 옵션들

docker build 명령어 사용 시 여러 옵션들이 있는데 많이 쓰이는 옵션들은 아래와 같다.

  • -t 생성될 이미지의 이름(tag)을 설정한다. -t myimage를 사용하면 이름은 myimage Tag는 latest 형태로 이미지 이름이 붙여진다. latest는 세부 tag가 없으면 자동으로 붙여지는데,-t myimage:second 형태로 사용할 경우 이름은 myimage, Tag는 second가 사용된다.

  • --build-arg 빌드 시 Dockerfile에 ARG로 선언된 값들을 설정할 수 있다. 보통 proxy를 설정할 때 많이 쓰인다. docker build --build-arg http_proxy=http://aa.bb.cc.dd:nnnn --build-arg xxx=bbb 형태로 사용하면 된다.

Dockerfile 명령어들

Dockerfile 작성 시 주로 많이 사용하는 명령어들은 아래와 같다.

FROM

부모 이미지를 설정한다. as를 사용해 따로 이름을 지정할 수도 있다. (멀티 스테이지 빌드 시 사용된다.)

FROM ubuntu:xenial
FROM ubuntu:xenial as builder

ENV

환경 변수를 설정한다. 당연한 얘기지만 설정한 라인 이후부터 효과가 있다. $variable_name 또는 ${variable_name} 을 통해 변숫값에 접근할 수 있다. 설정 방법은 아래와 같다.

ENV A 3
ENV B 4

ENV A=3 B=4

ENV A=3 \
    B=4

Dockerfile에 설정한 환경 변수들은 컨테이너에서도 유효하다. 즉, 컨테이너 안에서 printenv를 실행하면 Dockerfile에서 정의한 환경 변수들이 모두 출력 된다.

RUN

가장 많이 쓰이는 명령으로 실제 command를 실행할 때 사용된다.

RUN command param1 param2
RUN command1 param1 && command2 && ...
RUN ["executable", "param1", "param2"]

CMD , ENTRYPOINT

컨테이너가 시작될 때(docker run 또는 docker start) 실행할 기본 명령어 또는 스크립트를 설정한다. Dockerfile 내에 하나만 존재해야 하고 아래처럼 다양한 형태로 사용할 수 있다.

CMD command param1 param2
CMD ["executable", "param1", "param2"]

ENTRYPOINT도 마찬가지로 아래 형태로 사용할 수 있다.

ENTRYPOINT command param1 param2
ENTRYPOINT ["executable", "param1", "param2"]

위 두 명령은 같은 동작을 하는 것처럼 보이지만 차이점이 하나 있다. docker run 컨테이너를 실행할 때 아래와 같이 옵션으로 실행할 command를 지정할 경우에 그 결과가 달라지게 된다.

docker run <image_name>
docker run <image_name> echo hi

첫 번째의 경우 실행할 명령에 대한 옵션이 없기 때문에 CMD 또는 ENTRYPOINT에 정의된 명령이 실행된다. 하지만 두 번째의 경우 해당 이미지에 사용된 CMDENTRYPOINT 정의에 따라 결과가 달라진다.

# mytest Dockerfile
FROM ubuntu:xenial
CMD ["echo", "default_cmd"]

$ docker run mytest echo hi
hi

위와 같이 CMD가 사용된 경우 컨테이너 생성 시,echo default_cmd는 무시되고 echo hi가 실행된다. 하지만, 아래처럼 ENTRYPOINT를 사용했을 경우에는 echo hiENTRYPOINT로 지정된 명령의 인자로 들어가게 된다.

# mytest Dockerfile
FROM ubuntu:xenial
ENTRYPOINT ["echo", "default_entry"]

$ docker run mytest echo hi
default_entry echo hi

결과에서 보듯이 최종적으로 echo default_entry echo hi가 실행됐다. 아래처럼 CMDENTRYPOINT가 같이 사용된 경우에는 어떻게 될까?

# mytest Dockerfile
FROM ubuntu:xenial
ENTRYPOINT ["echo", "default_entry"]
CMD ["echo", "default_cmd"]

$ docker run mytest
default_entry echo default_cmd

$ docker run mytest echo hi
default_entry echo hi

ENTRYPOINTCMD가 같이 사용되면 CMD에 설정한 값들은 ENTRYPOINT의 인자로 자동으로 들어간다. 그래서 첫 번째 docker run시에 결과가 echo default_entry echo default_cmd처럼 나온 것이다. 두 번째 docker run처럼 뒤에 실행할 명령을 지정했을 경우 CMD를 대체해서 echo default_entry echo hi이 최종 결과로 출력 된다.

COPY

밖에 있는 파일을 이미지 안에 복사한다. 예를 들어 로컬에 있는 sample이라는 파일을 이미지 안에 넣고 싶을 경우 아래처럼 사용하면 된다.

$ mkdir test
$ touch test/sample

$ vi test/Dockerfile
FROM ubuntu:xenial
COPY sample /root/

$ docker build test -t mytest
$ docker run --rm mytest ls /root
sample

위 명령을 실행하면 test/sample 파일이 도커 이미지의 /root/sample에 복사된 것을 확인할 수 있다.

USER

기본적으로 Dockerfile에서 수행되는 명령은 ROOT 권한으로 실행된다. 하지만 USER 명령으로 사용자 이름을 설정하게 되면 그 이후부터 수행되는 명령(RUN, …)들은 그 사용자 권한으로 실행된다.

WORKDIR

작업 디렉터리를 설정한다. 이후에 수행되는 명령(RUN,…)들은 해당 디렉터리에서 실행된다.

RUN useradd -ms /bin/bash kim
USER kim
ENV HOME /home/kim
WORKDIR $HOME
RUN ls

kim이라는 사용자를 추가하고 해당 사용자로 전환 및 기본 디렉터리를 해당 사용자 홈 디렉터리로 설정하는 예제이다. ls명령은 kim 유저 권한으로 /home/kim 디렉터리에서 실행된다.

EXPOSE

컨테이너 내부의 포트를 외부에서 접근할 수 있게 한다. EXPOSE로 선언했다고 바로 포트가 외부에서 접근 가능한 것은 아니고 docker run시에 -p host_port:container_port 옵션으로 외부에서 사용할 포트를 설정해 주어야 한다.

# Dockerfile
EXPOSE 80/tcp

$ docker run -p 8000:80/tcp <myimage>

위의 예제처럼 컨테이너 내부에서 사용하는 80포트를 EXPOSE 명령을 사용해서 노출시키고, 외부에서는 8000 포트를 사용해 컨테이너 내부에 접근할 수 있다.

VOLUME

이 명령은 특정 디렉터리의 내용을 컨테이너가 아닌 Host에 저장하도록 설정한다. 컨테이너의 경우 자신의 R/W 레이어를 사용해서 파일이 저장되는데, VOLUME으로 지정된 디렉터리는 별도로 저장되고 docker volume 명령을 통해 따로 관리할 수 있다.

유용한 팁

Multi-stage 빌드

최신 도커(버전 17.05 이상)에서는 Multi-stage 빌드를 지원한다. 멀티 스테이지 빌드는 이미지를 만들 때 불필요한 파일들이 포함되는 것을 방지하는데 매우 유용하다.

예를 들어, 특정 라이브러리(libxxx라고 가정)를 포함하는 이미지를 만든다고 했을 때, libxxx를 만들기 위한 의존성 패키지들이 이미지에 포함되어야 할 것이다. 하지만 이들 중에는 라이브러리를 생성할 때에만 필요하고 실제 동작할 때에는 필요 없는 것들이 있을 수 있다. (예: doxygen 등)

이럴 경우 아래와 같이 접근할 수 있다.

  1. libxxx를 만들어 내는 이미지(이미지 A)를 만든다. (의존성 패키지 포함)
  2. 이 이미지에서 libxxx만 빼낸다. (docker cp 사용)
  3. libxxx만 포함된 새로운 이미지(이미지 B)를 만들어서 배포한다.

이미지 A, B를 만들기 위해 2개의 Dockerfile이 필요하고, docker cp는 컨테이너의 파일을 빼내는 것이기 때문에 이미지 A를 사용한 임시 컨테이너도 하나 만들어야 한다. 그리고 이 과정을 자동화하기 위한 쉘 스크립트도 하나 필요할 것이다. (실제 상세한 예제 파일들은 도커 문서에 있다.)

이 과정을 쉽게 해결해 준 것이 바로 멀티 스테이지 빌드인데, 사용법도 아주 명료하고 간단하다. Dockerfile 안에 FROM을 여러 개 사용해 스테이지를 분리하고, COPY 명령에 --from={from index or name} 옵션을 통해 어떤 스테이지에서 파일을 빼올지 명시해 주면 끝난다. 도커 사이트에 있는 아래 예제를 보면 이해하기 쉬울 것이다.

# Dockerfile
FROM golang:1.7.3
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html
COPY app.go .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=0 /go/src/github.com/alexellis/href-counter/app .
CMD ["./app"]

$ docker build -t alexellis2/href-counter:latest .

하나의 Dockerfile 안에 FROM이 2개가 있는데, FROM golang:1.7.3으로 시작하는 스테이지가 하나 있고(인덱스 0), FROM alpine:latest로 시작하는 스테이지가 있다.(인덱스 1)

첫 번째 스테이지는 app을 빌드 하는 과정을 진행하고, 두 번째 스테이지는 첫 번째 스테이지의 결과물만 쏙 빼서(COPY --from=0) 자신의 /root/에 복사한다. 결국 docker build 명령을 통해 이미지를 빌드 하면 중간 과정들은 모두 사라지고 마지막 이미지만 최종 결과물로 남는다.

위 예제는 스테이지를 가리킬 때 숫자 번호를 사용했는데, as 옵션을 통해 스테이지의 이름을 지정하는 것도 가능하다.

FROM goland:1.7.3 as builder
...

FROM alpine:latest
COPY --from=builder ...

이렇게 FROMas builer 옵션으로 스테이지의 이름을 builder라고 설정한 후 나중에 COPY할 때 --from=builder 옵션을 통해 대상 스테이지를 지정할 수 있다.

squash로 layer 합치기

앞의 설명에서 Dockerfile를 이용해 이미지를 빌드 할 때 각 단계별로 변경 레이어가 생긴다고 했다. 그리고 이 때문에 최종 배포시에는 최대한 불필요한 용량을 줄이기 위해 명령들을 하나로 합쳐(&& 사용) 레이어를 줄여야 한다고 했다. 하지만, squash를 사용하면 이러한 노력 없이도 이미지 빌드 시 도커가 자동으로 레이어를 하나로 합치게 할 수 있다. 아직은 이 옵션이 experimental 상태이기 때문에 사용하려면 도커 설정에서 아래와 같이 기능을 활성화해줘야 한다.

# /etc/docker/daemon.json file
{
    "experimental":true
}

$ sudo service docker restart

자, 이제 squash 옵션을 이용해 빌드 해 보자.

# test/Dockerfile
FROM ubuntu:xenial
RUN touch /root/a
RUN touch /root/b
RUN touch /root/c

$ docker build test -t tmp1
$ docker build --squash test -t tmp2

squash 옵션을 적용한 것과 적용하지 않은 것 2개의 이미지를 생성했다. docker image inspect 명령으로 두 이미지 간에 차이가 있는지 확인해 보자.

$ docker image inspect tmp1
"Layers": [
    "sha256:a94e0d5a7c404d0e6fa15d8cd4010e69663bd8813b5117fbad71365a73656df9",
    "sha256:88888b9b1b5b7bce5db41267e669e6da63ee95736cb904485f96f29be648bfda",
    "sha256:52f389ea437ebf419d1c9754d0184b57edb45c951666ee86951d9f6afd26035e",
    "sha256:52a7ea2bb533dc2a91614795760a67fb807561e8a588204c4858a300074c082b",
    "sha256:db584c622b50c3b8f9b8b94c270cc5fe235e5f23ec4aacea8ce67a8c16e0fbad",

    "sha256:e80b8eac1111b97140e65116d4d773b8531c4b6ddc8b8fa6ff72fad8991c633c",
    "sha256:5b9c8e647a6774bdc315abfdb863547aed6e81d8844a1c891df9a593001f1518",
    "sha256:edbd65baf5086d774c2f2260c43bba297f29876da5249c815682b05ae27b9be9"
]

$ docker image inspect tmp2
"Layers": [
    "sha256:a94e0d5a7c404d0e6fa15d8cd4010e69663bd8813b5117fbad71365a73656df9",
    "sha256:88888b9b1b5b7bce5db41267e669e6da63ee95736cb904485f96f29be648bfda",
    "sha256:52f389ea437ebf419d1c9754d0184b57edb45c951666ee86951d9f6afd26035e",
    "sha256:52a7ea2bb533dc2a91614795760a67fb807561e8a588204c4858a300074c082b",
    "sha256:db584c622b50c3b8f9b8b94c270cc5fe235e5f23ec4aacea8ce67a8c16e0fbad",

    "sha256:64ec37dd0fd1aaac8807f2b959f891bfc5ab854665fdfa6ea2e5d5405b9dd9ce"
]

부모 이미지(ubuntu:xenial)의 레이어 5개를 빼고 보면 --squash 옵션으로 빌드 한 이미지의 레이어는 1개, 그렇지 않은 이미지는 3개로 구성되어 있음을 확인할 수 있다.

배포하기

내가 만든 이미지를 이제 다른 사람들이 받을 수 있게 배포해 보자. 도커는 이미지를 저장하고 다운로드할 수 있는 Public 저장소를 무료로 제공해주고 있다. https://cloud.docker.com/ 가입하면 이미지를 공유할 수 있다.

로그인하기

가입이 끝났으면 docker login 명령으로 서버에 로그인을 한다. 아이디와 비밀번호를 입력하면 로그인이 된다. docker login -u {your-id} -p {your-password} 옵션도 사용 가능하다.

이름 설정하기

서버에 업로드하려면 먼저 이름을 {your-id}/{repository}[:tag] 형식에 맞게 맞춰줘야 한다. 만약 업로드할 이미지 이름이 위 형식에 맞지 않는다면 아래 명령을 통해 이름을 추가할 수 있다.

docker tag myimage myid/myimage1:latest
docker tag myimage myid/myimage2:xenial

업로드하기

업로드는 아주 간단하다. docker push 명령에 위에서 설정한 이름을 적어주면 된다.

docker push myid/myimage1:latest

이제 도커 허브 사이트에 이미지가 잘 올라갔는지 확인해 보면 된다.

빌드 자동화 - Continuous Integration

도커는 Github 등과 연동을 통한 CI도 지원한다. 개인 Github에 project 생성 후 Dockerfile를 작성해 놓으면 자동으로 이미지를 빌드 해서 도커 허브에 업로드된다. 현재는 아래 2가지 방법을 사용해 CI 설정을 할 수 있다.

Docker cloud가 나중에 나온 것으로 좀 더 깔끔한 환경을 제공해 주고, 내부적으로 이미지 빌드 시 캐쉬 사용이 가능한 것 같다. (특정 단계에서 빌드 실패를 실패했을 경우 다음 빌드 시 성공한 부분까지는 캐쉬를 이용하여 빠르게 넘어감)

하지만, 둘 다 무료라서 그런지 많이 느려서 결국 Travis CI를 사용하게 된다. 아래는 도커 이미지 빌드 및 업로드를 위해 개인적으로 사용하고 있는 .travis.yml 샘플이다.

language: bash
sudo: required
services: docker
env:
  global:
  - secure: cMzbzOjh4Hm5YP5yCs1Tzsye4Zf...

install:
  - echo '{"experimental":true}' | sudo tee /etc/docker/daemon.json
  - sudo service docker restart

script:
  - docker build --squash -t webispy/test .

before_deploy:
  - docker images
  - docker login -u webispy -p $DOCKER_PASS

deploy:
  provider: script
  script: docker push webispy/test

$DOCKER_PASS는 Travis CI의 Encrypting environment variables 설정 가이드를 참고해서 비밀번호 환경 변수를 설정하면 된다.

$ travis encrypt DOCKER_PASS={my password} --add env.global

References

  • https://www.docker.com/what-container
  • https://docs.docker.com/get-started/
  • https://docs.docker.com/engine/docker-overview/
  • https://docs.docker.com/storage/
  • https://docs.docker.com/engine/reference/commandline/image_build/
  • https://subicura.com/2017/01/19/docker-guide-for-beginners-1.html
  • https://en.wikipedia.org/wiki/UnionFS
  • https://ssup2.github.io/theory_analysis/Union_Mount_AUFS_Docker_Image_Layer/
  • https://docs.travis-ci.com/user/environment-variables/