Next.js로 구성된 서비스가 AWS Amplify로 배포되어 서비스되고 있었는데, AWS ECS로 배포 방식을 변경하면서 Github Action을 사용하여 CI/CD 파이프라인을 구축한 경험을 공유하려 한다.
AWS Amplify
는 프론트엔드 및 모바일 애플리케이션 개발을 위한 통합 서비스로, 기본적으로 서버리스(Serverless) 아키텍처를 지원한다. 단순한 설정만으로 웹 애플리케이션을 Amazon S3
와 Amazon CloudFront
를 활용해 전 세계에 배포할 수 있다.
Amplify의 큰 장점은 코드 몇 줄만으로도 애플리케이션을 배포할 수 있는 간편한 인터페이스를 제공한다는 점이다. 또한, 완전 관리형 서비스이기 때문에 인프라 관리에 대한 복잡함을 덜어주며, 개발자는 애플리케이션 개발에 더 집중할 수 있다. 서버리스 아키텍처 덕분에 트래픽 변화에 따라 자동으로 확장 및 축소가 가능해, 예상치 못한 사용자 증가에도 유연하게 대응할 수 있다.
이러한 장점 덕분에 AWS Amplify는 빠르게 제품을 출시하려는 스타트업에서 많이 사용하는 서비스다. 초기 개발과 배포를 신속하게 진행하고자 하는 환경에서 특히 유용하며, 시간과 자원을 절약할 수 있다.
하지만 Amplify에는 단점도 있다. 서버리스의 특성 상 콜드 스타트 문제가 발생할 수 있는데, 이는 서버리스 함수가 처음 호출될 때 지연이 생기는 현상으로, 사용자 경험에 부정적인 영향을 줄 수 있다. 이 문제 때문에 우리는 인프라를 AWS ECS로 전환하기로 결정했다. ECS로의 전환 이유와 그 과정에 대해서는 뒤에서 자세히 다룰 것이다.
AWS Amplify를 사용하면서 몇 가지 문제를 발견했다. 서비스에 접속하여 첫 요청을 할 때 간헐적으로 웹 서버에서 처음 내려주는 HTML 문서가 지연되는 현상이 발생하고 있었다.
로컬 개발 환경에서는 이 현상이 발생하지 않았지만, Amplify로 배포된 환경에서는 발생했다는 것을 확인했다.
문제가 발생할 때 최소 15초부터 길게는 20초까지 HTML 전송이 지연되었다. 네트워크가 느려서 발생한 문제는 아니었고, 서버 응답을 무려 17.32초나 기다려야 했다. 따라서 Amplify에 문제가 있을 것이라 생각하고 AWS Amplify 대시보드를 살펴보았다.
대시보드에서 지표를 첫 번째 바이트까지의 시간으로 설정해 보니, 예상보다 자주 Latency가 발생하고 있었다. 유저가 서비스에 접속할 때 빈 화면을 15초 이상 보는 경우가 있었으며, 이는 치명적인 사용자 경험 문제를 초래했다. 서비스 유저도 점점 늘고 있었기에, 이 문제는 반드시 해결해야 했다.
원인은 앞서 언급한 대로 Amplify의 Cold Start
문제였다. 콜드 스타트에 대해 최근 AWS Developer Bootcamp에서 학습한 내용을 기반으로 더 자세히 설명하겠다.
또 다른 AWS 서버리스 서비스인 AWS Lambda를 예로 들어 설명해보자.
일반적인 함수 사이클에서 함수가 호출될 때, 함수 실행 환경이 존재하는지 확인한다. 실행 환경이 없으면 컴퓨팅 리소스를 할당하고, 함수 코드를 다운로드하며, 실행 환경을 시작한다. 그 다음, 함수의 INIT 코드를 실행한 후에야 비로소 함수가 실행되고 종료된다. 이 전체 과정을 Cold Start
라고 한다. 만약 이 함수가 직전에 한 번 실행되고 다시 호출된다면, 컴퓨팅 리소스를 할당하고, 함수 코드를 다운로드하며, INIT 코드를 실행하는 과정을 생략하고 바로 함수를 실행할 수 있다. 이를 Warm Start
라고 한다. 쉽게 말해, 서버가 미리 띄워져 있지 않으면 환경을 설정하는 데 시간이 소요되어 첫 요청이 느려지는 현상이 발생하는 것이다.
우리 외에도 이미 Amplify의 콜드 스타트 문제를 겪은 글이 많았다. 위의 링크는 AWS Amplify를 칭찬하면서도 콜드 스타트 문제를 지적한 사례이다:
Amplify의 GitHub Issue 페이지에서도 사용자가 콜드 스타트 문제를 지적해 왔다. 새로 배포하거나 앱이 유휴 상태가 된 후 모든 페이지에서 10~12초의 지연이 발생한다고 한다. 시간이 지나도 문제가 해결되지 않은 것으로 보인다.
새로 배포 시 지연이 발생하는 것을 확인했으므로 잦은 배포 지양
-> 우리 서비스는 고객에게 빠르게 다가가야 하고 빠르게 변경되고 잦은 배포가 있기 때문에 맞지 않았다.
Route 53에서 헬스체크를 사용하여 지속적으로 요청 혹은 람다를 사용해서 지속적으로 요청
-> 단순히 ping을 날렸을 때에는 해결할 수 없다고 다른 유저가 공유했다. 캐시 응답인것 같다.
Amplify 서비스 해제하고 다른 배포 방식 선택
우리는 3번을 선택했고 배포 방식으로 ECS를 선택했다. 우리 회사의 대부분의 서비스가 ECS로 배포되고 있었기 때문이기도 하다.
다른 팀원이 Amplify에서 개선했다고 하니까 빌드 이미지를 최신 버전인 Amazon Linux 2023
으로 바꿔보는것을 권유했지만, 이 버전이 콜드 스타트를 개선한 버전이라고 명확하게 찾을 수 없기도 헀고 섣불리 변경했다가 서비스에 어떤 사이드 이펙트가 터질지 몰라서 ECS로 배포하기로 결정했다.
ECS에 배포하려면 먼저 도커파일을 작성해야 한다. 도커 이미지는 최대한 가볍게 만들어야 한다.
아래와 같은 자료를 참고해서 도커파일을 작성했다.
개발환경에서 사용할 도커파일과, 배포환경에서 사용할 도커파일을 나눠서 작성했다. 개발 버전에서는 HTTPS 인증서 사용을 위해 프록시를 사용하는 과정이 도커파일에 추가 되었다.
// Dockerfile.development
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# 패키지 매니저 통해 dependecies 설치
COPY package.json yarn.lock* package-lock.json* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else echo "Lockfile not found." && exit 1; \
fi
# 소스 코드만 변경되었을 때에는 node_modules 그대로 사용 (캐싱)
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 빌드
RUN \
if [ -f yarn.lock ]; then yarn run build; \
else echo "Lockfile not found." && exit 1; \
fi
# 프로젝트 실행
FROM base AS runner
WORKDIR /app
ENV NODE_ENV development
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# proxy를 위한 PEM 파일 복사
COPY www.***.pem www.***.pem ./
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
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
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD yarn proxy & HOSTNAME="0.0.0.0" node server.js
// Dockerfile.production
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# 패키지 매니저 통해 dependecies 설치
COPY package.json yarn.lock* package-lock.json* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else echo "Lockfile not found." && exit 1; \
fi
# 소스 코드만 변경되었을 때에는 node_modules 그대로 사용 (캐싱)
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 메모리 제한 증가 (메모리 부족 이슈가 있어서 추가)
ENV NODE_OPTIONS="--max-old-space-size=4096"
# 빌드
RUN \
if [ -f yarn.lock ]; then yarn run build; \
else echo "Lockfile not found." && exit 1; \
fi
# 프로젝트 실행
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
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
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js
배포 환경에서 ECS 클러스터가 'x86_64' 아키텍처의 EC2 인스턴스로 구성되어 있어서 배포환경에서 플랫폼을 지정해주었다. 아래와 같은 에러가 떴었다.
"exec /user/local/bin/docker-entrypoint.sh: exec format error"
# dev
docker build -f Dockerfile.development -t community_group:dev .
# prod
docker build --platform linux/amd64 -f Dockerfile.production -t community_group_frontend .
{
"taskDefinitionArn": "***",
"containerDefinitions": [
{
"name": "***",
"image": "***",
"cpu": 0,
"portMappings": [
{
"containerPort": 80,
"hostPort": 80,
"protocol": "tcp"
},
{
"containerPort": 22,
"hostPort": 22,
"protocol": "tcp"
},
{
"containerPort": 443,
"hostPort": 443,
"protocol": "tcp"
}
],
"essential": true,
"environment": [],
"mountPoints": [],
"volumesFrom": [],
"dockerLabels": {
"Privileged": "true"
},
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "***",
"awslogs-region": "ap-northeast-2",
"awslogs-stream-prefix": "ecs"
}
},
"systemControls": []
}
],
"family": "***",
"taskRoleArn": "***",
"executionRoleArn": "***",
"networkMode": "awsvpc",
"revision": 1,
"volumes": [],
"status": "ACTIVE",
"requiresAttributes": [
{
"name": "com.amazonaws.ecs.capability.logging-driver.awslogs"
},
{
"name": "ecs.capability.execution-role-awslogs"
},
{
"name": "com.amazonaws.ecs.capability.ecr-auth"
},
{
"name": "com.amazonaws.ecs.capability.docker-remote-api.1.19"
},
{
"name": "com.amazonaws.ecs.capability.task-iam-role"
},
{
"name": "ecs.capability.execution-role-ecr-pull"
},
{
"name": "com.amazonaws.ecs.capability.docker-remote-api.1.18"
},
{
"name": "ecs.capability.task-eni"
}
],
"placementConstraints": [],
"compatibilities": [
"EC2",
"FARGATE"
],
"requiresCompatibilities": [
"FARGATE"
],
"cpu": "2048",
"memory": "4096",
"runtimePlatform": {
"operatingSystemFamily": "LINUX"
},
"registeredAt": "2024-05-29T07:08:45.574Z",
"registeredBy": "***",
"tags": [
{
"key": "ecs:taskDefinition:createdFrom",
"value": "ecs-console-v2"
},
{
"key": "ecs:taskDefinition:stackId",
"value": "***"
}
]
}
ELB 대상그룹 설정: 대상은 등록하지 않아도 된다. 추후 동적으로 등록된다.
ELB 생성 및 리스너 설정
도메인 생성 (Route 53) 및 ELB 연결
ECS 생성 및 배포 (수동 배포)
name: Deploy to https://xxx.com
# prod 브랜치에 푸쉬 되었을 때 워크플로우가 실행됩니다.
on:
push:
branches:
- prod
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: ***
ECS_SERVICE: ***
ECS_CLUSTER: ***
ECS_TASK_DEFINITION: ./***-tdf.json
CONTAINER_NAME: ***
permissions:
contents: read
actions: read
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
# 워크플로우 시작 슬랙 알림 전송
- name: Notify Slack - Github Action Workflow Started
uses: 8398a7/action-slack@v3
with:
status: custom
fields: repo,commit,author
custom_payload: |
{
"text": "*${{ github.actor }}* triggered a deploy workflow.",
"attachments": [{
"author_name": "Github-Actions",
"fallback": "Deploy workflow started",
"color": "#797b7d",
"fields": [{
"title": "Repository",
"value": "${{ github.repository }}",
"short": true
},
{
"title": "Branch",
"value": "${{ github.ref }}",
"short": true
}]
}]
}
text: |
*${{ github.actor }}* triggered a deploy workflow.
Commit message: ${{ github.event.head_commit.message }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Checkout
uses: actions/checkout@v4
# AWS 로그인 (인증)
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
# S3 버킷으로부터 환경변수 다운
- name: Download Environment File from S3 bucket
run: |
aws s3 cp s3://***-env/***.env .
mv ./***.env ./.env
- name: Build, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
# Build a docker container and
# push it to ECR so that it can
# be deployed to ECS.
docker build --platform linux/amd64 -t ***:latest -f Dockerfile.production .
docker push ***:latest
echo "image=***:latest" >> $GITHUB_OUTPUT
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ${{ env.ECS_TASK_DEFINITION }}
container-name: ${{ env.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ env.ECS_SERVICE }}
cluster: ${{ env.ECS_CLUSTER }}
wait-for-service-stability: true
# 워크플로우가 성공했을 때 슬랙 알림 전송
- name: Notify Slack - Workflow Success
if: ${{ success() }}
uses: 8398a7/action-slack@v3
with:
status: success
fields: repo,message,commit,author,action,eventName,ref,workflow
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
# 워크플로우가 실패했을 때 슬랙 알림 전송
- name: Notify Slack - Workflow Failure
if: ${{ failure() }}
uses: 8398a7/action-slack@v3
with:
status: failure
fields: repo,message,commit,author,action,eventName,ref,workflow
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
prod
브랜치에 푸쉬가 되면 Github Action의 워크플로우가 트리거 되어 자동 배포가 진행된다. 배포 되고 나서 새로운 테스크가 올라갔을 때 바로 라우팅시키다보니 준비되지 않았을 때 트래픽이 컨테이너로 갔다.
ECS 서비스 업데이트 설정에서 상태 검사 유예 기간을 60초로 설정하여 테스크가 올라가고 나서 로드밸런싱을 60초 있다가 트래픽을 보내도록 설정했다.
Github Action은 워크플로우가 병렬적으로 이루어진다고 한다. 만약 한명이 배포 중에 또 배포를 한다면 두 워크플로우는 동시에 진행된다.
Cloudfront로 배포되고 있었다. Cloudfront의 동작을 편집하여 Amplify에서 ECS가 연결된 ALB로 연결해두면서 인프라 전환을 정상적으로 마치게 되었다.
도커 빌드 시간이 생각보다 오래 걸려서 줄이기 위해 공부해야겠다.