Porta
Porta CI/CD 파이프라인 구축기: 변경 감지부터 선택 배포까지

엔지니어링

Porta CI/CD 파이프라인 구축기: 변경 감지부터 선택 배포까지

Porta가 어떻게 변경 감지, 선택 테스트, GHCR 이미지 빌드, 운영 배포 자동화를 하나의 흐름으로 묶었는지 정리해요.

이 글의 동작 설명에 참고한 공식 문서:

핵심 목표는 단순했어요.

  1. 바뀐 영역만 테스트하고
  2. 필요한 이미지만 빌드하고
  3. 운영 서버에서는 필요한 서비스만 안전하게 재기동하는 것

처음부터 거대한 배포 플랫폼을 도입한 건 아니고, GitHub Actions와 GHCR, 그리고 self-hosted deploy runner를 최대한 단순하게 연결하는 방향을 택했어요.

이 파이프라인이 풀고 싶었던 문제

모노레포에서 API와 Web이 같이 움직이기 시작하면 금방 이런 문제가 생겨요.

  • Web만 바뀌었는데 API 테스트까지 매번 다 돌게 된다
  • API만 바뀌었는데 Web 이미지까지 불필요하게 다시 만든다
  • 운영 반영은 결국 사람이 서버에 들어가서 하나씩 확인하게 된다
  • 실패했을 때 어느 단계에서 끊겼는지 로그를 다시 뒤져야 한다

이번 CI/CD 구조는 이 네 가지를 줄이는 쪽으로 설계돼 있어요.

1. 시작은 항상 변경 감지예요

workflow의 첫 job은 detect-changes예요.

여기서 apps/api/**, apps/web/**, docker-compose.yml, 그리고 수동 실행 입력값을 읽어 어떤 범위를 건드려야 하는지 먼저 결정해요.

if [[ "$compose_changed" == "true" ]]; then
  deploy_services="all"
elif [[ "$api_changed" == "true" && "$web_changed" == "true" ]]; then
  deploy_services="api,web"
elif [[ "$api_changed" == "true" ]]; then
  deploy_services="api"
elif [[ "$web_changed" == "true" ]]; then
  deploy_services="web"
fi

이 결정이 뒤의 테스트, 이미지 빌드, deploy job까지 그대로 전달돼요.

즉 "무엇이 바뀌었는지"를 맨 앞에서 결정하고, 나머지는 그 결과만 소비하는 구조예요.

이게 단순하지만 꽤 중요해요.

복잡한 조건문이 각 job 안에 흩어지지 않고, 변경 범위가 하나의 출력으로 정리되기 때문이에요.

2. 테스트와 이미지 빌드도 선택적으로 움직여요

문서와 workflow를 같이 보면, 테스트는 아래처럼 분리돼 있어요.

  • API 테스트: uv run pytest -q
  • Web 테스트: npm ci && npm test

그리고 둘 다 변경이 있을 때만 돌아요.

예를 들어 apps/web/**만 바뀌면 web-tests만 돌고, apps/api/**만 바뀌면 api-tests만 돌아요.

이미지 빌드도 같은 방식이에요.

  • api-image는 API가 바뀌었을 때만
  • web-image는 Web이 바뀌었을 때만

또 하나 괜찮았던 점은 push와 publish를 분리한 것이에요.

PR이나 브랜치에서는 이미지를 빌드해서 검증만 하고, 실제 GHCR push는 main에서만 이뤄져요.

이렇게 하면 리뷰 단계에서는 검증 비용만 쓰고, 운영 반영 가능한 이미지는 기본 브랜치 기준으로만 관리할 수 있어요.

3. 운영 배포는 self-hosted deploy runner에서 끝나요

배포 런북에서 특히 눈에 띄는 부분은 SSH 기반 수동 배포를 걷어내고, self-hosted deploy runner가 배포 단계를 맡도록 했다는 점이에요.

deploy job은 아래 전제를 명확하게 둬요.

  • runner labels: self-hosted, linux, x64
  • 구성된 배포 경로: $DEPLOY_PATH
  • 배포 환경에서 Docker와 Docker Compose 사용 가능
  • deploy workspace에 docker-compose.yml을 쓸 수 있어야 함

그 다음 순서는 꽤 직선적이에요.

  1. 저장소에서 최신 docker-compose.yml을 checkout
  2. 배포 경로로 docker-compose.yml 동기화
  3. docker compose config >/dev/null로 환경 검증
  4. 변경된 서비스만 pull
  5. 변경된 서비스만 up -d --wait --wait-timeout 180

docker-compose.yml 자체가 바뀌면 여기서는 보수적으로 전체 docker compose up -d --wait --wait-timeout 180을 실행해요.

즉 서비스 코드 변경과 인프라 wiring 변경을 같은 수준으로 취급하지 않아요.

그 차이를 workflow가 알고 있는 셈이에요.

4. 운영 배포에서 중요한 건 성공보다 실패 처리예요

이 workflow에서 개인적으로 좋았던 부분은 실패 처리를 꽤 명시적으로 쓴 점이에요.

단순히 docker compose up만 하고 끝내지 않아요.

배포 step 안에는 docker compose pull 실패를 위한 retry 함수가 있고, 최대 4번까지 backoff를 두고 다시 시도해요.

pull_service_with_retry() {
  local service="$1"
  local attempt=1
  local max_attempts=4
  local delay_seconds=5
 
  while true; do
    if docker compose pull "$service"; then
      return 0
    fi
 
    if [[ "$attempt" -ge "$max_attempts" ]]; then
      return 1
    fi
 
    sleep "$delay_seconds"
    attempt=$((attempt + 1))
    delay_seconds=$((delay_seconds * 2))
  done
}

그리고 실패하면 바로 아래 단계에서 진단 정보를 모아요.

  • docker compose ps
  • 변경된 서비스의 최근 로그
  • all 배포였던 경우 더 넓은 범위의 서비스 진단 정보

운영에서 중요한 건 "실패했다"보다 "어디서 실패했는지 바로 알 수 있느냐"라서, 이 부분은 문서화된 운영 루프와도 잘 맞아요.

Selective deploy rules and deploy step excerpt

5. 배포 알림도 운영 정보 중심으로 보내요

Slack 알림 payload를 보면 감정적인 메시지보다 운영 정보가 우선이에요.

  • 환경
  • 어떤 서비스가 배포됐는지
  • 커밋 SHA
  • 실행한 actor
  • 배포 결과
  • workflow 맥락

이 정도면 "무엇이, 어느 환경에, 어떤 결과로" 반영됐는지를 채널에서 바로 볼 수 있어요.

운영 대응에서 필요한 최소 정보만 보내는 방향으로 잘 정리돼 있어요.

6. 이 설계에서 남겨둔 트레이드오프

이 설계에는 아직 의도적으로 수동으로 남겨둔 것도 있어요.

대표적으로 SQLite에서 PostgreSQL로의 1회성 데이터 이관은 자동화하지 않았어요.

배포 문서도 여기서는 수동 cutover 절차를 메인 파이프라인과 분리해 둬요.

이건 좋은 선택이라고 봐요.

한 번성 데이터 전환까지 배포 workflow에 섞어버리면 실패 반경이 너무 커지기 때문이에요.

또 다른 trade-off는 self-hosted runner 자체가 운영 자산이라는 점이에요.

GitHub-hosted runner보다 인프라 의존성은 늘어나지만, 배포 단계가 실제 런타임과 가까운 곳에서 실행된다는 장점이 있어요.

마무리

이번 CI/CD 파이프라인에서 중요한 건 화려한 도구 조합이 아니라 흐름의 명확성이었어요.

  • 변경 감지는 맨 앞에서 한 번만
  • 테스트와 이미지 빌드는 범위에 맞게
  • 배포는 production runner에서 필요한 서비스만
  • 실패 시 진단과 배포 알림까지 같은 workflow 안에서

Porta처럼 API, Web, 배포 문서가 함께 움직이는 프로젝트에서는 이 정도의 명시성이 유지보수 비용을 많이 줄여줘요.

다음에 이 구조를 더 다듬는다면, deploy 단계의 메트릭 수집과 post-deploy smoke check를 같은 흐름 안에 넣는 쪽을 먼저 볼 것 같아요.