next.config.mjs
도커 이미지를 빌드하기에 앞서 next.js 빌드 결과물과 관련한 설정을 해줘야 한다.
나는 정적 export를 원하는 상황이 아니었으므로, standalone
옵션으로 설정해주었다.
const nextConfig = {
output: "standalone",
};
export default nextConfig;
Dockerfile
다음으로 Next.js 애플리케이션을 도커 이미지로 빌드하기 위해 Dockerfile
을 작성한다.
Next.js 공식 문서에서 제공하는 예제 코드가 있어서 이를 참고해서 작성했다.
Dockerfile
코드는 다음과 같이 크게 세 가지 스테이지로 이루어져 있다.
이처럼 Multi-stage builds를 사용하는 이유는 최종적으로 만들어지는 도커 이미지의 크기를 최소화하기 위함이다.
실제로 Multi-stage builds를 사용했을 때와 사용하지 않았을 때의 이미지 사이즈를 비교해보면 146.68MB와 2.51GB로 큰 차이가 존재함을 확인할 수 있다.
# 기본 이미지로 node:18-alpine 사용
FROM node:18-alpine AS base
# 1) 의존성 설치 스테이지
FROM base AS deps
# node:18-alpine은 musl libc를 사용하는데, musl libc와 glibc 간 호환성 문제가 있을 수 있기 때문에 libc6-compat 설치
RUN apk add --no-cache libc6-compat
# 작업 디렉토리를 /app으로 설정
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 corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# 2) 빌드 스테이지
FROM base AS builder
# 작업 디렉토리를 /app으로 설정
WORKDIR /app
# node_modules 디렉토리 복사
COPY --from=deps /app/node_modules ./node_modules
# 소스코드 복사
COPY . .
# 소스코드 빌드
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# 3) 프로덕션 이미지 실행 스테이지
FROM base AS runner
# 작업 디렉토리를 /app으로 설정
WORKDIR /app
# NODE_ENV 환경 변수 설정
ENV NODE_ENV production
# 그룹 및 유저 생성
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# 빌드된 자원 복사
COPY --from=builder /app/public ./public
# .next 디렉토리 권한 설정
RUN mkdir .next
RUN chown nextjs:nodejs .next
# 빌드 결과물 복사 및 파일 소유자 설정
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# 사용자 설정
USER nextjs
# 3000번 포트를 외부로 노출
EXPOSE 3000
# 포트 환경 변수 설정
ENV PORT 3000
# 컨테이너가 시작될 떄 실행할 명령어 설정: next.js를 빌드한 결과물 중 하나인 server.js를 실행
CMD HOSTNAME="0.0.0.0" node server.js
.dockerignore
도커 이미지에 필요하지 않은 파일들은 .dockerignore
에 작성해준다.
.github
.git
.idea
.next
.gitignore
node_modules
npm-debug.log
Dockerfile
README.md
앞서 도커 이미지 빌드를 위한 준비를 마쳤으니, 이제 빌드된 도커 이미지를 저장하고 실행할 환경을 마련해야 한다.
나는 Google Cloud의 Artifact Registry에 도커 이미지를 저장하고, Cloud Run을 통해 도커 이미지가 실행되도록 했다.
먼저 도커 이미지를 관리하기 위해 Artifiact Registry에서 저장소를 생성해준다.
관리 리전으로는 asia-northeast1
(도쿄)를 선택했다.
asia-northeast3
(서울) 리전도 있지만, 도쿄에 비해 비용이 비싸고, 도메인 매핑 기능을 사용할 수 없는 등 제약사항이 존재해서 asia-northeast1
(도쿄)를 사용하기로 했다.
GitHub Action에서 Google Cloud에 도커 이미지를 푸시하고 실행하려면 권한 설정이 필요하다.
이를 위해 IAM 및 관리자 > 서비스 계정 페이지에서 서비스 계정을 생성한 뒤 JSON 타입의 비공개 키를 생성하고 다운 받았다.
Cloud Run을 사용하면 Artifact Registry에 있는 도커 이미지를 가져와서 배포할 수 있고, 도메인 맵핑을 간편하게 구현할 수 있어서 좋았다.
우선 서비스를 하나 생성한 뒤, 커스텀 도메인 관리 버튼 > 매핑 추가 버튼 > Cloud Run 도메인 매핑 버튼을 눌러 도메인을 매핑해준다.
매핑 추가 팝업에서 Verify a new domain을 선택하면 Google Search Console 사이트로 연결되는데, 여기에서 도메인 소유권 확인을 마친 다음, 다시 Google Cloud로 돌아와서 내가 사용하고자 하는 도메인을 선택해주면 된다.
이 절차를 마치면 DNS 레코드가 발급되는데, 나는 가비아에서 발급 받은 도메인을 사용하기 때문에 이 레코드들을 가비아 사이트에 작성해주었다.
이제 마지막으로 GitHub의 main 브랜치 코드가 변경되었을 때 자동으로 배포가 되도록 하는 설정만 추가해주면 된다.
앞서 IAM에 발급 받은 JSON key나 region, registry 이름 등은 보안상 공개되지 않는 것이 좋기 때문에, YAML 파일에 직접 작성하지 않고 GitHub Action Secret에 있는 값을 불러와서 사용하도록 했다.
secret 값들은 Settings > Secrets and Variables > Actions > Repository Secrets > New repository secret 경로로 접속해서 등록해주면 된다.
.github/workflows/google-cloud-run-deploy.yml
.github/workflows
에 YAML 파일을 추가함으로써 GitHub Action에서 실행할 작업을 정의할 수 있다.
아래의 코드는 main 브랜치에 코드가 푸시되거나, 풀 리퀘스트가 머지될 때마다 도커 이미지를 빌드해서 Google Cloud의 Artifact Registry에 이미지를 푸시하고, Cloud Run에 배포되도록 하는 코드이다.
주의해야 할 점은 {'on': {pull_request: {branches: [main]}}}
만 작성하면 아직 메인 브랜치에 풀 리퀘스트가 merge되지 않았는데도, Github Action이 실행되므로 {'on': {pull_request: {types: [closed]}}}
를 추가해주고 {jobs: {dockerize-and-deploy: {if: github.event.pull_request.merged == true}}}
조건을 걸어줘야 한다.
name: Deploy to Google Cloud Run
env:
SERVICE_NAME: giftogether
on:
push:
branches:
- main
pull_request:
branches:
- main
types:
- closed
jobs:
dockerize-and-deploy:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Google Cloud Auth
uses: 'google-github-actions/auth@v2'
with:
project_id: ${{ secrets.GCP_PROJECT_ID }}
credentials_json: ${{ secrets.GCP_CREDENTIALS }}
- name: Set up Cloud SDK
uses: 'google-github-actions/setup-gcloud@v2'
- name: Configure Docker
run: |
gcloud auth configure-docker ${{ secrets.GCP_REGION }}-docker.pkg.dev
- name: Build and Push Docker Image
run: |
docker build -t "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }}" .
docker push "${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }}"
- name: Deploy to Cloud Run
run: |
gcloud run deploy ${{ env.SERVICE_NAME }} \
--image=${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }} \
--region=${{ secrets.GCP_REGION }} \
--platform=managed \
--allow-unauthenticated
로컬에서 도커 이미지를 빌드해보면 두 번째 빌드부터는 캐싱된 내용이 있기 때문에 좀 더 빠른 속도로 이미지가 빌드됨을 확인할 수 있다.
하지만 GitHub Actions의 빌드는 매번 새로운 가상 환경에서 실행되기 때문에 도커 캐싱 기능을 사용하려면 별도의 설정을 추가해줘야 한다.
도커 캐싱 기능을 사용하려면, 먼저 docker/setup-buildx-action@v3
를 사용하도록 steps
에 아래 단계를 추가해준다.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
그리고 캐싱된 내용을 사용할 수 있도록 steps
에서 name: Build and Push Docker Image
단계를 아래와 같이 수정해준다.
- name: Build and Push Docker Image
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
그러면 빌드 속도가 얼마나 빨라지는지 확인해보자.
캐싱 기능을 사용하지 않을 때는 재배포를 해도 매번 새로 의존성을 설치하고 빌드하다보니 첫 배포와 비슷하게 4분 54초의 시간이 걸렸다.
하지만 캐싱 기능을 추가하고 새로 배포를 해보면, 2분 20초로 빌드 시간이 확연히 줄어든 것을 확인할 수 있다.
이와 관련해서 좀 더 자세한 내용은 GitHub Actions에서 도커 캐시를 적용해 이미지 빌드하기에서 확인할 수 있다.
만약 NEXT_PUBLIC_
으로 시작하는 런타임 환경변수를 사용하고 있다면 Dockerfile
과 GitHub Action Flow에서 환경변수를 사용할 수 있도록 별도의 설정을 해줘야 한다.
.env
를 GitHub에 올리는 것은 보안상 좋지 않으므로 나는 NEXT_PUBLIC_
환경 변수를 GitHub Action Secret으로 등록해서 도커 이미지를 빌드할 때 가져다 쓸 수 있도록 했다.
우선 GitHub Action Flow의 도커 이미지 빌드를 정의하는 부분에서 아래와 같이 build-args
를 추가해줘야 한다. 이는 도커 이미지를 빌드할 때 환경변수를 사용할 수 있도록 Dockerfile
에 환경 변수를 전달해주는 코드이다.
🔻 google-cloud-run-deploy.yml
- name: Build and Push Docker Image
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
build-args: |
NEXT_PUBLIC_OPENAI_API_KEY=${{ secrets.NEXT_PUBLIC_OPENAI_API_KEY }}
tags: ${{ secrets.GCP_REGION }}-docker.pkg.dev/${{ secrets.GCP_PROJECT_ID }}/${{ secrets.GCP_REGISTRY_NAME }}/${{ secrets.DOCKER_IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
다음으로 Dockerfile의 빌드 스테이지 부분 코드를 아래와 같이 수정해준다.
소스코드 빌드를 실행하기에 앞서 build-args
를 통해 넘겨 받은 값을 환경 변수로 셋팅해주는 코드이다.
🔻 Dockerfile
# 2) 빌드 스테이지
FROM base AS builder
# 작업 디렉토리를 /app으로 설정
WORKDIR /app
# node_modules 디렉토리 복사
COPY --from=deps /app/node_modules ./node_modules
# 소스코드 복사
COPY . .
ARG NEXT_PUBLIC_OPENAI_API_KEY
ENV NEXT_PUBLIC_OPENAI_API_KEY=$NEXT_PUBLIC_OPENAI_API_KEY
# 소스코드 빌드
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi