[서버 개발 일지 #17] CI/CD

KimGwangmin·2026년 10월 26일

지난 글에서는 도메인을 연결하고 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>, <도메인> 같은 자리표시자로 적는다. 실제 값은 저장소 밖에서 관리한다.

1. CI/CD란

한 줄 설명

용어하는 일
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이번 작업 전부

2. 먼저 알아둘 개념: GitHub Actions와 워크플로

CI와 CD는 모두 GitHub Actions 위에서 돈다. 용어를 먼저 정리한다.

용어설명
GitHub ActionsGitHub이 제공하는 자동 실행 서비스. 저장소에서 어떤 일이 생기면(이벤트) 정해 둔 작업을 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.ymlCode style (Checkstyle)코딩 규칙 검사PR, main 푸시
test.ymlTests (Test)./gradlew test로 전체 테스트(Testcontainers의 MySQL 포함)PR, main 푸시, 수동
identifier-check.ymlIdentifier check문서·설정에 계정 ID 등 식별 정보가 섞였는지 검사PR, main 푸시, 수동
deploy.ymlDeployCD: 빌드, 푸시, 배포main 푸시, 수동

트리거에 대한 오해 하나

세 CI 워크플로의 on:은 pull_request:(브랜치 제한 없음)와 push: branches: [main]이 따로 있다.

상황실행되는가
dev를 대상으로 한 PR된다 (pull_request에는 브랜치 제한이 없다)
main을 대상으로 한 PR된다
dev에 직접 푸시안 된다 (push는 main만)
main에 푸시(병합 포함)된다

그래서 파일에 main만 적혀 있어도, PR 단계의 검사는 dev 대상 PR에서도 돌고 있었다. 다만 브랜치 보호는 main에만 걸려 있어서 dev에서는 검사가 실패해도 병합 버튼이 막히지 않는다.

3. 5단계: CI 마무리 (브랜치 보호와 검증)

3.1 브랜치 보호가 왜 필요한가

검사가 있어도 실패한 PR을 병합할 수 있다면 검사는 참고용에 그친다. 브랜치 보호는 "지정한 검사를 통과하고 리뷰를 받아야 병합 버튼이 눌린다"를 GitHub이 강제하게 만든다. 6단계에서는 main 병합이 곧 운영 배포이므로, 이 규칙이 깨진 코드가 운영에 나가는 것을 막는 안전장치다.

3.2 선행 조건: 검사가 한 번은 실행되어 있어야 한다

규칙의 "필수 검사" 목록에는 최근에 실제로 실행된 적이 있는 검사만 검색된다. 새로 만든 Identifier check는 PR에서 실행된 적이 없으면 목록에 나타나지 않는다. 그래서 먼저 작업 브랜치로 PR(드래프트)을 열어 세 검사가 모두 돈 뒤에 규칙을 만들었다.

3.3 설정 절차

저장소 → Settings → Branches(또는 Rules → Rulesets) → 새 규칙, 대상 브랜치 main.

  1. Require a pull request before merging: 승인 1명 이상(협업 규칙 문서의 방침과 같다)
  2. Require status checks to pass before merging: 검색창에서 Checkstyle, Test, Identifier check를 추가한다. 이름은 워크플로 이름이 아니라 잡 이름으로 보인다
  3. (선택) Require branches to be up to date: 병합 직전 최신 main 기준으로 검사를 다시 돌린다. 안전하지만 PR을 자주 갱신해야 한다
  4. (선택) Do not allow bypassing the settings: 관리자도 규칙을 따르게 한다

3.4 검증: 규칙이 정말 막는가

설정만 해 두고 동작을 확인하지 않으면 믿을 수 없다. 실패하는 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()하는 테스트 BranchProtectionProbeTestTest 실패
가짜 ARN 예시 한 줄(arn:aws:iam::<가짜 12자리>:role/example)이 든 PROBE.mdIdentifier 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

4. 6단계: CD 설계

4.1 풀어야 할 문제

GitHub의 러너(남의 서버)가 우리 AWS 계정에 이미지를 올리고, 우리 EC2에 명령을 내려야 한다. 어떻게 신원을 증명하고, 어떻게 접속할까?

문제흔한 방법우리가 고른 방법이유
AWS에 어떻게 인증하나IAM 사용자의 액세스 키를 GitHub 시크릿에 저장GitHub OIDC로 임시 자격 증명 받기장기 키는 유출되면 영구적으로 쓰인다. OIDC는 키가 없고, 받는 자격은 약 1시간 뒤 만료된다
EC2에 어떻게 명령하나SSH로 접속해 실행SSM Run CommandSSH는 22번 포트와 키 관리가 필요하다. SSM은 포트를 열지 않고, IAM 권한으로 통제하고, 실행 기록이 남는다
스크립트를 EC2에 어떻게 두나서버에 미리 복사워크플로가 실행 시점에 저장소의 deploy.sh 내용을 SSM으로 전달서버의 스크립트가 저장소와 어긋날 일이 없다
배포 시점main에 병합될 때마다 자동production 환경의 승인 한 번을 거침첫 CD이므로 사람이 눈으로 확인하고 시작한다. 롤백까지 검증한 뒤 승인을 빼도 된다. 배포 관리자가 한 명이라 오버헤드가 작다
이미지 태그수동 연습용 m1, m2커밋 SHA 앞 12자태그가 곧 커밋이라 "무엇이 배포됐는지" 추적되고, ECR의 태그 변경 불가 설정과도 맞는다

4.2 CD의 흐름

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가 가벼워졌다.

5. AWS 쪽 준비: OIDC 공급자와 IAM 역할

5.1 먼저 개념

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)을 확인한다 → 임시 자격 증명이 나온다. 저장되는 비밀이 없다.

5.2 OIDC 공급자 등록 (계정당 1회)

IAM 콘솔(서울 리전 확인) → 자격 증명 공급자 → 공급자 추가.

항목값
공급자 유형OpenID Connect
공급자 URLhttps://token.actions.githubusercontent.com
대상(Audience)sts.amazonaws.com

지문(thumbprint)은 AWS가 알아서 처리해서 입력하지 않는다.

5.3 배포용 IAM 역할 만들기

  1. IAM → 역할 → 역할 생성 → 신뢰할 수 있는 엔터티 웹 자격 증명
  2. 자격 증명 공급자 token.actions.githubusercontent.com, Audience sts.amazonaws.com
  3. 마법사의 GitHub 조직/저장소 입력란은 건너뛴다(다음 단계에서 정책을 직접 쓴다). 권한은 붙이지 않고 이름만 blackhole-github-deploy로 정해 만든다

5.4 신뢰 정책: 누가 이 역할을 입을 수 있나

역할 → 신뢰 관계 → 정책 편집.

{
  "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장).

5.5 권한 정책: 입으면 무엇을 할 수 있나

역할 → 권한 → 인라인 정책 생성(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)허용하는 것이유
EcrLoginECR 로그인 토큰 발급docker push 전에 필요하다. 이 권한은 특정 리포지토리로 좁힐 수 없어 Resource가 *다
EcrPush이 리포지토리 하나에만 이미지 올리기(레이어 업로드, PutImage)와 태그 조회(DescribeImages)푸시에 필요한 최소 동작이다. DescribeImages는 "같은 커밋이면 빌드를 건너뛰는" 확인에 쓴다
RunDeployCommandSSM 명령 실행. 단, AWS-RunShellScript라는 문서를 이 인스턴스에 대해서만SendCommand는 "어떤 명령 문서를, 어떤 인스턴스에" 두 가지 리소스를 모두 지정해야 하고, 둘 다 좁혔다
ReadCommandResult명령 실행 상태와 출력 조회이 조회 동작은 리소스를 좁힐 수 없어 *다

하지 않는 것에 주목한다: 이 역할로는 인스턴스를 만들거나 지울 수 없고, 다른 리포지토리에 이미지를 올릴 수 없고, DB나 Parameter Store를 읽을 수 없다. 최악의 경우에도 피해가 "이 이미지를 올리고 이 서버에서 셸 명령을 돌리는 것"에 그친다. 다만 셸 명령 실행은 강력한 권한이라, 그래서 신뢰 정책과 환경 승인을 겹겹이 좁혀 두는 것이다.

DescribeImages를 나중에 추가한 이유: 처음에는 푸시 동작만 넣었는데, ECR 태그가 변경 불가라 같은 커밋을 다시 배포하면 푸시가 거부된다. 이를 피하려고 워크플로가 이미지가 이미 있는지 확인하게 만들었고, 그 확인에 DescribeImages가 필요했다.

6. GitHub 쪽 준비: Environment와 시크릿

6.1 Environment(환경)란

배포 대상마다 별도 규칙과 별도 비밀값을 붙이는 GitHub 기능이다. 잡에 environment: production을 쓰면 그 환경의 규칙이 적용된다.

설정효과
Required reviewers지정한 사람이 승인해야 잡이 시작된다. 배포 관리자가 혼자라 "Prevent self-review"는 꺼 두어야 본인이 승인할 수 있다
Deployment branches이 환경을 쓸 수 있는 브랜치를 main으로만 제한한다. 다른 브랜치의 워크플로는 이 환경의 시크릿에 닿지 못한다
Environment secrets이 환경을 참조하는 잡에서만 읽을 수 있는 시크릿

6.2 설정 절차

저장소 → Settings → Environments → New environment, 이름 production.

  1. Required reviewers에 본인을 추가한다
  2. Deployment branches and tags를 Selected branches로 하고 main만 추가한다
  3. Environment secrets에 두 개를 추가한다.
시크릿 이름값
AWS_ROLE_ARN5장에서 만든 역할의 ARN(blackhole-github-deploy)
EC2_INSTANCE_ID배포 대상 EC2의 인스턴스 ID

화면에서 production에 "2 protection rules(승인자, 브랜치 제한)", "2 secrets"로 표시되면 된다.

6.3 값을 변수(Variables)가 아니라 시크릿에 둔 이유

GitHub에는 시크릿 외에 변수(Variables) 도 있다. 둘의 실제 차이는 로그다.

시크릿변수
로그에 찍히면***로 가려진다그대로 보인다
어울리는 값노출되면 곤란한 값로그에 보여야 편한 비민감 설정(리전, 리포지토리 이름, 기능 스위치, 버전 등)

이 저장소와 Actions 로그는 공개다. 역할 ARN과 인스턴스 ID는 그 자체로는 악용되기 어렵지만(역할은 신뢰 정책이, 인스턴스는 IAM 권한이 막는다), 계정 ID가 들어 있어 식별 정보로 분류했다. 변수에 두면 "저장소 파일에는 없지만 로그에는 있는" 구멍이 생기고, 로그는 식별 정보 검사로 걸러지지 않는다. 그래서 시크릿에 둔다. 리전(ap-northeast-2)과 리포지토리 이름은 이미 공개 문서에 있는 값이라 워크플로 상단 env:에 직접 적었다.

7. deploy.yml 해설

.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 ]]

7.1 머리말과 트리거

부분의미
on.push.branches: [main]main에 푸시(병합 포함)될 때 자동 실행
on.workflow_dispatchActions 탭의 Run workflow 버튼으로 사람이 직접 실행. 같은 커밋 재배포, 병합 없이 경로 시험, 실패 후 재시도에 쓴다. 버튼은 워크플로 파일이 기본 브랜치(이 저장소는 dev)에 있어야 나타나고, production이 main만 허용하므로 실행할 때 브랜치로 main을 골라야 한다
permissions: contents: read이 워크플로가 GitHub 토큰으로 할 수 있는 일을 코드 읽기로 최소화
concurrencygroup: deploy-production으로 배포는 동시에 하나만 돈다. cancel-in-progress: false라 진행 중인 배포를 취소하지 않고 기다린다(배포 도중 취소하면 서버가 어중간한 상태가 될 수 있다)
env리전과 ECR 리포지토리 이름. 전체 잡에서 $AWS_REGION 등으로 쓴다

7.2 test 잡

actions/checkout으로 코드를 받고, setup-java로 JDK 21을 설치하고(Gradle 캐시 포함), ./gradlew test를 실행한다. PR 단계의 Test와 같은 내용이지만, 배포 직전에 한 번 더 확인하는 안전장치다. 실패하면 deploy 잡이 시작되지 않는다(needs: test).

같은 일을 test.yml이 main 푸시에서도 하고 있어 테스트가 한 번 더 돈다. 재사용(workflow_call)으로 합칠 수도 있지만, 호출한 쪽과 호출된 쪽의 concurrency 그룹이 충돌해 서로 취소하는 문제가 있어 단순하게 중복을 택했다. 약 1~2분의 비용이다.

7.3 deploy 잡의 스텝

스텝하는 일과 이유
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 IDaws sts get-caller-identity로 계정 ID를 조회해 ::add-mask::로 로그에서 가리고, ECR 주소(REGISTRY)를 만든다. 도커와 SSM 출력에 이미지 주소가 섞여 나올 수 있어서 공개 로그에 계정 ID가 남지 않게 한다
Set image tagTAG를 커밋 SHA 앞 12자로 정한다
Check whether the image already existsaws ecr describe-images로 이 태그가 ECR에 이미 있는지 본다. 있으면 IMAGE_EXISTS=true. ECR 태그가 변경 불가라 같은 커밋 재배포가 푸시에서 실패하는 것을 피한다
Log in to ECR이미지를 올릴 때만 ECR 로그인
Build and push imagedocker build --platform linux/amd64 --provenance=false로 빌드하고 docker push. EC2가 x86(amd64)이라 플랫폼을 고정하고, --provenance=false는 ECR에 어테스테이션 항목이 별개 이미지처럼 섞이지 않게 한다(수동 배포 때와 같은 옵션)
Deploy on EC2 via SSM아래에 따로 설명

7.4 SSM으로 배포하는 스텝

EC2는 SSH로 접속하지 않고, SSM Run Command로 "이 셸 명령을 이 인스턴스에서 실행해 달라"고 요청한다. 인스턴스에서는 SSM 에이전트가 요청을 받아 실행한다(2단계에서 에이전트와 인스턴스 역할을 이미 설정했다).

  1. 스크립트 전달: deploy/deploy.sh 내용을 base64로 인코딩해 한 줄 문자열로 만든다(줄바꿈, 따옴표 같은 특수문자 문제를 피한다). 원격에서 실행할 명령은 임시 파일을 만들고, 디코딩해서 저장하고, bash <파일> <태그>로 실행하고, 종료 코드를 보관한 뒤 파일을 지우고, 그 종료 코드로 끝낸다.
  2. 요청 만들기: jq로 JSON을 만들고 aws ssm send-command에 넘겨 명령 ID를 받는다. 문서는 AWS-RunShellScript, 대상은 EC2_INSTANCE_ID다. TimeoutSeconds는 인스턴스가 명령을 받을 때까지의 제한, executionTimeout:["900"]은 실행 시간 제한(15분)이다. 배포 스크립트의 헬스체크 대기(최대 120초)와 이미지 받기를 충분히 포함한다.
  3. 기다리기: 5초마다 aws ssm get-command-invocation으로 상태를 확인한다. Pending, InProgress, Delayed면 계속 기다리고, 그 외(Success, Failed, TimedOut 등)가 되면 나온다. aws ssm wait는 대기 한도가 약 100초라 배포가 더 걸릴 수 있어서 직접 반복문을 썼다(최대 약 10분).
  4. 결과 출력: 상태와 표준 출력/오류를 로그에 보여 준다. deploy.sh의 [deploy] 로그가 그대로 보인다.
  5. 판정: 상태가 Success가 아니면 스텝이 실패하고 잡도 실패한다. 롤백에 성공했더라도 deploy.sh는 종료 코드 1을 돌려주므로(3단계 규칙) 이번 배포는 실패로 표시된다. 서비스가 살아 있는 것과 배포가 성공한 것은 다르기 때문이다.

실패하면 GitHub이 기본 설정으로 실행한 사람에게 이메일을 보낸다. 이것이 6단계의 "실패 알림"이다. 별도 알림(Slack 등)은 필요해지면 추가한다.

7.5 로그에 값이 남지 않게 한 방법 정리

값보호 방법
역할 ARN, 인스턴스 ID환경 시크릿(로그에서 ***)
계정 IDconfigure-aws-credentials의 자동 마스킹과 직접 ::add-mask::
워크플로 안에서값을 echo로 찍거나 set -x로 실행 추적을 켜지 않는다

마스킹은 값 전체가 일치할 때만 된다. 예를 들어 ARN 안의 계정 ID가 따로 찍히는 경우는 계정 ID 마스킹이 따로 필요해서 위 방법을 겹쳤다. 그래도 첫 실행의 로그는 직접 훑어서 확인한다(8장).

8. 첫 실행: 확인할 것과 예상되는 실패

아직 실행 전이라 이 장은 확인 목록과 예상이다. 실행 후 실제 결과로 갱신한다.

8.1 실행 순서

  1. 작업 브랜치 → dev 병합(squash)
  2. dev → main PR(필수 검사와 리뷰 통과) 후 병합. 이 PR은 dev의 모든 변경을 운영에 내보내므로 팀원과 범위를 맞춘다
  3. main 병합이 곧바로 Deploy 워크플로를 시작한다. test 잡이 돌고 deploy 잡이 승인 대기에서 멈춘다
  4. Actions → 해당 실행 → Review deployments → production 체크 → Approve and deploy
  5. 로그를 읽고 아래를 확인한다

8.2 체크리스트

확인기준
승인 대기가 뜬다잡이 "Waiting"으로 멈추고 승인 버튼이 보인다
OIDC 역할 가정"Configure AWS credentials" 스텝이 성공한다
이미지 푸시ECR에 태그(커밋 SHA 앞 12자)가 생긴다
SSM 실행"SSM 상태: Success", 로그에 [deploy] 줄이 보인다
서비스https://<도메인>/actuator/health가 UP
로그에 식별 정보가 없다실행 로그 전체에서 계정 ID, 역할 ARN, 인스턴스 ID가 ***로만 나온다. 보이면 해당 실행 로그를 삭제한다(Actions → 실행 → Delete all logs)
롤백 시험일부러 헬스체크가 실패하는 변경을 배포했을 때 이전 태그로 되돌아가고 잡이 실패로 끝난다

8.3 예상되는 실패와 원인

증상원인확인과 해결
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인지 본다
푸시에서 AccessDeniedEcrPush/EcrLogin 권한이나 리포지토리 ARN 오타권한 정책의 ARN을 확인한다
ImageTagAlreadyExistsException같은 태그를 다시 올림DescribeImages 권한이 있으면 건너뛴다. 없으면 권한을 추가한다
AccessDeniedException ... ssm:SendCommand문서나 인스턴스 ARN이 틀림두 리소스 ARN을 확인한다
SSM 상태가 Failed/TimedOut인스턴스가 SSM에 없거나 deploy.sh가 실패출력에서 [deploy] 오류 줄을 읽는다. 인스턴스가 SSM 관리 대상에 보이는지 본다
실행됐는데 서비스가 안 올라옴앱 기동 실패deploy.sh가 이전 태그로 되돌리고 종료 코드 1을 낸다. EC2에서 docker logs로 원인을 본다

9. 작업 중 겪은 일

  • main이 빈 뼈대라서 보호 규칙을 검증하기 어려웠다. PR 검사가 PR 브랜치의 워크플로 파일로 돈다는 점을 이용해, 필요한 워크플로를 검증용 브랜치에 복사해 넣었다. "설정했다"와 "동작하는 것을 봤다"는 다르므로 실패하는 PR을 직접 만든 것이 가치가 있었다.
  • Environment를 쓰면 OIDC의 sub가 바뀐다. 처음 생각한 신뢰 정책(...:ref:refs/heads/main)은 승인 단계를 넣는 순간 맞지 않게 된다. 신뢰 정책은 환경 이름에 맞추고, 브랜치 제한은 환경의 배포 브랜치 규칙으로 옮겨야 했다.
  • 변수와 시크릿은 저장소가 아니라 로그 관점에서 갈린다. 저장소 파일에 값을 쓰지 않는 규칙(식별 정보 검사)만 지키면 로그가 빈틈이 된다. 공개 저장소에서는 로그도 공개라는 점을 고려했다.
  • ECR 태그 변경 불가는 재배포의 걸림돌이 되기도 한다. 같은 커밋을 다시 배포하면 푸시가 거부되므로, "이미 있으면 건너뛴다"를 넣고 그에 필요한 권한(DescribeImages)을 정책에 더했다.
  • 커밋 접두사를 잘못 붙였다. CD 워크플로를 추가한 커밋에 ci(deploy)를 붙였는데, 이 워크플로는 CI가 아니라 CD라서 맞지 않았다. 푸시한 뒤라 이력은 고치지 않고, 이 브랜치를 squash 병합하며 최종 커밋 메시지를 정리하기로 했다.

10. 정리

이 단계에서 정해진 것

항목결정이유
AWS 인증GitHub OIDC로 임시 자격 증명저장된 키가 없다. 신뢰 정책으로 저장소와 환경을 정확히 제한한다
서버 접속SSM Run CommandSSH 포트, 키 관리가 필요 없다. 권한은 IAM으로 통제된다
배포 로직의 위치deploy.sh가 하고, 워크플로는 실행과 판정만손 절차를 검증한 스크립트를 그대로 재사용한다
배포 승인production 환경의 승인 한 번첫 CD이므로 사람이 보고 시작한다. 검증 후 제거 가능
비밀 아닌 식별 정보환경 시크릿, 계정 ID는 실행 중 조회·마스킹저장소와 로그가 공개다
이미지 태그커밋 SHA 앞 12자, 이미 있으면 건너뜀추적성과 재배포 가능성
동시 실행하나만, 앞선 배포는 취소하지 않고 대기배포 도중 취소를 막는다

배운 점

  • CD의 어려움은 배포 자체보다 신원과 권한이다. 스크립트는 3단계에서 이미 검증해 두었고, 이번 작업의 대부분은 "누가 어디까지 할 수 있게 할 것인가"를 좁게 정의하는 일이었다.
  • 신뢰 정책(누가)과 권한 정책(무엇을)을 따로 좁힌다. 한쪽이 느슨해도 다른 쪽이 막지만, 둘 다 좁게 쓰는 것이 기본이다.
  • 자동화는 실패했을 때의 모양까지 설계해야 한다. 롤백, 종료 코드, 로그에서의 값 보호, 동시 실행 처리가 모두 "잘못됐을 때 덜 상하게" 하는 장치다.
  • 검증은 실패하는 쪽을 먼저 본다. 보호 규칙은 일부러 깨진 PR로, 롤백은 일부러 실패하는 배포로 확인한다.

남은 것

  • main 병합 후 첫 실행 결과로 이 글의 8장을 갱신한다.
  • 실패 알림 메일이 실제로 오는지, ECR 수명 주기 규칙(최근 10개 유지)이 롤백 대상 이미지를 지우지 않는지 확인한다.
  • 7단계(운영 최소 점검): 로그와 디스크 정리, 인증서 만료 알람, 백업, 인증 경로 요청 빈도 제한을 다룬다.

0개의 댓글