현재 다니는 회사 프로젝트들의 특성상 배포 주기가 굉장히 짧기 때문에 CD가 구현되어 있더라도 매번 빌드하고 배포까지 걸리는 시간을 합치면 상당했고 기다리는 시간을 줄이려고 찾아보다가 적용한 방법을 정리해보려고 한다.
1. 깃헙 브랜치 push
2. GitHub Actions 실행
1) 도커 이미지 빌드후 AWS ECR에 push
2) AWS CodeDeploy 트리거 실행
3. EC2에서 배포 파이프라인 실행
4. 배포 완료
중간에 몇가지 과정이 생략되어 있지만 추상화하면 위 과정으로 배포가 이루어지고 GitHub Actions에서 이미지가 빌드되는 단계가 가장 많이 시간이 오래 걸린다.
빌드 시간을 줄이기 위해서 GitHub Actions 인스턴스(runner라고 불리는)의 성능을 업그레이드 하는 방법도 있겠지만 추가적인 금액이 들고 현재 배포 관련 코드를 봐도 충분히 시간을 줄일 수 있는 여지가 많다고 생각해서 관련된 문서를 찾아보게 되었다.
처음
두번째
참고 : Deploying: Continuous Integration (CI) Build Caching | Next.js
문제는 GitHub Actions 실행될 때마다 새로운 인스턴스에서 실행되기 때문에 로컬처럼 이전 빌드시에 생성된 .next/cache
에 접근할 수 없다. 다행히 깃헙에서 공식으로 지원하는 actions/cache
를 사용하면 각각의 인스턴스끼리도 캐시를 공유할 수 있어서 적용해 보기로 했다.
도커는 기본적으로 빌드 속도를 높이기 위해 캐시를 사용하는데 각 단계별로 레이어를 만들어두었다가 동일한 명령어가 실행되면 만들어둔 레이어를 재사용하고 변경되는 단계가 있다면 레이어를 다시 만드는 방식으로 캐시를 사용하게 된다. 이 부분도 nextjs와 같은 이유로 이전에 생성된 레이어를 사용하지 못하게 되는데 docker에서 만든 docker/build-push-action
를 사용하면 캐시를 공유할 수 있다.
- 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-action
와 docker/build-push-action
으로 변경하기로 했다.
- 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
폴더를 다른 인스턴스가 생성한 캐시에서 가져오는 부분이다. key
와 restore-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 트리거 실행 이후에 실행되기 때문에 배포 시간과는 관계가 없다.
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" ]
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 ./
//next.config.js
module.exports = {
output: 'standalone',
}
Dockerfile에서 AS
키워드를 사용하면 한 파일 내에서 여러 이미지를 사용할 수 있어서 .next/cache
폴더를 export 하기 위한 이미지(nextjs-cache), 빌드를 위한 이미지(builder), 서버 실행을 위한 이미지(runner)를 정의해서 도커 이미지를 최적화하였다.
기존
변경 후
최적화 후 도커 이미지 빌드 후 ECR에 push 하는 시간이 16m 21s에서 12m 26s로 약 4분정도 시간이 줄어들었다.
부트캠프에서나 개인적으로 프로젝트를 진행할 때 배포는 맡아서 했어서 배포 관련 코드나 AWS에 대해서는 이해하고 있었는데 최적화한 경험은 처음이라 재미있었다. AWS 권한 때문에 EC2나 ECR를 직접 접속할 수가 없어서 생각보다 더 시간이 걸렸고 결국 로컬에서 직접 이미지 빌드해서 문제를 발견했다. 다음에는 권한 달라고 해서 직접 EC2 접속하는게 나을 거 같다.
참고 문서