미소 플랫폼팀에서 일하는 GY라고 합니다.

미소에서는 서버리스 애플리케이션들을 제외한 모든 애플리케이션 배포에 컨테이너를 적극 활용하고 있습니다. 미소 플랫폼팀에서 일하며 도커 빌드를 이용하여 다양한 프로그래밍 언어와 패키지들로 구성된 수많은 애플리케이션들을 배포하는 과정에서 하나 둘 쌓인 팁들을 공유해보고자 합니다.

이미지 사이즈 줄이기

배포가 완료된 컨테이너에서 이미지 사이즈가 퍼포먼스에 미치는 영향은 거의 없습니다. 이미지 사이즈 줄이는 일에 너무 연연해 할 필요는 없다는 말이죠. 하지만, 개발 중에 잦은 배포가 필요한 스테이징 서버나 CI 서버에서 빌드가 수행될 때 쌓이는 수많은 이미지들이 디스크 공간을 잠식하는 문제들이 생길 수 있고, 컨테이너 레지스트리에서 이미지를 당겨올 때 느린 전송 속도도 신경 쓰일 때가 있습니다.

최적화된 베이스 이미지

이미지 사이즈를 줄이기 위해서는 빌드할 애플리케이션에 최적화된 베이스 이미지를 선택하는 것이 제일 중요한 일일 것 같습니다. 이미지 사이즈에 중점을 두는 경우, 흔히 도커의 기본이 되는 알파인 리눅스 이미지가 추천됩니다. 알파인 리눅스는 디스크 공간에 최적화되어 있는 리눅스 배포판으로 APK라는 패키지 시스템을 이용하여 다양한 라이브러리와 툴들을 패키지로 설치할 수 있습니다. 도커 허브의 공식 이미지들 중 alpine 태그가 붙은 이미지를 베이스로 지정하는 순간, 알파인 리눅스의 패키지 시스템으로 원하는 이미지를 빌드할 수 있습니다. 단, 한 가지 유의할 점은 알파인 리눅스에서 사용하는 기본 런타임인 musl이 가장 널리 사용되는 GNU C 런타임과 완전히 다른 방식으로 구현되었기에 미묘한 차이가 발생할 있다는 점입니다. 미소에서는 아직 사용상에 특이사항을 발견하지 못했습니다.

불필요한 의존성 패키지 제거

공개된 Dockerfile들을 보다 보면, 패키지를 설치할 때 아래와 같은 명령(instruction)행들을 자주 보게 됩니다.

RUN apk add —no-cache —virtual .build-deps make gcc ...

이 명령은 주어진 패키지 목록을 설치할 때 .build-deps라는 이름의 로컬 패키지를 가상으로 만들어 설치할 수 있게 해줍니다. gccmake 등과 같이 빌드 과정에 필요한 개발툴들은 빌드가 끝나면 더 이상 존재할 필요가 없습니다. 위에서와 같은 명령으로 설치된 패키지들은 따로 열거하여 지정하지 않아도 다음과 같이 한 번에 지울 수 있습니다.

RUN apk del .build-deps
알파인 리눅스가 아니더라도, 빌드 과정에서 생성되는 파일들을 지워주면 전체 이미지 사이즈 감소에 큰 도움이 됩니다.

멀티 스테이지 빌드

앞서 불필요한 의존성 패키지 제거에서는 불필요한 패키지를 직접 찾아서 제거함으로써 런타임 이미지의 사이즈를 줄일 수 있었는데요. 이번에는 아예 빌드용 베이스 이미지와 실제 런타임에 사용될 베이스 이미지를 분리하는 방법을 소개해드리려 합니다. 빌드 환경과 런타임 환경은 매우 큰 차이가 있습니다. 별다른 의존성 없이 실행 파일만으로 작동하는 Go 애플리케이션을 빌드한다고 가정하면, 어떤 런타임 환경을 선택하느냐에 따라 큰 차이가 있습니다. 다음 결과를 비교해볼까요?
$ docker image ls --filter reference=alpine; \
  docker image ls --filter reference=golang:alpine | tail -n1
REPOSITORY   TAG       IMAGE ID       CREATED      SIZE
alpine       latest    d7d3d98c851f   8 days ago   5.53MB
golang       alpine    759ab1463be2   8 days ago   328MB

차이가 꽤 크죠? Go 언어용 빌드 환경을 제공하기 위해 필요한 의존성이 그만큼 크기 때문이죠. 그런데 최종 결과물이 다른 어떤 것에도 의존하지 않는 것이 확실하다면, 빌드 스테이지와 런타임 스테이지에 사용될 베이스 이미지를 다르게 선택하는 것만으로도 이미지 사이즈에서 큰 이득을 볼 수 있습니다.

FROM golang AS builder
...
RUN go build -o /go/myapp src/*.go

FROM alpine
COPY --from=builder /go/myapp /app
CMD ["/go/myapp"]

여러분이 런타임에 필요한 애플리케이션의 의존성을 정확하게 알고 있다면, 멀티 스테이지 빌드는 매우 유용하게 쓰일 수 있습니다.

한 줄 세우기

도커 허브나 깃허브 등을 통해 공개된 많은 Dockerfile에서 아래와 같은 패턴으로 실행되는 명령들을 보셨을 거에요.

RUN set -ex \
  && cd /tmp \
  && wget -q -O tmp.tar.gz https://github.com/ledgetech/lua-resty-http/archive/v${LUA_RESTY_HTTP_VERSION}.tar.gz \
  && tar xvfz tmp.tar.gz \
  && mv lua-resty-http-${LUA_RESTY_HTTP_VERSION}/lib/resty/*.lua ${OPENRESTY_PATH}/lualib/resty/ \
  && rm -rf tmp.* lua-resty-http-* \
  && wget -q -O envsubst https://github.com/a8m/envsubst/releases/download/v${ENVSUBST_VERSION}/envsubst-`uname -s`-`uname -m` \
  && mv envsubst /usr/local/bin/ \
  && chmod +x /entrypoint /usr/local/bin/envsubst

여러가지 작업들을 굳이 한 줄의 쉘 명령으로 만들려고 노력한 흔적이 보이는데요. 이렇게 하는 이유는 최종 이미지를 구성하는 레이어의 수를 줄일 수 있기 때문인데, 실제 레이어의 수가 줄어들수록 이미지 사이즈 감축에 도움이 됩니다. 파일시스템에 쓰기 작업이 있는 명령들의 경우 특히 더 도움이 된답니다. 이에 관해서는 레드햇 개발자님이 쓰신 글을 링크할께요. 레이어에 대해서는 이어지는 문단에서 좀더 자세하게 설명할께요.

애플리케이션별 최적화

도커와는 상관없는 부분인데, 애플리케이션별로 최종 빌드의 사이즈를 줄이는 각각의 팁들이 있습니다. 미소에서 많이 사용하는 Next JS 앱의 경우, 버전 12에서 추가된 Output File Tracing 기능을 이용하면 트리셰이킹의 활성화와 더불어 node_modules를 통째로 번들링할 수 있어 상당한 사이즈 감소 효과를 얻을 수 있었습니다. Node JS로 만든 서버의 경우, @vercel/ncc 컴파일러로 비슷한 효과를 얻을 수 있었구요. 쓰고보니, 미소에서는 이래저래 Vercel산 제품을 많이 쓰고 있네요.

빌드 타임 단축하기

이미지 사이즈는 CI 빌드 및 배포에만 영향을 주는 반면, 도커 빌드 실행이 오래 걸리는 문제는 더 자주 반복되는 문제로 개발 시간을 잡아먹는 성가신 문제가 될 수 있습니다.

.dockerignore

도커는 빌드를 실행하기 전에 파일시스템의 변경사항을 확인하기 위해 현재 작업 중인 디렉토리를 기준으로 모든 파일 목록을 점검하고 일일이 변경사항을 비교해야 합니다. 도커가 왜 이 작업을 수행해야 하는지는 다음 문단의 내용을 이해하면 자연스럽게 설명됩니다. 아무튼, 이때 node_modules, .git 같은 디렉토리는 도커 빌드에는 사용되지 않으면서 불필요하게 많은 연산 자원을 소모하게 만듭니다. Git의 .gitignore 파일과 유사한 메커니즘을 제공하는 .dockerignore 파일을 이용하면 빌드에서 제외할 파일 목록을 지정할 수 있습니다. 잘 알려진 파일 외에도 현재 작업 디렉토리에 빌드와 상관없는 파일이 너무 많은 경우, 도커 빌드는 쓸모없는 일에 시간을 낭비하고 있으므로, 빌드 제외 규칙을 잘 설정하는 것이 중요합니다.

레이어 캐시

레이어 캐시 활용을 극대화하기 위해서는 먼저 빌드 과정 전체에 대해 정확하게 꿰고 있어야 합니다. 아래와 같이 Node JS 앱을 빌드하는 Dockerfile의 예를 보도록 하죠.

FROM node:alpine
ADD . .
RUN yarn install

통상 yarn install은 원격 패키지 저장소에서 파일들을 다운로드하여 로컬에 저장하는데, 이는 많은 시간을 소모합니다. 의존 패키지가 많을수록 더 많은 시간을 소모하게 되죠. 그런데 위 Dockerfile에 따르면, 파일시스템 상에 아주 작은 변경이 생겨도 yarn install이 수행될 수밖에 없습니다. 비효율의 극치입니다. 소스코드가 변경될 때마다 패키지 설치를 진행하니까요. 다음 예와 비교해보록 하죠.

FROM node:alpine
ADD package.json yarn.lock ./
RUN yarn install
ADD src src

순서가 살짝 변경되었고, 파일을 추가하는 방식도 바뀌었습니다. 무슨 차이가 있을까요? RUN yarn install 행은 오직 package.jsonyarn.lock 파일에 대해서만 영향을 받습니다. 이 두 파일이 변경되면 명령이 실행됩니다. 그외 다른 파일의 변경이 있을 경우, 도커의 빌드 컨텍스트가 파일의 변경 사항을 알고 기존에 만들어진 레이어 캐시를 재사용하게 되므로 빠르게 넘어갈 수 있습니다. 이제 여러분의 src 디렉토리에 있는 파일이 변경되어도 문제없습니다. 내친 김에 이 레이어란 놈에 대해 좀더 알아볼까요? 라고 이어가려 했으나 그렇게 되면 이 포스트를 완성하지 못할 것 같네요. 😓 짧게 요약하면, Dockerfile 각각의 행은 파일시스템의 상태와 어우러져 스냅샷 레이어를 만들어내고, 이 레이어들이 누적되어 최종 이미지를 만들어내게 됩니다. 도커로 빌드한 이미지는 Dockerfile을 구성하는 행 만큼의 레이어로 구성되어 있다고 이해해도 틀린 말은 아닌 셈입니다.

불필요한 조각 파일 안 만들기

도커 이미지를 구성하기 위해 자잘한 조각 파일들이 필요한 경우가 많은데요. 그런 파일들 때문에 파일시스템 레이아웃이 불필요하게 복잡해지는 경우들이 종종 생기곤 합니다. 자잘한 조각 파일들은 굳이 따로 파일로 저장하지 않고 HEREDOC을 지원하는 Dockerfile 프론트엔드를 활성화시키면 좀더 편하게 작업할 수 있답니다. 아래와 같이 좀 복잡한 HEREDOC도 사용할 수 있는데요.

# syntax = docker/dockerfile:1.4
FROM alpine
RUN <<FILE1 cat > file1 && <<FILE2 cat > file2
I am
first
FILE1
I am
second
FILE2

이런 기능이 생겨서 얼마나 유용한지 모릅니다.

마치며

도움이 되셨나요? 미소에서는 빌드/배포 파이프라인 뿐만 아니라 많은 반복적인 작업들을 수행할 때도 도커 컨테이너를 활용해서 out-of-box 실행이 가능한 환경을 만들어나가고 있습니다. 미소 플랫폼팀에서는 도커와 Kubernetes 그리고 Serverless 같은 기술을 활용해서 서비스 인프라를 개선하는 작업을 하고 있습니다. 앞으로도 도움 되는 팁으로 찾아 뵙겠습니다. 계속 관심 가져 주세요!