3장에선 Dockerfile 스크립트에 몇 가지 인스트럭션을 작성해 애플리케이션을 컨테이너로 실행하는 방법을 배웠다.
애플리케이션을 패키징할 때 필요한 일이 한가지 더 있다. Dockerfile 스크립트 안에서 명령을 실행하는 것이다.
빌드 중에 실행한 명령과 이로 인해 일어난 파일 시스템 변경은 이미지 레이어에 그대로 저장된다고 앞서 학습했다. 이러한 기능을 통해 압축 파일을 압축 해제하거나 윈도 인스톨러를 실행하는 등 다양한 일을 패키징 과정에 포함시킬 수 있다.
빌드 툴체인(링커, 컴파일러, 패키지 관리자, ...)을 한 번에 패키징해서 공유하면 신규로 팀에 참여한 개발자도 이 도구를 설치하는데 짧은 시간만 소요될 것이다. Docker가 이를 가능하게 한다.
1. 개발에 필요한 모든 도구를 배포하는 Dockerfile 스크립트를 작성한 다음 이미지를 만든다.
2. 애플리케이션 패키징을 위한 Dockerfile 스크립트에서 이 이미지를 사용해 소스 코드를 컴파일함으로써 애플리케이션을 패키징한다.
관련 예시의 Dockerfile 스크립트이다.
FROM diamol/base AS build-stage
RUN echo 'Building...' > /build.txt
FROM diamol/base AS test-stage
COPY --from=build-stage /build.txt /build.txt
RUN echo 'Testing...' >> /build.txt
FROM diamol/base
COPY --from=test-stage /build.txt /build.txt
CMD cat /build.txt
해당 스크립트는 빌드가 여러 단계로 나뉘는 멀티 스테이지 빌드를 적용한다.
각 빌드의 단계는 FROM 인스트럭션으로 시작된다. 필요한 경우 빌드 단계에 AS 파라미터를 이용해 이름을 붙일 수도 있다. 즉 마지막 단계에선 이름이 없다. 최종 산출물은 마지막 단계의 내용물을 담은 도커 이미지이다.
각 빌드 단계는 독립적으로 실행되지만, 앞선 단계에서 만들어진 디렉터리나 파일을 복사할 수 있다. COPY 부분을 보면 --from 인자를 사용해 해당 파일이 호스트 컴퓨터의 파일 시스템이 아니라 앞선 빌드 단계의 파일 시스템에 있는 파일임을 알려 준다.
이 중 RUN인스트럭션을 처음 보는데 해당 인스트럭션은 파일을 생성하기 위해 사용했다. RUN 인스트럭션에서 실행할 수 있는 명령에는 특별한 제한이 없지만, FROM 인스트럭션에서 지정한 이미지에서 실행할 수 있어야 한다. 여기서는 diamol/base를 기반 이미지로 지정했으며, 이 이미지가 echo 명령을 포함하고 있기 때문에 이 RUN 인스트럭션이 정상적으로 동작한다.
각 빌드 단계는 서로 격리돼 있다는 것을 이해해야 한다. 즉 이전 빌드 단계에서 명시적으로 복사해 온 거산 포함할 수 있으며, 어느 한 단계에서라도 명령이 실패하면 전체 빌드가 실패한다.
docker image build -t multi-stage .
위의 명령을 입력하면 Dockerfile 스크립트에 작성된 순서를 따라 빌드가 진행되는 것을 볼 수 있다.
각 단계마다 완성 결과를 가져올 수 있다. 즉 애플리케이션의 진정한 이식성을 확보할 수 있다.
이제 도커를 이용해 이 애플리케이션을 빌드하고 실행하는 과정을 체험해보자. 이 애플리케이션을 빌드하고 실행하기 위해 자바 빌드 도구를 따로 설치할 필요 없다. 필요 도구는 모두 도커 이미지를 통해 가져온다.
애플리케이션은 자바 빌드 도구인 메이븐과 OpenJDK를 사용한다. 메이븐은 빌드 절차와 의존 모듈의 압수 방법을 정의하는 도구이고 OpenJDK는 자바 런타임이자 개발자 키트이다. 메이븐은 빌드 절차가 정의된 XML문서를 사용하며, mvn 명령을 실행해 사용한다.
FROM diamol/maven AS builder
WORKDIR /usr/src/iotd
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY . .
RUN mvn package
# app
FROM diamol/openjdk
WORKDIR /app
COPY --from=builder /usr/src/iotd/target/iotd-service-0.1.0.jar .
EXPOSE 80
ENTRYPOINT ["java", "-jar", "/app/iotd-service-0.1.0.jar"]
먼저 FROM 인스트럭션이 여러 개 있는 것으로 보아 멀티 스테이지 빌드가 적용된 스크립트다. 그리고 도커의 레이어 캐시를 활용할 수 있도록 인스트럭션이 배치되었다는 것 또한 알 수 있다.
첫 단계 builder 단계에서 하는 일은 다음과 같다.
해당 단계가 끝나면 컴파일된 애플리케이션이 해당 단계의 파일 시스템에 만들어진다.
중간에 문제가 있다면 전체 빌드도 실패한다.
이제 다음 단계를 보자.
docker image build -t image-of-the-day .
이 애플리케이션은 NASA의 오늘의 천문사진 서비스에서 오늘 자 사진을 받아오는 간단한 REST API다. 앞으로 여러 개의 컨테이너를 실행해 이들이 서로 통신하게 할 것이다. 컨테이너는 컨테이너가 실행될 때 부여되는 가상 네트워크 내 가상 IP를 통해 서로 통신한다. 이 가상 네트워크 역시 명령행 인터페이스를 통해 관리할 수 있다.
docker network create nat
해당 명령어는 nat이라는 이름의 도커 네트워크를 생성하는 것이다.
docker container run --name iotd -d -p 800:80 --network nat image-of-the-day
해당 명령어는 앞서 빌드한 이미지로부터 컨테이너를 실행하되, 80번 포트를 호스트 컴퓨터를 통해 공개하고 nat 네트워크에 컨테이너를 접속하는 것이다.
http://http://localhost:800/image
에 들어가면 JSON포맷으로 볼 수 있다.
여기서 중요한 것은 docker만 설치돼 있다면 어디서든 애플리케이션을 실행할 수 있다는 것이다. 오직 소스코드와 Dockerfile 스크립트만 있으면 된다.
정말 중요한 것은 최종적으로 생성되는 애플리케이션 이미지에 빌드 도구는 포함되지 않는다는 점이다.
자바 애플리케이션은 컴파일을 거쳐야 하기 때문에 빌드 단계에서 소스 코드를 복사한 다음 패키징 과정을 통해 JAR 파일을 생성했었다. JAR 파일은 컴파일된 애플리케이션을 담은 파일로 이 파일이 최종 애플리케이션 이미지에 복사되며 소스 코드는 여기에 포함되지 않는다.
Node.js 애플리케이션은 자바스크립트로 구현된다. 즉 인터프리터형 언어로 별도의 컴파일 절차가 필요 없다. 또한 컨테이너화된 Node.js 애플리케이션을 실행하려면 Node.js 런타임과 소스 코드가 애플리케이션 이미지에 포함돼야 한다. Node.js는 npm이라는 패키지 관리자를 사용해 의존 모듈을 관리한다.
FROM diamol/node AS builder
WORKDIR /src
COPY src/package.json .
RUN npm install
# app
FROM diamol/node
EXPOSE 80
CMD ["node", "server.js"]
WORKDIR /app
COPY --from=builder /src/node_modules/ /app/node_modules/
COPY src/ .
이번에도 애플리케이션을 패키징하고 다른 도구 없이 도커만 설치된 환경에서 애플리케이션을 실행 해보자. 두 이미지 모두 diamol/node를 기반 이미지로 사용한다. 이 임지는 Node.js 런타임과 npm이 설치된 이미지다.
builder 단계에서 애플리케이션의 의존 모듈이 정의된 package.json 파일을 복사한 다음, npm install명령을 실행해 의존 모듈을 내려받는다.
최종 단계에서 공개할 HTTP 포트와 애플리케이션 시작 명령을 지정한다. 최종 단계는 작업 디렉터리를 만들고 호스트 컴퓨터로부터 애플리케이션 아티팩트를 모두 복사해 놓는 것으로 끝난다. src 디렉터리는 애플리케이션의 진입점 역할을 하는 server.js 파일을 비롯해 여러 자바스크립트 파일을 담고 있다.
docker image build -t access-log .
를 실행하면 이미지를 빌드한다.
docker container run --name accesslog -d -p 801:80 --network nat access-log
지금 빌드한 access-log 이미지로 컨테이너를 실행하되, 이 컨테이너를 nat 네트워크에 연결하며 80번 포트를 공개할 때 치는 명령어가 위와 같다.
http://localhost:801/stats
지금까지 남긴 로그 건수를 ㅗ학인할 수 있다.
Go는 어떤 플랫폼(윈도, 리눅스, ...)이든 해당 플랫폼에서 동작하는 바이너리를 컴파일 할 수 있는 언어이다. 애초에 도커는 Go로 구현됐다.
FROM diamol/golang AS builder
COPY main.go .
RUN go build -o /server
# app
FROM diamol/base
ENV IMAGE_API_URL="http://iotd/image" \
ACCESS_API_URL="http://accesslog/access-log"
CMD ["/web/server"]
WORKDIR /web
COPY index.html .
COPY --from=builder /server .
RUN chmod +x server
Go는 네이트비 바이너리로 컴파일된다. 그러므로 각 빌드 단계는 서로 다른 기반 이미지를 사용한다. builder 단계의 기반임지는 Go언어 도구가 설치된 diamol/golang이다. 그 다음 애플리케이션 단계는 최소한의 운영체제 레이어만을 포함하는 이미지를 사용한다. 여기서는 diamol/base 이미지를 사용한다.
그 다음에는 몇가지 설정을 환경변수 형태로 설정하고 컴파일된 바이너리를 실행해 애플리케이션을 시작한다. 애플리케이션 단계는 builder 단계에서 빌드한 웹 서버 바이너리와 이 웹 서버가 제공할 HTML 파일을 복사하는 과정으로 마무리된다. 이후 바이너리 파일이 chomod 명령을 통해 명시적으로 권한을 부여받는다.(윈도우에서는 효과가 없다.)
docker image build -t image-gallery .
여기서 잠깐.
빌드에 사용된 Go 빌드 도구 이미지와 빌드된 Go 애플리케이션 이미지의 크기를 비교해 보자.
docker image ls -f reference=diamol/golang -f reference=image-gallery
이거 왜 하나만 뜨지....
중요한건 image-gallery가 훨씬 작다. 실제 크기가 아니라 논리적 크기인데 이는 이미지 간에 많은 수의 레이어가 공유됨을 알려주는 것이다. 이는 공격이 가능한 부분 자체를 줄일 수 있다는 점에서 큰 장점이다.
이제 해당 애플리케이션을 실행시킬 것인데 지금까지 했던 애플리케이션을 하나로 묶는 역할을 이번 애플리케이션이 한다. 앞서 빌드한 애플리케이션이 제공하는 API를 사용하는 것이 이번 애플리케이션 이기 때문이다.
docker container run -d -p 802:80 --network nat image-gallery
해당 명령어는 Go 애플리케이션 이밎로 컨테너를 실행하되, 컨테이너를 nat 네트워크에 접속하고 80번 포트를 호스트 컴퓨터의 포트를 통해 공개하라는 의미이다.
현재 세 개의 컨테이너에 거쳐 실행되는 분산 애플리케이션이 실행된 것이다. 현재 생각해보면 어떤 언어의 빌드 도구도 설치할 필요가 없었다.
장점을 나열한 섹션이다.
1. 표준화 가능
2. 성능 향상
3. 멀티 스테이지 Dockerfile 스크립트를 통해 빌드 과정을 세밀하게 조정하며 최종 산출물인 이미지를 가능한 작게 유지
이번 문제는 최적화 문제이다. 해당 디렉터리에는 Go로 구현된 간단한 웹 서버 애플리케이션과 이를 Dockerfile 스크립트가 있다. 이 빌드 스크립트는 최적화가 필요하다.
1. 현재의 Dockerfile 스크립트로 이미지를 빌드하라. 이어서 Dockerfile을 최적화한 다음 새로운 이미지를 빌드하라.
2. 현 이미지는 리눅스 환경에서 800MB이다. 최적화된 이미지는 15MB이다.
3. 현재 Dockerfile 스크립트에 포함된 HTML 파일의 내용을 수정하면 일곱 단계의 빌드를 재수행한다.
4. Dockerfile 스크립트를 최적화해서 HTML 파일을 수정하더라도 재수행하는 빌드 단계가 한 단계가 되도록 하라.
자 한번 풀어보자. 먼저 Dockerfile 스크립트로 이미지를 빌드해보자.
이를 통해 832MB임을 확인할 수 있다.
이를 통해 18MB임을 알 수 있다.
자 이제 일반적인 Dockerfile을 살펴보자.
FROM diamol/golang
WORKDIR web
COPY index.html .
COPY main.go .
RUN go build -o /web/server
RUN chmod +x /web/server
CMD ["/web/server"]
ENV USER=sixeyed
EXPOSE 80
너무 모르니까 정답을 보자면
FROM diamol/golang AS builder
COPY main.go .
RUN go build -o /server
RUN chmod +x /server
# app
FROM diamol/base
EXPOSE 80
CMD ["/web/server"]
ENV USER="sixeyed"
WORKDIR web
COPY --from=builder /server .
COPY index.html .
자 이게 왜 더 빠른지 판단을 해보자.
먼저 golang은 build 단계에서 필요하다. 다만 이후에는 필요하지 않으므로 최소한의 운영체제 레이어만을 포함하는 것으로 바꿔줘야 한다. 그렇기에 diamol/base로 바꾼 것이다.
그렇다면 이렇게 바꿔도 되지 않을까?
FROM diamol/golang
COPY main.go .
RUN go build -o /web/server
RUN chmod +x /web/server
# app
FROM diamol/base
EXPOSE 80
CMD ["/web/server"]
ENV USER=sixeyed
WORKDIR web
COPY index.html .
실제로 굉장히 크기가 줄어들었다는 것을 확인 할 수 있다.
하지만 마지막 요건인 HTML 파일을 수정했을 때를 대비해야한다.
또한 build하는 단계에서 /web이라는 디렉토리를 이용하지 않으므로 지워줘야 한다.
FROM diamol/golang AS builder
COPY main.go .
RUN go build -o /server
RUN chmod +x /server
# app
FROM diamol/base
EXPOSE 80
CMD ["/web/server"]
ENV USER=sixeyed
WORKDIR web
COPY --from=builder /server .
COPY index.html .
이를 통해 배울 수 있는 것은 FROM의 값, 즉 사용되는 이미지가 굉장한 영향을 미친다는 것을 확인할 수 있다.