본문 바로가기
프로그래밍

서버 한 대로 무중단 배포하기: Caddy 헬스체크가 못 막는 502

by me_in_sk 2026. 8. 24.
반응형
이미지 편집 엔진

서버 한 대로 무중단 배포하기: Caddy 헬스체크가 못 막는 502

상태 위치 조사부터 재시도 설정까지, 실측 557건으로 확인한 절차
핵심 요약
  • 무중단의 전제는 서버 대수가 아니라 상태의 위치다. 상태를 안 가진 계층만 교체할 수 있고, 그 계층만 골라내면 VPS 한 대로도 된다.
  • 인스턴스를 둘 띄우되 로드밸런싱은 하지 않는 선택도 정당하다. 프로세스 메모리에 사는 것(요청 한도 카운터 등)이 있으면 트래픽을 나누는 순간 그 값이 두 배가 된다.
  • 헬스체크만으로는 못 막는 구간이 원리적으로 남는다. 관찰이 주기적이기 때문이다. 롤링 중 555번 중 3번이 502였다.
  • 연결 실패에만 재시도를 걸어 그 구간을 덮었다. 재측정 557번 중 실패 0, 전체 교체 54초.

1.서버 한 대로 무중단이 되는 조건

배포할 때마다 사이트가 몇십 초씩 끊긴다. 흔한 첫 반응은 "서버를 한 대 더 사야 하나"인데, 대수를 늘리기 전에 먼저 답해야 할 질문이 있다. 내 스택에서 상태는 어디 있는가.

교체할 수 있는 것은 상태를 안 가진 계층뿐이다. 그러니 조사부터 한다 — 각 컨테이너를 지금 죽이면 무엇이 사라지는지를 적어 본다.

브라우저 편집 내용, 파일 생성, 사용자 설정 서버 재시작과 무관 추론 작업 결과 (프로세스 메모리, 30분 TTL) 재시작하면 전부 사라짐 요청 한도 카운터뿐. 그 외 무상태 교체 가능

이 표가 목표를 정해 준다. 편집은 브라우저에서 일어나고 웹은 페이지 서버 겸 프록시라, 웹 인스턴스를 갈아 끼우면 그 순간 날아가던 요청만 끊긴다. 반면 추론 컨테이너를 재시작하면 보관 중이던 결과가 전부 사라진다 — 편집하던 사용자가 작업을 잃는다.

그래서 웹만 무중단으로 교체하기로 범위를 좁혔다. 욕심을 줄인 결정 같지만 실용적으로는 대부분을 덮는다. 배포의 대다수가 문구·페이지·정적 자산 같은 웹 변경이기 때문이다. 추론 쪽 변경은 여전히 중단을 동반하고, 그건 이 글이 해결하지 않는다.

먼저 할 일은 설정이 아니라 조사다

"무중단 배포"를 검색하면 바로 blue-green 설정이 나오지만, 그 앞에 어느 계층이 무상태인지 확인하는 단계가 있다. 프로세스 메모리에 캐시·세션·카운터·작업 결과 중 하나라도 들고 있으면 그 계층은 교체 대상이 아니다. 이 목록을 안 만들고 시작하면 무중단인 줄 알았던 배포가 조용히 무언가를 지운다.

2.인스턴스를 둘 띄우면 무엇이 같이 두 배가 되나

같은 이미지를 두 번 띄우고 앞에 프록시를 두는 것까지는 평범하다. 그런데 여기서 갈리는 결정이 하나 있다. 둘에 트래픽을 나눌 것인가.

성능만 보면 나누는 게 당연해 보인다. 그런데 앞에서 만든 목록을 다시 보면 웹에도 상태가 하나 있었다 — 요청 한도 카운터다. 이건 각 프로세스의 메모리에 산다.

나누면 생기는 일text
공표한 한도    IP당 분 10회

인스턴스 1대   한 프로세스가 10까지 센다           -> 실제 10
인스턴스 2대   각자 절반씩 받아 각자 10까지 센다   -> 실제 20

로드밸런싱을 켜는 순간 남용 방어가 조용히 절반으로 약해진다. 성능 설정이 기능 변경이 되는 것이고, 아무 에러도 나지 않으니 알아채기 어렵다. 인메모리 캐시나 스티키하지 않은 세션이 있어도 같은 종류의 일이 생긴다.

선택지는 둘이다. 카운터를 공유 저장소로 옮기거나, 애초에 나누지 않거나. 여기서는 두 번째를 골랐다. 두 번째 인스턴스를 둔 목적이 성능이 아니라 교체이므로, 평소에 놀아도 손해가 없기 때문이다.

Caddyfile — 나누지 않는 이중화caddyfile
reverse_proxy web:3000 web-standby:3000 {
    lb_policy first          # 살아 있는 것 중 목록에서 가장 앞선 것
    health_uri /api/health
    health_interval 5s
    fail_duration 10s
}

first는 평상시 첫 번째만 쓰다가, 그 인스턴스가 사라지면 두 번째로 넘어가고, 돌아오면 곧바로 되돌린다. 대기 인스턴스가 트래픽을 받는 시간은 배포하는 몇십 초뿐이다.

3.의존 서비스를 건드리지 않고 하나만 교체하기

Compose에서 서비스 하나만 다시 만들려면 --no-deps가 필요하다. 웹이 추론에 depends_on으로 걸려 있으면, 이게 없을 때 compose가 의존 대상까지 함께 재생성하기 때문이다.

web 롤링 교체bash
$C up -d --no-deps --build web-standby   # ① 대기 인스턴스 먼저
$C up -d --no-deps --build web           # ② 그동안 standby가 받는다

이 단어가 위험한 이유는 빠뜨려도 아무 신호가 없다는 데 있다. 두 명령 모두 성공하고, 컨테이너는 뜨고, 사이트는 열린다. 차이는 그 시각에 작업 중이던 사람에게만 보인다.

기억에 의존하는 절차는 언젠가 틀린다

한 단어를 매번 기억하는 것으로 사용자 작업을 지키는 구조는 오래 못 간다. 급할 때, 새벽에, 혹은 다른 사람이 배포할 때 빠진다. 손으로 두 번 이상 친 배포 명령은 스크립트로 옮기는 게 맞다. 여기서 스크립트가 하는 일은 자동화가 아니라 기억을 대신하는 것이다.

4.헬스체크를 걸어 뒀는데 왜 502가 남나

여기까지 하면 끝난 것 같다. 헬스체크가 죽은 인스턴스를 걸러 주고, 정책이 살아 있는 쪽을 고른다. 실제로 그런지 재 봤다 — 롤링 배포를 돌리면서 0.25초 간격 3스레드로 요청을 계속 넣었다.

1차 측정text
총 요청   555
실패        3   ──▶ 502 Bad Gateway

헬스체크를 5초 간격으로 걸어 뒀는데도 뚫렸다. 이유는 설정 실수가 아니라 구조에 있다 — 헬스체크는 주기적 관찰이고, 관찰과 관찰 사이에는 아무도 보고 있지 않다.

t=0.0s 헬스체크 통과. 프록시: "web은 healthy" t=1.2s web 컨테이너가 내려간다 t=1.3s 요청 도착 ──▶ 아직 healthy로 알고 web을 고름 ──▶ 연결 실패 ──▶ 502 t=5.0s 다음 헬스체크에서 비로소 unhealthy로 표시

여기에 두 번째 사실이 겹친다. 부하 분산 정책은 고르는 규칙일 뿐 실패 처리 규칙이 아니다. 고른 대상에 연결이 안 되면 그대로 502를 돌려준다 — 옆에 멀쩡한 인스턴스가 있는데도. 정책 이름에 "healthy한 것 중에서 고른다"고 적혀 있어서 실패 처리까지 해 줄 것으로 읽기 쉽지만, 그건 다른 설정의 몫이다.

간격을 줄이는 것으로는 못 닫는다

헬스체크 간격을 1초로 줄이면 구멍도 1초가 되지만 0은 되지 않는다. 대신 매초 프로브 트래픽을 낸다. 주기적 관찰로 원리적으로 못 덮는 구간이므로, 관찰을 촘촘히 하는 대신 실패했을 때의 행동을 정하는 쪽이 맞다. 관찰을 늘려 해결하려는 충동은 다른 영역에서도 자주 틀린다.

5.재시도를 안전하게 켜는 조건

그러면 재시도를 켜면 되는데, 여기서 대개 멈칫한다. 요청을 다시 보내는 게 안전한가. 프록시에는 그 구간만 겨냥한 설정이 따로 있고, 안전한 이유도 구조적이다.

Caddyfile — 앞 블록에 두 줄 추가caddyfile
lb_try_duration 5s      # 최대 5초 동안 다른 upstream으로 다시 시도
lb_try_interval 250ms  # 250ms 간격으로

재시도라고 하면 보통 멱등성 걱정이 따라붙는다. 여기서는 걱정할 필요가 없는데, 이유가 구조적이다 — 재시도 대상이 연결 실패로 한정되기 때문이다.

무엇을 다시 보내도 되나text
연결 자체가 안 됨       -> 서버가 요청을 본 적이 없다   -> 다시 보내도 안전
요청을 보낸 뒤 끊김     -> 처리됐는지 알 수 없다       -> 재시도하지 않는다

업로드처럼 부작용이 있는 요청에도 안전한 것은 이 구분 덕분이다. 그리고 이 구분은 프록시가 해 주는 것이라 애플리케이션이 아무것도 안 해도 된다.

2차 측정 — 같은 조건text
총 요청   557
실패        0
web이 내려간 뒤 프록시가 그 사실을 알기까지 — 헬스체크 5초 간격 요청은 0.25초 간격으로 계속 들어온다 lb_policy first 만 0s 1s 2s 3s 4s 5s 6s 헬스체크 헬스체크 web 정지 Caddy는 아직 healthy로 안다 → 죽은 대상을 고르고 502 555회 중 502가 3회 + lb_try_duration 5s 0s 1s 2s 3s 4s 5s 6s 헬스체크 헬스체크 web 정지 dial 실패 → 250ms 뒤 standby로 재시도 557회 중 실패 0
정책은 정상인 upstream을 고르기만 한다. 고른 대상이 방금 죽었을 때 넘겨주는 것은 재시도 설정의 몫이다.

실제 배포 로그로 보면 전체가 54초 만에 지나간다. 그 동안 사이트는 계속 응답했다.

배포 로그text
08:09:34  web-standby 교체
08:10:20  web 교체 (그동안 standby가 트래픽을 받는다)
08:10:27  배포 후 검증
08:10:28  완료                        # 총 54초
이 절차로 배포되는 쪽

Reflatten — 평평한 이미지를 편집 가능한 텍스트로

지금 열리는 페이지가 이 롤링 교체로 올라간 것이다. 한 장 올려 보면 편집 화면까지 그대로 이어진다.

6.대기 인스턴스가 본 인스턴스와 같다는 보장

대기 인스턴스는 본 인스턴스와 같을 때만 쓸모가 있다. 하나가 다른 빌드 인자를 받거나 메모리 한도가 다르면, 배포 순간에 아무도 안 볼 때 미묘하게 다른 것이 트래픽을 받는다. 프로덕션 오버레이에 필수값이 몰려 있는 구조라면 특히 위험하다.

일차 방어는 정의를 하나만 두는 것이다. Compose는 YAML 앵커를 그대로 쓴다.

정의는 한 번, 인스턴스는 둘yaml
x-web-service: &web-service
  build: { ... }
  environment: { ... }
  deploy: { ... }

web:
  <<: *web-service
  volumes: [ web_cache:/app/.next/cache ]

web-standby:
  <<: *web-service
  profiles: [ bluegreen ]
  volumes: [ web_standby_cache:/app/.next/cache ]   # 캐시는 일부러 분리

그런데 앵커에서 일부만 다르게 하는 순간 빈틈이 생긴다. 나중에 누가 오버레이에서 한쪽만 고쳐도 드러나지 않기 때문이다. 그래서 병합 결과를 직접 대조하는 검사를 CI에 붙였다.

두 서비스가 실제로 같은지 필드 단위 비교python
# 정당하게 다른 것만 제외한다
IGNORED = {"container_name", "profiles", "volumes", "networks"}

config = json.load(sys.stdin)          # docker compose config --format json
web, standby = services["web"], services["web-standby"]

differences = [k for k in sorted((set(web) | set(standby)) - IGNORED)
               if web.get(k) != standby.get(k)]

비교 대상은 YAML 원문이 아니라 compose가 병합한 최종 결과다. 앵커를 썼는지와 무관하게, 오버레이까지 적용된 뒤에 실제로 같은지를 본다. 현재 15개 필드가 대조되고, 일부러 하나를 어긋나게 넣어 검사가 실패하는 것도 확인했다.

7.로컬에서는 인스턴스를 하나만

개발 중에 인스턴스를 둘 띄울 이유가 없다. Compose 프로필로 대기 인스턴스를 가둬 두고 배포 호스트만 켜면 되는데, 그러면 프록시 설정이 존재하지 않는 호스트를 가리키게 된다.

이게 문제가 안 되는 이유는 단순하다. 이름이 해석되지 않는 upstream은 그냥 영원히 healthy가 아니다. 정책이 healthy한 것 중에서 고르므로 자연스럽게 단독 구성이 되고, 프록시 설정 파일을 환경별로 나눌 필요가 없다.

대신 반대 방향으로 조용해진다

배포 호스트에서 프로필 켜는 것을 잊으면 대기 인스턴스가 아예 없는 채로 배포가 돌아간다. 사이트는 정상 동작하고 명령도 성공하므로 아무도 모른 채 중단 배포로 되돌아간다. 이건 검사로 막기 어려운 자리라 실행서에 적어 두는 것으로 남겼다 — "켜져 있어야 정상"인 설정은 꺼져 있을 때 티가 나지 않는다는 것을 전제하고 다뤄야 한다.

8.이 방식이 안 맞는 경우

이건 교과서적 blue-green이 아니다. 진짜라면 전체 스택 두 벌을 두고 통째로 전환한 뒤 문제가 있으면 즉시 되돌린다. 여기서는 한 계층만, 그것도 순차로 갈아 끼우고, 되돌리기도 이전 커밋으로 같은 절차를 다시 도는 방식이다.

비용크기판단
인스턴스가 하나 늘어난다 메모리 한도 512M × 2. 실사용은 인스턴스당 84MiB 교체 목적이라 평소에 놀아도 손해가 없다. 한도를 실사용에 맞춰 내리면 부담이 더 준다
같은 이미지를 두 번 빌드·기동 배포 시간 두 배. 실측 54초 레이어 캐시가 대부분 먹는다. 사람이 아니라 CI가 기다리는 시간이다
전환 중 카운터가 리셋된다 새로 뜬 프로세스의 카운터는 0 배포 순간에 한해 한도가 느슨해진다. 창이 수십 초라 남용 경로로는 쓸모가 없다
상태 있는 계층은 여전히 중단 보관 중인 작업 소실 같은 방식을 적용하려면 상태를 프로세스 밖으로 빼야 한다. 무중단 배포보다 훨씬 큰 변경이다

그래서 전체 스택 두 벌을 감당할 메모리가 있거나, 상태 있는 계층까지 무중단이어야 한다면 이 글의 방식으로는 부족하다. 반대로 배포의 대부분이 무상태 계층 변경이고 서버가 한 대라면, 필요한 만큼만 무중단으로 만들고 나머지는 절차로 다루는 쪽이 규모에 맞는다.

9.정리

서버를 늘리지 않고 무중단 배포를 붙일 때 밟는 순서로 묶으면 이렇다.

  1. 계층별로 "지금 죽이면 무엇이 사라지나"를 적는다. 무상태 계층만 교체 대상이다. 이걸 안 하고 시작하면 무중단인 줄 알았던 배포가 조용히 무언가를 지운다.
  2. 프로세스 메모리에 사는 것을 센다. 카운터·캐시·세션이 있으면 로드밸런싱은 성능 설정이 아니라 기능 변경이다. 나누지 않는 이중화도 정당한 선택이다.
  3. 의존 서비스를 함께 재생성하지 않도록 막는다. 빠뜨려도 신호가 없으므로 기억이 아니라 스크립트에 넣는다.
  4. 헬스체크는 주기적이라 구멍이 남는다. 간격을 줄이는 대신 실패했을 때의 행동을 정한다.
  5. 재시도는 연결 실패에만 건다. 그러면 부작용 있는 요청에도 안전하다.
  6. 두 인스턴스가 같은지 기계가 대조하게 한다. 원문이 아니라 병합된 최종 설정을 비교한다.

설정 자체는 몇 줄이었다. 그 몇 줄이 맞는지는 롤링 중에 555번을 찔러 보고서야 알았다. 3번의 502가 없었다면 지금도 무중단이라고 믿고 있었을 것이고, 그건 사용자가 먼저 발견했을 것이다.

이미지 편집 엔진 · 배포와 검증

반응형

댓글