목차
24 장

CI/CD — GitHub Actions + ECR + ECS

OIDC로 액세스 키 없는 GitHub Actions, ECR push, Task Definition 자동 갱신, ECS Service 롤링 배포, deployment circuit breaker와 자동 롤백, CodeDeploy blue/green까지. 한 번의 git push로 끝나는 배포 흐름을 정리합니다.

22장 인프라 골격에서 ECS Service를 손으로 띄우고, 23장 RDS 연동에서 RDS와 마이그레이션을 손으로 돌렸습니다. 본 챕터는 그 모든 손작업을 한 번의 git push로 묶는 흐름입니다.

4부의 세 번째 챕터로, 다룰 내용은 다음과 같습니다.

  • GitHub Actions ↔ AWS 인증을 액세스 키 없이 — OIDC
  • 빌드 → ECR push → Task Definition 갱신 → Service update → 마이그레이션 워크플로우
  • 자동 롤백 — Deployment Circuit Breaker
  • 점진적 배포 — 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 Task Definition ← 새 image 로 새 revision
   ├─ 6) Update Service          ← 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 로 마이그레이션 task definition 새 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 Service Task Definition
      - name: Render service task definition
        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 — 자동 롤백 #

22장 인프라 골격에서 잠깐 다룬 기능입니다. 새 배포가 안 뜨면 자동으로 이전 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 (23장)
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 태그로 배포 #

Task Definition image가 :latest현재 어떤 코드가 돌고 있는지 추적이 불가 합니다. ECR의 image digest (@sha256:...)까지 명시하거나 git SHA 태그를 씁니다.

4) Migration RunTask의 IP 부족 #

Free Tier의 default subnet은 IP가 작아서 운영 task + migration task가 동시에 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분으로 둡니다.

연습문제 #

  1. OIDC가 IAM 사용자 액세스 키 방식보다 안전한 이유를 §“GitHub OIDC"를 근거로 두 가지 적어 보세요. Trust Policy의 sub 패턴을 repo:myorg/blog-api:*로 두면 왜 위험한지도 한 줄로 설명해 보세요.
  2. 마이그레이션 RunTask 단계에서 종료 코드를 검사하지 않으면 어떤 사고가 생기는지 §“함정 2"를 근거로 설명하고, 워크플로우 YAML의 어느 부분이 이 검사를 담당하는지 가리켜 보세요.
  3. ECS Rolling + Circuit Breaker와 CodeDeploy Blue/Green을 각각 어떤 상황에서 고를지 §“어느 쪽을 쓸 것인가” 표를 근거로 정리하세요. 25장 Terraform 입문에서 이 배포 설정을 코드로 옮길 때 deployment_circuit_breaker 블록이 어디에 들어갈지 미리 떠올려 두면 좋습니다.

한 줄 요약: GitHub Actions OIDC는 액세스 키 없이 단명 토큰으로 AWS 역할을 맡고, Trust Policy의 sub 패턴으로 어느 브랜치·환경만 배포할지 제한한다. 워크플로우는 test → build/push → migration RunTask(종료 코드 검사) → Service deploy → wait-stable 순이며, Deployment Circuit Breaker가 실패 시 자동 롤백한다. 앱 시크릿은 Secrets Manager, CI 자체 토큰만 GitHub secrets에 둔다.

다음 챕터 #

배포는 자동화됐습니다. 하지만 인프라 자체 — VPC / SG / RDS / ALB / ECS — 는 여전히 콘솔과 CLI로 손에 두고 있습니다. 새 환경을 한 번 더 똑같이 띄울 수 있겠습니까. 다음 25장 IaC — Terraform 입문에서는 인프라를 코드로 옮깁니다. provider / resource / state의 모양, S3 + DynamoDB backend, 모듈로 dev/prod 분리, 그리고 22장의 인프라를 한 줄씩 코드화하는 흐름까지 다룹니다.

X