GitHub Actions 기반 Blue-Green 배포를 GitLab CI/CD로 이전한 과정
GitHub Actions 무료 사용량 한계에 도달하면서 Podman, Caddy, Infisical 기반 무중단 배포 환경을 GitLab CI/CD로 이전한 기록입니다.
vibePulse는 Podman, Caddy, Infisical을 기반으로 Blue-Green 무중단 배포를 운영하고 있습니다.
서비스 규모는 크지 않지만, 배포 과정에서는 Web, API, Worker, DB Migration 이미지를 각각 빌드하고 배포합니다. 초기에는 GitHub Actions를 사용해 큰 문제 없이 운영하고 있었습니다.
하지만 예상보다 빠르게 한계에 도달했습니다.
프로젝트 하나를 운영한 지 한 달도 되지 않았는데 GitHub Actions 무료 제공 시간 2,000분의 90% 이상을 사용했다는 경고를 받았습니다.
Blue-Green 배포 구조에서는 컨테이너 이미지를 자주 빌드하고 배포하게 됩니다. 지금은 작은 프로젝트라도 서비스가 성장하고 배포 빈도가 늘어나면 GitHub Actions 사용량은 계속 증가할 가능성이 높았습니다.
결국 비용 문제는 시간 문제라고 판단했습니다.
그래서 이번 작업의 목표는 명확했습니다.
- GitHub Actions → GitLab CI/CD 이전
- 기존 Blue-Green 배포 구조 유지
- 무중단 배포 유지
- 자동 롤백 유지
- Podman, Caddy, Infisical 구성 유지
즉, 새로운 배포 구조를 만드는 것이 아니라 기존 배포 시스템을 GitHub에서 GitLab로 이사시키는 작업이었습니다.
기존 배포 구조
이전 작업을 시작하기 전에 현재 구조를 다시 정리했습니다.
Build
→ Push Image
→ SSH
→ Blue Slot 기동
→ Health Check
→ DB Migration
→ Caddy Switch
→ Public Health Check
→ Old Slot 제거
트래픽은 항상 Caddy를 통해 전달되며, 새로운 버전은 사용 중이지 않은 슬롯에 먼저 배포됩니다.
정상 동작이 확인되면 트래픽을 전환하고, 문제가 발생하면 롤백하는 방식입니다.
기존 아키텍처

GitLab Runner 구축
먼저 GitLab Runner를 설치하고 등록했습니다.
정상 등록 여부를 확인했습니다.
Running with gitlab-runner 19.1.0
on vibepulse-podman-runner
Runner 등록은 문제없이 완료되었습니다.
하지만 첫 번째 Deploy Job이 실행되지 않았습니다.
deploy
Pending
This job has not started yet
This job is in pending state and is waiting to be picked by a runner
처음에는 Runner 상태 문제라고 생각했습니다.
하지만 원인은 단순했습니다.
GitLab Job에 지정된 Tag와 Runner Tag가 일치하지 않았습니다.
Runner Tag를 수정한 후 Deploy Job이 정상적으로 실행되었습니다.
기존 Blue-Green 구조 점검
CI/CD 플랫폼을 바꾸기 전에 먼저 배포 스크립트가 어떤 구조인지 확인했습니다.
grep -nE "blue|green|ACTIVE|Caddy|health|rollback|compose" scripts/deploy.sh
결과를 확인해 보니 이미 필요한 기능이 대부분 구현되어 있었습니다.
blue) echo "3110 3111 3112"
green) echo "3210 3211 3212"
switching Caddy
public health gate
rollback
즉, 배포 전략 자체를 다시 만들 필요는 없었습니다.
GitHub Actions에 의존하던 부분만 제거하고 GitLab CI/CD에 연결하면 되는 상태였습니다.
Blue-Green 포트 구성
슬롯별 포트 구성은 다음과 같습니다.
| Slot | Web | API | Worker |
|---|---|---|---|
| Blue | 3110 | 3111 | 3112 |
| Green | 3210 | 3211 | 3212 |
트래픽 전환은 모두 Caddy가 담당합니다.
Caddy 구성 정리
현재 Caddy 상태를 확인했습니다.
sudo systemctl enable --now caddy
sudo systemctl status caddy --no-pager
결과는 정상입니다.
Active: active (running)
방화벽도 함께 확인했습니다.
sudo systemctl is-active firewalld
sudo firewall-cmd --state
active
running
허용된 서비스는 다음과 같습니다.
dhcpv6-client
http
https
ssh
80, 443 포트 모두 정상적으로 개방되어 있었습니다.
Caddy Import 구조 적용
기존 Caddyfile은 기본 상태였습니다.
:80 {
root * /usr/share/caddy
file_server
}
하지만 deploy.sh를 확인해 보니 배포 시 다음 파일을 생성하도록 되어 있었습니다.
/etc/caddy/sites/vibepulse.caddy
즉, Import 기반 구성을 전제로 작성된 스크립트였습니다.
그래서 Caddyfile을 다음과 같이 수정했습니다.
import /etc/caddy/sites/*
적용 과정은 다음과 같습니다.
sudo mkdir -p /etc/caddy/sites
printf 'import /etc/caddy/sites/*\n' | sudo tee /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
결과는 정상입니다.
Valid configuration
Reloaded Caddy
GitHub 환경변수 의존성 제거
GitLab에서 첫 배포를 시도하자 GitHub 시절의 흔적이 드러나기 시작했습니다.
OWNER required
첫 번째 에러는 다음과 같았습니다.
OWNER required
원인은 다음 코드였습니다.
: "${OWNER:?OWNER required}"
GitHub Actions에서는 자연스럽게 사용되던 값이 GitLab에서는 존재하지 않았습니다.
해결 방법은 간단했습니다.
export OWNER="$CI_REGISTRY_IMAGE"
GitLab Registry 경로를 그대로 활용하도록 변경했습니다.
HEALTH_URL required
두 번째 에러입니다.
HEALTH_URL required
원인은 동일했습니다.
: "${HEALTH_URL:?HEALTH_URL required}"
환경별 Health URL을 동적으로 읽어오도록 수정했습니다.
eval "HEALTH_URL=\${${DEPLOY_PREFIX}_HEALTH_URL}"
이후 환경에 따라 적절한 URL이 자동으로 선택되었습니다.
Registry 구조 재설계
다음 문제는 이미지 Pull 단계에서 발생했습니다.
manifest unknown
로그를 확인해 보니 이미지 경로가 이상했습니다.
ghcr.io/registry.goodtek.xyz/...
GitHub Container Registry 기준으로 작성된 구조가 GitLab Registry와 충돌하고 있었습니다.
기존 구조
ghcr.io/<owner>/vibepulse-web
ghcr.io/<owner>/vibepulse-api
ghcr.io/<owner>/vibepulse-worker
변경 후
registry.goodtek.xyz/goodtek/vibepulse/web
registry.goodtek.xyz/goodtek/vibepulse/api
registry.goodtek.xyz/goodtek/vibepulse/worker
registry.goodtek.xyz/goodtek/vibepulse/db-migrate
Registry 구조를 GitLab 기준으로 재설계했습니다.

Compose 및 Deploy 스크립트 수정
기존 Compose 설정은 GHCR을 직접 참조하고 있었습니다.
기존 설정입니다.
image: ghcr.io/${GHCR_OWNER}/vibepulse-web:${IMAGE_TAG}
변경 후에는 Registry 종류와 무관하게 사용할 수 있도록 수정했습니다.
image: ${IMAGE_REGISTRY}/web:${IMAGE_TAG}
API와 Worker도 동일하게 변경했습니다.
image: ${IMAGE_REGISTRY}/api:${IMAGE_TAG}
image: ${IMAGE_REGISTRY}/worker:${IMAGE_TAG}
배포 스크립트에서도 GitHub Registry 관련 변수들을 제거했습니다.
기존:
GHCR_OWNER
GHCR_USER
GHCR_PULL_TOKEN
변경:
IMAGE_REGISTRY
이미지 Pull 코드 역시 단순해졌습니다.
img="${IMAGE_REGISTRY}/${svc}:${IMAGE_TAG}"
Registry 인증 문제
배포 도중 또 하나의 문제가 발생했습니다.
403 Forbidden
원인은 서버 측 Registry 인증 실패였습니다.
GitHub Container Registry 기준으로 작성된 로그인 방식을 GitLab Registry 기준으로 변경한 뒤 정상적으로 이미지 Pull이 가능해졌습니다.
최종 Pipeline
최종 Pipeline은 다음과 같이 구성했습니다.
CI
→ Build Web
→ Build API
→ Build Worker
→ Build DB-Migrate
→ Deploy
실행 결과는 다음과 같습니다.
Pipeline #17
Passed
build:web
build:api
build:worker
build:db-migrate
deploy
모든 Job이 정상적으로 통과했습니다.
GitHub Actions와 GitLab CI/CD 비교
이번 이전 작업에서 변경된 요소를 정리하면 다음과 같습니다.
| 항목 | 이전 | 이후 |
|---|---|---|
| CI/CD | GitHub Actions | GitLab CI/CD |
| Registry | GHCR | GitLab Registry |
| Runner | GitHub Hosted Runner | Self-hosted Runner |
| Secret 관리 | Infisical | Infisical |
| Reverse Proxy | Caddy | Caddy |
| Runtime | Podman | Podman |
| Deployment | Blue-Green | Blue-Green |
| Rollback | 자동 | 자동 |
배포 전략 자체는 거의 변경되지 않았습니다.
최종 Blue-Green 배포 흐름
최종 배포 흐름은 다음과 같습니다.

마치며
이번 이전 작업을 통해 다시 한번 확인할 수 있었습니다.
좋은 배포 구조는 특정 CI/CD 플랫폼에 의존하지 않습니다.
CI/CD는 교체할 수 있어야 하고, 배포 전략은 독립적으로 유지될 수 있어야 합니다.
이번 GitLab CI/CD 이전 과정에서도 Blue-Green 배포, 자동 롤백, Health Check, Secret 관리 구조를 거의 수정하지 않고 유지할 수 있었습니다. 이는 배포 전략이 GitHub Actions 자체에 종속되어 있지 않았기 때문입니다.
결과적으로 GitHub Actions 사용량과 비용 부담은 줄이면서도 기존 무중단 배포 경험은 그대로 유지할 수 있었습니다.
앞으로 다른 CI/CD 플랫폼으로 이전하더라도 동일한 원칙을 적용할 수 있을 것이라고 생각합니다.
배포 전략은 남기고, CI/CD는 교체할 수 있게 만드는 것.
이번 이전 작업에서 얻은 가장 큰 교훈이었습니다.