Coolify HEALTHCHECK 버그를 발견하고 오픈소스 PR을 올리기까지
1탄에서 NestJS 크론 작업을 Worker 프로세스로 분리하는 작업을 마쳤습니다. 코드 쪽은 깔끔하게 정리되었고, 남은 건 배포뿐이었습니다. Coolify에서 Worker용 애플리케이션을 만들고, Dockerfile의 CMD만 바꿔서 올리면 끝이라고 생각했습니다. 그런데 배포 직후, 예상치 못한 곳에서 문제가 생겼습니다.
HEALTHCHECK가 안 꺼진다
Worker는 HTTP 서버를 띄우지 않습니다. createApplicationContext로 NestJS DI 컨테이너만 부팅하는 구조니까요. 당연히 HEALTHCHECK는 필요 없습니다. 헬스체크 엔드포인트를 호출할 HTTP 서버 자체가 없으니까요.
처음에는 Dockerfile을 하나만 두고, multi-stage target으로 API와 Worker를 나눠쓰려 했습니다. api stage에는 HEALTHCHECK와 EXPOSE가 있고, worker stage에는 둘 다 없는 구조입니다.
# --- api stage ---
FROM node:20-alpine AS api
HEALTHCHECK --interval=30s CMD curl -f http://localhost:3000/health
EXPOSE 3000
CMD ["node", "main.js"]
# --- worker stage ---
FROM node:20-alpine AS worker
CMD ["node", "worker.js"]
Coolify에서 dockerfile_target_build를 worker로 설정했습니다. Health Check UI에서도 비활성화했습니다. 설정할 수 있는 건 다 했습니다.
그런데 Worker 컨테이너가 계속 unhealthy로 표시되었습니다. Docker 공식 문법인 HEALTHCHECK NONE을 worker stage에 추가해봤지만 결과는 동일했습니다.
소스코드를 뒤져보다
설정으로 해결이 안 되니, Coolify 소스코드를 직접 확인해보기로 했습니다. HEALTHCHECK 감지 로직이 어딘가에 있을 거라는 생각이었습니다.
Application.php의 2146번째 줄 부근에서 원인을 찾았습니다.
$hasHealthcheck = str($dockerfile)->contains('HEALTHCHECK');
Dockerfile 전체 텍스트에서 "HEALTHCHECK"라는 문자열이 포함되어 있는지만 확인합니다. dockerfile_target_build로 어떤 stage를 빌드하든, stage 구분 없이 감지합니다. HEALTHCHECK NONE도 "HEALTHCHECK"라는 문자열을 포함하고 있으니 동일하게 걸립니다.
즉, 하나의 Dockerfile에 HEALTHCHECK가 어디에든 한 번이라도 적혀 있으면, Coolify는 해당 애플리케이션에 HEALTHCHECK가 있다고 판단하는 것이었습니다.
기능 추가 시점의 불일치
이 버그의 근본 원인이 흥미로웠습니다. 두 기능이 서로 다른 시점에 추가되면서 생긴 문제였습니다.
- 2023년 11월:
dockerfile_target_build추가 — multi-stage Dockerfile에서 특정 target만 빌드하는 기능 - 2024년 3월: HEALTHCHECK 자동 감지 로직 추가 — Dockerfile에서 HEALTHCHECK 구문을 찾아
custom_healthcheck_found플래그를 설정 - 2025년 3월:
parseHealthcheckFromDockerfile로 리팩토링
HEALTHCHECK 감지가 추가될 때, 이미 4개월 전에 도입된 dockerfile_target_build를 고려하지 않은 것입니다. "Dockerfile에 HEALTHCHECK가 있으면 헬스체크를 켜자"는 자동화가, multi-stage 사용자의 유연성을 오히려 깨뜨린 셈이었습니다.
자동화 기능을 추가할 때, 기존에 있던 기능과의 조합을 놓치는 건 생각보다 흔한 일이라는 생각이 들었습니다. 기능 하나하나는 합리적이지만, 조합했을 때 예상치 못한 결과가 나오는 것입니다.
일단 워크어라운드
소스코드를 수정할 수 있을 때까지 기다릴 수는 없었습니다. 배포가 막혀 있었으니까요.
Dockerfile을 두 개로 분리했습니다. Dockerfile.coolify는 API용, Dockerfile.coolify.worker는 Worker용입니다. Worker Dockerfile에는 HEALTHCHECK와 EXPOSE를 아예 넣지 않았습니다. builder stage가 중복되는 게 마음에 걸리지만, Coolify의 문자열 검색을 확실하게 피하는 방법이었습니다.
이걸로 Worker 배포는 해결되었습니다. 하지만 근본적인 문제는 여전히 남아 있었습니다.
이슈 제보
Coolify GitHub에 이슈 #9475를 올렸습니다. 재현 단계, 소스코드 근거, 수정 제안을 포함했습니다.
이슈를 쓸 때 신경 쓴 점은 몇 가지 있었습니다. 단순히 "안 돼요"가 아니라, 왜 안 되는지 소스코드 레벨에서 근거를 제시하는 것이 중요하다고 생각했습니다. 재현 단계를 구체적으로 적어서 메인테이너가 바로 확인할 수 있게 했고, 최신 버전(v4.0.0-beta.470)에서도 동일한 코드인지 확인한 후 올렸습니다.
코드 수정 — PR 제출
이슈만 올리고 끝낼 수도 있었지만, 소스코드를 이미 분석한 상태였으니 직접 수정해보기로 했습니다.
환경 세팅
Coolify는 PHP + Laravel 기반입니다. 로컬에 PHP 개발 환경이 없었기 때문에 Composer와 Pest(테스트 프레임워크)를 설치하는 것부터 시작했습니다. 익숙하지 않은 언어의 프로젝트에 기여하려면 이런 환경 세팅이 첫 번째 허들입니다.
Application.php 수정
수정의 핵심은 extractTargetStageLines() 메서드를 추가하는 것이었습니다.
private function extractTargetStageLines($allLines)
{
$targetStage = $this->dockerfile_target_build;
if (empty($targetStage)) {
return $allLines; // 기존 동작 유지
}
// FROM ... AS <stage> 패턴으로 stage 경계 파싱
// target stage 범위 내 라인만 반환
}
이 메서드가 하는 일은 단순합니다. FROM ... AS <stage> 패턴으로 stage 경계를 파싱하고, dockerfile_target_build에 지정된 target stage 범위 내의 라인만 반환합니다. 기존의 HEALTHCHECK 감지 로직은 이 메서드가 반환한 라인에서만 검색하도록 변경했습니다.
추가로 HEALTHCHECK NONE 패턴을 만나면 즉시 return하도록 처리했습니다. Docker 공식 문법대로, HEALTHCHECK를 명시적으로 비활성화하는 것을 존중하는 것입니다.
dockerfile_target_build가 설정되지 않았을 때는 기존과 동일하게 Dockerfile 전체를 검색합니다. 하위호환성을 깨뜨리지 않는 것이 중요했습니다.
테스트 작성
Pest로 테스트 6개를 작성했습니다.
| 테스트 케이스 | 기대 결과 |
|---|---|
| target=api | HEALTHCHECK 감지됨 |
| target=worker (핵심) | HEALTHCHECK 감지 안 됨 |
| target 미설정 | 전체 라인 검색 (기존 동작) |
| HEALTHCHECK NONE | 명시적 비활성화 |
| 대소문자 혼합 | 대소문자 무시하고 감지 |
| 존재하지 않는 target | 전체 라인으로 fallback |
두 번째 케이스가 이번 버그의 핵심입니다. 기존 코드에서는 이 테스트가 실패합니다 — worker stage에 HEALTHCHECK가 없는데도 api stage의 것이 감지되니까요. 기존 테스트 3개도 모두 통과하는 것을 확인했습니다.
PR 제출 삽질
코드 수정보다 PR 제출이 더 험난했습니다.
첫 번째 PR(#9476)은 Coolify의 quality check bot이 자동으로 닫아버렸습니다. 원인이 여러 가지였습니다.
- target branch:
main이 아니라next로 보내야 했습니다 - PR 템플릿: Contributor Agreement 섹션이 포함된 필수 템플릿을 사용해야 했습니다
- blocked terms: 특정 AI 도구 관련 문구가 차단 목록에 있었습니다
두 번째 PR(#9477)에서는 모든 조건을 맞춰서 제출했고, quality check를 통과했습니다. 현재 메인테이너 리뷰를 기다리고 있습니다.
정리
Worker 배포에서 만난 HEALTHCHECK 문제가, 결국 Coolify 소스코드를 분석하고 PR을 제출하는 데까지 이어졌습니다. 처음부터 오픈소스 기여를 하겠다고 계획한 것은 아니었습니다. 배포 삽질을 하다 보니 버그를 발견했고, 소스코드를 읽다 보니 수정할 수 있겠다는 생각이 들었고, 수정했으니 PR을 올리는 게 자연스러운 흐름이었습니다.
PR이 머지되면 contributor 목록에 이름이 올라갑니다. 닫히고 메인테이너가 직접 구현하더라도 이슈 보고자로서의 기록은 남습니다. 하지만 어떤 결과가 나오든, 버그 발견에서 소스 분석, 수정, 테스트, PR 제출까지의 전 과정을 경험한 것 자체가 의미 있다는 생각이 들었습니다.
돌이켜보면, 이 모든 게 "HEALTHCHECK가 왜 안 꺼지지?"라는 사소한 의문에서 시작되었습니다. 설정으로 해결이 안 될 때, 소스코드를 직접 읽어보는 습관이 때로는 예상치 못한 곳으로 이끌어줍니다.
관련 글
Deep Health Check로 Next.js 배포 안정성 높이기: 롤링 업데이트의 함정을 피하는 방법
Coolify 롤링 업데이트에서 Health Check가 성공해도 실제 페이지 렌더링이 실패하는 문제를 경험했습니다. ESM 모듈 호환성 에러와 정적 렌더링 실패를 해결하고, Deep Health Check를 구현하여 배포 안정성을 개선한 과정을 공유합니다.
SaaS 블로그 플랫폼을 Coolify로 배포하며 마주친 실전 문제들
글력(SaaS 블로그 플랫폼)을 Coolify에 처음 배포하면서 겪은 메모리 최적화, OG 이미지 문제, 봇 스캔 방어, Redis 연결, CORS까지. 실전에서 하나씩 해결한 과정을 정리했습니다.
Liveness와 Readiness: 컨테이너 헬스체크의 두 가지 관점
Docker나 Kubernetes 환경에서 헬스체크를 구현할 때 Liveness와 Readiness의 차이를 이해하고, NestJS Backend와 Next.js Web에서 실제로 구현한 경험을 정리했습니다.