14 분 소요

Coolify에서 main 자동 배포 대신 GitHub Release 태그로 배포하고 롤백하기

GitHub을 공식 저장소로 사용하는 개발자라면 stage 환경에서는 main으로 배포가 되지만, 실서버는 Release를 배포의 기준으로 하는 경우가 많습니다.

이 경우 롤백을 하기 위해서는 배포 프로세스를 건너 뛰고 수동으로 배포하는 경우가 있는데요, GitHub의 기본 기능으로 롤백이 가능합니다.

Coolify의 배포 히스토리

wifinote.net 의 배포 프로세스를 구성할 때 GitHub Release를 기준으로 도커 이미지를 빌드하고 Coolify API를 통해 배포하는 구조를 구성했습니다. 실제 운영 중인 wifinote.net에 적용한 방법과 구현 과정에서 정리한 내용을 소개합니다.

1. main 자동 배포 대신 릴리스 태그를 사용하는 이유

개발 중에는 코드를 수정할 때마다 자동으로 배포되는 방식이 편리합니다. 하지만 프로덕션 환경에서는 배포할 시점을 별도로 관리하는 편이 유리할 수 있습니다.

예를 들어 main 브랜치에 여러 변경 사항이 들어왔다고 해서 모든 변경 사항을 즉시 운영 환경에 반영할 필요는 없습니다. 기능 개발과 테스트가 끝났더라도 배포를 잠시 미루고 싶을 수 있고, 운영 환경에 반영한 버전이 무엇인지 명확하게 기록하고 싶을 수도 있습니다.

GitHub Release를 배포 기준으로 사용하면 이러한 문제를 단순하게 정리할 수 있습니다. v1.3.4, v1.3.5, v1.4.0처럼 버전을 부여하고, 실제 배포할 릴리스를 선택하면 됩니다. 배포 대상이 브랜치의 최신 상태가 아니라 명시적인 버전 태그가 되므로 배포 시점과 버전을 함께 관리하기도 쉬워집니다.

게다가 GitHub 액션을 이용해서 CHANGELOG.md 를 작성하면 별다른 도큐먼트를 제작하지 않아도 실서버 배포 기록을 Issues와 PR 들과 연결을 할 수 있게 됩니다.

롤백 역시 같은 방식으로 접근할 수 있습니다. 이전 릴리스의 도커 이미지가 남아 있다면 새 이미지를 다시 빌드하지 않고 기존 버전을 배포 대상으로 지정할 수 있습니다.

2. 전체 배포 구조

현재 구성은 GitHub 액션, GitHub Container Registry(GHCR), Coolify API를 연결하는 방식입니다.

깃헙에서 빌드 후 Coolify로 배포

GitHub Release가 발행되면 GitHub 액션이 해당 릴리스 태그를 기준으로 도커 이미지를 빌드하고 GHCR에 게시합니다. 이후 Coolify API를 호출해 프로덕션 애플리케이션의 이미지 태그를 변경하고 배포를 시작합니다.

Coolify에서 도커 이미지를 빌드할 수도 있지만, CPU 사용량이 높아집니다.

Coolify에서 빌드 머신을 별도로 둘 수도 있지만, GitHub에서 빌드하는 편이 편리합니다.

배포 대상은 Laravel을 사용하기 때문에 web, worker, scheduler 세 애플리케이션입니다. 세 애플리케이션은 동일한 도커 이미지를 사용하되 실행 역할이 다르므로, 배포할 때 같은 릴리스 버전을 사용하도록 관리합니다.

전체 흐름은 다음과 같습니다.

  1. GitHub에서 v1.4.0과 같은 Release를 발행합니다.
  2. GitHub 액션가 해당 태그의 소스를 체크아웃합니다.
  3. 도커 이미지를 빌드하고 ghcr.io/replworks/wifinote:v1.4.0으로 게시합니다.
  4. Coolify API를 호출해 세 애플리케이션의 이미지 태그를 변경합니다.
  5. 각 애플리케이션의 배포를 시작합니다.
  6. Coolify가 새 컨테이너를 실행하고 헬스체크를 통과하면 기존 컨테이너를 정리합니다.

여기서 중요한 점은 이미지 빌드와 배포를 분리했다는 것입니다. 이미지를 만들었다고 해서 곧바로 프로덕션에 반영되는 것은 아닙니다. 배포 워크플로가 실행되어야 실제 운영 환경의 이미지 버전이 변경됩니다.

3. GitHub 액션에서 릴리스 태그로 이미지 빌드하기

배포 워크플로는 GitHub Release 발행 이벤트를 기준으로 실행합니다. 필요한 경우 workflow_dispatch를 통해 특정 태그를 지정하여 수동으로 실행할 수도 있습니다.

수동 실행을 지원하는 이유는 이미 발행한 릴리스를 다시 배포할 수 있어야 하기 때문입니다. 배포 과정에서 문제가 발생했거나 같은 버전을 다시 반영해야 할 때, 새로운 릴리스를 만들지 않고 기존 태그를 대상으로 작업할 수 있습니다.

이미지 이름에도 릴리스 태그를 사용합니다.

ghcr.io/replworks/wifinote:v1.4.0

production처럼 계속해서 다른 이미지를 가리키는 태그 대신 버전별 태그를 사용하면 어떤 버전의 이미지를 배포했는지 확인하기 쉽습니다. 이전 버전의 이미지도 별도로 남아 있으므로 롤백 대상으로 사용할 수 있습니다.

빌드 과정에서는 GitHub 액션의 Buildx와 캐시를 활용하고, 현재 배포 환경에 맞춰 ARM64 이미지를 빌드합니다. 또한 Laravel Nova와 같이 인증이 필요한 패키지를 설치하기 위한 자격 증명은 GitHub Secrets에 저장하고 도커 빌드 시 필요한 단계에 전달합니다.

이때 비밀 값을 이미지에 그대로 포함하지 않도록 주의해야 합니다. 빌드에 필요한 자격 증명은 가능한 한 빌드 과정에서만 사용하고 최종 이미지에 남지 않도록 구성해야 합니다.

4. Coolify API를 직접 호출하는 이유

Coolify의 자동 배포 기능은 일반적인 브랜치 기반 배포에는 편리합니다. 하지만 이번처럼 GitHub Release 태그를 배포 기준으로 삼으려면 이미지 태그를 먼저 변경한 뒤 해당 버전을 배포하는 과정이 필요합니다.

단순히 배포 웹훅을 호출하는 것만으로는 원하는 이미지 버전으로 전환된다는 것을 보장하기 어렵습니다. 웹훅이 기존에 설정된 배포 대상을 다시 배포하는 동작이라면, 먼저 배포 대상 이미지와 태그를 변경해야 하기 때문입니다.

그래서 현재 구성에서는 Coolify API를 통해 애플리케이션 설정을 수정한 다음 배포를 시작합니다.

설정 변경 요청에는 대략 다음과 같은 값이 포함됩니다.

{
  "docker_registry_image_name": "ghcr.io/replworks/wifinote",
  "docker_registry_image_tag": "v1.4.0",
  "git_commit_sha": "v1.4.0",
  "is_auto_deploy_enabled": false
}

실제 워크플로에서는 이 요청을 web, worker, scheduler 각각에 보냅니다. 이미지 태그를 변경한 뒤 각 애플리케이션의 배포 API를 호출하는 방식입니다.

여기서 git_commit_sha라는 필드에 릴리스 태그를 전달하고 있다는 점은 구분해서 볼 필요가 있습니다. 현재 워크플로에서는 배포 이력과 롤백 작업에 릴리스 식별자를 연결하기 위해 이 값을 전달하고 있습니다. 다만 필드 이름만으로 Coolify 내부에서 해당 값이 어떤 방식으로 처리되는지 단정해서는 안 됩니다. 사용 중인 Coolify 버전의 API 동작과 실제 롤백 결과를 함께 확인하는 것이 좋습니다.

또한 main에 push 될 때 마다 배포가 될 가능성이 있으므로 (오류가 나겠지만) 자동 배포는 비활성화했습니다.

5. 세 애플리케이션의 버전을 함께 관리하기

이번 구성에서 놓치지 않아야 할 부분은 web, worker, scheduler가 서로 다른 애플리케이션이지만 같은 도커 이미지를 사용한다는 점입니다.

서비스 하나에 도커 세개

이미지 태그를 변경할 때 한 애플리케이션만 변경되거나, 일부 애플리케이션의 배포만 완료된다면 서로 다른 버전이 동시에 실행될 수 있습니다. 따라서 세 애플리케이션 모두 같은 릴리스 태그를 사용하도록 관리해야 합니다.

현재 워크플로는 각 애플리케이션의 설정을 순서대로 변경하고 배포를 시작합니다. 다만 세 애플리케이션의 변경을 하나의 원자적인 작업으로 처리하는 구조는 아닙니다. 중간 단계에서 실패할 가능성까지 고려한다면 각 애플리케이션의 최종 이미지 태그와 배포 상태를 별도로 확인해야 합니다.

API 요청이 성공했다는 사실과 애플리케이션이 정상적으로 서비스를 제공한다는 사실도 다릅니다. 배포 자동화의 성공 여부를 판단할 때는 API 응답뿐 아니라 Coolify의 배포 결과, 헬스체크, 실제 서비스 상태를 함께 확인하는 것이 중요합니다.

전 Coolify 에서 배포 실패가 발생할 경우 email와 slack으로 알람이 오도록 설정해서 사용합니다.

6. 이전 릴리스로 롤백하기

롤백은 별도의 GitHub 액션 워크플로로 구성했습니다. 이 워크플로는 자동으로 실행하지 않고 수동으로 실행하며, 되돌릴 릴리스 태그를 입력받습니다.

예를 들어 현재 운영 버전이 v1.4.0이고 이전 버전인 v1.3.4로 돌아가야 한다면, 롤백 워크플로에서 해당 태그를 지정합니다. 워크플로는 Coolify의 롤백 API를 호출하면서 대상 태그를 전달합니다.

개념적으로는 다음과 같은 요청입니다.

{
  "commit": "v1.3.4"
}

실제 요청은 각 애플리케이션에 대해 수행합니다. 따라서 롤백 워크플로를 실행한 뒤에는 세 애플리케이션의 결과를 모두 확인해야 합니다. 하나의 애플리케이션에서 롤백이 성공했다고 해서 나머지 애플리케이션까지 정상적으로 되돌아갔다고 판단해서는 안 됩니다.

이 방식이 제대로 동작하려면 롤백 대상 버전의 도커 이미지가 GHCR에 남아 있어야 합니다. 버전별 이미지를 삭제하는 정책을 사용한다면 롤백할 이미지까지 함께 삭제하지 않도록 보관 정책을 설계해야 합니다.

롤백은 Coolify의 관리자 화면에서도 할 수 있지만, 전 모든 작업은 GitHub 내에서 작동하고 기록되어야 한다는 원칙을 가지고 있기 때문에 Coolify의 기능은 되도록 사용하지 않습니다.

7. 실제 롤백 결과 확인하기

실제 운영 환경에서 v1.3.4로 롤백한 기록을 확인했습니다. Coolify의 Deployment History에는 다음과 같이 기록되어 있습니다.

Status   Source    Commit    Duration
Success  Rollback  v1.3.4    00m 20s

Coolify 로그에서는 새 컨테이너가 시작된 뒤 헬스체크를 기다리고, 정상 상태를 확인한 다음 기존 컨테이너를 제거하는 과정을 확인할 수 있었습니다.

10:48:57 Starting deployment of ghcr.io/replworks/wifinote:v1.3.4
10:49:00 ... v1.3.4 Pulled
10:49:01 ... new container Started
10:49:01 Waiting for healthcheck
10:49:12 ... "healthy"
10:49:13 Removing old containers
10:49:13 Rolling update completed

이 로그에서는 이미지 다운로드와 컨테이너 시작 이후 헬스체크가 통과했고, 기존 컨테이너가 제거되면서 롤링 업데이트가 완료된 것을 확인할 수 있습니다. Deployment History에 기록된 전체 롤백 시간은 20초였습니다.

다만 이 기록은 해당 배포 작업의 결과를 보여주는 것이므로, 세 애플리케이션 전체가 정상적으로 롤백되었는지를 확인하려면 각 애플리케이션의 배포 이력과 로그도 함께 확인해야 합니다.

8. 이 방식의 한계와 운영 시 주의할 점

GitHub Release를 기준으로 배포하면 버전 관리가 명확해지지만, 모든 문제가 자동으로 해결되는 것은 아닙니다.

첫째, 세 애플리케이션의 설정 변경과 배포는 순차적으로 진행됩니다. 따라서 중간에 실패하면 일부 애플리케이션만 새 버전을 사용하게 될 수 있습니다. 배포 결과를 애플리케이션별로 확인하고, 실패 시 다시 실행하거나 이전 버전으로 복구할 수 있는 절차가 필요합니다.

둘째, API 요청이 성공했다고 해서 실제 서비스가 정상 상태라는 뜻은 아닙니다. 헬스체크와 서비스 응답까지 확인해야 배포 완료를 판단할 수 있습니다.

셋째, 롤백은 애플리케이션 이미지뿐 아니라 데이터베이스와 외부 시스템의 상태에도 영향을 받습니다. 새 버전에서 데이터베이스 스키마를 변경했다면 이전 이미지로 돌아가는 것만으로 문제가 해결되지 않을 수 있습니다. 파괴적인 데이터 변경이나 하위 호환성이 없는 마이그레이션은 별도로 고려해야 합니다.

넷째, 현재 빌드는 ARM64를 대상으로 합니다. 다른 아키텍처의 서버에 같은 이미지를 배포하려면 빌드 대상과 이미지 지원 범위를 다시 검토해야 합니다.

마지막으로 GitHub 액션에서 사용하는 토큰과 Coolify API 자격 증명은 GitHub Secrets로 관리하고, 필요한 권한만 부여해야 합니다. 배포 권한을 가진 자격 증명이 로그나 이미지에 노출되지 않도록 하는 것도 중요합니다.

마치며

이번 구성의 핵심은 배포를 코드 변경에 자동으로 연결하지 않고, 명시적인 릴리스 버전에 연결한 것입니다. GitHub Release를 배포 기준으로 삼고, 버전별 도커 이미지를 GHCR에 보관하며, Coolify API로 해당 버전을 배포하거나 이전 버전으로 되돌릴 수 있도록 구성했습니다.

이렇게 하면 어떤 버전을 배포할지 직접 결정할 수 있고, 문제가 생겼을 때도 새로운 이미지를 다시 빌드하는 대신 기존 릴리스 태그를 기준으로 복구를 시도할 수 있습니다.

물론 API 요청이 성공하는 것과 서비스가 정상적으로 동작하는 것은 별개의 문제입니다. 특히 여러 애플리케이션을 함께 배포하는 환경에서는 각 애플리케이션의 상태를 확인하는 절차까지 배포 과정에 포함해야 합니다.

자동 배포 자체가 나쁜 것은 아닙니다. 다만 프로덕션 환경에서는 자동화의 편리함뿐 아니라 배포 시점, 버전 식별, 복구 절차까지 함께 설계해야 합니다. 이번 구성은 그 과정을 GitHub Release와 Coolify API를 이용해 명시적으로 관리하는 방법입니다.

끝으로 Coolify 에 Rolling Update 를 사용하면 무중단 배포가 가능합니다. 다만 /health 요청을 미리 구현해 놓고 Coolify Health 기능과 연결을 해 놓아야 합니다. 배포가 끝나고 /health 에 접근이 되야 기존 도커연결을 끊고 새 도커와 연결을 합니다.

만약 도커 뿐만이 아니라 서버 자체가 장애가 발생했다면, Coolify에서 데이터베이스 백업을 지원하기 때문에 어렵지 않게 복구가 가능합니다. 다만, 스토리지 백업은 지원하되 복구를 지원하지 않습니다. 복구를 수월하게 하기 위해서 Cool Restore 패키지를 오픈했습니다. 이에 대해서는 이 글에서 자세한 내용을 확인할 수 있습니다.