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 (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이 그 토큰을 신뢰하게 만듭니다.
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 등록 #
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1Thumbprint는 GitHub OIDC의 SSL 인증서 SHA1입니다. AWS 콘솔 GUI에서 자동으로 가져옵니다.
IAM Role — 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/main | main 브랜치만 |
repo:myorg/blog-api:ref:refs/tags/* | 태그 푸시만 |
repo:myorg/blog-api:environment:production | environment 게이트 통과만 |
repo:myorg/blog-api:* | 위험 — 모든 PR에서도 이 역할 사용 가능 |
운영 권장은 environment 게이트 + main/tag 만입니다.
Permissions Policy #
배포에 필요한 액션만 부여합니다.
{
"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 워크플로우 #
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: write | OIDC 토큰 발급 권한. 빼면 STS AssumeRole 401 |
environment: production | GitHub environment 게이트 — 수동 승인, 시크릿 분리 |
aws-actions/amazon-ecs-render-task-definition | 베이스 JSON + 새 image → 새 JSON 생성 |
aws-actions/amazon-ecs-deploy-task-definition | RegisterTaskDefinition + UpdateService + wait |
wait-for-service-stability | 안정 상태까지 대기 — 실패 시 step fail |
3) Deployment Circuit Breaker — 자동 롤백 #
22장 인프라 골격에서 잠깐 다룬 기능입니다. 새 배포가 안 뜨면 자동으로 이전 task definition으로 되돌리는 기능입니다.
aws ecs update-service \
--cluster blog-cluster --service blog-api \
--deployment-configuration "
deploymentCircuitBreaker={enable=true,rollback=true},
maximumPercent=200,
minimumHealthyPercent=100"동작 방식은 다음과 같습니다.
- 새 task가 healthy 상태에 못 들어가면 ECS가 카운트합니다.
- 일정 횟수 / 시간 안에 healthy 못 도달하면 배포 실패로 판정합니다.
rollback=true면 이전 task definition으로 자동 복귀합니다.
GitHub Actions 단계에서는 wait-for-service-stability가 false를 반환하므로 워크플로우도 fail 합니다.
직접 롤백 #
자동 롤백이 안 되거나 사후 조사가 필요한 경우입니다.
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-deployment4) 점진적 배포 — Canary / Blue-Green #
기본 ECS rolling은 새 task가 healthy가 되면 즉시 트래픽을 받습니다. 더 보수적인 모양이 필요하면 CodeDeploy가 역할합니다.
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 #
Linear (10%씩 5분 간격) — 50분 만에 100%
Canary (10% → 5분 대기 → 90% 한 번에)
AllAtOnce (즉시 100% — Blue/Green 의 가장 빠른 모양)CodeDeploy의 deployment configuration 이름은 다음과 같습니다.
CodeDeployDefault.ECSAllAtOnceCodeDeployDefault.ECSLinear10PercentEvery1MinutesCodeDeployDefault.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 Actions | CodePipeline | |
|---|---|---|
| 트리거 | push / PR / schedule | CodeCommit / GitHub / S3 / ECR push |
| 빌드 | runners 풀 (호스티드/자가호스팅) | CodeBuild |
| 배포 | 직접 호출 또는 actions | CodeDeploy / ECS / CFN / Lambda |
| 가격 | 호스티드 분당 / 자가호스팅 무료 | 파이프라인당 $1/월 + CodeBuild |
| 장점 | 코드와 워크플로우 한곳, 생태계 풍부 | AWS 네이티브 통합, IAM 일관 |
| 단점 | OIDC 셋업 / 시크릿 별도 관리 | 외부 서비스 통합 약함 |
GitHub에 코드가 있다면 GitHub Actions가 자연스럽습니다. 회사 보안 정책으로 코드가 CodeCommit이라면 CodePipeline입니다.
6) 환경 분리 — dev / staging / prod #
한 워크플로우에 환경별로 분기합니다.
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 ID | GitHub vars |
| 클러스터 이름 / 서비스 이름 | GitHub vars 또는 워크플로우 env |
| DB 비밀번호 / API 키 | AWS Secrets Manager (23장) |
| GitHub deploy role ARN | GitHub 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-tasks의 exitCode를 확인하고 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분으로 둡니다.
연습문제 #
- OIDC가 IAM 사용자 액세스 키 방식보다 안전한 이유를 §“GitHub OIDC"를 근거로 두 가지 적어 보세요. Trust Policy의
sub패턴을repo:myorg/blog-api:*로 두면 왜 위험한지도 한 줄로 설명해 보세요. - 마이그레이션 RunTask 단계에서 종료 코드를 검사하지 않으면 어떤 사고가 생기는지 §“함정 2"를 근거로 설명하고, 워크플로우 YAML의 어느 부분이 이 검사를 담당하는지 가리켜 보세요.
- 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장의 인프라를 한 줄씩 코드화하는 흐름까지 다룹니다.