AWS GitHub Actions로 ECR·ECS 자동 배포 파이프라인 구축하는 7단계 실전 가이드

목차

배포 자동화, 왜 GitHub Actions인가

2024 Stack Overflow Developer Survey 기준으로 CI/CD 도구 점유율을 보면 GitHub Actions가 Jenkins를 넘어선 지 꽤 됐다. 나도 이전 회사에서 Jenkins 파이프라인을 3년 넘게 관리했는데, 지금 회사로 옮기고 나서 AWS GitHub Actions CI/CD 배포 구조로 전환하면서 체감이 확 달라졌다.

이전 회사에서는 Jenkins 서버만 4대였다. 빌드 에이전트 관리, 플러그인 충돌, 자바 버전 이슈까지 — 인프라 운영에 공수가 꽤 들어갔다. 근데 지금은 10명도 안 되는 팀이라 그런 여유가 없다. GitHub Actions는 별도 서버 관리 없이 .github/workflows 디렉토리에 YAML 하나 넣으면 바로 돌아간다. 솔직히 처음엔 “이게 되나?” 싶었는데, 프로덕션 배포까지 다 올리고 보니 돌아갈 이유가 없더라.

이 글은 AWS ECR에 Docker 이미지를 빌드해서 푸시하고, ECS Fargate 서비스를 자동으로 업데이트하는 파이프라인을 처음부터 끝까지 구축한 기록이다. OIDC 인증 설정부터 블루/그린 배포 전환까지 다 포함했다.

Jenkins vs GitHub Actions — 배포 도구 비교

먼저 두 도구를 비교한 표부터 보자. 내가 직접 두 환경 모두 써본 기준이라 편향이 있을 수 있다.

항목JenkinsGitHub Actions
서버 관리자체 서버 필수 (EC2 등)GitHub 호스팅 러너 제공
설정 방식Jenkinsfile + 웹 UIYAML 파일 (.github/workflows/)
AWS 인증IAM 키 직접 관리OIDC로 임시 자격증명 발급
비용서버 비용 + 운영 인력Public 레포 무료, Private은 월 2,000분 무료
플러그인 생태계방대하지만 충돌 잦음Marketplace Action 풍부
러닝커브높음낮음
병렬 빌드에이전트 수에 의존매트릭스 전략으로 쉽게 확장

솔직히 Jenkins가 나쁜 도구라는 건 아니다. 복잡한 파이프라인 오케스트레이션이 필요하면 Jenkins가 아직 유리한 면이 있다. 다만 “코드 푸시 → 이미지 빌드 → ECS 배포” 같은 직선형 파이프라인은 GitHub Actions가 훨씬 깔끔하다.

AWS 인프라 사전 준비

본격적으로 파이프라인을 만들기 전에 AWS 쪽에 세팅해야 할 것들이 있다. ECR 레포지토리, ECS 클러스터, 태스크 정의, 서비스 — 이 네 가지는 미리 만들어둬야 한다.

ECR 레포지토리 생성

aws ecr create-repository \
  --repository-name my-app \
  --region ap-northeast-2 \
  --image-scanning-configuration scanOnPush=true \
  --encryption-configuration encryptionType=AES256

scanOnPush=true는 꼭 켜두자. 이미지 푸시할 때마다 취약점 스캔이 자동으로 돌아간다. 이전 회사에서는 이걸 안 켜놨다가 보안팀 감사에 걸린 적이 있다.

ECS 클러스터와 서비스

aws ecs create-cluster \
  --cluster-name my-app-cluster \
  --capacity-providers FARGATE FARGATE_SPOT \
  --default-capacity-provider-strategy \
    capacityProvider=FARGATE,weight=1 \
    capacityProvider=FARGATE_SPOT,weight=3

FARGATE_SPOT을 섞으면 비용이 체감상 60~70% 정도 줄어든다. 다만 스팟은 언제든 회수될 수 있으니 프로덕션 트래픽을 전부 스팟에 태우면 안 된다. 위 설정은 스팟 3 : 온디맨드 1 비율인데, 프로덕션이면 역전시키는 게 안전하다.

태스크 정의는 JSON 파일로 관리하는 게 좋다. 이건 뒤에서 GitHub Actions 워크플로에서 직접 참조할 파일이다.

{
  "family": "my-app-task",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "512",
  "memory": "1024",
  "executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
  "containerDefinitions": [
    {
      "name": "my-app",
      "image": "123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/my-app:latest",
      "portMappings": [
        {
          "containerPort": 8080,
          "protocol": "tcp"
        }
      ],
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/my-app",
          "awslogs-region": "ap-northeast-2",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ]
}

이 파일을 레포 루트에 task-definition.json으로 저장해둔다.

OIDC 인증 설정 — IAM 키 하드코딩은 이제 그만

여기가 제일 중요한 부분이다. 예전에는 AWS Access Key / Secret Key를 GitHub Secrets에 넣고 aws-actions/configure-aws-credentials에 전달하는 방식이 일반적이었다. 근데 이건 키가 유출되면 끝이다. 실제로 GitHub 레포가 public으로 잘못 전환되면서 키가 노출된 사고를 뉴스에서 여러 번 봤다.

OIDC(OpenID Connect) 방식은 GitHub Actions 러너가 AWS STS에 직접 임시 토큰을 요청하는 구조다. 영구 키 자체가 존재하지 않으니 유출될 것도 없다.

IAM Identity Provider 등록

AWS 콘솔에서 IAM → Identity providers → Add provider로 가거나, CLI로 하면 이렇다.

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

thumbprint는 GitHub Actions OIDC 공급자의 인증서 지문이다. GitHub 공식 문서에서 최신 값을 확인하는 게 좋다. AWS가 2023년부터 thumbprint 검증을 자체적으로 하기 시작해서 아무 값이나 넣어도 동작하긴 하는데, 명시적으로 넣어두는 게 맞다.

IAM Role 생성

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:my-org/my-app:ref:refs/heads/main"
        }
      }
    }
  ]
}

Condition 부분이 핵심이다. sub 필드에서 어떤 레포의 어떤 브랜치에서만 이 역할을 사용할 수 있는지 제한한다. 처음에 이걸 *로 열어놨다가 코드 리뷰에서 걸렸다. 보안 관점에서 브랜치까지 특정하는 게 맞다.

이 역할에는 ECR 푸시 + ECS 업데이트 권한을 붙여야 한다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
        "ecr:PutImage",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload"
      ],
      "Resource": "arn:aws:ecs:ap-northeast-2:123456789012:repository/my-app"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecs:UpdateService",
        "ecs:DescribeServices",
        "ecs:DescribeTaskDefinition",
        "ecs:RegisterTaskDefinition",
        "iam:PassRole"
      ],
      "Resource": "*"
    }
  ]
}

iam:PassRole을 빼먹으면 ecs:RegisterTaskDefinition이 실패한다. 이것 때문에 30분 날렸다. 에러 메시지가 “AccessDenied”만 뜨고 어떤 권한이 부족한지 안 알려줘서 한참 헤맸다.

AWS GitHub Actions CI/CD 배포 워크플로 작성

드디어 본론이다. .github/workflows/deploy.yml 파일을 만든다.

name: Deploy to ECS

on:
  push:
    branches: [main]

env:
  AWS_REGION: ap-northeast-2
  ECR_REPOSITORY: my-app
  ECS_SERVICE: my-app-service
  ECS_CLUSTER: my-app-cluster
  CONTAINER_NAME: my-app
  TASK_DEFINITION: task-definition.json

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: ${{ env.AWS_REGION }}

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

      - name: Build, tag, and push image to ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Fill in the new image ID in the task definition
        id: task-def
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ${{ env.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@v2
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true

permissions 블록에 id-token: write를 넣는 걸 잊으면 OIDC 인증이 안 된다. 바로 이거다. 이걸 빼먹고 “왜 인증이 안 되지?” 하면서 Role trust policy만 30분 째 들여다봤던 적이 있다.

wait-for-service-stability: true는 새 태스크가 안정 상태가 될 때까지 워크플로가 기다린다는 뜻이다. 이걸 false로 하면 배포가 성공한 것처럼 보이는데 실제로는 태스크가 크래시 루프에 빠져 있을 수 있다. 무조건 true로 해놓자.

이미지 태그 전략

위 워크플로에서 이미지 태그를 github.sha로 잡았다. latest를 쓰면 안 되는 이유가 있다.

  • ECS가 새 태스크를 띄울 때 이미지 태그가 같으면 풀을 안 하는 경우가 있다
  • 롤백할 때 어떤 커밋의 이미지인지 추적이 안 된다
  • ECR 이미지 라이프사이클 정책과 충돌한다

커밋 SHA를 태그로 쓰면 모든 이미지가 유니크하고, git log와 1:1 매핑이 된다. 개인적으로 이게 제일 깔끔하다.

빌드 최적화 — 캐시와 멀티스테이지

프로덕션에 올리고 나면 빌드 시간이 신경 쓰이기 시작한다. 처음에 우리 앱 이미지 빌드가 8분 넘게 걸렸다. 프론트 빌드가 포함된 모노레포라 그런 것도 있었는데, Docker 레이어 캐시를 적용하고 나서 체감상 절반 이하로 줄었다.

Docker 레이어 캐시 적용

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

      - name: Build and push with cache
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.login-ecr.outputs.registry }}/${{ env.ECR_REPOSITORY }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

docker/build-push-action을 쓰면 GitHub Actions 캐시 백엔드(type=gha)를 바로 쓸 수 있다. 별도 캐시 서버 없이도 레이어 캐시가 동작한다. mode=max는 모든 레이어를 캐시에 올리겠다는 뜻이다.

멀티스테이지 Dockerfile

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER appuser
EXPOSE 8080
CMD ["node", "dist/main.js"]

빌드 스테이지에서 npm ciCOPY . . 전에 실행하는 게 포인트다. package.json이 안 바뀌면 npm ci 레이어가 캐시에서 바로 가져와진다. 이건 기본 중의 기본인데, 의외로 안 하는 프로젝트가 많다.

최종 이미지에 빌드 도구가 안 들어가니 이미지 크기도 줄고, 보안 공격 표면도 줄어든다. USER appuser로 비루트 사용자를 지정하는 것도 보안상 좋다.

트러블슈팅 — 실제로 겪은 문제들

여기서부터는 내가 실제로 겪은 문제 위주로 정리한다. 공식 문서에 없는 내용도 있다.

exec format error 문제

M1 Mac에서 빌드한 이미지를 ECR에 올리고 ECS에서 돌렸더니 exec /usr/local/bin/node: exec format error가 떴다. ARM 이미지를 x86 Fargate에서 돌리려고 해서 생긴 문제다. GitHub Actions 러너는 x86이니까 CI에서 빌드하면 안 생기는데, 로컬에서 수동 푸시할 때 걸렸다.

해결은 간단하다. Buildx로 플랫폼을 명시하면 된다.

docker buildx build --platform linux/amd64 -t my-app:latest .

아니면 Dockerfile 첫 줄에 FROM --platform=linux/amd64 node:20-alpine을 박아넣어도 된다.

태스크가 계속 재시작되는 문제

배포하면 ECS 서비스가 ACTIVE인데 태스크가 STOPPED → 새 태스크 → STOPPED를 반복하는 경우. 이건 대부분 헬스체크 실패다.

ALB 타겟 그룹의 헬스체크 경로가 /인데 앱은 /health에서만 200을 리턴한다든지. 아니면 헬스체크 interval이 5초인데 앱 시작이 10초 걸리는 경우. healthCheckGracePeriodSeconds를 서비스 레벨에서 올려주면 해결된다.

aws ecs update-service \
  --cluster my-app-cluster \
  --service my-app-service \
  --health-check-grace-period-seconds 120

이전 회사에서는 이런 거 하나 바꾸려면 Terraform plan → 승인 → apply 프로세스가 있었는데, 지금은 CLI 한 줄이면 끝난다. 물론 이것도 나중에 IaC로 관리하는 게 맞다. 아직 거기까지는 못 했다.

ECR 로그인 토큰 만료

ECR 로그인 토큰은 12시간짜리다. GitHub Actions에서는 매 실행마다 amazon-ecr-login 액션이 새 토큰을 받으니까 문제 없는데, 로컬에서 docker push할 때 “denied: Your authorization token has expired”가 뜨면 다시 로그인하면 된다.

aws ecr get-login-password --region ap-northeast-2 | \
  docker login --username AWS --password-stdin \
  123456789012.dkr.ecr.ap-northeast-2.amazonaws.com

블루/그린 배포와 롤백 전략

기본 ECS 배포는 롤링 업데이트다. 새 태스크를 띄우고, 헬스체크 통과하면 기존 태스크를 내린다. 대부분의 경우 이걸로 충분하다. 근데 프로덕션에서 문제가 생겼을 때 즉시 롤백하려면 블루/그린이 더 안전하다.

CodeDeploy 연동

ECS 블루/그린 배포는 AWS CodeDeploy를 통해 한다. GitHub Actions 워크플로에서 CodeDeploy를 트리거하는 방식이다.

      - name: Deploy to ECS (Blue/Green)
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.task-def.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true
          codedeploy-appspec: appspec.yaml
          codedeploy-application: my-app-deploy
          codedeploy-deployment-group: my-app-deploy-group

appspec.yaml이 추가됐다. 이 파일은 레포 루트에 이렇게 만든다.

version: 0.0
Resources:
  - TargetService:
      Type: AWS::ECS::Service
      Properties:
        TaskDefinition: 
        LoadBalancerInfo:
          ContainerName: "my-app"
          ContainerPort: 8080

은 배포 시 자동으로 치환된다. CodeDeploy가 새 태스크 세트를 띄우고 테스트 리스너로 트래픽을 보낸 뒤, 문제 없으면 프로덕션 리스너를 전환한다. 이 과정에서 수동 승인 단계를 끼워넣을 수도 있다.

롤백은 CodeDeploy 콘솔에서 버튼 하나로 된다. 이전 태스크 세트가 아직 살아있으니 트래픽만 다시 돌리면 끝이다. 롤링 업데이트에서는 이미 내려간 태스크를 다시 살려야 하니까 시간이 더 걸린다.

다만 블루/그린은 설정할 게 많다. ALB에 테스트 리스너를 추가로 만들어야 하고, CodeDeploy 애플리케이션과 배포 그룹도 세팅해야 한다. 소규모 팀이면 롤링 업데이트로 시작하고, 서비스가 안정화된 후에 전환하는 게 현실적이다.

환경별 배포 분기 — staging과 production

실무에서는 하나의 워크플로로 끝나지 않는다. staging과 production을 분리하고, 환경별로 다른 AWS 리소스에 배포해야 한다.

GitHub Environments 활용

name: Deploy

on:
  push:
    branches: [main, develop]

jobs:
  set-env:
    runs-on: ubuntu-latest
    outputs:
      environment: ${{ steps.set.outputs.environment }}
    steps:
      - id: set
        run: |
          if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
            echo "environment=production" >> $GITHUB_OUTPUT
          else
            echo "environment=staging" >> $GITHUB_OUTPUT
          fi

  deploy:
    needs: set-env
    runs-on: ubuntu-latest
    environment: ${{ needs.set-env.outputs.environment }}
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: ${{ vars.AWS_REGION }}
      # ... 이하 동일

environment 키워드를 쓰면 GitHub Settings → Environments에서 환경별로 변수와 시크릿을 관리할 수 있다. production 환경에는 Required reviewers를 걸어서 수동 승인을 강제할 수도 있다.

vars.AWS_ROLE_ARN처럼 환경 변수로 Role ARN을 분리하면 staging과 production이 서로 다른 AWS 계정을 바라볼 수 있다. 우리 팀은 AWS 계정 자체를 분리하는 건 아직 못 했고, 같은 계정 내에서 리소스 이름으로 구분하고 있다. 이 부분은 더 공부해야 한다.

ECR 이미지 라이프사이클 정책

이미지가 계속 쌓이면 ECR 저장 비용이 나온다. 라이프사이클 정책으로 오래된 이미지를 자동 삭제하자.

aws ecr put-lifecycle-policy \
  --repository-name my-app \
  --lifecycle-policy-text '{
    "rules": [
      {
        "rulePriority": 1,
        "description": "Keep last 20 images",
        "selection": {
          "tagStatus": "any",
          "countType": "imageCountMoreThan",
          "countNumber": 20
        },
        "action": {
          "type": "expire"
        }
      }
    ]
  }'

20개만 유지하면 대부분 충분하다. 롤백이 필요한 상황에서 20번 전 커밋까지 돌아갈 일은 거의 없으니까.

CI/CD 파이프라인 모니터링과 알림

배포 파이프라인이 돌아가는 것만으로는 부족하다. 실패했을 때 알림이 와야 한다.

Slack 알림 연동

      - name: Notify Slack on failure
        if: failure()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: repo,message,commit,author,action
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

if: failure()로 실패할 때만 알림을 보낸다. 성공할 때도 보내면 슬랙이 알림으로 도배된다. 처음에 전부 보내게 해놨다가 팀에서 항의 들어와서 바꿨다. 성공 알림은 주간 리포트로 대체하는 게 낫다.

여담인데, 이전 회사에서는 PagerDuty로 배포 실패 알림을 받았다. 새벽에 배포 실패 알림 때문에 깬 적이 한두 번이 아니다. 지금은 업무 시간에만 배포하도록 workflow_dispatch를 쓰거나, cron 스케줄을 업무 시간으로 제한하는 방식으로 바꿨다.

개인적으로 AWS GitHub Actions CI/CD 배포 파이프라인을 구축하면서 가장 만족스러운 건 모든 설정이 코드로 관리된다는 점이다. 이전 회사에서는 Jenkins 설정이 웹 UI에 있어서 누가 뭘 바꿨는지 추적이 안 됐다. 지금은 워크플로 변경 이력이 전부 git log에 남으니까 트러블슈팅할 때 편하다.

개인적인 결론

6개월 정도 이 구조로 운영한 소감을 써보면 — GitHub Actions + ECS Fargate 조합은 소규모 팀에 거의 최적이다. 서버 관리 부담이 제로에 가깝고, YAML 하나로 전체 파이프라인이 정의된다. AWS GitHub Actions CI/CD 배포 파이프라인을 처음 구축하는 거라면 이 글의 구조를 그대로 따라가도 충분히 동작한다.

다만 아쉬운 점도 있다. GitHub Actions 러너가 미국 리전이라 ECR 푸시가 느리다. 셀프 호스티드 러너를 서울 리전 EC2에 올리면 빨라지겠지만 그러면 또 서버 관리가 생긴다. 그리고 워크플로 YAML이 복잡해지면 가독성이 급격히 떨어진다. 재사용 가능한 워크플로(reusable workflows)나 composite action으로 쪼개는 게 맞는데, 아직 거기까지 정리할 시간을 못 냈다.

이 파이프라인이 안정화되면 다음 단계는 Terraform이나 AWS CDK로 인프라 자체를 코드화하는 것이다. 지금은 CLI와 콘솔로 리소스를 만들고 있는데, 팀원이 더 늘어나면 IaC 없이는 관리가 안 될 거다. 그리고 GitHub Actions의 셀프 호스티드 러너를 ECS 위에 올리는 것도 한번 시도해볼 만하다. actions-runner-controller를 쓰면 쿠버네티스에서 러너를 오토스케일링할 수 있는데, ECS 기반으로도 비슷한 구조를 만들 수 있다고 들었다.