Dev 배포 파이프라인 - 100-hours-a-week/KTB4-2nd-Cloud GitHub Wiki

배포 파이프라인

여담은 FE, BE, AI 저장소가 따로 있고, 운영 서버도 App EC2(FE, BE)와 Worker EC2(AI) 두 대로 나뉘어 있습니다. 저장소마다 코드가 바뀌는 시점은 다르지만, 운영에는 세 서비스가 서로 맞는 버전으로 올라가 있어야 했습니다.

그래서 이미지는 각 저장소에서 만들고, 운영에 올릴 조합은 Cloud 저장소 한 곳에서 정해 두 서버에 한 번에 반영하도록 만들었습니다.

설계 단계에서 정한 구조는 CI 파이프라인 설계와 CD 파이프라인 설계에 있습니다.


전체 흐름

flowchart LR
  subgraph APP["FE / BE / AI 저장소"]
    A1["main 병합"] --> A2["이미지 빌드<br/>linux/amd64"]
    A2 --> A3[("GHCR<br/>sha 태그 + digest")]
  end

  subgraph CLOUD["Cloud 저장소"]
    B1["매니페스트 PR<br/>자동 갱신"] --> B2["조합 확인 후 병합"]
    B2 --> B3["V1 Production Deployment<br/>수동 실행 + 승인"]
  end

  subgraph AWS["AWS"]
    C1["App EC2<br/>FE, BE"]
    C2["Worker EC2<br/>AI"]
  end

  A3 -- "image-published" --> B1
  B3 -- "OIDC + SSM" --> C1
  B3 -- "OIDC + SSM" --> C2
  A3 -. "digest로 pull" .-> C1
  A3 -. "digest로 pull" .-> C2
Loading

저장소별 이미지 발행

FE, BE, AI 저장소에 main 푸시 때 이미지를 GHCR에 올리는 워크플로를 하나씩 추가했습니다. 세 워크플로는 구조를 똑같이 맞추고, 저장소마다 필요한 부분만 따로 넣었습니다.

- name: Build and push
  id: build
  uses: docker/build-push-action@<commit-sha> # v7.4.0
  with:
    context: .
    platforms: linux/amd64
    push: true
    provenance: false
    tags: ${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
    cache-from: type=gha
    cache-to: type=gha,mode=max
  • 태그는 sha-<커밋 SHA> 하나만 붙이고 latest는 만들지 않았습니다. 운영에 뭐가 떠 있는지 태그만 보고 알 수 있어야 했습니다.
  • provenance는 껐습니다. 배포할 때 실행 중인 컨테이너의 digest를 매니페스트 값과 그대로 비교하기 위해서입니다.
  • 푸시는 저장소의 GITHUB_TOKEN으로 해서 CI에 따로 토큰을 두지 않았고, Action은 모두 커밋 SHA로 고정했습니다.
  • 빌드가 끝나면 워크플로 요약에 이미지와 digest를 남깁니다.

저장소마다 추가한 부분은 이렇습니다.

  • FE: 코드를 확인해보니 API 주소가 빌드 결과에 들어가고, 값이 없으면 localhost:8080으로 들어가는 구조였습니다. 빌드 인자로 운영 주소를 넣도록 했고, Backend 경로가 /api로 바뀐 뒤에는 https://yeodam-2gether.com/api로 바꿨습니다.
  • AI: 모델을 이미지에 포함해야 해서 빌드 전에 모델을 받습니다. 매번 받지 않도록 AI 팀이 만들어둔 fetch_model.py --cache-key 값으로 캐시를 걸었습니다.

운영 조합을 Cloud 저장소에서 관리하기

세 이미지가 따로 발행되니, 배포할 때마다 세 개의 태그를 따로 골라야 했습니다. 그래서 운영에 올릴 조합을 Cloud 저장소의 deploy/image-versions.env 파일 하나에 모았습니다.

FRONTEND_IMAGE=ghcr.io/100-hours-a-week/yeodam-frontend:sha-<commit>
FRONTEND_DIGEST=sha256:<digest>
BACKEND_IMAGE=ghcr.io/100-hours-a-week/yeodam-backend:sha-<commit>
BACKEND_DIGEST=sha256:<digest>
AI_IMAGE=ghcr.io/100-hours-a-week/yeodam-ai-worker:sha-<commit>
AI_DIGEST=sha256:<digest>

이 파일과 Compose, Nginx 설정, 배포 스크립트가 같은 커밋에 들어 있습니다. 배포할 때는 Cloud 커밋 하나만 고르면 되고, 되돌릴 때도 직전 커밋을 다시 적용하면 됩니다.

운영 로그의 release 값도 여기에 맞췄습니다. Nginx에는 Cloud 커밋을, Backend와 AI에는 각 이미지 커밋을 남겨서 특정 시점에 어떤 조합이 떠 있었는지 로그로 확인할 수 있습니다.

부하 테스트 때 실행 기록에 적어둔 커밋과 실제로 떠 있던 커밋이 달랐던 것도 이 값으로 찾았습니다.

매니페스트 갱신 자동화

설계에서는 배포하려는 담당자가 이 파일을 직접 고쳐 PR을 올리는 방식이었습니다. 그런데 중간 배포 때 워크플로 요약의 40자 SHA와 digest를 서버 환경 파일로 직접 옮겨보니, 값이 잘리거나 다른 실행의 값을 가져올 뻔한 일이 있었습니다.

그래서 값을 옮기는 일은 자동화하고, 조합을 확인해서 병합하는 것만 사람이 하도록 바꿨습니다.

sequenceDiagram
  participant R as FE/BE/AI 저장소
  participant C as Cloud 워크플로
  participant B as automation/release-manifest
  participant P as 매니페스트 PR

  R->>C: image-published (컴포넌트, 태그, digest)
  C->>C: 저장소, 태그, digest 형식 검증
  C->>B: 최신 브랜치 fetch 후 해당 컴포넌트만 갱신
  C->>P: 열린 PR이 있으면 갱신, 없으면 생성
Loading

자동화한 뒤 세 저장소가 거의 동시에 이미지를 발행하는 경우를 확인해보니, 워크플로 실행을 순서대로 세워둬도 마지막 갱신만 남을 수 있었습니다. 먼저 받아둔 브랜치를 기준으로 푸시하는 게 문제였습니다.

그래서 다음처럼 바꿨습니다.

  • 컴포넌트별로 동시성 그룹 분리
  • 푸시 직전에 원격 브랜치를 다시 받아 자기 줄만 적용
  • 충돌하면 최대 5회 재시도

이후 세 이벤트가 하나의 PR에 모두 반영되는 걸 확인했습니다.


배포 워크플로

배포 워크플로를 만들기 전에 중간 배포를 SSM 세션에서 직접 진행했습니다. 서버마다 Compose 파일을 받고, 환경 파일을 고치고, 컨테이너를 올리고, Nginx를 전환하는 과정을 한 번 끝까지 해봤습니다.

그 순서를 서버 공통 스크립트 deploy-release.sh로 옮겼고, GitHub Actions는 승인과 인증을 거친 뒤 이 스크립트를 SSM으로 실행하는 역할만 합니다.

배포는 main 병합과 함께 자동으로 시작하지 않고 수동으로 실행합니다. 여행 생성은 사진 업로드부터 AI 처리까지 수 분 동안 이어져서, 배포 시점은 사람이 정하도록 했습니다.

sequenceDiagram
  autonumber
  actor Op as 배포 요청자
  participant GH as GitHub Actions
  participant E as App, Worker EC2
  participant PS as Parameter Store
  participant R as GHCR

  Op->>GH: 배포 실행 (Cloud 커밋 선택)
  GH-->>Op: production 승인 요청
  Op->>GH: 승인
  GH->>GH: OIDC로 배포 Role 획득
  GH->>E: Worker 시작, SSM 연결 대기
  GH->>E: SSM으로 deploy-release.sh 실행
  E->>PS: 런타임 설정, GHCR 인증 조회
  E->>R: digest로 이미지 pull
  E->>E: compose 재생성, health와 digest 확인
  GH->>E: Worker 전원 상태 복원
Loading

deploy-release.sh가 서버에서 하는 일은 다음과 같습니다.

순서 작업 App Worker
1 Cloud 커밋 아카이브를 /opt/yeodam/releases/<커밋>에 준비 ✅ ✅
2 Parameter Store 값으로 /run/yeodam/*.env 생성 (root:root 600) ✅ ✅
3 임시 Docker 설정에서 GHCR 로그인, pull, 로그아웃 ✅ ✅
4 Compose로 컨테이너 재생성 FE, BE AI
5 컨테이너 health와 실행 이미지 digest 확인 ✅ ✅
6 nginx -t, HTTPS 경유 Backend 확인, CloudWatch Agent 설정 반영 ✅ -
7 현재와 직전 성공 릴리스 기록, 사용하지 않는 이미지 정리 ✅ ✅
8 유휴 자동 종료 타이머 설치 - ✅

Worker의 유휴 자동 종료 타이머는 배포가 성공한 뒤에만 설치합니다. 검증에 실패한 릴리스가 서버를 끄는 동작까지 바꾸지 않게 하려고 맨 뒤로 뒀습니다.


첫 배포가 세 번 실패한 과정

워크플로를 처음 실행했을 때는 세 번 연속으로 실패했습니다.

1. OIDC Role을 받지 못함

Could not assume role with OIDC:
Not authorized to perform sts:AssumeRoleWithWebIdentity

Role ARN과 Audience는 맞았는데 Role 획득이 계속 거절됐습니다. CloudTrail에서 실패한 요청을 열어보니 실제 Subject에 조직과 저장소의 ID가 붙어 있었습니다.

GitHub 문서의 기본 형식으로 적어둔 신뢰 조건과 달랐던 것이라, CloudTrail에서 본 값으로 조건을 고쳤습니다. (GitHub OIDC Role 획득 실패)

2. SSM에서 스크립트 첫 줄부터 실패

script.sh: 1: set: Illegal option -o pipefail

로컬 스크립트는 Bash였지만 SSM이 전달받은 명령을 /bin/sh로 실행하고 있었습니다. 명령 전체를 exec bash -c로 감싸서 넘기도록 바꿨습니다.

3. 릴리스 디렉터리 생성 실패

mktemp: failed to create directory via template
“/opt/yeodam/releases/.staging.XXXXXX”: No such file or directory

원격에서 변수가 풀리도록 Here-document 안에서 $를 escape하면서 따옴표까지 같이 escape했는데, 그 따옴표가 경로에 그대로 들어가 있었습니다.

두 번째 문제를 고친 뒤 작은 명령으로만 확인하고 바로 배포해서 놓친 부분이었습니다. 이 뒤로는 원격 명령을 고치면 실제로 만들어진 명령 전체를 App EC2의 /tmp 아래 분리된 경로에서 먼저 끝까지 실행해봅니다.

그렇게 확인한 뒤 다시 실행한 배포에서 전체 과정이 성공했습니다.


권한과 비밀값

배포 워크플로에는 서버에 명령을 보내는 권한만 주고, 운영 비밀값은 각 EC2가 자기 Instance Role로 Parameter Store에서 직접 읽도록 했습니다.

주체 인증 허용 범위 비밀값 조회
이미지 워크플로 GITHUB_TOKEN 자기 패키지 푸시 없음
배포 워크플로 GitHub OIDC 지정한 App, Worker에 SSM 명령, Worker 시작과 중지 없음
App EC2 Instance Role App 실행 /yeodam/v1/app, /yeodam/v1/deploy
Worker EC2 Instance Role AI 실행 /yeodam/v1/worker, /yeodam/v1/deploy
  • DB 비밀번호나 OAuth 키가 GitHub Secrets나 워크플로 로그를 거치지 않습니다.
  • 값을 바꿀 때는 Parameter Store만 고치고 다시 배포하면 됩니다.
  • 배포 Role은 Cloud 저장소의 production 환경에서 온 요청만 받습니다.
  • GHCR pull용 PAT는 /yeodam/v1/deploy에 두고, 배포 중에만 임시 Docker 설정에 넣었다가 지웁니다.

실패 시 복구

flowchart TD
  S["배포 시작"] --> P{"이미지 pull 성공?"}
  P -- 아니오 --> KEEP["컨테이너 교체 전 종료<br/>기존 운영 유지"]
  P -- 예 --> R["App, Worker 컨테이너 교체"]
  R --> C{"health와 digest<br/>모두 정상?"}
  C -- 예 --> OK["현재, 직전 릴리스 기록"]
  C -- 아니오 --> RB["두 서버 모두<br/>직전 성공 릴리스 재적용"]
Loading

한 서버만 실패해도 FE, BE, AI 세 이미지를 모두 이전 조합으로 되돌립니다. BE와 AI 사이의 요청 형식이 바뀐 적이 실제로 있어서, 한쪽만 새 버전으로 남는 상태를 가장 피하고 싶었습니다.

각 서버는 현재와 직전 성공 릴리스를 릴리스 디렉터리에 기록하고, rollback-release.sh가 직전 릴리스의 설정과 digest를 다시 적용합니다.

복구에 필요한 정보를 서버 안에 두어 외부 조회 없이 끝나도록 했습니다. 직전 이미지가 서버에 없으면 같은 digest로 GHCR에서 다시 받습니다.


운영하면서 생긴 일

빌드는 통과했는데 서버에서 기동 실패

Backend 새 이미지를 배포했더니 컨테이너가 계속 재시작됐습니다. 로그를 보니 Flyway 마이그레이션 V4 파일이 두 개 들어 있었습니다.

이미지 빌드와 발행은 문제없이 통과했고, 운영 DB에 붙는 순간에야 드러났습니다. Backend 팀이 파일 버전을 바꾼 이미지를 다시 발행했고, Backend 컨테이너만 교체해 복구했습니다.

이미지가 만들어졌다는 것만으로는 조합이 동작하는지 알 수 없어서, 배포 전에 세 이미지를 실제로 띄워보는 검증이 필요하다는 걸 이때 확인했습니다.

Worker 디스크 부족으로 이미지 pull 실패

배포를 몇 번 반복하자 Worker에서 새 AI 이미지 pull이 실패했습니다. 확인해보니 이전 AI 이미지들이 그대로 남아 디스크를 차지하고 있었습니다.

배포가 성공한 뒤 컨테이너가 참조하지 않는 이미지를 정리하는 단계를 추가했고, Worker의 가용 공간이 7.7GB에서 15GB로 돌아왔습니다. 정리 단계가 실패하더라도 이미 검증된 릴리스는 되돌리지 않고 경고만 남깁니다.

업로드 중에 배포

팀원이 사진을 올리던 중에 배포를 실행해 Backend 컨테이너가 교체됐고, 진행 중이던 마지막 업로드 요청이 502로 끝났습니다. Nginx 5xx Alarm으로 바로 감지했습니다.

새 업로드를 막고 진행 중인 요청이 끝나길 기다리는 Drain은 Backend에서 활성 업로드를 조회하는 방법이 정해지면 배포 절차에 넣을 예정입니다. 그전까지는 진행 중인 업로드가 없는지 확인하고 배포합니다. (배포 중 사진 업로드 중단)

⚠️ **GitHub.com Fallback** ⚠️