Docker container | Virtual machine |
---|---|
도커는 Linux, Mac, Windows 환경에서 모두 사용 가능하다. 단, Windows의 경우 도커가 내부적으로 사용하는 기술(Windows의 Hyper-V)이 VirtualBox / VMware와 충돌하기 때문에 도커 사용이 끝난 후 Windows 설정에서 Hyper-V를 Disable 시켜야 VirtualBox / VMware를 다시 사용할 수 한다. (참고: Docker Toolbox를 사용하면 이런 문제를 해결할 수 있지만 공식 사이트에서는 Docker for Windows를 추천하고 있다.)
이미지에 대해 도커 사이트에는 “컨테이너 생성을 위한 명령들이 포함된 읽기 전용 템플릿” 이라고 설명하고 있다. 이해하기 쉽게 설명하면 원하는 서비스 또는 애플리케이션의 운영/동작에 필요한 각종 프로그램과 설정(환경 변수, 시작 시 실행될 커맨드 등)들을 하나로 묶어 놓은 형태라고 보면 된다.
An image is a read-only template with instructions for creating a Docker container. – https://docs.docker.com/engine/docker-overview
읽기 전용인 이유는 이 이미지를 기반으로 여러 컨테이너가 실행될 수 있기 때문이다. 만약 이런 상황에서 베이스 이미지의 파일이 변경된다면 동작 중인 컨테이너에 문제를 일으킬 수 있다.
– 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
이미지를 실행한 상태로 기존 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는 여러 디렉터리들을 합쳐서 하나로 보이게 하는 방법이다. 예를 들어, /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
– https://ssup2.github.io/theory_analysis/Union_Mount_AUFS_Docker_Image_Layer/
이것을 가능하게 해주는 구현 물로 여러 가지가 있는데, 도커에서는 스토리지 드라이버 설정을 통해 원하는 것을 선택해서 사용할 수 있다. 도커 문서에 보면, Docker CE on Ubuntu 기준으로 aufs
, devicemapper
, overlay2
, overlay
, zfs
, vfs
등을 사용할 수 있다. 현재 추천하고 있는 것은 overlay2
이다.
도커 이미지를 생성하기 위해 사용하는 파일이다. 이미지에 포함될 내용 및 컨테이너 생성 시 동작할 명령, 네트워크 포트 설정 등을 전부 이 파일에 기술해 놓고 docker build
를 실행하면 원하는 이미지가 생성된다.
도커 이미지를 저장하고 다운로드하기 위한 저장소(서버)로 Public 또는 Private 하게 운영할 수 있다. 대부분 https://hub.docker.com 저장소를 사용한다.
$ 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
특정 사용자를 docker
그룹에 추가하면 sudo 없이 docker 명령 사용이 가능하다.
$ sudo usermod -aG docker {your-user-name}
회사 네트워크 사용 등으로 인해 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
가끔 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
만약 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
도커 내부적으로 사용하는 파일시스템을 선택할 수 있는데, 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 --help
를 통해 사용 가능한 명령어들을 확인할 수 있는데, 자주 쓰는 명령어들은 아래와 같다.
{your-id}/{image-name}
형태로 되어 있어야 한다.마찬가지로 docker image --help
를 치면 사용할 수 있는 옵션들이 나온다. 자주 쓰는 명령어들에 대해 하나씩 살펴보면 아래와 같다.
docker image rm
명령으로도 지울 수 있다.)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 기반 애플리케이션을 실행하고 싶을 경우(예: 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은 아래와 같이 매우 간단한 구조로 되어 있다.
# 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
명령어 사용 시 여러 옵션들이 있는데 많이 쓰이는 옵션들은 아래와 같다.
-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 작성 시 주로 많이 사용하는 명령어들은 아래와 같다.
부모 이미지를 설정한다. as
를 사용해 따로 이름을 지정할 수도 있다. (멀티 스테이지 빌드 시 사용된다.)
FROM ubuntu:xenial
FROM ubuntu:xenial as builder
환경 변수를 설정한다. 당연한 얘기지만 설정한 라인 이후부터 효과가 있다. $variable_name
또는 ${variable_name}
을 통해 변숫값에 접근할 수 있다. 설정 방법은 아래와 같다.
ENV A 3
ENV B 4
ENV A=3 B=4
ENV A=3 \
B=4
Dockerfile에 설정한 환경 변수들은 컨테이너에서도 유효하다. 즉, 컨테이너 안에서 printenv
를 실행하면 Dockerfile에서 정의한 환경 변수들이 모두 출력 된다.
가장 많이 쓰이는 명령으로 실제 command를 실행할 때 사용된다.
RUN command param1 param2
RUN command1 param1 && command2 && ...
RUN ["executable", "param1", "param2"]
컨테이너가 시작될 때(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
에 정의된 명령이 실행된다. 하지만 두 번째의 경우 해당 이미지에 사용된 CMD
와 ENTRYPOINT
정의에 따라 결과가 달라진다.
# mytest Dockerfile
FROM ubuntu:xenial
CMD ["echo", "default_cmd"]
$ docker run mytest echo hi
hi
위와 같이 CMD
가 사용된 경우 컨테이너 생성 시,echo default_cmd
는 무시되고 echo hi
가 실행된다. 하지만, 아래처럼 ENTRYPOINT
를 사용했을 경우에는 echo hi
가 ENTRYPOINT
로 지정된 명령의 인자로 들어가게 된다.
# mytest Dockerfile
FROM ubuntu:xenial
ENTRYPOINT ["echo", "default_entry"]
$ docker run mytest echo hi
default_entry echo hi
결과에서 보듯이 최종적으로 echo default_entry echo hi
가 실행됐다. 아래처럼 CMD
와 ENTRYPOINT
가 같이 사용된 경우에는 어떻게 될까?
# 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
ENTRYPOINT
와 CMD
가 같이 사용되면 CMD
에 설정한 값들은 ENTRYPOINT
의 인자로 자동으로 들어간다. 그래서 첫 번째 docker run
시에 결과가 echo default_entry echo default_cmd
처럼 나온 것이다. 두 번째 docker run
처럼 뒤에 실행할 명령을 지정했을 경우 CMD
를 대체해서 echo default_entry echo hi
이 최종 결과로 출력 된다.
밖에 있는 파일을 이미지 안에 복사한다. 예를 들어 로컬에 있는 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에 복사된 것을 확인할 수 있다.
기본적으로 Dockerfile에서 수행되는 명령은 ROOT 권한으로 실행된다. 하지만 USER
명령으로 사용자 이름을 설정하게 되면 그 이후부터 수행되는 명령(RUN
, …)들은 그 사용자 권한으로 실행된다.
작업 디렉터리를 설정한다. 이후에 수행되는 명령(RUN
,…)들은 해당 디렉터리에서 실행된다.
RUN useradd -ms /bin/bash kim
USER kim
ENV HOME /home/kim
WORKDIR $HOME
RUN ls
kim이라는 사용자를 추가하고 해당 사용자로 전환 및 기본 디렉터리를 해당 사용자 홈 디렉터리로 설정하는 예제이다. ls
명령은 kim 유저 권한으로 /home/kim 디렉터리에서 실행된다.
컨테이너 내부의 포트를 외부에서 접근할 수 있게 한다. EXPOSE로 선언했다고 바로 포트가 외부에서 접근 가능한 것은 아니고 docker run
시에 -p host_port:container_port
옵션으로 외부에서 사용할 포트를 설정해 주어야 한다.
# Dockerfile
EXPOSE 80/tcp
$ docker run -p 8000:80/tcp <myimage>
위의 예제처럼 컨테이너 내부에서 사용하는 80포트를 EXPOSE
명령을 사용해서 노출시키고, 외부에서는 8000 포트를 사용해 컨테이너 내부에 접근할 수 있다.
이 명령은 특정 디렉터리의 내용을 컨테이너가 아닌 Host에 저장하도록 설정한다. 컨테이너의 경우 자신의 R/W 레이어를 사용해서 파일이 저장되는데, VOLUME
으로 지정된 디렉터리는 별도로 저장되고 docker volume
명령을 통해 따로 관리할 수 있다.
최신 도커(버전 17.05 이상)에서는 Multi-stage 빌드를 지원한다. 멀티 스테이지 빌드는 이미지를 만들 때 불필요한 파일들이 포함되는 것을 방지하는데 매우 유용하다.
예를 들어, 특정 라이브러리(libxxx
라고 가정)를 포함하는 이미지를 만든다고 했을 때, libxxx
를 만들기 위한 의존성 패키지들이 이미지에 포함되어야 할 것이다. 하지만 이들 중에는 라이브러리를 생성할 때에만 필요하고 실제 동작할 때에는 필요 없는 것들이 있을 수 있다. (예: doxygen
등)
이럴 경우 아래와 같이 접근할 수 있다.
libxxx
를 만들어 내는 이미지(이미지 A)를 만든다. (의존성 패키지 포함)libxxx
만 빼낸다. (docker cp
사용)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 ...
이렇게 FROM
에 as builer
옵션으로 스테이지의 이름을 builder
라고 설정한 후 나중에 COPY
할 때 --from=builder
옵션을 통해 대상 스테이지를 지정할 수 있다.
앞의 설명에서 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
이제 도커 허브 사이트에 이미지가 잘 올라갔는지 확인해 보면 된다.
도커는 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