[Homelab] 노드 drain이 15분씩 걸리던 이유: CNPG primary의 PDB와 ‘미리 옮겨두기’


홈랩 Proxmox 노드 5대는 한 달에 두 번씩 정해진 날짜에 자동으로 재부팅됩니다. 노드가 내려가기 전에는 그 위의 RKE2 워커 VM이 스스로 kubectl drain을 돌리고요. 그런데 어떤 날은 drain이 1~2분이면 끝나고, 어떤 날은 딱 15분(타임아웃)을 꽉 채우고 끝났습니다.

처음엔 엉뚱한 놈을 의심했고, 저널을 실측해 보니 범인은 따로 있었습니다. 그리고 고치는 방법이 “재부팅 직전에 서두르기”가 아니라 “다음 재부팅을 미리 준비하기”였다는 게 이번 글의 요점입니다.

🧭 환경

  • Proxmox 호스트 pve1~5, 그 위에 RKE2 워커 VM agent1~5 (agentN은 pveN 위에 있음)
  • 호스트 /etc/crontab에 노드별 고정 날짜 재부팅: pve5(1, 15일) → pve1(3, 18일) → pve2(6, 21일) → pve3(9, 24일) → pve4(12, 27일), 19:00
  • 호스트가 pvenode stopall로 VM을 끄면, 게스트 안의 cola-rke2-drain.service의 ExecStop이 drain을 돌림 (--timeout=900)
  • CloudNativePG(CNPG) 클러스터 3개(메인, 벡터, Nextcloud DB), 각각 인스턴스 3개

🔎 처음 의심한 건 Immich ML이었다

drain이 느린 노드에는 Immich 머신러닝 파드가 있는 것 같았습니다. 무겁고, 자리가 정해진(anti-affinity) 파드라 쫓겨나면 갈 데가 없을 것 같았죠. 그래서 9/28~10/1의 drain 저널을 노드별로 뽑아봤습니다.

노드별 drain 소요 시간
노드 / 시각소요PDB 거부끝까지 안 나간 파드
agent2 9/30 23:3791초0없음
agent1 9/30 23:52123초0없음
agent2 9/28 17:29416초238CNPG 3
agent5 9/30 21:06901초6CNPG 3 (replica)
agent5 10/1 19:00902초62CNPG 3 (전부 primary)

완전히 틀렸습니다. ML 파드가 있던 agent1과 agent2가 가장 빨랐고(91~123초), ML이 없는 agent5가 15분이었습니다. ML 파드는 PDB도 preStop도 없어서 그냥 나갑니다. 느린 drain에 매번 공통으로 있던 건 CNPG였습니다.

🔐 primary의 PDB는 구조적으로 allowed=0이다

CNPG는 클러스터마다 PodDisruptionBudget을 만드는데, primary용 PDB는 항상 allowed=0입니다. primary를 eviction으로 내보내는 건 영원히 거부되고, primary를 옮기는 건 오직 switchover(promote)로만 하라는 설계입니다.

그래서 primary가 있는 노드를 drain하면 이렇게 됩니다.

kubectl drain → primary eviction 요청 → PDB 거부 → 5초 후 재시도 → 거부 → ...
→ 900초 타임아웃 → drain 실패로 넘어감 → VM 종료 → primary 강제 하강 → 계획에 없던 failover

10/1 19:00 agent5 drain이 15분 걸린 이유도 여기서 나왔습니다. 하루 전인 9/30에 pve3를 재부팅하려고 primary 3개를 agent5로 switchover해 뒀거든요. 그런데 agent5(pve5)가 바로 다음 차례였습니다. switchover는 공짜가 아니라 다음 drain의 비용을 미리 정하는 행위였던 겁니다.

🧮 primary를 어디로 옮겨야 하나

재부팅 순서가 노드별 고정이니까 “다음에 언제 재부팅되는지”를 계산할 수 있습니다. primary를 옮길 대상은 다음 재부팅까지 가장 오래 남은 노드입니다. 보통은 방금 재부팅을 마친 노드(순서상 N-1)고요.

여기서 한 번 더 함정을 밟았습니다. 처음엔 순서만 보고 (S - M) mod 5 공식으로 순위를 매겼는데, 간격이 2~4일로 불균등하고 호출하는 시점에 따라 실제 남은 일수가 달라집니다. 10/2 새벽에 수동으로 돌렸을 때 이 공식은 그날 저녁에 재부팅될 agent5를 2순위로 골랐습니다. 그래서 지금 스크립트는 crontab 날짜표를 그대로 들고 있다가 “지금부터 그 노드의 다음 재부팅까지 실제 일수”로 정렬합니다.

REBOOT_DAYS="1:3,18 2:6,21 3:9,24 4:12,27 5:1,15"   # 호스트 crontab 사본. 날짜를 바꾸면 여기도 같이

🤔 primary를 노드마다 흩어놓을 필요는 없었다

처음엔 primary 3개를 노드 3곳에 흩어두자고 생각했습니다. 예상 못 한 장애가 나도 한 번에 셋이 다 끊기지 않게요. 그런데 따져보니 이 홈랩에는 안 맞았습니다.

  • 세 앱은 서로 의존하지 않습니다. 사용자는 저 혼자고요. 60초짜리 중단 1번이 60초짜리 중단 3번보다 낫습니다.
  • switchover 총량은 같습니다. 모아두면 12일쯤마다 3개, 흩어두면 사흘마다 1개, 둘 다 하루 0.25회입니다. 흩어도 비용이 줄지 않고 잘게 쪼개질 뿐입니다.
  • 모아두는 쪽이 스크립트가 짧습니다. 노드 종료 경로에 들어가는 코드는 짧을수록 좋습니다.

🛠️ 해결: 부팅 20분 뒤에 ‘다음 노드’를 미리 비운다

핵심 아이디어는 이겁니다. 노드 N이 방금 부팅했으면 N은 앞으로 2주 가까이 안 건드려지는 가장 안전한 자리입니다. 그러니 N이 부팅하고 나서 다음 차례인 N+1의 primary를 N으로 미리 옮겨두면, 며칠 뒤 N+1의 drain은 primary를 0개 보고 금방 끝납니다. 마감이 없으니 서두를 필요도 없죠.

거기에 종료 시점 안전망을 하나 더 둡니다. 정기 주기를 벗어난 재부팅(수동 qm reboot, 게스트 안 reboot, HA 재시작)은 미리 준비가 안 돼 있으니까요.

cola-cnpg-promote-ahead.timer    OnBootSec=20min   → 스크립트 --mode=ahead    (S = 다음 노드)
cola-rke2-drain.service          ExecStart = uncordon
                                 ExecStop  = cordon
                                           → promote --mode=evacuate (S = 이 노드, 300초 예산)
                                           → drain --timeout=900
                                 TimeoutStopSec=1800

스크립트는 하나고 모드만 다릅니다. 공통 본체는 “노드 S가 곧 drain된다 → S의 primary를 옮긴다”입니다.

⚠️ 설계하면서 걸린 함정들

부팅 쪽을 ExecStart에 넣지 않은 이유. 방금 부팅한 노드의 replica는 아직 따라잡는 중일 수 있습니다(pg_rewind 포함). 뒤처진 replica로 promote하면 안 되고, 그걸 기다리느라 부팅이 막히면 안 됩니다. 20분 뒤 타이머로 빼면 이미 따라잡았고 아무것도 막지 않습니다.

시간 산수. 원래 TimeoutStopSec이 1200초였습니다. promote 예산 300초 + drain 900초 = 1200초로 여유가 정확히 0입니다. promote가 예산을 다 쓰면 drain이 끝나기 전에 systemd가 SIGKILL을 보내서 지금보다 나빠집니다. 1800초로 올렸습니다(호스트 쪽 stopall --timeout 2400이 상한).

fail-open 두 겹. systemd는 ExecStop 명령 하나가 실패하면 뒤의 ExecStop을 실행하지 않습니다. promote가 죽으면 drain이 아예 안 돕니다. 그래서 ExecStop=-...처럼 - 접두사로 실패를 무시하게 하고, 스크립트 자체도 timeout 300으로 감싸고 어떤 경로로든 exit 0 합니다.

promote는 병렬로. 세 클러스터는 서로 독립이라 하나씩 promote하고 기다릴 이유가 없습니다. 전부 던지고 한 루프에서 폴링하면 예산이 합이 아니라 최댓값이 됩니다. 실측해 보니 벡터 DB는 12초, Nextcloud DB는 94초였습니다. Nextcloud DB는 smart shutdown이 클라이언트 연결을 기다려서 느렸고, smartShutdownTimeout(180초)이 상한이라 300초 예산 안에 들어옵니다.

기다리는 조건. “새 primary 라벨이 붙었나”가 아니라 “이 노드에 primary가 0개인가”를 기다립니다. 옛 primary는 삭제되지 않고 같은 파드에서 replica로 강등돼 남습니다. 그러면 drain이 여전히 쫓아내야 하지만, 이제 replica라서 PDB가 허락합니다. 그게 목적의 전부입니다.

🚧 절대 하면 안 되는 것 세 가지

  • cola-rke2-drain.service를 restart/stop 하지 않기. stop이 곧 drain입니다. 유닛 파일을 바꿨으면 daemon-reload만 합니다.
  • ahead 타이머를 주기 실행으로 바꾸지 않기. 각 노드가 자기 다음 노드를 비우니(1→2, 2→3, …, 5→1), 다섯 개가 계속 돌면 모든 노드가 누군가의 비움 대상이 돼서 primary가 끝없이 떠돕니다. OnBootSec 단발이라 “방금 부팅한 노드 하나”만 돕니다.
  • 타이머를 enable --now로 켜지 않기. OnBootSec=20min 시점은 이미 지났으니 켜는 즉시 실제 promote가 일어납니다. enable만 하고 다음 부팅에 맡깁니다.

✅ 결과 (2026-10-14 추가)

설치 후 CNPG 클러스터의 currentPrimaryTimestamp에 두 경로가 모두 찍혀 있습니다.

  • ahead: pve2가 10/6 19:57에 부팅했고, 24분 뒤인 20:21에 agent3에 있던 메인 DB와 벡터 DB의 primary가 옮겨졌습니다. 부팅 20분 뒤 타이머가 그대로 동작한 겁니다.
  • evacuate: Nextcloud DB의 primary는 10/9 19:00:27에 바뀌었습니다. pve3 정기 재부팅(19:00) 직후로, 종료 경로가 drain 전에 primary를 먼저 옮긴 겁니다.

정직하게 덧붙이면, 모든 15분 drain이 CNPG primary 때문은 아니었습니다. 9/30 agent5 건은 replica였는데도 eviction 수락 뒤 grace 기간을 끝까지 채웠습니다. SIGTERM에 안 죽는 앱은 이 장치로 안 고쳐집니다. 그래도 primary가 원인인 15분은 이제 없습니다.

📝 정리

  • CNPG primary의 PDB는 구조적으로 allowed=0입니다. primary가 있는 노드의 drain은 반드시 타임아웃까지 갑니다.
  • switchover는 공짜가 아닙니다. 다음 drain의 비용을 미리 정하는 행위입니다. 다음 차례 노드로 보내면 사흘 뒤에 또 옮겨야 합니다.
  • 재부팅 날짜가 고정이면 “다음 재부팅까지 남은 일수”를 계산할 수 있습니다. 순서 공식이 아니라 달력으로 계산합니다.
  • 부팅 20분 뒤에 다음 노드를 미리 비우고, 종료 때는 안전망만 둡니다. 종료 경로의 코드는 짧고, 실패해도 drain은 돌게(fail-open) 만듭니다.
  • 짐작으로 범인을 정하지 말고 저널부터 뽑습니다. 이번에도 제일 유력해 보이던 용의자가 무죄였습니다. ㅎㅎ

“[Homelab] 노드 drain이 15분씩 걸리던 이유: CNPG primary의 PDB와 ‘미리 옮겨두기’”에 대한 댓글 1개

  1. […] 노드 drain 글에서도 같은 이야기를 했지만, 재부팅 순서가 고정이면 장애를 “예측 가능한 이벤트”로 바꿀 수 있습니다. MDS도 마찬가지였습니다. ㅎㅎ […]

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다