이전 글에서는 PostgreSQL과 S3 데이터를 이전하고, CloudFront 이미지 도메인과 API DNS를 새 인프라로 전환했다.
서비스 요청은 새 EC2로 전달되고 있었지만 한 가지가 더 남아 있었다. main 브랜치에 코드가 반영됐을 때 실행되는 배포 파이프라인도 기존 EC2에서 새 EC2로 옮겨야 했다.
이번 글에서는 다음 작업을 다룬다.
- 새 EC2에 GitHub Actions Self-hosted Runner 등록
- Deploy Key를 이용한 저장소 접근
- CI와 운영 배포 작업 분리
- Runner 라벨을 이용한 배포 대상 전환
- Docker Compose 운영 설정 강화
.env와 인증정보의 Docker 이미지 포함 방지- 배포 후 컨테이너와 API 검증
기존 CI/CD 구조
백엔드 프로젝트는 GitHub Actions를 통해 배포하고 있었다.
기존 배포 흐름은 다음과 같았다.
main브랜치에 코드 반영- GitHub Actions에서 코드 검사와 Docker 이미지 빌드
- EC2의 Self-hosted Runner가 배포 Job 수신
- EC2에서 최신 코드 Pull
- Docker Compose 이미지 재빌드
- 컨테이너 재생성
- API Health Check
EC2만 새 계정으로 이전하고 Runner를 그대로 두면 이후 배포는 계속 기존 EC2에서 실행된다.
따라서 DNS 전환과 별개로 GitHub Actions가 어느 Runner를 선택하는지도 변경해야 했다.
1. CI와 배포 Runner 분리
Self-hosted Runner는 GitHub Actions Job을 운영 서버에서 직접 실행한다.
편리하지만 PR에서 실행되는 임의의 코드까지 운영 서버가 처리하게 하면 위험하다. GitHub도 공개 저장소에서 Fork PR이 Self-hosted Runner에서 위험한 코드를 실행할 가능성을 경고한다. GitHub Self-hosted Runner 등록 문서
이번 프로젝트는 CI와 배포 실행 환경을 분리했다.
| 작업 | 실행 환경 |
|---|---|
| Ruff 검사 | GitHub-hosted Runner |
| Python Compile 검사 | GitHub-hosted Runner |
| Docker 이미지 빌드 검사 | GitHub-hosted Runner |
| 운영 서버 배포 | 새 EC2 Self-hosted Runner |
PR과 main 브랜치의 CI는 ubuntu-latest에서 실행하고, CI가 성공한 main 브랜치만 운영 EC2의 Self-hosted Runner에서 배포한다.
이 구조에서는 PR 코드가 운영 서버에서 직접 실행되지 않는다.
2. 새 EC2에 Deploy Key 구성
배포 스크립트는 EC2의 프로젝트 디렉터리에서 git fetch와 git pull을 실행한다.
Private Repository에서 이 명령을 실행하려면 EC2가 GitHub 저장소를 읽을 수 있어야 한다. 이를 위해 새 EC2에서 배포 전용 SSH 키를 생성했다.
생성된 파일의 역할은 다음과 같다.
| 파일 | 용도 |
|---|---|
hbr-blog-be-new | EC2가 보관할 개인 키 |
hbr-blog-be-new.pub | GitHub에 등록할 공개 키 |
GitHub Repository의 다음 경로에서 공개 키를 Deploy Key로 등록했다.
Settings → Deploy keys → Add deploy key
Deploy Key 이름은 hbr-blog-be-new로 지정했다.
배포 서버는 코드를 읽기만 하면 되므로 쓰기 권한은 허용하지 않았다.
SSH 설정에는 GitHub 전용 Host Alias를 추가했다.
연결은 다음 명령으로 확인했다.
인증에 성공하면 GitHub Shell을 제공하지 않는다는 안내와 함께 저장소 인증 성공 메시지가 반환된다.
Deploy Key는 Self-hosted Runner 등록 토큰과 역할이 다르다.
| 인증정보 | 역할 |
|---|---|
| Runner 등록 토큰 | EC2 Runner를 GitHub Actions에 등록 |
| Runner 자격증명 | GitHub Actions Job 수신 |
| Deploy Key | EC2 프로젝트 디렉터리에서 Git 저장소 접근 |
Runner가 GitHub Actions에 연결됐다고 해서 EC2의 별도 프로젝트 디렉터리에서 자동으로 git pull할 수 있는 것은 아니다. 이번 배포 방식에서는 Deploy Key가 별도로 필요했다.
3. 프로젝트와 Runner 디렉터리 분리
Runner 설치 디렉터리는 프로젝트 내부가 아닌 별도 경로로 구성했다.
| 구성 요소 | 경로 |
|---|---|
| 백엔드 프로젝트 | /home/ubuntu/hbr_blog_be |
| Actions Runner | /home/ubuntu/actions-runner |
초기에는 Runner를 프로젝트 하위에 설치했지만 다음 문제를 방지하기 위해 분리했다.
- Docker Build Context에 Runner 파일이 포함될 가능성
- Git 작업과 Runner 업데이트 파일의 혼재
- 프로젝트 디렉터리 정리 과정에서 Runner가 손상될 가능성
- 소유권과 서비스 관리 범위가 불명확해지는 문제
Runner 자체는 애플리케이션 소스가 아니므로 프로젝트와 생명주기를 분리하는 편이 적절하다.
4. Self-hosted Runner 등록
GitHub Repository의 다음 경로에서 새 Runner 등록 명령을 확인했다.
Settings → Actions → Runners → New self-hosted runner
운영체제는 Linux, 아키텍처는 x64를 선택했다.
Runner 패키지를 /home/ubuntu/actions-runner에 설치한 후 저장소와 연결했다.
등록 토큰은 일회성이고 유효시간이 짧다. 블로그, Git 저장소, 화면 캡처에 노출하면 안 된다.
Runner 이름과 라벨은 다음과 같이 구분했다.
| 항목 | 값 | 역할 |
|---|---|---|
| Runner name | hbr-blog-be-new | GitHub 화면에서 Runner 식별 |
| Custom label | prod-new | Workflow가 새 운영 Runner 선택 |
GitHub Actions는 runs-on에 지정된 모든 라벨을 가진 Runner를 선택한다. 사용자 지정 라벨을 이용하면 기존 EC2와 새 EC2가 동시에 등록된 상태에서도 배포 대상을 명확하게 구분할 수 있다. GitHub Runner 라벨 문서

5. Runner를 systemd 서비스로 등록
SSH 세션이 종료돼도 Runner가 계속 Job을 수신하려면 백그라운드 서비스로 실행해야 한다.
Runner가 제공하는 서비스 스크립트를 사용했다.
systemd 상태에서도 확인할 수 있다.
정상적으로 연결된 경우 다음 상태를 확인할 수 있다.
서버 재부팅 후에도 Runner가 자동으로 시작되도록 서비스가 enabled 상태인지 확인했다.

6. CI Workflow 구성
CI는 GitHub-hosted Runner에서 실행한다.
CI 단계에서는 다음 항목을 확인한다.
- Python 개발 의존성 설치
- Ruff 정적 검사
- 애플리케이션과 Alembic 파일 Compile 검사
- Docker 이미지 빌드 가능 여부
permissions: contents: read를 지정해 Workflow의 기본 권한도 필요한 범위로 제한했다.
concurrency는 같은 브랜치나 PR에서 새 CI가 시작되면 이전 실행을 취소한다. 짧은 시간에 여러 Commit이 Push됐을 때 불필요한 중복 빌드를 줄일 수 있다.
7. CI 성공 후에만 새 EC2 배포
배포 Workflow는 CI Workflow가 완료된 뒤 실행된다.
workflow_run은 대상 Workflow가 성공하거나 실패해도 완료 시점에 발생한다. 따라서 배포 Job에 다음 조건이 필요하다.
이 조건으로 CI가 성공한 경우에만 운영 배포를 실행한다. GitHub workflow_run 문서
Runner 선택 조건에는 prod-new 라벨을 추가했다.
배열에 지정된 라벨을 모두 가진 Runner만 Job을 받을 수 있다. 따라서 기존 Runner가 여전히 Online 상태이더라도 prod-new 라벨이 없다면 새 배포 Job을 실행하지 않는다.
8. 배포 스크립트
Self-hosted Runner는 프로젝트 디렉터리에서 다음 스크립트를 실행한다.
git pull --ff-only
배포 서버에서 임의의 Merge Commit이 생성되지 않도록 Fast-forward만 허용한다.
서버의 main 브랜치가 원격 브랜치와 갈라져 있다면 배포를 실패시킨다. 운영 서버에서 코드를 직접 수정하지 않는다는 전제가 필요하다.
docker compose up -d --build
최신 소스로 API와 Worker 이미지를 다시 빌드하고 필요한 컨테이너를 재생성한다.
PostgreSQL 데이터는 Named Volume에 저장돼 있으므로 API와 Worker 컨테이너 재생성으로 삭제되지 않는다.
Health Check 재시도
컨테이너가 시작됐다고 애플리케이션이 즉시 요청을 받을 수 있는 것은 아니다.
Alembic Migration과 FastAPI 시작 시간을 고려해 3초 간격으로 최대 20번 Health Check를 시도한다.
60초 안에 성공하지 못하면 배포 Workflow를 실패 처리한다.
다만 현재 스크립트는 실패 시 자동으로 이전 이미지로 되돌리지는 않는다. 현재 단계에서는 장애 감지까지 자동화했으며 자동 롤백은 별도 개선 과제로 남겼다.
9. 운영 Docker Compose 설정 강화
개발 환경의 Compose 설정을 운영 환경에 그대로 사용하면 불필요한 포트와 소스 디렉터리가 외부에 노출될 수 있다.
운영 Compose Override에서는 다음 항목을 변경했다.
PostgreSQL과 Redis 포트 제거
개발 설정에는 다음 포트가 호스트에 공개돼 있었다.
| 서비스 | 개발 환경 |
|---|---|
| PostgreSQL | 5432:5432 |
| Redis | 6379:6379 |
운영 Override에서는 ports: !reset []로 호스트 포트 매핑을 제거했다.
API와 Worker는 Docker Compose 내부 네트워크에서 서비스 이름으로 접근한다.
- PostgreSQL:
db:5432 - Redis:
redis:6379
따라서 데이터베이스와 Redis를 EC2 외부에 공개할 필요가 없다.
API는 Loopback에만 바인딩
FastAPI는 다음과 같이 127.0.0.1에만 바인딩했다.
외부 사용자는 8152번 포트로 직접 접근하지 않고 Nginx의 80번과 443번 포트를 통해서만 API를 호출한다.
개발용 Bind Mount 제거
개발 환경에서는 코드 변경을 즉시 반영하기 위해 프로젝트 디렉터리를 /code에 Mount한다.
운영 환경에서는 이 Mount를 제거하고 Docker 이미지에 포함된 코드만 실행한다.
이렇게 하면 EC2 프로젝트 파일이 컨테이너 실행 환경을 직접 덮어쓰지 않는다.
자동 재시작 적용
API, Worker, PostgreSQL과 Redis에는 다음 정책을 적용했다.
Docker Daemon이나 EC2가 재시작된 뒤에도 컨테이너가 다시 실행될 수 있다.
10. .env와 마이그레이션 파일의 이미지 포함 방지
Docker는 Build Context에 포함된 파일을 Docker Daemon으로 전달한다.
Dockerfile이 현재 .env를 복사하지 않더라도 Build Context에 민감정보를 포함하지 않는 것이 안전하다.
.dockerignore에 다음 항목을 추가했다.
제외 대상은 다음과 같다.
| 항목 | 제외 이유 |
|---|---|
.env, .env.* | 운영 비밀번호와 설정값 |
*.pem | SSH 및 인증서 개인 키 |
*.dump | 데이터베이스 백업 |
actions-runner/ | Runner 실행 파일과 자격증명 |
.git, .github | 이미지 실행에 불필요 |
| Python Cache | 불필요한 이미지 용량 증가 |
.env.example은 실제 비밀값이 없는 문서이므로 예외적으로 Build Context에 포함할 수 있도록 했다.
배포된 컨테이너에서도 .env 파일이 포함되지 않았는지 확인했다.
결과는 다음과 같았다.
.env 파일이 이미지에 없더라도 환경변수 자체는 컨테이너 실행 시 전달된다. 따라서 Docker Inspect 권한과 서버 접근 권한 역시 제한해야 한다.
11. Migration Branch에서 배포 대상 변경
Self-hosted Runner 변경은 main에 바로 Push하지 않았다.
Runner 라벨과 Compose 설정을 잘못 지정하면 기존 운영 배포까지 중단될 수 있기 때문에 마이그레이션 브랜치에서 작업했다.
작업 순서는 다음과 같다.
migration/ec2-account-move브랜치 생성.env.example정리- 운영 Compose 설정 강화
- Deploy Workflow의 Runner 라벨을
prod-new로 변경 .dockerignore보강- Pull Request 생성
- CI 성공 확인
main으로 Merge- 새 Self-hosted Runner에서 배포 실행 확인
배포 대상을 라벨로 분리했기 때문에 PR 단계에서는 새 EC2 배포가 실행되지 않았다. main Merge 후 CI가 성공한 시점에 새 Runner가 배포 Job을 수신했다.
12. 배포 완료 검증
Pull Request를 main으로 Merge한 후 GitHub Actions에서 CI와 Deploy Workflow가 모두 성공했는지 확인했다.

새 EC2에서도 상태를 확인했다.
확인 결과 main 브랜치의 최신 Merge Commit이 반영돼 있었고 컨테이너가 정상적으로 실행 중이었다.
내부와 외부 API를 각각 확인했다.
두 요청 모두 다음 응답을 반환했다.
내부 Health Check는 Docker와 FastAPI 상태를 확인하고, 외부 Health Check는 DNS, HTTPS, Nginx와 FastAPI까지 전체 경로를 확인한다.

13. 네트워크와 이미지 보안 최종 확인
운영 서버에서 실제 Listen 포트를 확인한다.
기대하는 상태는 다음과 같다.
| 포트 | 바인딩 | 용도 |
|---|---|---|
| 80 | 0.0.0.0 | Nginx HTTP 및 HTTPS Redirect |
| 443 | 0.0.0.0 | Nginx HTTPS |
| 8152 | 127.0.0.1 | Nginx가 접근하는 FastAPI |
| 5432 | 호스트 미노출 | PostgreSQL |
| 6379 | 호스트 미노출 | Redis |
Docker Compose에서도 포트 상태를 확인했다.
API에만 다음 포트가 표시돼야 한다.
PostgreSQL과 Redis에는 호스트 포트 매핑이 없어야 한다.

14. 기존 Runner 정리
새 Runner의 배포가 한 번 성공했다고 기존 Runner를 즉시 제거하지는 않았다.
다음 항목을 확인한 뒤 기존 Runner를 정리하는 것이 안전하다.
- 새 Runner가 재부팅 후에도 Online 상태인지
- 연속된
main배포가 정상인지 - API와 Worker가 새 이미지로 갱신되는지
- PostgreSQL Volume이 유지되는지
- Health Check 실패가 Actions 실패로 반영되는지
- 기존 EC2로 롤백할 필요가 없는지
안정화 기간이 끝난 후 기존 EC2에서 Runner 서비스를 중지한다.
GitHub Repository의 Actions Runner 설정에서도 기존 Runner를 제거한다.
Runner와 기존 EC2를 제거하기 전에는 PostgreSQL Dump, S3 객체와 인증서 상태를 다시 확인한다.
마이그레이션 결과

이번 작업으로 다음 구성이 완성됐다.
- CI는 GitHub-hosted Runner에서 실행
- 운영 배포만 새 EC2 Self-hosted Runner에서 실행
prod-new라벨로 배포 대상 명확화- CI 성공 후에만 배포 Workflow 실행
- 배포 서버의 저장소 접근은 읽기 전용 Deploy Key 사용
- PostgreSQL과 Redis의 호스트 포트 제거
- FastAPI를
127.0.0.1:8152에만 바인딩 - 운영 환경의 개발용 Bind Mount와 Reload 제거
.env, 개인 키와 Dump 파일을 Docker Build Context에서 제외- 배포 후 내부·외부 Health Check 자동 실행
이번 마이그레이션에서 Self-hosted Runner는 단순한 자동화 도구가 아니라 운영 서버에서 명령을 실행할 수 있는 주체였다.
따라서 Runner를 Online으로 만드는 것보다 다음 세 가지가 더 중요했다.
- 어떤 Workflow가 운영 Runner에서 실행되는지 제한한다.
- 어떤 Runner가 운영 배포 Job을 받을지 라벨로 구분한다.
- 배포 스크립트가 실패를 감지하고 민감정보를 이미지에 포함하지 않도록 한다.
이 과정을 끝으로 EC2, PostgreSQL, S3, CloudFront, DNS와 CI/CD 파이프라인의 AWS 계정 간 이전을 완료했다.