아침 브리핑 봇이 이런 표를 보내왔다. 6노드 K3s 홈랩(전 노드 무선) 이야기다.

파드 재시작 증가분 누적
elastic-operator-0 +70 157회
lemuel-entity-operator +50 79회
strimzi-cluster-operator +32 79회
settlement-financial 외 2종 +16/+15/+13 신규

권고 사항은 “해당 노드의 리소스 부족(Memory/Disk)이나 Operator 설정 오류를 점검하십시오”였다. 결론부터 — 리소스는 범인이 아니었다. 범인은 라벨 하나였고, 그 라벨의 알리바이는 주석 속에 있었다.

1. 화살표부터 뒤집기 — 재시작의 타임스탬프를 본다

재시작 횟수는 누적 계수다. 언제 죽었는지가 없으면 “지금 아픈 것”과 “어제 아팠던 흔적”을 구분할 수 없다. 그래서 표의 파드마다 마지막 종료 시각을 실측했다:

settlement-financial:  16회, 마지막 종료 09-13 19:13 KST
settlement-investment: 15회, 마지막 종료 09-13 19:14 KST
settlement-market:     13회, 마지막 종료 09-13 19:15 KST

세 개가 2분 안에 몰려 있고, 그 시각은 어제 잇사갈 노드의 무선 동글이 뽑혔던 바로 그 창이다. 이후 27시간 재시작 0회. 즉 “신규 관측”으로 보고된 세 줄은 현재 진행형 장애가 아니라 어제 사건의 화석이었다. 이 세 줄에 대한 조사는 여기서 끝난다.

반면 entity-operator 는 오늘도 죽고 있었다. 이전 컨테이너의 로그를 보면:

org.apache.kafka.common.errors.TimeoutException:
  Timed out waiting to send the call. Call: fetchMetadata

Kafka AdminClient 가 브로커 메타데이터를 못 가져와서 기동 자체가 실패한다. 이 파드는 잇사갈(무선이 가장 약한 노드)에 있었고, 브로커는 일원 노드의 SSD PV 에 고정돼 있다. 즉 오퍼레이터가 관리 대상과 다른 노드에, 하필 가장 약한 링크 건너편에 앉아 있었다. 메모리도 디스크도 설정도 아니다 — 위치의 문제다.

2. 진범 — 주석은 의도를 말하고, 라벨은 현실을 말한다

가장 재시작이 많았던 elastic-operator(ECK)의 GitOps 매니페스트에는 이렇게 적혀 있었다:

# operator 는 르무엘(가벼운 control-plane) 에 두지 말고 데이비드(모니터링) 에 둔다
nodeSelector:
  monitoring: "true"

의도는 명확하다: david 노드에 두겠다. nodeSelector 는 라벨이 일치하는 노드에만 파드를 배치하는 가장 단순한 스케줄링 제약이다.1 그런데 노드 라벨을 실측하니:

david    monitoring=<없음>
ilwon    monitoring=true
louise   monitoring=true

정작 david 에는 그 라벨이 없었다. 라벨은 ilwon 과 louise 에 있었고, 스케줄러는 지시받은 대로 그중 하나 — 무선이 약한 louise — 에 파드를 놓았다. 누적 157회 재시작은 그 결과다.

여기서 무서운 건 실패의 형태다. 라벨이 어디에도 없었다면 파드는 Pending 으로 남고 즉시 눈에 띄었을 것이다. 하지만 라벨이 엉뚱한 노드에 있으면 스케줄링은 성공한다. 에러도, 경보도, Pending 도 없다. 주석(의도)과 라벨(현실)이 어긋난 채로 시스템은 “정상 동작”하고, 비용은 재시작 카운터에만 조용히 쌓인다. 주석은 코드가 아니므로 아무도 그것을 검증하지 않는다.

3. 처방 — 통신 지역성으로 배치한다

무선 클러스터에서 진짜 지연에 민감한 건 데이터플레인이 아니라 컨트롤 루프라는 게 이번 관찰의 요지다. 오퍼레이터는 apiserver 나 브로커와 상시 커넥션을 유지해야 하고, 그 커넥션이 약한 링크를 건너면 타임아웃 → 재시작 → 백오프를 반복한다. 그래서 “누구와 통신하는가”를 기준으로 세 개를 고정했다:

오퍼레이터 통신 상대 고정 노드 근거
entity-operator Kafka 브로커 ilwon 브로커가 있는 노드 — 무선 홉 자체가 사라진다. Strimzi 는 entityOperator.template.pod.affinity 로 배치를 지정할 수 있다2
strimzi-cluster-operator apiserver ilwon control-plane 노드라 apiserver 가 로컬이다
elastic-operator apiserver·ES david 원래 주석의 의도대로. ECK Helm 차트는 nodeSelector 값을 노출한다3

고정 방식은 라벨 재분배가 아니라 잘 알려진 노드 라벨 kubernetes.io/hostname4 핀을 택했다. david 에 monitoring=true 를 붙이는 게 “원래 설계”에 가깝지만, 그 라벨을 셀렉터로 쓰는 다른 워크로드가 있으면 라벨 하나가 여러 파드를 동시에 움직인다 — 사고를 고치려다 사고 범위를 넓히는 길이다. hostname 핀은 이 파드 하나만 움직인다. 대신 노드가 죽으면 파드가 Pending 으로 남는데, 이 오퍼레이터들은 잠깐 없어도 데이터플레인이 계속 도는 종류라 (브로커·ES 는 오퍼레이터 없이도 동작한다) 가용성보다 위치 확실성을 택했다.

4. 적용 후 실측

GitOps 로 커밋 → ArgoCD 동기화 → 파드 위치 실측:

strimzi-cluster-operator → ilwon   (재시작 0 으로 재출발)
lemuel-entity-operator   → ilwon   (재시작 0 으로 재출발)
elastic-operator-0       → david   (재시작 0 으로 재출발)

재시작이 실제로 멎었는지는 하루짜리 계수로 판단할 문제라 오늘은 “위치가 의도와 일치한다”까지만 사실로 적는다. 참고로 잇사갈 무선은 오늘 USB 연장선을 달아 -74 → -65 dBm, tx 17 → 103 Mbit/s 로 개선됐다 — 하지만 링크가 좋아졌다고 오퍼레이터를 약한 노드에 되돌릴 이유는 없다. 좋은 링크는 보너스고, 배치는 구조다.

5. 남는 교훈

  1. 재시작 카운터에는 시계가 없다. 누적 횟수만 보고 원인을 추정하면 어제의 화석과 오늘의 출혈을 구분하지 못한다. lastState.terminated.finishedAt 부터 본다.
  2. 의도를 주석에만 적으면 드리프트는 조용하다. “david 에 둔다”는 문장은 6개월간 아무도 검증하지 않았다. 의도는 검증 가능한 형태(정확한 셀렉터, 또는 라벨 존재를 확인하는 게이트)로 적어야 한다.
  3. 무선 클러스터의 최우선 배치 대상은 컨트롤 루프다. 파드가 어느 노드에 뜨는지 신경 쓰지 않아도 되는 건 유선 데이터센터의 사치다. 링크 품질이 비대칭인 클러스터에서는 “누구와 통신하는가”가 곧 배치 기준이 된다.

References

  1. Kubernetes 공식 문서 — Assigning Pods to Nodes (nodeSelector·nodeAffinity) 

  2. Strimzi 공식 문서 — Configuring Strimzi (EntityOperatorTemplate, template.pod.affinity

  3. Elastic 공식 문서 — Install ECK using the Helm chart 

  4. Kubernetes 공식 문서 — Well-Known Labels, Annotations and Taints (kubernetes.io/hostname