GitHub Actions에서 Docker 이미지 빌드 최적화하기(Nextjs)

안광의·2023년 7월 15일
4

개발 지식 저장소

목록 보기
6/7
post-thumbnail

시작하며

현재 다니는 회사 프로젝트들의 특성상 배포 주기가 굉장히 짧기 때문에 CD가 구현되어 있더라도 매번 빌드하고 배포까지 걸리는 시간을 합치면 상당했고 기다리는 시간을 줄이려고 찾아보다가 적용한 방법을 정리해보려고 한다.



배포 과정

1. 깃헙 브랜치 push
2. GitHub Actions 실행
    1) 도커 이미지 빌드후 AWS ECR에 push
    2) AWS CodeDeploy 트리거 실행
3. EC2에서 배포 파이프라인 실행
4. 배포 완료

중간에 몇가지 과정이 생략되어 있지만 추상화하면 위 과정으로 배포가 이루어지고 GitHub Actions에서 이미지가 빌드되는 단계가 가장 많이 시간이 오래 걸린다.



빌드 시간 줄이기

빌드 시간을 줄이기 위해서 GitHub Actions 인스턴스(runner라고 불리는)의 성능을 업그레이드 하는 방법도 있겠지만 추가적인 금액이 들고 현재 배포 관련 코드를 봐도 충분히 시간을 줄일 수 있는 여지가 많다고 생각해서 관련된 문서를 찾아보게 되었다.



캐시

nextjs

처음

두번째


nextjs 프로젝트를 로컬에서 빌드를 하면 처음 빌드할 때보다 두번째 빌드할 때 시간이 주는 것을 알 수 있는데, 빌드 후에 생성된 `.next/cache` 폴더에 다음 빌드 시에 필요한 파일들이 캐시되어 있기 때문이다.

참고 : Deploying: Continuous Integration (CI) Build Caching | Next.js


문제는 GitHub Actions 실행될 때마다 새로운 인스턴스에서 실행되기 때문에 로컬처럼 이전 빌드시에 생성된 .next/cache에 접근할 수 없다. 다행히 깃헙에서 공식으로 지원하는 actions/cache를 사용하면 각각의 인스턴스끼리도 캐시를 공유할 수 있어서 적용해 보기로 했다.

참고 : Cache · Actions · GitHub Marketplace



Docker

도커는 기본적으로 빌드 속도를 높이기 위해 캐시를 사용하는데 각 단계별로 레이어를 만들어두었다가 동일한 명령어가 실행되면 만들어둔 레이어를 재사용하고 변경되는 단계가 있다면 레이어를 다시 만드는 방식으로 캐시를 사용하게 된다. 이 부분도 nextjs와 같은 이유로 이전에 생성된 레이어를 사용하지 못하게 되는데 docker에서 만든 docker/build-push-action를 사용하면 캐시를 공유할 수 있다.

참고 : Docker Setup Buildx · Actions · GitHub Marketplace




github actions

기존 yml 파일

      - name: Cache node modules
        uses: actions/cache@v2
        with:
          path: node_modules
          key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.OS }}-build-
            ${{ runner.OS }}-
 
      - name: Build and push to ECR
        uses: whoan/docker-build-with-cache-action@v5
        with:
          username: "${{ secrets.AWS_ACCESS_KEY_ID }}"
          password: "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
          registry: #ecr registry 주소
          image_name: project
          dockerfile: Dockerfile

      - name: Trigger the CodeDeploy in EC2 instance
        run: aws deploy #CodeDeploy 트리거 실행
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

위 코드는 기존 yml 파일의 일부분인데 actions/cache가 적용되어 있지만 뭔가 잘못되어 있다는 걸 알 수 있다. 이름은 Cache node modules로 npm 패키지 설치 파일들을 캐싱한다고 되어 있지만 현재 패키지 설치는 도커 이미지 빌드 과정에서 이루어지기 때문에 의미가 없다. 아마 이전에는 인스턴스에서 패키지 설치를 하고 파일을 전부 복사해서 도커 이미지를 빌드하는 방식이였던 것으로 추정되는데 그때 남아있는 불필요한 레거시 코드 같았다.

기존에 사용하던 whoan/docker-build-with-cache-action도 캐시를 지원하고 있었지만 작동하지 않았고 Docker에서 공식으로 지원하고 필요한 기능이 있는 docker/setup-buildx-actiondocker/build-push-action으로 변경하기로 했다.


변경 후 yml 파일

      - name: Cache nextjs build
        uses: actions/cache@v3
        id: yarn-cache
        with:
          path: |
            ${{ steps.yarn-cache-dir-path.outputs.dir }}
            ${{ github.workspace }}/.next/cache
          key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
          restore-keys: |
            ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-

      - name: print nextjs cache directory
        continue-on-error: true
        run:
          ls -la ${{ github.workspace }}/.next/cache

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v1

      - name: Build docker image and push to ECR
        uses: docker/build-push-action@v4
        env:
          DOCKER_BUILDKIT: 1
          BUILDKIT_PROGRESS: plain
        with:
          context: .
          file: Dockerfile
          push: true
          tags: #ecr 주소
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Trigger the CodeDeploy in EC2 instance
        run: aws deploy #CodeDeploy 트리거 실행
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Exporting nextjs build cache
        uses: docker/build-push-action@v4
        env:
          DOCKER_BUILDKIT: 1
          BUILDKIT_PROGRESS: plain
        with:
          context: .
          file: Dockerfile
          target: nextjs-cache
          outputs: type=local,dest=.
          push: false
          tags: #ecr 주소
          cache-from: type=gha

      - name: print nextjs cache directory after build
        continue-on-error: true
        run:
          ls -la ${{ github.workspace }}/.next/cache

단계별로 작성된 코드를 살펴보자면,

Cache nextjs build

이 단계가 nextjs의 빌드에 필요한 /.next/cache 폴더를 다른 인스턴스가 생성한 캐시에서 가져오는 부분이다. keyrestore-keys 설정은 공식문서를 참고해서 작성했는데 제대로 actions/cache 문서를 읽어보진 않아서 확실하진 않지만

key는 현재 인스턴의 키값을 설정하는 부분이고
restore-keys는 캐시를 가져올 인스터스의 키값을 설정하는 부분인 것 같다.

-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }} 이 분을 보니 yarn.lock 파일과 js 또는 ts jsx 또는 tsx 파일들을 해싱해서 key를 생성하고 yarn.lock 파일이 동일한 캐시를 불러올 것이다.

참고 : Deploying: Continuous Integration (CI) Build Caching | Next.js


Set up Docker Buildx
도커 이미지 빌드시 GitHub Cache를 생성할 수 있도록 설정하는 단계


Build docker image and push to ECR

cache-from: type=gha
cache-to: type=gha,mode=max

도커 이미지를 생성하고 ECR에 푸시하는 단계로 위처럼 옵션을 설정해줘야 GitHub Cache를 사용할 수 있다.


Exporting nextjs build cache

outputs: type=local,dest=.

.next/cache 폴더를 캐싱해주기 위해 인스턴스에 빌드된 파일을 export 하는 부분이다. export된 파일은 Cache nextjs build 단계에서 설정한 GitHub Cache에 저장되기 때문에 cache-to 옵션은 설정하지 않아도 된다. 그리고 Build docker image and push to ECR 단계에서 생성된 도커 레이어를 그대로 사용하기 때문에 빌드되는데 오래걸리지 않고 CodeDeply 트리거 실행 이후에 실행되기 때문에 배포 시간과는 관계가 없다.

Docker 이미지 빌드

기존 Dockerfile

FROM node:lts-alpine

COPY ./package*.json ./
COPY ./yarn.lock ./

RUN yarn install --frozen-lockfile

COPY ./ ./

RUN yarn build

EXPOSE 3000

CMD [ "pm2-runtime", "start", "npm", "--", "run", "serve" ]

변경 후 Dockerfile

FROM node:lts-alpine AS base

FROM base AS deps

RUN apk add --no-cache libc6-compat

WORKDIR /app

COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \
  else echo "Lockfile not found." && exit 1; \
  fi

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# debug files
RUN ls -la

RUN yarn build

FROM scratch AS nextjs-cache
COPY --from=builder /app/.next/cache ./.next/cache

FROM base AS runner
WORKDIR /app


ENV NODE_ENV production

ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]

도커 이미지 빌드도 최적화 할 수 있는 방법을 찾아보다가 nextjs의 standalone와 Docker의 Multi-stage builds 기능을 활용하여 이미지 자체의 크기를 줄일 수 있는 방법을 찾아서 적용해 보았다.

nextjs를 빌드하면 실제 프로덕션에 필요한 노드 모듈이나 파일들 뿐만아니라 개발 환경에서 필요한 파일들도 포함되는데 standalone 옵션을 사용하면 프로덕션에 필요한 노드 모듈이나 파일을 .next/standalone 폴더에 생성해준다. 단 public 폴더나 .next/static 폴더는 따로 생성해주지 않기 때문에 복사해주어야 한다.

.env.local 문제
작성하지 않았지만 GitHub actions에서는 GitHub secret에서 환경변수를 가져와서 .env.production.local 파일을 만드는 단계가 있어서 기존 .env.production 파일과 생성된 .env.production.local의 변수를 사용해서 빌드를 진행하게 되는데 생성된 이미지를 확인해보니 .next/standalone 폴더에는 .env.production.local 파일을 생성해주지 않았다. 클라이언트에서 사용하는 변수(NEXT_PUBLIC으로 시작하는)는 빌드 시점에 static한 값으로 빌드되기 때문에 문제가 없지만 api 기능처럼 서버사이드에서 사용하는 환경변수를 불러오지 못해서 Dockerfile에 아래 코드를 추가하였다.

COPY --from=builder --chown=nextjs:nodejs /app/.env.production.local ./

참고 : Configuring: Environment Variables | Next.js

//next.config.js
module.exports = {
  output: 'standalone',
}

참고 : next.config.js Options: output | Next.js


Dockerfile에서 AS 키워드를 사용하면 한 파일 내에서 여러 이미지를 사용할 수 있어서 .next/cache 폴더를 export 하기 위한 이미지(nextjs-cache), 빌드를 위한 이미지(builder), 서버 실행을 위한 이미지(runner)를 정의해서 도커 이미지를 최적화하였다.

참고 : Multi-stage builds | Docker Documentation


결과

기존

변경 후

최적화 후 도커 이미지 빌드 후 ECR에 push 하는 시간이 16m 21s에서 12m 26s로 약 4분정도 시간이 줄어들었다.




마치며

부트캠프에서나 개인적으로 프로젝트를 진행할 때 배포는 맡아서 했어서 배포 관련 코드나 AWS에 대해서는 이해하고 있었는데 최적화한 경험은 처음이라 재미있었다. AWS 권한 때문에 EC2나 ECR를 직접 접속할 수가 없어서 생각보다 더 시간이 걸렸고 결국 로컬에서 직접 이미지 빌드해서 문제를 발견했다. 다음에는 권한 달라고 해서 직접 EC2 접속하는게 나을 거 같다.



참고 문서

profile
개발자로 성장하기

0개의 댓글