이번 개인프로젝트의 배포에 도커를 사용해보았다. 예상했던 것보다 훨씬 더 좋은 것 같다.
사실 이전에도 도커에 대해 흥미가 있었는데, 잠시 학습을 미뤄둔 적이 있다.
이번 기회에 정리를 해 두고, 잊어버리면 다시와서 봐야겠다 ㅋㅋ
리눅스 컨테이너 기술을 사용. 컨테이너를 생성 및 관리해 애플리케이션에 필요한 환경을 제공해주는 오픈소스 가상화 플랫폼이다.
컨테이너에 대해 모르고 보면 이게 대체 뭔 소린가 싶다.
컨테이너라는 건, 하나의 서비스만이 존재하는 작은 컴퓨팅 환경이라고 보면 된다. (2개 이상의 서비스도 공존할 수 있지만 전혀 추천되지 않는 방법이고, 도커의 사용 의미가 퇴색된다)
그 컨테이너를 실행시키면 들어있던 서비스가 실행이 되고, 접근 가능하게 된다.
내가 처음에 이해가 힘들었던 건, 그래서 지금 있는 서비스 환경(mysql, redis 등)을 어떻게 담는데? 하는 부분이다.
이 부분은 Docker Image를 생성하는 것으로 가능해진다.
애초에 컨테이너에 서비스 환경을 담기 위해서는 Docker를 통해 Docker Image를 불러와서 그 Image를 기반으로 Container를 생성시켜야 한다.
그냥 내가 지금 사용하고 있는 환경에 .docker 따위의 파일을 추가한다고 도커 기반으로 돌아가는 게 아니라는 말이다.
예를 들어, 현재 내가 쓰고 있는 Mysql을 도커 기반으로 돌리고 싶다고 하자.
그러면 일단 Mysql의 실행파일이 필요할 것이다. 그리고 온전한 서비스를 제공하기 위해 그 실행 파일이 참조하는 모든 라이브러리나 환경값도 필요할 것이다.
그것들을 뭉뚱그린 다음, Docker Container를 제작에 필요한 환경값을 추가해 Docker Image 파일을 만든다.
그 후, Docker를 통해 해당 Image 기반으로 컨테이너를 만들어서 돌리면 내가 쓰던 Mysql이 도커 기반으로 동작하게 되는 것이다.
컨테이너에 대해 알게되면 위 말이 더 잘 이해가 갈 테니 이쯤에서 넘어가자.
컨테이너는 어디가고 왜 이미지냐?
컨테이너가 곧 이미지의 형상화이기 때문이다.
이해를 돕기 위해 비유를 통한 예를 들겠다.
한 10년 전만 해도 불법복제가 만연하면서 Windows의 iso 파일이 나돌아다니는 일이 많았다.
이게 바로 이미지다.
그리고 컨테이너는 이 이미지를 이용해 설치된 모든 Windows들이다.
Docker Image라고 하는 건,
특정 프로세스를 실행하기 위해 필요한 모든 것들 (응용 프로그램, 라이브러리, 기타 서비스 환경, 설정값)을 뭉뚱그려 놓은 읽기 전용 템플릿이다.
위에 예로 든 Windows.iso의 경우, Windows 설치라는 프로세스에 필요한 모든 것들이 다 들어있는 것이다.
그럼 MySql이미지는? 위에서 말했듯이 MySql을 실행하는데 필요한 모든 것들이 들어있다.
여기서 iso파일과 도커 이미지의 차이가 드러난다.
iso는 '설치용'이지만, 도커 이미지는 '실행용'이다.
그냥 가져와서 이미지대로 컨테이너를 만들어 띄우면 바로 실행이 된다. 그리고 그렇기 때문에 Docker를 통해서 컨테이너를 생성해 돌린 서비스만이 도커 기반으로 돌아간다.
말했다시피, Docker Image는 읽기 전용이다.
그런데 가끔보면 Image를 수정한다는 말이 보인다. 이는 Layer를 추가/제거/교환한다는 말로 치환된다.
Docker Image는 여러 개의 Read-only Layer로 구성이 된다.
그렇기 때문에 Image의 수정은 거기에 새 Layer를 추가하거나, 특정 Layer를 제거하거나, 교환하는 방식으로 이루어진다.
그에 따라 이미지를 공유할 때는 바뀐 부분(Layer)만 공유하는 것도 가능하다.
위에서 Docker Image는 읽기 전용 템플릿이라고 했다.
Docker Container는 이 템플릿을 기반으로 Read & Write layer가 추가된 상태로 생성/배포 된 인스턴스이다.
그러나 한 컨테이너에는 하나의 환경을 담는 것이 권장되고 있으므로 컨테이너(=서비스)라고 봐도 큰 무리는 없다.
인스턴스화 된 이후 변경되는 부분은 Read & Write layer에만 기입되기에 여러 개의 컨테이너를 띄워도 컴퓨터 자원을 적게 먹는다는 소리가 나오는 것이다.
또한 컨테이너는 하나의 컴퓨팅 환경이라고도 위에서 말했다.
그렇기 때문에 컨테이너를 삭제하면 그동안 컨테이너에서 생성한 파일들이 사라진다. 예를 들어, Mysql 컨테이너를 삭제한다면 그 안의 데이터는 모두 없어질 것이다.
이를 극복하기 위해 Docker Volume, Bind-mount를 이용해 저장공간에 영속성을 부여하는 방법이 있다.
마지막으로 컨테이너는 가상 OS와는 다르게 호스트 OS 환경 위에서 동작하기 때문에 시스템 콜 등의 자원을 공유한다.
Docker Image를 만들기 위해 필요한 스크립트 파일이다.
비유로 이해하자면 .sql 파일이다.
.sql 파일을 불러와서 실행시키면 그대로 테이블이 생성/삭제되고, 데이터가 들어가지 않는가.
Docker는 지정한 Dockerfile을 읽고 그대로 Image를 생성한다.
Dockerfile은 명령어 인자
구조의 연속으로 이루어진다.
예를 들면 이런 식.
# 이것은 주석
FROM {이미지 이름}:{이미지 태그}
COPY {원본 실행 파일 경로}/{원본 실행 파일} {컨테이너 내에서 쓸 파일}
ENTRYPOINT [{명령어}, {옵션}, {컨테이너 내에서 쓸 파일}]
구체적인 예시는 이렇다.
# jar 이미지를 만들어보자
FROM openjdk:11-jre-slim
COPY build/libs/myproject.jar yaaloo.jar
ENTRYPOINT ["java", "-jar", "yaaloo.jar"]
해석하자면 openjdk 11 이미지를 이용해서 빌드 된 jar 파일을 app.jar라는 이름으로 컨테이너 내에 복사한다.
그리고 해당 컨테이너가 실행 시에 java -jar app.jar
명령을 실행시킨다는 소리다.
참고로 저 명령은 터미널에서 jar 파일을 실행시킨다.
Dockerfile 내에 사용할 수 있는 명령어는 그 외에도 더 있다.
여러 명령어들에 대해 조금 더 알아보자.
아래 모든 설명은 Dockerfile reference를 직접 해석해 옮겼으며, 원문을 보고 싶다면 가장 아래 Reference의 주소를 참조하자.
# 3개의 형식이 있다
FROM [--platform=<platform>] <image> [AS <name>]
FROM [--platform=<platform>] <image>[:<tag>] [AS <name>]
FROM [--platform=<platform>] <image>[@<digest>] [AS <name>]
Dockerfile의 시작을 알리는 명령어다.
--platform
인자는 Optional이며 대개 복수의 platform 지정이 필요할 때 사용한다.
예를 들면 linux/arm64
windows/amd64
와 같은 식이다.
AS
역시 생략이 가능하며, 보자마자 알았겠지만 alias로 사용된다.
COPY --from=<name>
과 같은 식으로 사용할 수 있다.
tag
의 기본값은 latest
이다. 태그를 찾을 수 없으면 에러가 발생한다.
Dockerfile은 몇 개의 예외를 제외하면 반드시 FROM
으로 시작해야 한다.
그 예외란 parser directives, comments, 그리고 전역으로 사용된 ARG
이다.
명령어 중에 FROM
이전에 등장할 수 있는 것은 ARG
가 유일하다.
왜냐? FROM
의 인자로 사용이 가능하기 때문이다.
ARG CODE_VERSION=latest
FROM base:${CODE_VERSION}
CMD /code/run-app
위 처럼 사용이 가능한데 사실 별 쓸모없는 예시다. 왜냐면 tag의 기본값이 latest이기 때문.
참고로 FROM
이전에 선언한 ARG
는 전역 변수이기에 FROM
이후에는 사용이 불가하다.
ARG VERSION=latest
FROM busybox:$VERSION
ARG VERSION
이렇게 값 없이 build stage (FROM 이후)에 재선언하면 기본값을 사용할 수 있다는데, 그걸 대체 어디다 쓸까?
ARG <name>[=<default value>]
Dockerfile 내 사용 예시는 바로 위에 있으니 필요 없을 것 같다.
그보다 ARG
로 선언된 변수에는 중요한 특징이 하나 있는데,
docker build --build-arg <varname>=<value>
와 같은 형식으로 외부에서 값을 넣어줄 수 있다는 것이다.
물론 선언되지 않은 변수에 값을 대입하려 하면 경고가 발생한다.
미리 선언된 변수들도 존재한다.
HTTP_PROXY
http_proxy
HTTPS_PROXY
https_proxy
FTP_PROXY
ftp_proxy
NO_PROXY
no_proxy
ALL_PROXY
all_proxy
당연히 --build-arg
옵션을 통해 해당 변수들에 값을 넣어도 경고는 발생하지 않는다.
참고로, 이 삽입 기능을 이용해 api key라던지 하는 걸 입력할 생각이라면 그만두는 게 좋다.
모든 Build-time variable의 값은 docker history
명령을 통해 해당 이미지의 유저들에게 공개된다.
비밀스러운 key를 삽입하는 용도로 Docker 측에서는 RUN --mount=type=secret
을 추천하고 있다.
환경변수를 선언할 수 있다.
FROM busybox
ENV FOO=/bar # ENV FOO /bar 와 같은 형식으로 사용도 가능. 같은 의미다.
WORKDIR ${FOO} # WORKDIR /bar
ADD . $FOO # ADD . /bar
COPY \$FOO /quux # COPY $FOO /quux
FOO
라는 환경변수를 선언해 /bar
를 등록해 사용하고 있다.
${FOO}
= $FOO
이다. 취급은 완전히 동일하다.
그래도 괄호가 있는 쪽에 익숙해지는 게 좋은데, ${foo}_bar
와 같이 공백이 없는 변수명에 함께 사용할 수 있기 때문이다.
그 외에도 기본적인 bash
문법의 적용이 가능하다고 한다.
${variable:-foo}
를 사용하면 변수의 값이 있을 시에는 변수값, 아니면 기본값이 foo
가 된다.
${variable:+foo}
를 사용하면 변수값이 있으면 사용하는 것은 같지만, 기본값이 빈 문자열이 된다.
ENV
로 선언된 환경 변수는 아래 명령어들에서 활용 가능하다.
ADD
COPY
ENV
EXPOSE
FROM
LABEL
STOPSIGNAL
USER
VOLUME
WORKDIR
ONBUILD
그런데 FROM
에서 어떻게 사용이 가능한 지는 나도 모르겠다.
간단한 테스트를 해보았는데,
failed to solve with frontend dockerfile.v0: failed to create LLB definition
에러가 발생하였다. 그리고 동일한 구조에서 ENV
를 ARG
로 변경하였더니 정상 동작하였다.
ENV
로 선언된 환경 변수는 ARG
처럼 build 명령의 옵션을 통해 제공될 수 없으며, 언제나 동일 이름의 ARG
변수를 오버라이드한다.
그러니 둘을 함께 쓰고 싶다면 다음과 같은 형태를 취함이 좋다.
FROM ubuntu
ARG CONT_IMG_VER
ENV CONT_IMG_VER=${CONT_IMG_VER:-v1.0.0}
환경변수는 영속성을 지니기 때문에 원치 않는 부가 효과를 초래할 수 있다.
그렇기 때문에 build stage에서만 적용하고, 최종 이미지에서는 제거하고 싶은 옵션이라면 RUN
명령에 해당 옵션을 포함하던지, ARG
를 통한 선언한 변수를 이용하는 것이 좋다.
ADD [--chown=<user>:<group>] [--checksum=<checksum>] <src>... <dest>
ADD [--chown=<user>:<group>] ["<src>",... "<dest>"]
가장 앞의 --chown
옵션은 Linux 위에서 동작하는 컨테이너 전용 옵션이다. Mac은 모르겠고, Windows에서는 사용이 불가하다.
기본적으로 Add <src>... <dest>
의 형태로 많이 사용한다.
COPY
와 비슷한데, 파일, 폴더, 원격 url 파일을 <src>
에서 이미지 내 파일시스템의 <dest>
경로로 복사한다.
<src>
의 경로를 지정하는데에는 wildcard도 사용이 가능하다.
# 둘 다 가능
ADD hom* /mydir/
ADD hom?.txt /mydir/
또한 <src>
의 경로는 반드시 docker build
명령에서 명시된 경로 (보통 . 찍어서 현재 경로를 root로 잡더라)의 하위 경로여야 한다.
이유는 build 시 명시 된 경로부터 하위 디렉토리까지 docker daemon에 올라간 다음 진행되기 때문.
그러니까 ADD ../something /something
이런 건 안 된다. 없는 걸 어떻게 참조하겠나.
<dest>
는 절대경로이거나, WORKDIR
명령어로 설정된 경로를 기준으로 한 상대경로가 될 수 있다.
ADD test.txt relativeDir/ # <WORKDIR>/relativeDir/
ADD test.txt /absoluteDir/ # /absoluteDir/
그러니까 dest 부분을 상대경로로 하고 싶다면 WORKDIR 설정이 되어있어야 한다.
<src>
가 url일 때 귀찮은 점이 있는데,
우선 해당 url이 인증을 통한 제한이 걸려있다면 RUN wget
RUN curl
혹은 컨테이너 내의 인증이 가능한 어떤 툴을 이용할 필요가 있다.
ADD
는 authentication을 지원하지 않는다.
--checksum
은 원격 파일을 검증하는데 이용된다. 현재는 HTTP sources 밖에 지원하지 않는다고 한다.
그 밖에 ADD
는 .tar .gz 형식의 archive 압축 파일을 자동으로 압축 해제해 복사한다.
COPY [--chown=<user>:<group>] <src>... <dest>
COPY [--chown=<user>:<group>] ["<src>",... "<dest>"]
--chown
옵션의 조건은 ADD
와 같다.
그 외에도 ADD
와 유사한데, 원격 url 파일을 대상으로 삼지는 못 한다. 또한 archive 압축 파일을 자동으로 압축 해제하지도 않는다.
COPY
만의 옵션으로는 --from
이 있다.
FROM
명령어 파트에서 말했던 AS <name>
즉, alias를 통해 해당 build stage에 접근 & 이용할 수 있게 되는 옵션인데, multi stage 빌드와 연관이 깊다.
해당 내용에 대해서는 필자의 배움이 얕아 잘못된 정보를 전달할 수 있기 때문에
여기를 참조하자. 잘 설명해 놓으셨다.
# 2가지 형식이 있다
RUN <command> # shell form. 플랫폼에 shell이 있어야 사용 가능.
RUN ["executable", "param1", "param2"]
docker image 위에서 shell command를 사용할 때 이용한다.
현재 이미지의 가장 위에 새 레이어를 생성해 커맨드를 실행, 그 결과를 커밋해 현 이미지에 적용한다.
결과가 커밋된 이미지는 Dockerfile 내 해당 구문 바로 아래부터 적용이 된다.
RUN
명령의 캐시는 자동으로 사라지지 않고 다음 빌드에도 적용이 된다.
예를 들어 RUN apt-get dist-upgrade -y
의 캐시 같은 것들.
이 캐시를 적용하고 싶지 않다면 docker build --no-cache
와 같은 방법을 사용 할 수 있다.
RUN --mount
를 사용하면 호스트의 파일 시스템 혹은 타 빌드 단계에 바인드 마운트를 형성한다.
이를 통해 각종 비밀스런 정보를 전달 가능하다.
Syntax :
--mount=[type=<TYPE>][,option=<value>[,option=<value>]...]
type에는 bind
cache
secret
ssh
가 있다.
# 3가지 형식이 있다
CMD ["executable","param1","param2"]
CMD ["param1","param2"] # ENTRYPOINT에 정의된 executable의 기본 인자로 사용
CMD command param1 param2 # shell form
RUN
과 비슷해 보이지만 가장 다른 부분은 CMD
는 이미지에게 수행할 명령을 지정한다는 것이다.
또한 build 단계에서 실행되지 않고 컨테이너의 run 단계에서 실행된다는 점도 다르다.
주로 이미지로 빌드된 애플리케이션을 실행할때 쓰인다.
# example
CMD ["java", "-jar", "myapp.jar"]
✨The main purpose of a CMD is to provide defaults for an executing container
Docker docs에 떡하니 나와있는 문장이다.
CMD
명령어로 지정된 인자들은 docker run
명령의 옵션으로 오버라이드 할 수 있다.
docker run -it image:tag echo -e hello
옵션 없이 실행하면 지정된 인자들이 기본값으로 적용된다.
ENTRYPOINT ["executable", "param1", "param2"] # exec form
실행 파일로 실행될 컨테이너를 구성할 수 있다.
shell form 역시 존재하지만, 각종 단점이 덕지덕지 붙어있기에 이용하지 않는 것을 추천한다.
대표적인 단점으로는 docker stop <container>
명령을 못 받는 것이 있다.
CMD
와 비슷한 역할을 하지만 보다 우선순위가 높다.
docker run <image>
에 붙는 모든 인자는 exec form의 ENTRYPOINT
뒤에 붙는다.
또한 CMD
에 지정된 인자들을 오버라이드 한다.
# example
...
ENTRYPOINT ["java"]
CMD ["-jar", "app.jar"]
-----
docker run -d -it myimage:mytag -jar yaaloo.jar
결과는 myimage:mytag의 이미지를 기반으로 컨테이너를 실행, 해당 컨테이너 내에서 java -jar yaaloo.jar -d
가 실행된다.
CMD
의 인자를 오버라이드 하지 않으면 해당 인자가 기본값으로 ENTRYPOINT
의 인자로 제공된다.
docker run --entrypoint
를 통해 ENTRYPOINT
도 오버라이드가 가능하다.
WORKDIR /path/to/workdir
working directory의 경로를 지정하며, 이 경로는 RUN
CMD
ENTRYPOINT
COPY
ADD
등의 명령에서 이용된다.
WORKDIR
명령을 통해 경로를 명시하지 않았어도 자동으로 생성되며 기본 경로는 /
이다.
재밌는 특성이 있는데, 해당 명령을 여러번 사용할 경우 이전 경로가 기준이 되어 상대경로로 동작한다.
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd
-----
결과는 /a/b/c
경로에 ENV
로 선언한 환경 변수를 이용할 수도 있다.
ENV DIRPATH=/path
WORKDIR $DIRPATH/yaaloo
RUN pwd
-----
결과는 /path/yaaloo
이 또한 docker run -w=<workdir>
로 오버라이드 할 수 있다.
VOLUME ["/yaaloo/data"]
지정된 이름의 경로에 volume의 마운트 포인트를 생성한다.
호스트 디렉토리를 컨테이너 내부 파일시스템의 특정 경로에 매핑시킨다...라는 글도 종종 찾아볼 수 있는데, 이는 약간 틀린 말이다.
docker 자체가 호스트 시스템 위에서 돌아가기 때문에 호스트 파일시스템의 일부를 사용한다 라는 측면에서는 맞는 말이긴 하지만, docker가 관리하는 volume 디렉토리를 매핑하는 개념이지 사람이 원하는 특정 디렉토리를 매핑하는 개념이 아니다.
해당 개념은 bind-mount라고 따로 있다.
volume이던 bind-mount이던 호스트 파일시스템과 연결이 되지 않는다면, 컨테이너가 내려갈 때 그 내부에 저장돼 있던 데이터는 모두 소실된다. DB 환경을 담당하고 있던 컨테이너라면? 치명적이다.
그렇기에 데이터에 영속성을 부여하기 위해 사용하는 방법 중 하나가 바로 volume이다.
간단히 컨테이너가 사용하는 데이터 저장소라고 이해하면 될 것 같다.
docker 측에서는 bind-mount 보다는 volume의 사용을 권장하고 있다.
그 이유는 호스트 파일시스템에 기생하는 느낌의 bind-mount와는 달리 volume은 docker가 모든 컨트롤을 가지고 있기 때문이다.
이 때문에 아래와 같은 장점들이 발생한다.
그렇다고 volume이 무조건 좋으냐? 하면 애매한 측면도 있다.
volume이 docker의 입장에서 좋다고 한다면, bind-mount는 직관적인 매핑으로 인해 사람의 측면에서 관리가 용이하기 때문.
자세한 비교는 여기를 살펴보자.
EXPOSE <port> [<port>/<protocol>...] # EXPOSE 8080, EXPOSE 80/udp
컨테이너의 runtime 중에 해당 포트에 대해 listen 상태로 만들어주는 명령어이다.
그런데 이거 생각보다 쓸모가 없다.
80번 포트를 열었다고 하더라도 결국 호스트 측의 포트와도 연결이 되어야 하는데, EXPOSE
로 컨테이너의 포트를 열고 docker run -P
로 자동 연결을 하면 호스트 측은 이상한 포트가 연결이 된다. 그래서 관리가 불편하다.
결국 대부분의 경우 docker run -p <hostport>:<containerport>
로 오버라이드 하는데, 이 옵션을 사용하면 굳이 Dockerfile 내에서 EXPOSE
로 포트를 열지 않아도 그냥 포트가 열린다.
그래서 나는 이 명령은 개인적으로 Dockerfile에는 포함하지 않는 편이다.
Dockerfile 내에서 자주 쓰이는 명령어들에 대해 살펴보았다. 모든 명령어들을 다 다루지는 않았다. USER
LABEL
등 기능에 딱히 크게 영향을 미치지 않는 명령어들은 생략했다.
생각보다 많아서 조금 힘들었는데, 정리를 하면서 몇 가지 배운 게 있어 뿌듯한 것도 같다.
다음번 Docker 관련 글은 docker-compose.yml에 대해 다뤄볼까 한다.
다중 컨테이너를 편하게 다루고 싶다면 필수적인 파일이라고 생각한다.
Dockerfile reference: https://docs.docker.com/engine/reference/builder/#from
Docker v17.06.0-ce에 도입된 multi-stage 빌드 사용하기: https://blog.outsider.ne.kr/1300
Docker 컨테이너에 데이터 저장 (볼륨/바인드 마운트): https://www.daleseo.com/docker-volumes-bind-mounts/