목차
- 프로젝트 배경 — 왜 자동 배포가 필요했나
- 기술 선택 — GitHub Actions + Docker를 고른 이유
- 워크플로우 설계 — 전체 흐름 잡기
- GitHub Actions 워크플로우 작성
- 시크릿 설정과 삽질 기록
- 헬스체크와 롤백 전략
- 실전에서 마주친 문제들
- 잘한 점 — 돌아보니 괜찮았던 것들
- 아쉬운 점 — 다음에는 고치고 싶은 것들
- 다음 프로젝트에 적용할 개선점
"배포할 때마다 SSH 접속해서 git pull 치는 거, 나만 불편한 건가?" 데이터 파이프라인 서버를 운영하면서 매번 이 생각을 했다. GitHub Actions Docker 자동 배포를 처음 알게 된 건 작년 말쯤이었는데, 사실 그 전까지는 쉘 스크립트 하나로 대충 돌리고 있었다. 배포 자동화라는 게 거창한 줄 알았는데, 막상 GitHub Actions Docker 자동 배포를 구축하고 나니까 "이걸 왜 이제야 했지" 싶었다.
이 글은 3개월 전에 끝난 프로젝트를 돌아보면서 쓰는 회고록이다. 뭘 잘했고 뭘 못했는지, 그리고 다음에는 어떻게 할 건지. 데이터 파이프라인 서버라서 일반 웹 앱과는 좀 다른 맥락이 있었다. 그 부분도 같이 정리한다.

프로젝트 배경 — 왜 자동 배포가 필요했나
원래 나는 데이터 엔지니어 출신이라 인프라보다는 Airflow DAG 짜는 데 시간을 더 많이 쓰는 사람이다. 근데 작년에 팀에서 백엔드도 같이 맡게 되면서 FastAPI 서버를 하나 운영하게 됐다. 데이터 수집 파이프라인이 돌아가는 서버인데, 처음에는 배포가 일주일에 한 번이라 수동으로 해도 괜찮았다.
문제는 수집 로직을 자주 고치기 시작하면서 생겼다. 하루에 두세 번 배포하는 날이 생기니까, SSH 접속 → git pull → docker compose down → docker compose up -d 이 루틴이 진짜 귀찮아졌다. 한번은 docker compose down 까지만 치고 다른 일 하다가 서버가 2시간 동안 죽어 있었던 적도 있다. 그때 슬랙 알림이 폭탄처럼 왔다. 짜증났다.
그래서 결심했다. main 브랜치에 푸시하면 서버에 알아서 올라가게 만들자. 이게 이 프로젝트의 시작점이다.
당시 서버 구성
- Oracle Cloud VM 1대 (ARM, 4코어 24GB)
- Ubuntu 22.04
- Docker 24.0 + docker-compose v2
- FastAPI + PostgreSQL + Redis
- Caddy로 리버스 프록시
단순한 구성이라 Kubernetes 같은 건 오버킬이었다. 그냥 docker compose로 충분한 규모.
기술 선택 — GitHub Actions + Docker를 고른 이유
CI/CD 도구를 고를 때 Jenkins, GitLab CI, CircleCI도 후보에 있었다. 근데 솔직히 고민할 것도 없었다.
| 도구 | 장점 | 단점 | 선택 이유 |
|---|---|---|---|
| GitHub Actions | GitHub에 붙어 있어서 별도 설정 없음 | 러너 분당 과금 (무료 2000분/월) | 이미 GitHub 쓰는 중 |
| Jenkins | 자유도 높음 | 서버 직접 운영, Java 의존 | 서버 하나 더 관리하기 싫음 |
| GitLab CI | GitLab 쓰면 편함 | 레포 이전 필요 | 안 씀 |
| CircleCI | 빠름 | 무료 플랜 제한적 | 굳이? |
GitHub Actions를 고른 건 단순히 "이미 GitHub을 쓰고 있었기 때문"이다. 대단한 기술적 판단은 없었다. 러너가 무료 2000분이면 내 규모에서는 차고 넘쳤다. Docker는 이미 로컬과 서버 모두에서 쓰고 있었으니까 자연스러운 선택이었다.
다만 self-hosted runner를 쓸지 GitHub-hosted runner를 쓸지는 좀 고민했다. 결론부터 말하면, Docker 이미지를 빌드해서 GitHub Container Registry(GHCR)에 올리고, 서버에서 pull 받는 방식을 택했다. self-hosted runner를 서버에 직접 띄우면 보안 이슈가 신경 쓰여서 뺐다. GitHub 공식 문서에서도 퍼블릭 레포에는 self-hosted runner를 권장하지 않는다.
워크플로우 설계 — 전체 흐름 잡기
GitHub Actions Docker 자동 배포의 큰 그림은 이렇다.
graph LR
A[main 푸시] --> B[GitHub Actions 트리거]
B --> C[Docker 이미지 빌드]
C --> D[GHCR에 푸시]
D --> E[서버에 SSH 접속]
E --> F[docker compose pull]
F --> G[docker compose up -d]
G --> H[헬스체크]
단계별로 보면 크게 두 파트다. 빌드 파트(이미지 만들어서 레지스트리에 올리기)와 배포 파트(서버에서 새 이미지 당겨서 띄우기). 이 두 개를 하나의 워크플로우 안에서 순차적으로 처리했다.
Dockerfile 작성
먼저 Dockerfile. 멀티스테이지 빌드를 썼다. Python 이미지가 기본적으로 무거워서, 빌드 스테이지에서 의존성만 설치하고 런타임 스테이지에서는 slim 이미지를 쓰는 식이다.
# Build stage
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Runtime stage
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
ENV PYTHONUNBUFFERED=1
EXPOSE 8001
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
처음에는 멀티스테이지 안 쓰고 그냥 한 스테이지로 했었다. 근데 이미지 크기가 1.2GB가 나와서 GHCR에 올리는 데만 4분이 걸리더라. 멀티스테이지로 바꾸니까 400MB 정도로 줄었다. 빌드 캐시까지 활용하면 체감상 2~3배 빠르다.
docker-compose.yml
version: "3.8"
services:
app:
image: ghcr.io/myorg/data-pipeline:latest
restart: unless-stopped
ports:
- "8001:8001"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
postgres:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: pipeline
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
depends_on에 condition: service_healthy를 넣은 건 DB가 먼저 뜨고 나서 앱이 시작되게 하려는 건데, 솔직히 이게 완벽하지는 않다. 앱 내부에서도 DB 연결 재시도 로직이 있어야 안전하다.
GitHub Actions 워크플로우 작성
이 부분이 핵심이다. .github/workflows/deploy.yml 파일 하나로 전부 처리한다.
name: Build and Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/data-pipeline
docker compose pull app
docker compose up -d app
sleep 5
curl -f http://localhost:8001/health || exit 1
몇 가지 포인트를 짚으면:
빌드 캐시가 진짜 중요하다
cache-from: type=gha와 cache-to: type=gha,mode=max 이 두 줄이 빌드 시간을 극적으로 줄여준다. GitHub Actions의 캐시 스토리지를 Docker 레이어 캐시로 쓰는 건데, 첫 빌드는 3분 걸리던 게 이후부터는 40초대로 내려갔다. Docker 공식 빌드 캐시 문서에 자세한 설명이 있다.
SSH 배포의 한계
appleboy/ssh-action으로 서버에 직접 SSH를 때리는 방식인데, 이게 좀 원시적이다. 개인적으로 이게 제일 깔끔하다고 생각했는데 나중에 아쉬운 점이 됐다. 이건 뒤에서 다시 다룬다.
태그 전략
latest와 커밋 SHA 두 개를 동시에 태그한다. latest는 배포용이고, SHA 태그는 롤백용이다. 문제 생기면 서버에서 docker compose pull 대신 특정 SHA 이미지로 직접 교체하면 된다.

시크릿 설정과 삽질 기록
GitHub Actions에서 서버에 SSH 접속하려면 시크릿 3개가 필요하다.
SERVER_HOST: 서버 IPSERVER_USER: SSH 유저명SSH_PRIVATE_KEY: SSH 개인키
이걸 GitHub 레포 Settings → Secrets and variables → Actions에서 넣으면 되는데, 여기서 한 시간 넘게 헤맸다.
SSH 키 포맷 문제
SSH 키를 복사할 때 마지막 줄바꿈을 빼먹어서 인증이 계속 실패했다. 에러 메시지가 ssh: handshake failed: ssh: unable to authenticate인데, 이게 키 포맷 문제인지 권한 문제인지 구분이 안 된다. 새벽 1시에 이거 잡겠다고 30분 날렸다. 결국 cat ~/.ssh/id_ed25519 | pbcopy로 키 전체를 복사하니까 됐다. 키 끝에 \n이 있어야 한다.
# 이렇게 하면 안 됨 (마지막 줄바꿈 누락 가능)
pbcopy < ~/.ssh/id_ed25519
# 이렇게 하면 확실함
cat ~/.ssh/id_ed25519 | pbcopy
사실 둘 다 동작은 같아야 하는데, 터미널에서 직접 드래그해서 복사하다가 마지막 줄을 빼먹는 실수를 했던 거다. 어이없지만 이런 게 실제로 시간을 잡아먹는다.
GHCR 권한 설정
GitHub Container Registry에 이미지를 푸시하려면 워크플로우에 packages: write 퍼미션을 명시해야 한다. 이거 빼먹으면 denied: permission_denied: write_package 에러가 뜬다. 처음에 이걸 몰라서 Personal Access Token까지 만들었다가 나중에 GITHUB_TOKEN으로 충분하다는 걸 알고 삭제했다.
permissions:
contents: read
packages: write # 이거 빼먹으면 GHCR 푸시 실패
헬스체크와 롤백 전략
배포 후 서버가 정상인지 확인하는 게 생각보다 까다로웠다.
단순 헬스체크의 문제
처음에는 curl -f http://localhost:8001/health만 넣었다. 근데 컨테이너가 올라오는 데 시간이 좀 걸리니까, sleep 5 후에 체크해도 가끔 실패했다. 특히 DB 마이그레이션이 있는 배포에서. 그래서 재시도 로직을 넣었다.
# deploy step의 script를 이렇게 바꿈
cd /opt/data-pipeline
docker compose pull app
docker compose up -d app
# 헬스체크 (최대 30초 대기)
for i in $(seq 1 6); do
sleep 5
if curl -sf http://localhost:8001/health > /dev/null; then
echo "Health check passed"
exit 0
fi
echo "Attempt $i failed, retrying..."
done
echo "Health check failed after 30s"
exit 1
롤백은 수동이다
아쉬운 얘기인데, 자동 롤백은 구현 안 했다. 헬스체크 실패하면 GitHub Actions에서 에러가 나고 슬랙 알림이 오는 정도다. 실제 롤백은 서버에 들어가서 이전 SHA 태그로 직접 교체한다.
# 롤백 시
docker compose down app
docker compose pull app # 이전 이미지가 latest가 아닌 경우
# 또는 특정 버전으로
docker tag ghcr.io/myorg/data-pipeline:abc1234 ghcr.io/myorg/data-pipeline:latest
docker compose up -d app
솔직히 이건 좀 불편하다. 다음 프로젝트에서는 blue-green 배포나 Watchtower 같은 걸 써볼 생각이다. 아직 못 해봤다.
실전에서 마주친 문제들
3개월 운영하면서 터진 것들. GitHub Actions Docker 자동 배포가 한번 세팅되면 알아서 잘 돌긴 하는데, 엣지 케이스는 있었다.
Docker 레이어 캐시 무효화
requirements.txt를 안 바꿨는데도 가끔 캐시가 안 먹는 경우가 있었다. GitHub Actions 캐시에 용량 제한(10GB)이 있어서, 다른 워크플로우가 캐시를 많이 쓰면 밀려나더라. 이건 docker/build-push-action의 캐시 키를 좀 더 구체적으로 잡으면 개선된다.
ARM 빌드 이슈
서버가 Oracle Cloud ARM이라 linux/arm64 이미지가 필요한데, GitHub-hosted runner는 linux/amd64다. 처음에 이걸 무시하고 배포했다가 exec format error가 떴다. QEMU로 크로스 빌드하면 해결은 되는데 빌드 시간이 3배로 뛴다.
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64 # ARM 전용
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
결국 self-hosted runner를 ARM 서버에 하나 더 띄울까도 고민했는데, 보안 부담 때문에 그냥 QEMU 크로스 빌드로 버텼다. 빌드가 느린 건 맞지만, 하루에 몇 번 안 하니까 참을 만했다.
.env 파일 관리
서버의 .env 파일은 GitHub Actions가 건드리지 않는다. 이게 맞는 건데, 새로운 환경변수가 추가됐을 때 서버 .env에 수동으로 넣어줘야 한다는 걸 까먹으면 서버가 터진다. 한번은 REDIS_URL을 새로 추가했는데 서버 .env에 안 넣어서 배포 후 500 에러가 2시간 동안 났다. 이때 드디어 됐을 때의 희열 같은 건 없었다. 그냥 허탈했다.
아 그리고 이건 주의해야 하는데, .env 파일을 절대 Git에 올리면 안 된다. .gitignore에 넣는 건 기본인데, 가끔 .env.production이나 .env.local을 실수로 커밋하는 경우가 있다. git-secrets 같은 도구를 pre-commit hook으로 걸어두는 게 좋다.

잘한 점 — 돌아보니 괜찮았던 것들
배포 시간이 극적으로 줄었다
수동으로 하면 SSH 접속부터 docker compose up까지 5분은 걸렸다. 터미널 켜고, 서버 주소 찾고, 명령어 치고. GitHub Actions Docker 자동 배포로 바꾸고 나서는 git push만 하면 된다. 체감상 배포에 쓰는 시간이 90% 줄었다.
배포 이력이 남는다
GitHub Actions 탭에 들어가면 언제 누가 뭘 배포했는지 전부 보인다. 이전에는 슬랙에 "배포했음" 메시지를 수동으로 보냈는데, 이제는 자동으로 추적된다. 장애 원인 추적할 때 이게 큰 도움이 된다.
이미지 태그로 롤백 가능
커밋 SHA로 이미지를 태깅해두니까, 문제가 생기면 이전 버전으로 돌리기가 쉽다. Docker 이미지 자체가 불변(immutable)이라 "이 버전은 확실히 동작한다"는 보장이 생긴다.
아쉬운 점 — 다음에는 고치고 싶은 것들
무중단 배포가 아니다
docker compose up -d app은 기존 컨테이너를 죽이고 새 컨테이너를 띄운다. 짧지만 다운타임이 있다. 내 경우에는 데이터 파이프라인 서버라서 외부 트래픽이 많지 않아 괜찮았는데, 사용자 대면 서비스라면 이건 안 된다. Docker Swarm의 rolling update나, Traefik 같은 프록시를 써서 무중단 배포를 해야 한다.
테스트가 없다
부끄러운 얘기인데, 이 워크플로우에 테스트 단계가 없다. 빌드하고 바로 배포해버린다. 진짜 CI/CD라면 CI(Continuous Integration) 부분에서 테스트를 돌려야 하는데, 당시에는 테스트 코드 자체가 거의 없었다. 이건 변명의 여지가 없다.
# 이렇게 테스트 job을 추가했어야 했다
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/ -v
build-and-push:
needs: test # 테스트 통과 후에만 빌드
# ...
시크릿 관리가 번거롭다
서버가 여러 대면 시크릿을 일일이 등록해야 한다. GitHub Environments 기능으로 스테이징/프로덕션을 분리하면 좀 낫긴 한데, 근본적으로 HashiCorp Vault 같은 시크릿 매니저를 쓰는 게 맞다. 이건 아직 안 해봤다.
SSH 기반 배포의 불안함
appleboy/ssh-action이 편하긴 한데, SSH 키를 GitHub 시크릿에 올려놓는 게 찝찝하다. 서버에 접속할 수 있는 키가 GitHub 인프라에 저장되어 있다는 거니까. 좀 더 안전하게 하려면 서버 쪽에서 webhook을 받아서 pull하는 방식이 낫다. Watchtower 같은 도구가 이런 걸 해준다.
다음 프로젝트에 적용할 개선점
이번에 GitHub Actions Docker 자동 배포를 세팅하면서 배운 것들을 바탕으로, 다음에는 이렇게 할 생각이다.
첫째, 테스트를 반드시 넣는다. CI 없는 CD는 의미가 없다. 최소한 핵심 엔드포인트에 대한 통합 테스트라도 넣어야 한다. pytest + httpx로 FastAPI 테스트를 작성하는 건 그리 어렵지 않다.
둘째, Watchtower로 SSH 의존을 없앤다. 서버에 Watchtower를 띄워놓으면 GHCR에 새 이미지가 올라왔을 때 알아서 pull하고 재시작해준다. SSH 키를 GitHub에 올릴 필요가 없어진다.
# docker-compose.yml에 Watchtower 추가
watchtower:
image: containrrr/watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_POLL_INTERVAL: 60
WATCHTOWER_LABEL_ENABLE: "true"
command: --interval 60
셋째, GitHub Environments로 스테이징 분리. main에 푸시하면 스테이징에 먼저 배포하고, 수동 승인 후 프로덕션에 올리는 식. GitHub Actions의 환경 보호 규칙이 이걸 지원한다.
넷째, 데이터 파이프라인 특성상 DB 마이그레이션 자동화도 고민 중이다. 지금은 마이그레이션이 필요하면 서버에 직접 들어가서 alembic upgrade head를 쳐야 한다. 이걸 배포 스크립트에 넣으면 되긴 하는데, 마이그레이션 실패 시 롤백이 까다로워서 아직 못 넣었다.
결국 GitHub Actions Docker 자동 배포의 본질은 "반복 작업을 코드로 만드는 것"이다. 처음 세팅하는 데 하루 정도 걸렸지만, 그 이후로 배포에 쓰는 시간과 스트레스가 확 줄었다. 완벽하지는 않아도 없는 것보다 100배 낫다. 배포 자동화에 관심이 있다면 GitHub Actions와 Docker Compose 조합이 진입 장벽이 제일 낮다고 생각한다. 여기서 더 나아가려면 ArgoCD 같은 GitOps 도구를 공부해볼 만하고, Kubernetes 기반 배포도 규모가 커지면 결국 가야 할 길이다.
관련 글
- AWS GitHub Actions로 ECR·ECS 자동 배포 파이프라인 구축하는 7단계 실전 가이드 – Jenkins에서 GitHub Actions로 CI/CD를 옮기면서 겪은 삽질과 해결 과정. AWS ECR에 이미지 푸시하고 ECS Far…
- Terraform S3 백엔드 + DynamoDB 락으로 팀 협업 충돌 방지하는 5단계 실전 설정 – tfstate 파일을 Git에 넣고 쓰다가 팀원과 충돌이 나서 인프라가 꼬였다. S3 원격 백엔드와 DynamoDB 상태 잠금을 설정하고 …