AWS 실전 #3 CI/CD: GitHub Actions + ECR + ECS

8 분 소요

#1에서 ECS 서비스를 손으로 띄우고, #2에서 RDS와 마이그레이션을 손으로 돌렸습니다. 이번 글은 그 모든 손작업을 한 번의 git push로 묶는 흐름입니다.

다룰 내용:

  • GitHub Actions ↔ AWS 인증을 액세스 키 없이, 즉 OIDC
  • 빌드 → ECR push → 작업 정의 갱신 → 서비스 갱신 → 마이그레이션 흐름
  • 자동 롤백. 배포 차단기
  • 점진적 배포. CodeDeploy blue/green / canary 살짝
  • CodePipeline과의 비교. 언제 어느 쪽

큰 그림 #

git push 한 번으로 끝나는 흐름
git push (main)
GitHub Actions
   ├─ 1) Test                    ← pytest / npm test
   ├─ 2) AWS OIDC assume-role   ← 액세스 키 없음
   ├─ 3) Build & push image     ← <git-sha> 태그
   │       ECR: blog-api:abc1234
   ├─ 4) Run migrations         ← ecs run-task (blog-api-migrate)
   │       대기 → 종료 코드 검사
   ├─ 5) Update 작업 정의       ← 새 image로 새 revision
   ├─ 6) Update 서비스           ← rolling 배포
   └─ 7) Wait services-stable    ← 5~10 분
           실패 시 circuit breaker 자동 롤백

이 흐름이 한 번에 돌게 만드는 것이 이 글의 목표입니다.

1) GitHub OIDC: 액세스 키 없는 인증 #

옛날 패턴은 IAM 사용자 → 액세스 키 발급 → GitHub Secrets에 저장. 위험합니다. git history 노출 / 키 회전 의무 / 추적 어려움.

OIDC (OpenID Connect) 패턴은 GitHub가 매 워크플로우 실행마다 **단명 토큰 (15분)**을 발급하고, AWS IAM이 그 토큰을 신뢰하게 만듭니다.

OIDC의 모양
GitHub Actions Job 시작
GitHub OIDC Provider가 JWT 발급
   {sub: "repo:myorg/blog-api:ref:refs/heads/main", aud: "sts.amazonaws.com"}
aws-actions/configure-aws-credentials
   ├─ STS:AssumeRoleWithWebIdentity
   ├─ AWS가 trust policy에서 sub claim 검증
임시 자격증명 (AccessKey / SecretKey / SessionToken)  TTL: 1h

한 번만: IAM OIDC Provider 등록 #

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

Thumbprint는 GitHub OIDC의 SSL 인증서 SHA1. AWS 콘솔 GUI에서 자동 가져옴.

IAM Role: Trust Policy #

github-actions-deploy 역할의 Trust Policy
{
  "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:myorg/blog-api:ref:refs/heads/main"
      }
    }
  }]
}

sub의 패턴이 핵심:

패턴의미
repo:myorg/blog-api:ref:refs/heads/mainmain 브랜치만
repo:myorg/blog-api:ref:refs/tags/*태그 푸시만
repo:myorg/blog-api:environment:productionenvironment 게이트 통과만
repo:myorg/blog-api:*위험. 모든 PR에서도 이 역할 사용 가능

운영 권장: environment 게이트 + main/tag만.

Permissions Policy #

배포에 필요한 액션만:

github-actions-deploy 권한
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ECR",
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:PutImage",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload"
      ],
      "Resource": "*"
    },
    {
      "Sid": "ECS",
      "Effect": "Allow",
      "Action": [
        "ecs:RegisterTaskDefinition",
        "ecs:DescribeTaskDefinition",
        "ecs:UpdateService",
        "ecs:DescribeServices",
        "ecs:RunTask",
        "ecs:DescribeTasks",
        "ecs:ListTasks"
      ],
      "Resource": "*"
    },
    {
      "Sid": "PassRole",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": [
        "arn:aws:iam::123456789012:role/ecsTaskExecutionRole",
        "arn:aws:iam::123456789012:role/blog-api-task-role"
      ]
    }
  ]
}

iam:PassRole이 빠지면 RegisterTaskDefinition이 실패합니다. Task Definition에 IAM 역할을 부여하는 행위는 그 역할을 “전달” 하는 것이라 별도 권한.

2) GitHub Actions 워크플로우 #

.github/workflows/deploy.yml
name: Deploy to ECS

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  id-token: write   # OIDC 토큰 발급 : 필수
  contents: read

env:
  AWS_REGION: ap-northeast-2
  ECR_REPOSITORY: blog-api
  ECS_CLUSTER: blog-cluster
  ECS_SERVICE: blog-api
  TASK_FAMILY: blog-api
  MIGRATE_FAMILY: blog-api-migrate

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.14" }
      - run: pip install -r requirements.txt -r requirements-dev.txt
      - run: pytest -q

  deploy:
    needs: test
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      # 1) AWS OIDC
      - 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 }}

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

      # 3) Build & push
      - name: Build and push
        id: build
        env:
          REGISTRY: ${{ steps.ecr.outputs.registry }}
          TAG: ${{ github.sha }}
        run: |
          docker build --platform=linux/amd64 \
            -t $REGISTRY/$ECR_REPOSITORY:$TAG \
            -t $REGISTRY/$ECR_REPOSITORY:latest .
          docker push $REGISTRY/$ECR_REPOSITORY:$TAG
          docker push $REGISTRY/$ECR_REPOSITORY:latest
          echo "image=$REGISTRY/$ECR_REPOSITORY:$TAG" >> $GITHUB_OUTPUT

      # 4) Migration RunTask
      - name: Run DB migrations
        env:
          IMAGE: ${{ steps.build.outputs.image }}
        run: |
          # 새 image로 마이그레이션 작업 정의 새 revision 등록
          DEF=$(aws ecs describe-task-definition --task-definition $MIGRATE_FAMILY \
            --query 'taskDefinition' --output json)
          NEW=$(echo "$DEF" | jq --arg I "$IMAGE" \
            '.containerDefinitions[0].image=$I |
             {family,taskRoleArn,executionRoleArn,networkMode,containerDefinitions,
              volumes,placementConstraints,requiresCompatibilities,cpu,memory}')
          NEW_ARN=$(aws ecs register-task-definition \
            --cli-input-json "$NEW" \
            --query 'taskDefinition.taskDefinitionArn' --output text)

          # RunTask
          TASK_ARN=$(aws ecs run-task --cluster $ECS_CLUSTER \
            --task-definition $NEW_ARN --launch-type FARGATE \
            --network-configuration "awsvpcConfiguration={
                subnets=[${{ secrets.MIGRATE_SUBNET_ID }}],
                securityGroups=[${{ secrets.FARGATE_SG_ID }}],
                assignPublicIp=ENABLED
              }" \
            --started-by "deploy-${{ github.sha }}" \
            --query 'tasks[0].taskArn' --output text)

          echo "Migration task: $TASK_ARN"
          aws ecs wait tasks-stopped --cluster $ECS_CLUSTER --tasks $TASK_ARN

          # 종료 코드 검사 (0이 아니면 fail)
          EXIT=$(aws ecs describe-tasks --cluster $ECS_CLUSTER --tasks $TASK_ARN \
            --query 'tasks[0].containers[0].exitCode' --output text)
          if [ "$EXIT" != "0" ]; then
            echo "Migration failed (exit=$EXIT)"
            aws logs tail /ecs/blog-api-migrate --since 10m
            exit 1
          fi

      # 5) Update 서비스 정의
      - name: Render service 정의
        id: render
        uses: aws-actions/amazon-ecs-render-task-definition@v1
        with:
          task-definition: ops/task-definition.json
          container-name: api
          image: ${{ steps.build.outputs.image }}

      # 6) Deploy to ECS Service
      - name: Deploy
        uses: aws-actions/amazon-ecs-deploy-task-definition@v2
        with:
          task-definition: ${{ steps.render.outputs.task-definition }}
          service: ${{ env.ECS_SERVICE }}
          cluster: ${{ env.ECS_CLUSTER }}
          wait-for-service-stability: true
          wait-for-minutes: 15

핵심 항목 정리:

항목의미
id-token: writeOIDC 토큰 발급 권한. 빼면 STS AssumeRole 401
environment: productionGitHub environment 게이트. 수동 승인, 시크릿 분리
aws-actions/amazon-ecs-render-task-definition베이스 JSON + 새 image → 새 JSON 생성
aws-actions/amazon-ecs-deploy-task-definitionRegisterTaskDefinition + UpdateService + wait
wait-for-service-stability안정 상태까지 대기. 실패 시 step fail

3) Deployment Circuit Breaker: 자동 롤백 #

#1에서 잠깐 다룬 내용. 새 배포가 안 뜨면 자동으로 이전 task definition으로 되돌리는 기능.

Service에 Circuit Breaker 활성화
aws ecs update-service \
  --cluster blog-cluster --service blog-api \
  --deployment-configuration "
    deploymentCircuitBreaker={enable=true,rollback=true},
    maximumPercent=200,
    minimumHealthyPercent=100"

동작 방식:

  1. 새 task가 healthy 상태에 못 들어가면 ECS가 카운트
  2. 일정 횟수 / 시간 안에 healthy 못 도달 → 배포 실패 판정
  3. rollback=true 면 이전 task definition으로 자동 복귀

GitHub Actions 단계에서는 wait-for-service-stability가 false를 반환하므로 워크플로우도 fail.

직접 롤백 #

자동 롤백이 안 되거나 사후 조사가 필요한 경우:

이전 revision으로 수동 롤백
PREV=$(aws ecs describe-task-definition --task-definition blog-api:42 \
  --query 'taskDefinition.taskDefinitionArn' --output text)

aws ecs update-service \
  --cluster blog-cluster --service blog-api \
  --task-definition $PREV \
  --force-new-deployment

4) 점진적 배포: Canary / Blue-Green #

기본 ECS rolling은 새 task가 healthy가 되면 즉시 트래픽 받습니다. 더 보수적인 모양이 필요하면 CodeDeploy가 나섭니다.

Blue/Green #

Blue/Green의 모양
Blue (현재 운영)  ←──── 100% 트래픽
Green 새 버전 띄우기 (Blue 그대로 살아 있음)
ALB Listener의 Test traffic으로 Green 검증
Listener의 100% 트래픽 → Green
Wait 타이머 (10~60분) : 문제 없으면 Blue 종료
                       문제 있으면 Listener 한 줄로 Blue 복귀 (즉시 롤백)

장점:

  • 즉시 롤백. Listener만 되돌리면 끝
  • 새 버전 검증할 시간을 명시적으로

단점:

  • 두 배 자원 (배포 중)
  • ALB Listener 패턴이 살짝 복잡 (Test listener + Production listener)
  • ECS Rolling보다 셋업 무거움

Canary #

Canary
Linear (10%씩 5분 간격) : 50분 만에 100%
Canary (10% → 5분 대기 → 90% 한 번에)
AllAtOnce (즉시 100% : Blue/Green의 가장 빠른 모양)

CodeDeploy의 deployment configuration 이름:

  • CodeDeployDefault.ECSAllAtOnce
  • CodeDeployDefault.ECSLinear10PercentEvery1Minutes
  • CodeDeployDefault.ECSCanary10Percent5Minutes

어느 경우에 쓸 것인가 #

경우권장
작은 운영 / 사이드 프로젝트ECS Rolling + Circuit Breaker
트래픽 큰 운영, 위험 변경CodeDeploy Blue/Green Linear
ML 추론 / 큰 메모리 모델Blue/Green (warmup 시간 필요)

이 시리즈는 ECS Rolling + Circuit Breaker를 기본으로 가정합니다. Blue/Green은 트래픽이 더 커진 뒤에 검토할 선택지입니다.

5) CodePipeline과의 비교 #

GitHub Actions 외에도 AWS 네이티브 CI/CD가 있습니다.

GitHub ActionsCodePipeline
트리거push / PR / scheduleCodeCommit / GitHub / S3 / ECR push
빌드runners 풀 (호스티드/자가호스팅)CodeBuild
배포직접 호출 또는 actionsCodeDeploy / ECS / CFN / Lambda
가격호스티드 분당 / 자가호스팅 무료파이프라인당 $1/월 + CodeBuild
장점코드와 워크플로우를 한곳에서 관리, 생태계 풍부AWS 네이티브 통합, IAM 일관
단점OIDC 셋업 / 시크릿 별도 관리외부 서비스 통합 약함

GitHub에 코드가 있다면 GitHub Actions가 자연스러움. 회사 보안 정책으로 코드가 CodeCommit이라면 CodePipeline.

6) 환경 분리: dev / staging / prod #

한 워크플로우에 환경별 분기:

.github/workflows/deploy.yml (환경 매트릭스)
on:
  push:
    branches: [main, develop]

jobs:
  deploy:
    strategy:
      matrix:
        include:
          - branch: develop
            env: dev
            cluster: blog-cluster-dev
            role: arn:aws:iam::123456789012:role/github-actions-deploy-dev
          - branch: main
            env: prod
            cluster: blog-cluster-prod
            role: arn:aws:iam::123456789012:role/github-actions-deploy-prod
    if: github.ref == format('refs/heads/{0}', matrix.branch)
    environment: ${{ matrix.env }}

GitHub environments (dev, prod)에 별도 secret / required reviewers / wait timer를 걸 수 있습니다. 운영 environment에는 2 인 승인 + 5분 대기 타이머 걸어서 실수를 방지합니다.

7) 비밀과 변수 다루기 #

어디에 두는가
AWS Account IDGitHub vars
클러스터 이름 / 서비스 이름GitHub vars 또는 워크플로우 env
DB 비밀번호 / API 키AWS Secrets Manager (#2)
GitHub deploy role ARNGitHub vars
Slack webhook (CI 알림)GitHub secrets

원칙: 앱 시크릿은 AWS Secrets Manager, GitHub secrets는 CI 자체에 필요한 토큰만.

함정: CI/CD 흐름에서 자주 만나는 것들 #

1) aws sts get-caller-identity가 401 #

OIDC 셋업 의심. 점검 순서:

  • permissions: id-token: write 누락
  • IAM Role trust policy의 sub 패턴이 실제 워크플로우의 repo:org/repo:ref:...와 정확히 일치하는가
  • OIDC Provider thumbprint가 최신인가
  • Role의 aud 조건이 sts.amazonaws.com 인가

2) Migration이 실패해도 Service가 업데이트됨 #

run-task의 종료 코드를 검사하지 않으면, 마이그레이션이 실패해도 워크플로우가 다음 단계로 갑니다. aws ecs describe-tasksexitCode 확인 + non-zero 면 exit 1.

3) latest 태그로 배포 #

작업 정의 이미지가 :latest현재 어떤 코드가 돌고 있는지 추적할 수 없습니다. ECR의 image digest(@sha256:...)까지 명시하거나 git SHA 태그를 사용합니다.

4) Migration RunTask의 IP 부족 #

Free Tier의 default subnet은 IP가 작아서 운영 작업과 마이그레이션 작업이 동시에 IP를 잡으려다 실패할 수 있습니다. 마이그레이션 전용 SG / subnet을 분리하거나, 배포 윈도에 IP가 있는지 확인합니다.

5) Circuit Breaker가 정상 배포까지 롤백 #

너무 짧은 헬스체크 grace period + 큰 부팅 시간 → 정상 배포가 unhealthy로 오인. health-check-grace-period-seconds를 앱 부팅 + 여유 (예: Django 90초)로.

6) GitHub Actions OIDC의 audience 캐시 #

sub 또는 aud를 바꿨는데 여전히 옛 값이 들어옵니다. 워크플로우 캐시가 아니라 새 job으로 다시 시작 해야 새 토큰이 발급됩니다.

7) ecs-deploy-task-definition의 stuck #

wait-for-service-stability: true로 두고, wait-for-minutes가 너무 짧으면 정상 배포도 실패 처리. 보수적으로 15~20분.

정리 #

이번 글에서 잡은 것:

  • OIDC. IAM OIDC Provider + Trust policy의 sub 패턴, id-token: write 권한
  • 권한 정책. ECR / ECS / iam:PassRole 세 그룹
  • 워크플로우. test → OIDC → ECR push → migration RunTask → 서비스 배포 → wait-stable
  • 배포 차단기. enable=true, rollback=true, maximumPercent / minimumHealthyPercent가 롤링 모양 결정
  • 수동 롤백. 이전 작업 정의 revision으로 update-service
  • Blue/Green & Canary. CodeDeploy의 ECSLinear10PercentEvery1Minutes
  • CodePipeline과의 비교. 코드 위치에 따라 자연스러운 선택
  • 환경 분리. branch matrix + GitHub environments의 승인/대기
  • 시크릿 관리. 앱 시크릿은 AWS Secrets Manager, CI 자체 토큰만 GitHub secrets
  • 함정. OIDC 401, migration 검증 누락, latest 태그, IP 부족, grace period, Stale 토큰, stuck stability wait

다음: IaC #

배포는 자동화됐습니다. 하지만 인프라 자체, 즉 VPC / SG / RDS / ALB / ECS는 여전히 콘솔 / CLI로 손에 두고 있습니다. 새 환경을 한 번 더 띄울 수 있겠습니까? 똑같이?

#4 IaC: Terraform 입문에서는 인프라를 코드로 옮깁니다. provider / resource / state의 모양, S3+DynamoDB backend, 모듈로 dev/prod 분리, 그리고 1 편의 인프라를 한 줄씩 코드화하는 흐름까지.

X