지난 글에서는 도메인을 연결하고 Nginx와 인증서로 HTTPS를 적용했다(4단계). 이번 글은 배포 계획의 마지막 자동화 단계인 CI/CD(5단계, 6단계)를 다룬다. 앞쪽 CI는 대부분 팀원이 만든 것이라 우리가 한 일(브랜치 보호, 검증)을 중심으로 짧게 정리하고, 뒤쪽 CD를 길게 다룬다.
main에 병합하면 GitHub Actions가 테스트하고, 이미지를 만들어 ECR에 올리고, EC2에서deploy.sh를 실행하도록 만드는 과정이다. 이 작업에 나오는 개념(워크플로, OIDC, IAM 역할, 신뢰 정책, Environment 등)은 모두 처음 겪는 것이라, 각 개념이 무엇이고 왜 필요한지부터 풀어서 쓰고, 나중에 그대로 따라 할 수 있도록 콘솔 절차, JSON, 워크플로 전체와 한 줄씩의 해설까지 남긴다.상태 메모: 이 글을 쓰는 시점에 CD 워크플로는 작성과 정적 점검까지 마쳤고, 실제 배포 실행은
main병합 이후에 한다. 그래서 7장은 "실행해서 확인할 것과 예상되는 실패"를 정리해 두었고, 실행 결과는 확인되는 대로 이 글에 덧붙인다.
이 글(과 저장소)은 공개라서 계정 ID, 역할 ARN, 인스턴스 ID, 도메인은
<계정ID>,<인스턴스 ID>,<도메인>같은 자리표시자로 적는다. 실제 값은 저장소 밖에서 관리한다.
| 용어 | 하는 일 |
|---|---|
| CI (Continuous Integration, 지속적 통합) | 코드가 합쳐지기 전에 자동으로 빌드·테스트·검사를 돌려, 깨진 코드가 들어오지 못하게 막는다 |
| CD (Continuous Delivery/Deployment, 지속적 배포) | 합쳐진 코드를 서버에 자동으로 내보낸다 |
CI는 이 코드를 받아도 안전한지, CD는 받은 코드를 어떻게 안전하게 서버에 올릴지를 맡는다. 손으로 하던 일을 사람이 아니라 정해진 절차(스크립트)가 대신하게 만드는 것이 공통점이다. 사람이 하면 빼먹거나 순서를 틀릴 수 있는 작업을, 매번 정해진 대로 일관되게 처리할 수 있다.
개발자
│ PR 생성
▼
[CI: PR 단계] 코드 스타일 검사 / 전체 테스트 / 식별 정보 검사
│ 모두 통과 (브랜치 보호 규칙)
▼
main 병합
│ push 이벤트
▼
[CD: 배포] 테스트 → (승인) → 이미지 빌드 → ECR 푸시 → EC2에서 deploy.sh 실행
│
교체 → 헬스체크 → 실패 시 이전 태그로 롤백
| 단계 | 내용 | 누가 |
|---|---|---|
| 5단계 | CI: 빌드·테스트 워크플로, 브랜치 보호 | 워크플로는 대부분 팀원, 브랜치 보호와 검증은 이번 작업 |
| 6단계 | CD: OIDC, IAM 역할, production 환경, deploy.yml | 이번 작업 전부 |
CI와 CD는 모두 GitHub Actions 위에서 돈다. 용어를 먼저 정리한다.
| 용어 | 설명 |
|---|---|
| GitHub Actions | GitHub이 제공하는 자동 실행 서비스. 저장소에서 어떤 일이 생기면(이벤트) 정해 둔 작업을 GitHub의 서버에서 실행해 준다 |
| 워크플로(workflow) | 자동화 한 건을 정의한 파일. .github/workflows/ 폴더의 YAML 파일 하나가 워크플로 하나다 |
| 이벤트(트리거) | 워크플로를 시작시키는 사건. PR 생성(pull_request), 브랜치에 푸시(push), 사람이 버튼으로 실행(workflow_dispatch) 등 |
| 잡(job) | 워크플로 안의 작업 묶음. 잡마다 새 가상 머신에서 따로 실행되고, 기본은 서로 병렬이다. needs:로 순서를 건다 |
| 스텝(step) | 잡 안의 한 단계. 셸 명령(run:) 하나 또는 남이 만든 재사용 부품(uses:) 하나 |
| 러너(runner) | 잡을 실제로 실행하는 가상 머신. 우리는 GitHub이 빌려주는 ubuntu-latest를 쓴다(Docker와 Java 설치 가능) |
| 액션(action) | 재사용 가능한 스텝 부품. actions/checkout@v4(코드 내려받기), aws-actions/configure-aws-credentials(AWS 인증) 등 |
| 시크릿(secret) | 워크플로에서만 읽을 수 있게 GitHub에 저장해 둔 값. 로그에 찍혀도 ***로 가려진다 |
| Environment(환경) | 배포 대상(production 등)을 GitHub에서 따로 관리하는 단위. 승인자, 허용 브랜치, 전용 시크릿을 붙일 수 있다 |
| 파일 | 이름(잡 이름) | 역할 | 트리거 |
|---|---|---|---|
code-style.yml | Code style (Checkstyle) | 코딩 규칙 검사 | PR, main 푸시 |
test.yml | Tests (Test) | ./gradlew test로 전체 테스트(Testcontainers의 MySQL 포함) | PR, main 푸시, 수동 |
identifier-check.yml | Identifier check | 문서·설정에 계정 ID 등 식별 정보가 섞였는지 검사 | PR, main 푸시, 수동 |
deploy.yml | Deploy | CD: 빌드, 푸시, 배포 | main 푸시, 수동 |
세 CI 워크플로의 on:은 pull_request:(브랜치 제한 없음)와 push: branches: [main]이 따로 있다.
| 상황 | 실행되는가 |
|---|---|
dev를 대상으로 한 PR | 된다 (pull_request에는 브랜치 제한이 없다) |
main을 대상으로 한 PR | 된다 |
dev에 직접 푸시 | 안 된다 (push는 main만) |
main에 푸시(병합 포함) | 된다 |
그래서 파일에 main만 적혀 있어도, PR 단계의 검사는 dev 대상 PR에서도 돌고 있었다. 다만 브랜치 보호는 main에만 걸려 있어서 dev에서는 검사가 실패해도 병합 버튼이 막히지 않는다.
검사가 있어도 실패한 PR을 병합할 수 있다면 검사는 참고용에 그친다. 브랜치 보호는 "지정한 검사를 통과하고 리뷰를 받아야 병합 버튼이 눌린다"를 GitHub이 강제하게 만든다. 6단계에서는 main 병합이 곧 운영 배포이므로, 이 규칙이 깨진 코드가 운영에 나가는 것을 막는 안전장치다.
규칙의 "필수 검사" 목록에는 최근에 실제로 실행된 적이 있는 검사만 검색된다. 새로 만든 Identifier check는 PR에서 실행된 적이 없으면 목록에 나타나지 않는다. 그래서 먼저 작업 브랜치로 PR(드래프트)을 열어 세 검사가 모두 돈 뒤에 규칙을 만들었다.
저장소 → Settings → Branches(또는 Rules → Rulesets) → 새 규칙, 대상 브랜치 main.
Checkstyle, Test, Identifier check를 추가한다. 이름은 워크플로 이름이 아니라 잡 이름으로 보인다main 기준으로 검사를 다시 돌린다. 안전하지만 PR을 자주 갱신해야 한다설정만 해 두고 동작을 확인하지 않으면 믿을 수 없다. 실패하는 PR을 일부러 만들어 병합이 막히는지 확인했다.
문제 하나: main이 거의 빈 뼈대였다. 보호 규칙은 main 대상 PR에만 적용되는데, 당시 main에는 프로젝트 초기의 뼈대와 code-style.yml뿐이었다. 그런데 PR 검사는 PR 브랜치에 있는 워크플로 파일로 실행된다. 그래서 main에서 갈라진 브랜치에 test.yml, identifier-check.yml을 가져다 넣으면 두 검사가 돈다. (넣지 않으면 두 검사가 아예 실행되지 않고 "대기 중"으로 남아, 실패가 막는 것은 확인할 수 없다.)
검증용 브랜치 만들기 (현재 작업 브랜치를 건드리지 않으려고 별도 worktree를 썼다):
git fetch origin
git worktree add -b test/branch-protection-check <임시 폴더> origin/main
# 임시 폴더에서:
mkdir -p scripts
cp <작업 저장소>/scripts/check-identifiers.sh scripts/
cp <작업 저장소>/.github/workflows/test.yml <작업 저장소>/.github/workflows/identifier-check.yml .github/workflows/
일부러 깨뜨릴 것 두 가지를 추가했다.
| 추가한 것 | 기대 결과 |
|---|---|
무조건 fail()하는 테스트 BranchProtectionProbeTest | Test 실패 |
가짜 ARN 예시 한 줄(arn:aws:iam::<가짜 12자리>:role/example)이 든 PROBE.md | Identifier check 실패 |
푸시하기 전에 로컬에서 검사 스크립트가 가짜 값을 잡는지 확인했다(git add 후 실행해야 한다. 스크립트가 Git이 추적하는 파일만 보기 때문이다).
PR을 열고 확인한 것: 대상 브랜치 main, 제목에 [DO NOT MERGE]. 결과는 기대한 대로 Test, Identifier check가 실패로 표시되고 병합 버튼이 막혔다. 실패한 테스트 이름은 PR → Checks → Test → Details → 로그에서 FAILED를 검색하거나, 실패 시 올라가는 test-reports 아티팩트의 index.html에서 확인한다. 리포트의 실패 항목이 BranchProtectionProbeTest인 것을 보고 의도한 실패임을 확정했다.
작은 혼란: 리포트의 테스트 수가 373개로 나왔다. main에는 테스트가 거의 없어서 맞지 않는 숫자였는데, 처음에 PR 대상을 dev로 잡았다가 main으로 바꾼 뒤 이전 실행 결과를 본 것이었다. PR 상단의 "into main" 문구와 Files changed의 파일 수(의도한 5개)로 대상이 맞는지 먼저 확인하는 것이 좋다.
확인이 끝난 PR은 닫고 브랜치와 worktree를 지운다.
git push origin --delete test/branch-protection-check
git worktree remove --force <임시 폴더>
git branch -D test/branch-protection-check
GitHub의 러너(남의 서버)가 우리 AWS 계정에 이미지를 올리고, 우리 EC2에 명령을 내려야 한다. 어떻게 신원을 증명하고, 어떻게 접속할까?
| 문제 | 흔한 방법 | 우리가 고른 방법 | 이유 |
|---|---|---|---|
| AWS에 어떻게 인증하나 | IAM 사용자의 액세스 키를 GitHub 시크릿에 저장 | GitHub OIDC로 임시 자격 증명 받기 | 장기 키는 유출되면 영구적으로 쓰인다. OIDC는 키가 없고, 받는 자격은 약 1시간 뒤 만료된다 |
| EC2에 어떻게 명령하나 | SSH로 접속해 실행 | SSM Run Command | SSH는 22번 포트와 키 관리가 필요하다. SSM은 포트를 열지 않고, IAM 권한으로 통제하고, 실행 기록이 남는다 |
| 스크립트를 EC2에 어떻게 두나 | 서버에 미리 복사 | 워크플로가 실행 시점에 저장소의 deploy.sh 내용을 SSM으로 전달 | 서버의 스크립트가 저장소와 어긋날 일이 없다 |
| 배포 시점 | main에 병합될 때마다 자동 | production 환경의 승인 한 번을 거침 | 첫 CD이므로 사람이 눈으로 확인하고 시작한다. 롤백까지 검증한 뒤 승인을 빼도 된다. 배포 관리자가 한 명이라 오버헤드가 작다 |
| 이미지 태그 | 수동 연습용 m1, m2 | 커밋 SHA 앞 12자 | 태그가 곧 커밋이라 "무엇이 배포됐는지" 추적되고, ECR의 태그 변경 불가 설정과도 맞는다 |
main 병합(push)
│
▼
[test 잡] 전체 테스트
│ 통과
▼
[deploy 잡] ── environment: production → 승인 대기(승인자가 Approve)
│
├─ ① OIDC로 AWS 역할을 가정해 임시 자격 증명을 받는다
├─ ② 이미지 태그 = 커밋 SHA 앞 12자. 이미 ECR에 있으면 빌드·푸시를 건너뛴다
├─ ③ docker build → ECR에 push
└─ ④ SSM으로 EC2에 deploy.sh 내용을 보내 실행 → 결과가 나올 때까지 확인
│
└ deploy.sh(3단계): 설정 검증 → 이미지 받기 → 교체 → 헬스체크 → 실패 시 롤백
핵심은 교체, 헬스체크, 롤백 로직이 deploy.sh에 이미 있다는 점이다. 워크플로는 그 스크립트를 EC2에서 실행시키고 종료 코드와 출력만 본다. 3단계에서 스크립트를 만들고 검증해 둔 덕분에 CD가 가벼워졌다.
IAM 역할(Role) 은 "입을 수 있는 권한 한 벌"이다. 사용자와 달리 비밀번호나 영구 키가 없다. 누군가 역할을 가정(assume) 하면 AWS가 임시 자격 증명(보통 1시간 뒤 만료)을 발급해 준다. 역할에는 두 가지 정책이 붙는다.
| 정책 | 질문 | 비유 |
|---|---|---|
| 신뢰 정책(trust policy) | 누가 이 역할을 입을 수 있나 | 출입증 발급 조건 |
| 권한 정책(permission policy) | 입으면 무엇을 할 수 있나 | 출입증으로 열리는 문 |
두 정책이 모두 맞아야 쓸 수 있고, 둘 중 하나라도 좁게 쓰면 안전하다.
OIDC(OpenID Connect) 는 "A 서비스가 발급한 신원 증명서를 B 서비스가 믿어 주는" 표준 방식이다. GitHub Actions는 워크플로를 실행할 때마다 서명된 신원 토큰을 만들 수 있다. 그 토큰에는 "이 실행은 저장소 X의, 환경 Y에서 도는 워크플로"라는 정보(클레임)가 들어 있다. AWS가 GitHub의 서명을 검증하고 조건이 맞으면 임시 자격을 준다. 이 신뢰 관계를 AWS에 등록하는 것이 OIDC 공급자(Identity Provider) 다.
| 토큰의 클레임 | 의미 | 예 |
|---|---|---|
aud(audience) | 이 토큰을 받을 대상 | sts.amazonaws.com |
sub(subject) | 누구의 실행인가 | repo:<조직>/<저장소>:environment:production |
흐름: 러너가 GitHub에서 토큰을 받는다 → AWS STS에 "이 토큰으로 역할 X를 가정하겠다"고 요청한다 → STS가 GitHub의 서명, 신뢰 정책의 조건(aud, sub)을 확인한다 → 임시 자격 증명이 나온다. 저장되는 비밀이 없다.
IAM 콘솔(서울 리전 확인) → 자격 증명 공급자 → 공급자 추가.
| 항목 | 값 |
|---|---|
| 공급자 유형 | OpenID Connect |
| 공급자 URL | https://token.actions.githubusercontent.com |
| 대상(Audience) | sts.amazonaws.com |
지문(thumbprint)은 AWS가 알아서 처리해서 입력하지 않는다.
token.actions.githubusercontent.com, Audience sts.amazonaws.comblackhole-github-deploy로 정해 만든다역할 → 신뢰 관계 → 정책 편집.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::<계정ID>:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:<조직>/<저장소>:environment:production"
}
}
}]
}
| 줄 | 의미 |
|---|---|
"Version": "2012-10-17" | 정책 문법의 버전. 항상 이 값을 쓴다 |
"Effect": "Allow" | 아래 조건을 만족하면 허용 |
"Principal": { "Federated": ... } | "누가"에 해당. 방금 등록한 GitHub OIDC 공급자가 증명한 신원이다(arn:aws:iam::<계정ID>:oidc-provider/...는 그 공급자의 주소) |
"Action": "sts:AssumeRoleWithWebIdentity" | 허용하는 행동은 "웹 신원 토큰으로 이 역할을 가정하기" 하나뿐이다 |
"aud": "sts.amazonaws.com" | 토큰이 AWS STS용으로 발급된 것이어야 한다 |
"sub": "repo:<조직>/<저장소>:environment:production" | 토큰이 이 저장소의 production 환경에서 실행된 워크플로여야 한다 |
StringEquals와 와일드카드 없음이 핵심이다. StringLike와 *로 느슨하게 쓰면 다른 브랜치나 포크, 다른 환경에서도 이 역할을 쓸 수 있게 된다. 정확히 일치시키면 지정한 저장소의 production 환경 잡 외에는 이 역할을 입을 수 없다.
왜 sub가 브랜치가 아니라 환경인가: 잡이 environment:를 쓰면 GitHub이 토큰의 sub를 repo:…:ref:refs/heads/main 대신 repo:…:environment:production으로 만든다. 그래서 신뢰 정책을 환경 이름에 맞춘다. 브랜치 제한은 환경 설정의 "배포 가능한 브랜치 = main"이 대신 맡는다(6장).
역할 → 권한 → 인라인 정책 생성(JSON), 이름 blackhole-github-deploy-policy.
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "EcrLogin", "Effect": "Allow", "Action": "ecr:GetAuthorizationToken", "Resource": "*" },
{ "Sid": "EcrPush", "Effect": "Allow",
"Action": ["ecr:BatchCheckLayerAvailability","ecr:InitiateLayerUpload","ecr:UploadLayerPart","ecr:CompleteLayerUpload","ecr:PutImage","ecr:DescribeImages"],
"Resource": "arn:aws:ecr:ap-northeast-2:<계정ID>:repository/feedablackhole-server" },
{ "Sid": "RunDeployCommand", "Effect": "Allow", "Action": "ssm:SendCommand",
"Resource": [
"arn:aws:ssm:ap-northeast-2::document/AWS-RunShellScript",
"arn:aws:ec2:ap-northeast-2:<계정ID>:instance/<인스턴스 ID>" ] },
{ "Sid": "ReadCommandResult", "Effect": "Allow",
"Action": ["ssm:GetCommandInvocation","ssm:ListCommandInvocations"], "Resource": "*" }
]
}
| 문장(Sid) | 허용하는 것 | 이유 |
|---|---|---|
EcrLogin | ECR 로그인 토큰 발급 | docker push 전에 필요하다. 이 권한은 특정 리포지토리로 좁힐 수 없어 Resource가 *다 |
EcrPush | 이 리포지토리 하나에만 이미지 올리기(레이어 업로드, PutImage)와 태그 조회(DescribeImages) | 푸시에 필요한 최소 동작이다. DescribeImages는 "같은 커밋이면 빌드를 건너뛰는" 확인에 쓴다 |
RunDeployCommand | SSM 명령 실행. 단, AWS-RunShellScript라는 문서를 이 인스턴스에 대해서만 | SendCommand는 "어떤 명령 문서를, 어떤 인스턴스에" 두 가지 리소스를 모두 지정해야 하고, 둘 다 좁혔다 |
ReadCommandResult | 명령 실행 상태와 출력 조회 | 이 조회 동작은 리소스를 좁힐 수 없어 *다 |
하지 않는 것에 주목한다: 이 역할로는 인스턴스를 만들거나 지울 수 없고, 다른 리포지토리에 이미지를 올릴 수 없고, DB나 Parameter Store를 읽을 수 없다. 최악의 경우에도 피해가 "이 이미지를 올리고 이 서버에서 셸 명령을 돌리는 것"에 그친다. 다만 셸 명령 실행은 강력한 권한이라, 그래서 신뢰 정책과 환경 승인을 겹겹이 좁혀 두는 것이다.
DescribeImages를 나중에 추가한 이유: 처음에는 푸시 동작만 넣었는데, ECR 태그가 변경 불가라 같은 커밋을 다시 배포하면 푸시가 거부된다. 이를 피하려고 워크플로가 이미지가 이미 있는지 확인하게 만들었고, 그 확인에 DescribeImages가 필요했다.
배포 대상마다 별도 규칙과 별도 비밀값을 붙이는 GitHub 기능이다. 잡에 environment: production을 쓰면 그 환경의 규칙이 적용된다.
| 설정 | 효과 |
|---|---|
| Required reviewers | 지정한 사람이 승인해야 잡이 시작된다. 배포 관리자가 혼자라 "Prevent self-review"는 꺼 두어야 본인이 승인할 수 있다 |
| Deployment branches | 이 환경을 쓸 수 있는 브랜치를 main으로만 제한한다. 다른 브랜치의 워크플로는 이 환경의 시크릿에 닿지 못한다 |
| Environment secrets | 이 환경을 참조하는 잡에서만 읽을 수 있는 시크릿 |
저장소 → Settings → Environments → New environment, 이름 production.
main만 추가한다| 시크릿 이름 | 값 |
|---|---|
AWS_ROLE_ARN | 5장에서 만든 역할의 ARN(blackhole-github-deploy) |
EC2_INSTANCE_ID | 배포 대상 EC2의 인스턴스 ID |
화면에서 production에 "2 protection rules(승인자, 브랜치 제한)", "2 secrets"로 표시되면 된다.
GitHub에는 시크릿 외에 변수(Variables) 도 있다. 둘의 실제 차이는 로그다.
| 시크릿 | 변수 | |
|---|---|---|
| 로그에 찍히면 | ***로 가려진다 | 그대로 보인다 |
| 어울리는 값 | 노출되면 곤란한 값 | 로그에 보여야 편한 비민감 설정(리전, 리포지토리 이름, 기능 스위치, 버전 등) |
이 저장소와 Actions 로그는 공개다. 역할 ARN과 인스턴스 ID는 그 자체로는 악용되기 어렵지만(역할은 신뢰 정책이, 인스턴스는 IAM 권한이 막는다), 계정 ID가 들어 있어 식별 정보로 분류했다. 변수에 두면 "저장소 파일에는 없지만 로그에는 있는" 구멍이 생기고, 로그는 식별 정보 검사로 걸러지지 않는다. 그래서 시크릿에 둔다. 리전(ap-northeast-2)과 리포지토리 이름은 이미 공개 문서에 있는 값이라 워크플로 상단 env:에 직접 적었다.
.github/workflows/deploy.yml 전체는 다음과 같다. 이어서 구간별로 설명한다.
# main에 병합되면 테스트 -> 이미지 빌드 -> ECR 푸시 -> EC2 배포(SSM) 순서로 자동 배포한다.
# 배포 잡은 GitHub Environment "production"을 쓴다: main에서만 실행되고, 승인자가 승인해야 시작한다.
# AWS 접속은 장기 키 없이 GitHub OIDC로 임시 자격 증명을 받아서 한다(역할의 신뢰 정책이 이 저장소의 production 환경으로 제한).
#
# 이 저장소와 Actions 로그는 공개다. 역할 ARN, 인스턴스 ID는 production 환경 시크릿에 두고(값이 로그에서 가려진다),
# 계정 ID는 실행 중에 조회해서 마스킹한다. 로그에 값을 직접 찍는 명령(echo, set -x 등)을 추가하지 않는다.
#
# 필요한 환경 시크릿: AWS_ROLE_ARN(배포용 IAM 역할), EC2_INSTANCE_ID(배포 대상 인스턴스)
name: Deploy
on:
push:
branches: [main]
# 같은 커밋을 다시 배포하거나 연습할 때 Actions 화면에서 직접 실행한다(main 브랜치에서만 승인 단계를 통과한다)
workflow_dispatch:
permissions:
contents: read
# 배포는 한 번에 하나만 실행한다. 앞선 배포를 취소하지 않고 기다린다(배포 도중 취소하면 서버가 어중간한 상태가 될 수 있다).
concurrency:
group: deploy-production
cancel-in-progress: false
env:
AWS_REGION: ap-northeast-2
ECR_REPOSITORY: feedablackhole-server
jobs:
test:
name: Test before deploy
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: gradle
- name: Run tests
run: bash ./gradlew --no-daemon test
deploy:
name: Build, push and deploy
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
# 이 환경의 승인 규칙과 브랜치 제한이 적용된다. OIDC 토큰의 sub도 이 환경 이름으로 만들어진다.
environment: production
permissions:
contents: read
id-token: write # OIDC 토큰 발급에 필요
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
mask-aws-account-id: true
# 도커와 SSM 출력에 계정 ID가 섞여 나올 수 있어서(이미지 주소) 로그에서 가린다.
- name: Mask account ID
run: |
account_id="$(aws sts get-caller-identity --query Account --output text)"
echo "::add-mask::$account_id"
echo "REGISTRY=${account_id}.dkr.ecr.${AWS_REGION}.amazonaws.com" >> "$GITHUB_ENV"
- name: Set image tag
run: echo "TAG=${GITHUB_SHA::12}" >> "$GITHUB_ENV"
# ECR 태그는 변경 불가라서, 같은 커밋을 다시 배포할 때는 이미 올라간 이미지를 그대로 쓴다.
- name: Check whether the image already exists
run: |
if aws ecr describe-images --repository-name "$ECR_REPOSITORY" --image-ids imageTag="$TAG" >/dev/null 2>&1; then
echo "IMAGE_EXISTS=true" >> "$GITHUB_ENV"
echo "이미지 $TAG 는 이미 ECR에 있어서 빌드와 푸시를 건너뜁니다."
else
echo "IMAGE_EXISTS=false" >> "$GITHUB_ENV"
fi
- name: Log in to ECR
if: env.IMAGE_EXISTS == 'false'
uses: aws-actions/amazon-ecr-login@v2
# --provenance=false: ECR에 어테스테이션 항목이 별개 이미지처럼 섞이는 것을 막는다(수동 배포와 동일)
- name: Build and push image
if: env.IMAGE_EXISTS == 'false'
run: |
docker build --platform linux/amd64 --provenance=false -t "$REGISTRY/$ECR_REPOSITORY:$TAG" .
docker push "$REGISTRY/$ECR_REPOSITORY:$TAG"
# EC2에는 deploy.sh를 따로 두지 않고, 이 커밋의 스크립트 내용을 SSM으로 전달해 실행한다(저장소와 항상 일치).
# 종료 코드가 0이 아니면 이 잡이 실패한다. 롤백에 성공해도 이번 배포는 실패로 처리된다(deploy.sh 규칙).
- name: Deploy on EC2 via SSM
env:
INSTANCE_ID: ${{ secrets.EC2_INSTANCE_ID }}
run: |
script_b64="$(base64 -w0 deploy/deploy.sh)"
remote="f=\$(mktemp); echo $script_b64 | base64 -d > \$f; bash \$f $TAG; rc=\$?; rm -f \$f; exit \$rc"
command_id="$(jq -n --arg id "$INSTANCE_ID" --arg cmd "$remote" \
'{DocumentName:"AWS-RunShellScript", InstanceIds:[$id], TimeoutSeconds:60,
Comment:"deploy", Parameters:{commands:[$cmd], executionTimeout:["900"]}}' \
| aws ssm send-command --cli-input-json file:///dev/stdin \
--query 'Command.CommandId' --output text)"
status=Pending
for _ in $(seq 1 120); do
sleep 5
status="$(aws ssm get-command-invocation --command-id "$command_id" --instance-id "$INSTANCE_ID" \
--query Status --output text 2>/dev/null || echo Pending)"
case "$status" in
Pending|InProgress|Delayed) ;;
*) break ;;
esac
done
echo "SSM 상태: $status"
aws ssm get-command-invocation --command-id "$command_id" --instance-id "$INSTANCE_ID" \
--query StandardOutputContent --output text
aws ssm get-command-invocation --command-id "$command_id" --instance-id "$INSTANCE_ID" \
--query StandardErrorContent --output text
[[ $status == Success ]]
| 부분 | 의미 |
|---|---|
on.push.branches: [main] | main에 푸시(병합 포함)될 때 자동 실행 |
on.workflow_dispatch | Actions 탭의 Run workflow 버튼으로 사람이 직접 실행. 같은 커밋 재배포, 병합 없이 경로 시험, 실패 후 재시도에 쓴다. 버튼은 워크플로 파일이 기본 브랜치(이 저장소는 dev)에 있어야 나타나고, production이 main만 허용하므로 실행할 때 브랜치로 main을 골라야 한다 |
permissions: contents: read | 이 워크플로가 GitHub 토큰으로 할 수 있는 일을 코드 읽기로 최소화 |
concurrency | group: deploy-production으로 배포는 동시에 하나만 돈다. cancel-in-progress: false라 진행 중인 배포를 취소하지 않고 기다린다(배포 도중 취소하면 서버가 어중간한 상태가 될 수 있다) |
env | 리전과 ECR 리포지토리 이름. 전체 잡에서 $AWS_REGION 등으로 쓴다 |
actions/checkout으로 코드를 받고, setup-java로 JDK 21을 설치하고(Gradle 캐시 포함), ./gradlew test를 실행한다. PR 단계의 Test와 같은 내용이지만, 배포 직전에 한 번 더 확인하는 안전장치다. 실패하면 deploy 잡이 시작되지 않는다(needs: test).
같은 일을 test.yml이 main 푸시에서도 하고 있어 테스트가 한 번 더 돈다. 재사용(workflow_call)으로 합칠 수도 있지만, 호출한 쪽과 호출된 쪽의 concurrency 그룹이 충돌해 서로 취소하는 문제가 있어 단순하게 중복을 택했다. 약 1~2분의 비용이다.
| 스텝 | 하는 일과 이유 |
|---|---|
environment: production | 이 잡이 시작되기 전에 승인 대기가 걸린다. 환경 시크릿에 접근하고, OIDC 토큰의 sub가 환경 이름이 된다 |
permissions: id-token: write | 러너가 OIDC 토큰을 발급받을 권한. 이 권한이 없으면 AWS에 인증할 수 없다 |
| Configure AWS credentials (OIDC) | aws-actions/configure-aws-credentials가 OIDC 토큰을 받아 AWS_ROLE_ARN 역할을 가정하고, 이후 스텝의 aws 명령이 쓸 임시 자격 증명을 환경변수로 설정한다. mask-aws-account-id는 로그에서 계정 ID를 가린다 |
| Mask account ID | aws sts get-caller-identity로 계정 ID를 조회해 ::add-mask::로 로그에서 가리고, ECR 주소(REGISTRY)를 만든다. 도커와 SSM 출력에 이미지 주소가 섞여 나올 수 있어서 공개 로그에 계정 ID가 남지 않게 한다 |
| Set image tag | TAG를 커밋 SHA 앞 12자로 정한다 |
| Check whether the image already exists | aws ecr describe-images로 이 태그가 ECR에 이미 있는지 본다. 있으면 IMAGE_EXISTS=true. ECR 태그가 변경 불가라 같은 커밋 재배포가 푸시에서 실패하는 것을 피한다 |
| Log in to ECR | 이미지를 올릴 때만 ECR 로그인 |
| Build and push image | docker build --platform linux/amd64 --provenance=false로 빌드하고 docker push. EC2가 x86(amd64)이라 플랫폼을 고정하고, --provenance=false는 ECR에 어테스테이션 항목이 별개 이미지처럼 섞이지 않게 한다(수동 배포 때와 같은 옵션) |
| Deploy on EC2 via SSM | 아래에 따로 설명 |
EC2는 SSH로 접속하지 않고, SSM Run Command로 "이 셸 명령을 이 인스턴스에서 실행해 달라"고 요청한다. 인스턴스에서는 SSM 에이전트가 요청을 받아 실행한다(2단계에서 에이전트와 인스턴스 역할을 이미 설정했다).
deploy/deploy.sh 내용을 base64로 인코딩해 한 줄 문자열로 만든다(줄바꿈, 따옴표 같은 특수문자 문제를 피한다). 원격에서 실행할 명령은 임시 파일을 만들고, 디코딩해서 저장하고, bash <파일> <태그>로 실행하고, 종료 코드를 보관한 뒤 파일을 지우고, 그 종료 코드로 끝낸다.jq로 JSON을 만들고 aws ssm send-command에 넘겨 명령 ID를 받는다. 문서는 AWS-RunShellScript, 대상은 EC2_INSTANCE_ID다. TimeoutSeconds는 인스턴스가 명령을 받을 때까지의 제한, executionTimeout:["900"]은 실행 시간 제한(15분)이다. 배포 스크립트의 헬스체크 대기(최대 120초)와 이미지 받기를 충분히 포함한다.aws ssm get-command-invocation으로 상태를 확인한다. Pending, InProgress, Delayed면 계속 기다리고, 그 외(Success, Failed, TimedOut 등)가 되면 나온다. aws ssm wait는 대기 한도가 약 100초라 배포가 더 걸릴 수 있어서 직접 반복문을 썼다(최대 약 10분).deploy.sh의 [deploy] 로그가 그대로 보인다.Success가 아니면 스텝이 실패하고 잡도 실패한다. 롤백에 성공했더라도 deploy.sh는 종료 코드 1을 돌려주므로(3단계 규칙) 이번 배포는 실패로 표시된다. 서비스가 살아 있는 것과 배포가 성공한 것은 다르기 때문이다.실패하면 GitHub이 기본 설정으로 실행한 사람에게 이메일을 보낸다. 이것이 6단계의 "실패 알림"이다. 별도 알림(Slack 등)은 필요해지면 추가한다.
| 값 | 보호 방법 |
|---|---|
| 역할 ARN, 인스턴스 ID | 환경 시크릿(로그에서 ***) |
| 계정 ID | configure-aws-credentials의 자동 마스킹과 직접 ::add-mask:: |
| 워크플로 안에서 | 값을 echo로 찍거나 set -x로 실행 추적을 켜지 않는다 |
마스킹은 값 전체가 일치할 때만 된다. 예를 들어 ARN 안의 계정 ID가 따로 찍히는 경우는 계정 ID 마스킹이 따로 필요해서 위 방법을 겹쳤다. 그래도 첫 실행의 로그는 직접 훑어서 확인한다(8장).
아직 실행 전이라 이 장은 확인 목록과 예상이다. 실행 후 실제 결과로 갱신한다.
dev 병합(squash)dev → main PR(필수 검사와 리뷰 통과) 후 병합. 이 PR은 dev의 모든 변경을 운영에 내보내므로 팀원과 범위를 맞춘다main 병합이 곧바로 Deploy 워크플로를 시작한다. test 잡이 돌고 deploy 잡이 승인 대기에서 멈춘다production 체크 → Approve and deploy| 확인 | 기준 |
|---|---|
| 승인 대기가 뜬다 | 잡이 "Waiting"으로 멈추고 승인 버튼이 보인다 |
| OIDC 역할 가정 | "Configure AWS credentials" 스텝이 성공한다 |
| 이미지 푸시 | ECR에 태그(커밋 SHA 앞 12자)가 생긴다 |
| SSM 실행 | "SSM 상태: Success", 로그에 [deploy] 줄이 보인다 |
| 서비스 | https://<도메인>/actuator/health가 UP |
| 로그에 식별 정보가 없다 | 실행 로그 전체에서 계정 ID, 역할 ARN, 인스턴스 ID가 ***로만 나온다. 보이면 해당 실행 로그를 삭제한다(Actions → 실행 → Delete all logs) |
| 롤백 시험 | 일부러 헬스체크가 실패하는 변경을 배포했을 때 이전 태그로 되돌아가고 잡이 실패로 끝난다 |
| 증상 | 원인 | 확인과 해결 |
|---|---|---|
Not authorized to perform sts:AssumeRoleWithWebIdentity | 신뢰 정책의 sub나 aud가 토큰과 다름. 환경 이름, 조직/저장소 철자, 환경 없이 실행된 잡 | 신뢰 정책의 sub가 repo:<조직>/<저장소>:environment:production과 정확히 같은지 본다. 잡에 environment: production이 있는지 본다 |
Credentials could not be loaded 또는 id-token 오류 | 잡에 permissions: id-token: write가 없음 | deploy 잡의 permissions를 확인한다 |
| 환경 시크릿이 비어 있음 | 시크릿을 저장소 시크릿이 아니라 다른 환경에 만들었거나 이름이 다름 | production 환경 시크릿의 이름이 AWS_ROLE_ARN, EC2_INSTANCE_ID인지 본다 |
푸시에서 AccessDenied | EcrPush/EcrLogin 권한이나 리포지토리 ARN 오타 | 권한 정책의 ARN을 확인한다 |
ImageTagAlreadyExistsException | 같은 태그를 다시 올림 | DescribeImages 권한이 있으면 건너뛴다. 없으면 권한을 추가한다 |
AccessDeniedException ... ssm:SendCommand | 문서나 인스턴스 ARN이 틀림 | 두 리소스 ARN을 확인한다 |
SSM 상태가 Failed/TimedOut | 인스턴스가 SSM에 없거나 deploy.sh가 실패 | 출력에서 [deploy] 오류 줄을 읽는다. 인스턴스가 SSM 관리 대상에 보이는지 본다 |
| 실행됐는데 서비스가 안 올라옴 | 앱 기동 실패 | deploy.sh가 이전 태그로 되돌리고 종료 코드 1을 낸다. EC2에서 docker logs로 원인을 본다 |
main이 빈 뼈대라서 보호 규칙을 검증하기 어려웠다. PR 검사가 PR 브랜치의 워크플로 파일로 돈다는 점을 이용해, 필요한 워크플로를 검증용 브랜치에 복사해 넣었다. "설정했다"와 "동작하는 것을 봤다"는 다르므로 실패하는 PR을 직접 만든 것이 가치가 있었다.sub가 바뀐다. 처음 생각한 신뢰 정책(...:ref:refs/heads/main)은 승인 단계를 넣는 순간 맞지 않게 된다. 신뢰 정책은 환경 이름에 맞추고, 브랜치 제한은 환경의 배포 브랜치 규칙으로 옮겨야 했다.DescribeImages)을 정책에 더했다.ci(deploy)를 붙였는데, 이 워크플로는 CI가 아니라 CD라서 맞지 않았다. 푸시한 뒤라 이력은 고치지 않고, 이 브랜치를 squash 병합하며 최종 커밋 메시지를 정리하기로 했다.| 항목 | 결정 | 이유 |
|---|---|---|
| AWS 인증 | GitHub OIDC로 임시 자격 증명 | 저장된 키가 없다. 신뢰 정책으로 저장소와 환경을 정확히 제한한다 |
| 서버 접속 | SSM Run Command | SSH 포트, 키 관리가 필요 없다. 권한은 IAM으로 통제된다 |
| 배포 로직의 위치 | deploy.sh가 하고, 워크플로는 실행과 판정만 | 손 절차를 검증한 스크립트를 그대로 재사용한다 |
| 배포 승인 | production 환경의 승인 한 번 | 첫 CD이므로 사람이 보고 시작한다. 검증 후 제거 가능 |
| 비밀 아닌 식별 정보 | 환경 시크릿, 계정 ID는 실행 중 조회·마스킹 | 저장소와 로그가 공개다 |
| 이미지 태그 | 커밋 SHA 앞 12자, 이미 있으면 건너뜀 | 추적성과 재배포 가능성 |
| 동시 실행 | 하나만, 앞선 배포는 취소하지 않고 대기 | 배포 도중 취소를 막는다 |
main 병합 후 첫 실행 결과로 이 글의 8장을 갱신한다.