도커빌드 시간을 3분의 1로 줄여보았다. Part 2

📅 2021. 03. 30

이번 글은 도커빌드 시간을 3분의 1로 줄여보았다. Part 1에서 이어지는 두 번째 글입니다. 지난 시간에는 이미지 용량을 줄이는 방법에 대해서 소개했었는데요. 오늘은 하나의 Dockerfile로 관리되던 서비스를 개별 도커파일로 분리하고 ECS에 배포한 과정에 대해서 소개하겠습니다.

Dockerfile 통합 vs 분리?

스마트스터디의 대부분의 프로젝트는 하나의 Dockerfile에 모든 환경을 구성하는 형태로 관리되고 있었습니다. Dockerfile이 하나인 것은 관리가 편하다는 장점이 있지만, 병렬 처리가 불가능하기 때문에 빌드 시간에 병목이 생긴다는 단점이 존재합니다. 예를 들어 프론트 빌드 시간이 6분, 백엔드 빌드 시간이 6분일 때 Dockerfile을 분리하면 6분 후에 빌드가 완료되지만 통합 Dockerfile에선 12분을 기다려야 합니다. 특히 잦은 배포가 필요한 서비스라면 이 시간이 굉장히 길게 느껴질 수 있습니다.

Dockerfile 서비스 별로 분리하기

이해를 돕기 위해 실제 저희 서비스에서 사용했던 통합 Dockerfile 예시를 가져와 보았습니다. 이 Dockerfile은 node 베이스 이미지를 사용하며 Strapi, Next.js, Nginx 서비스를 supervisord를 통해 어플리케이션을 구동하는 이미지입니다. 이 Dockerfile을 서비스별로 각각의 Dockerfile로 분리하는 과정에 대해서 설명드리겠습니다.

FROM ***/smartstudy/node:12.18.0

# 백엔드 환경 구성
COPY    api  /home/service/app/api/
WORKDIR /home/service/app/api/

RUN     yarn install
RUN     yarn build

# 프론트 환경 구성
COPY    frontend  /home/service/app/frontend/
WORKDIR /home/service/app/frontend/

RUN     yarn install
RUN     yarn build

# 실행 환경 구성
WORKDIR /home/service/

COPY    ./deployment/etc/          /etc/
COPY    ./deployment/*.sh          /home/service/
RUN    chmod +x /home/service/*.sh

CMD    ./setup_config.sh && ./run.sh

(보안을 위해 레지스트리 주소 일부를 *로 가렸습니다.)

이 이미지 안에는 node 뿐만 아니라 프로세스 관리도구인 supervisordnginx를 포함하고 있는데, 이미 용량이 1GB가 넘습니다. 지난 글에서 말씀드렸다시피 이미지 크기는 빌드 시간과 직결되기 때문에 베이스 이미지를 가볍게 하는 작업이 시급한 상태입니다. 이 이미지는 Dockerfile을 분리하면서 alpine과 같은 가벼운 이미지로 대체할 예정입니다. 또한 supervisord 는 어플리케이션 구동 시점에서 프론트엔드, 백엔드, nginx를 실행하는 역할을 하는데 Dockerfile을 분리하면 굳이 supervisord 를 설치할 필요가 없습니다. supervisord 를 제거해서 약 1.6MB의 용량을 또 절약할 수 있겠네요!

# 백엔드 환경 구성
COPY    api  /home/service/app/api/
WORKDIR /home/service/app/api/

RUN     yarn install
RUN     yarn build

# 프론트 환경 구성
COPY    frontend  /home/service/app/frontend/
WORKDIR /home/service/app/frontend/

RUN     yarn install
RUN     yarn build

빌드할 때 병목이 발생하는 구간입니다. 백엔드 빌드가 끝날 때 까지 프론트 빌드는 멍 때리는 것밖에 할 일이 없습니다.

"컵라면 7개라서 21분 기다린거야?"

Dockerfile을 분리하면 병렬로 빌드가 가능하니, 이 부분은 자연스럽게 해결됩니다.

Dockerfile 진짜진짜 분리하기

이제 각각의 서비스로 Dockerfile을 분리할 시간입니다. Next.js와 Strapi는 Node 베이스 이미지로, Nginx는 Nginx 공식 베이스 이미지를 사용해서 Dockerfile을 나눠보겠습니다.

# nextjs.Dockerfile
# 프론트 환경 구성
FROM mhart/alpine-node:14

# apt 필수 패키지 설치
# @sentry/webpack-plugin의 source map 업로드가 curl로 업로드함
RUN apk --no-cache add curl

WORKDIR /app
COPY    package.json  .
COPY    yarn.lock  .
RUN     yarn install
COPY    .  .
RUN     yarn build
RUN     yarn deploy
RUN     yarn install --production

# 최종 환경 구성
FROM mhart/alpine-node:14
WORKDIR /app
# 필수요소 복사
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/yarn.lock ./yarn.lock
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/next.config.js ./next.config.js
COPY --from=builder /app/node_modules ./node_modules

EXPOSE 3000
CMD ["yarn", "start"]
# strapi.Dockerfile
# 백엔드 환경 구성
FROM strapi/base:14-alpine AS builder

WORKDIR /app
COPY    .  .
RUN     yarn install
RUN     yarn build

# 최종 환경 구성
FROM mhart/alpine-node:14

WORKDIR /app
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/yarn.lock ./yarn.lock
COPY --from=builder /app/. .

EXPOSE 1337
CMD ["yarn", "start"]
# nginx.Dockerfile
FROM nginx:alpine

EXPOSE 8000
COPY nginx.conf /etc/nginx/nginx.conf
# nginx.conf
user nginx;
worker_processes 1;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
    # multi_accept on;
}

http {
    ##
    # Basic Settings
    ##

    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;
    keepalive_timeout 120;
    keepalive_requests 120;
    types_hash_max_size 2048;

    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    ##
    # Open file Cache Settings
    ##

    open_file_cache max=1000 inactive=20s;
    open_file_cache_valid 30s;
    open_file_cache_min_uses 5;
    open_file_cache_errors off;

    ##
    # Logging Settings
    ##

    access_log off;
    error_log off;

    ##
    # Gzip Settings
    ##

    gzip on;
    gzip_disable "msie6";
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;

    # ... (중간 생략)

    server {
        listen 8000;

        # ... (중간 생략)

        # Health check url
        location /_health.txt {
            access_log off;
            return 200;
        }

        server_name exmaple.co.kr;

        client_max_body_size 256M;

        access_log /var/log/nginx/access.log ltsv;
        error_log /var/log/nginx/error.log;

        location ~ ^/(admin|auth|connect|graphql|content-manager|content-type-builder|upload|uploads|users-permissions|favicon.ico|api|email) {
            proxy_pass http://example-strapi:1337;

            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_cache_bypass $http_upgrade;
        }

        location / {
            proxy_pass http://example-nextjs:3000;

            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_cache_bypass $http_upgrade;
        }

    }

}

베이스 이미지를 가볍게 바꾸고, multi-stage build를 이용해서 결과물의 용량을 최대한 줄였습니다. 이미지 용량을 줄이는 전략에 대해선 이미 이전 글에서 소개했었지요. Dockerfile을 분리하는 작업 자체는 까다로운 부분은 없었습니다. 가장 신경써야 할 부분은 Nginx 파트 쪽이었는데요. 기존에는 ECS의 같은 컨테이너 안에서 통신했지만 Dockerfile을 분리하면 서로 다른 컨테이너에서 서비스가 구동되므로 설정에 신경 써야 합니다.

저는 AWS Blog의 Deploying an NGINX Reverse Proxy Sidecar Container on Amazon ECS 글을 참조해서 서비스를 구성했습니다. 핵심은 nginx에 내장된 reverse-proxy를 이용해서 nginx가 요청을 받게 하고 url에 따라서 프론트 혹은 백엔드와 내부적으로 통신하도록 설정해주는 것입니다.

location / {
    proxy_pass http://example-nextjs:3000;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_cache_bypass $http_upgrade;
}

reverse-proxy 설정의 가장 핵심 부분인데요. /으로 시작하는 요청이 들어오면 example-nextjs 컨테이너의 3000번 포트와 데이터를 주고 받습니다. 그럼 example-nextjs 이름은 어디서 정의하느냐?

ECS 작업 정의 정의 단계에서 컨테이너 이름을 정할 수 있습니다. 이 부분이 반드시 nginx에서 정의한 이름과 동일해야 한다는 점! 중요합니다. 그리고 nginx 컨테이너를 정의하실 때 꼭 링크에 통신할 상대방 컨테이너 이름을 적어줘야 합니다.

AWS에서는 이 링크 설정에 대해서 다음과 같이 설명하고 있습니다.

This task definition causes ECS to start both an NGINX container and an application container on the same instance. Then, the NGINX container is linked to the application container. This allows the NGINX container to send traffic to the application container using the hostname app.

  • nginx 컨테이너와 지정한 컨테이너가 같은 인스턴스에 뜨는 것을 보장함

  • 서로 링크됨 -> nginx 컨테이너에서 트래픽을 주고 받을 수 있음

이렇게 설정을 하면 nginx 컨테이너만이 백엔드와 프론트엔드에 접근 가능한 유일한 컨테이너가 됩니다. 보안적으로 안전해질 뿐만 아니라, 컨테이너가 분리됨으로써 CPU 자원도 개별적으로 사용하기 때문에 성능 면에서도 유리합니다.

결론

Github Actions로 도커 이미지 빌드 결과

서비스 별로 Dockerfile 분리, multi-stage build 및 가벼운 베이스 이미지를 사용한 이미지 용량 최소화, ECS에 reverse-proxy를 사용한 컨테이너 구성을 통해서

  • CI 빌드 시간 20분에서 6분으로 감소
  • 보안성 증대
  • CPU 자원의 효율적인 사용
  • ECS에서 컨테이너가 차지하는 용량(disk space)감소 — 가끔 인스턴스에 용량이 넘쳐서 컨테이너가 안 띄어질 때가 있음

과 같은 성과를 얻을 수 있었습니다. 이제 빌멍(빌드되는거 보면서 멍때리기) 안 해도 되겠네요!

읽어주셔서 감사합니다 🙏

Medium에서 보기