태그 보관물: Helm

Upgrading Kubernetes Add-ons: Calico, MetalLB, and Traefik

개요

Kubernetes 클러스터를 v1.33에서 v1.36까지 올리면서 Kubernetes 애드온 업그레이드도 함께 진행했습니다. 클러스터 코어만 올리면 끝나는 작업이라 생각했는데, 실제로는 CNI·로드밸런서·Ingress·메트릭 수집기를 각각 어느 시점에 어떤 버전으로 올릴지가 전체 일정과 무중단 여부를 결정했습니다.

이 글에서는 Calico, MetalLB, metrics-server의 버전 업그레이드와 Traefik 이중화 작업을 정리합니다. 특히 업스트림 표준 매니페스트를 그대로 적용했을 때 로컬 커스터마이즈가 조용히 사라지는 문제와, 그 과정에서 실측한 서비스 단절 시간에 초점을 맞췄습니다.

결과부터 말하면 애드온 업그레이드 구간은 전부 무중단이었습니다. 다만 그렇게 만들기 위해 사전에 확인해야 했던 항목이 네 가지 있었고, 그중 두 개는 kubectl diff 출력을 눈으로 확인해야만 발견되는 것이었습니다.

환경

자체 관리형 Kubernetes 클러스터입니다. 컨트롤플레인 3대와 워커 3대로 구성되어 있고, 컨테이너 런타임은 Docker + cri-dockerd입니다.

구성요소 업그레이드 전 업그레이드 후 방식
Kubernetes v1.33.7 v1.36.3 kubeadm, 마이너 3단계
Calico v3.30.7 v3.32.1 매니페스트, 2단계 경유
MetalLB v0.14.5 v0.16.0 매니페스트 + nodeSelector 재적용
metrics-server v0.7.1 v0.9.0 이미지만 교체
Traefik v3.7.5 (replica 1) v3.7.5 (replica 2) Helm values 변경
CoreDNS v1.12.0 v1.14.2 kubeadm 자동

MetalLB는 L2 모드로 운영 중이며 VIP 3개를 할당하고 있습니다. Ingress는 Traefik이 담당하고, Calico는 IPIP 모드에 자체 IPPool을 사용합니다. 노드 IP와 호스트명은 이 글에서 일반화해 표기합니다.

단계별 절차

1. 애드온 업그레이드 순서 설계

가장 먼저 결정한 것은 순서였습니다. 애드온마다 지원하는 Kubernetes 버전 범위가 다르기 때문에, 아무 시점에나 올리면 중간 상태가 공식 테스트 매트릭스 밖으로 나갑니다.

Calico의 경우 v3.31은 Kubernetes 1.32~1.35를, v3.32는 1.34~1.36을 테스트합니다. 그래서 v3.30.7에서 v3.32.1로 한 번에 올리지 않고 두 단계로 나눴습니다.

K8s 1.33 상태 → Calico v3.31.6 적용   (1.32~1.35 지원)
K8s 1.34, 1.35 업그레이드
K8s 1.35 상태 → Calico v3.32.1 적용   (1.34~1.36 지원)
K8s 1.36 업그레이드

이렇게 하면 모든 중간 상태가 지원 범위 안에 들어옵니다. MetalLB와 metrics-server는 Kubernetes 버전 의존성이 느슨해서 클러스터 업그레이드를 모두 마친 뒤 마지막에 올렸습니다.

2. Traefik 이중화 — 클러스터 업그레이드 전 필수 선행 작업

클러스터 업그레이드는 워커 노드를 순차적으로 drain합니다. 그런데 Traefik이 replica 1개로 떠 있었기 때문에, 그 노드를 drain하는 순간 모든 Ingress 호스트가 30~60초씩 끊기는 구조였습니다. 마이너 3단계 × 워커 3대면 이 단절이 반복됩니다.

Helm values에 replica 2개, PodDisruptionBudget, 노드 분산 anti-affinity를 추가했습니다.

deployment:
  replicas: 2

podDisruptionBudget:
  enabled: true
  minAvailable: 1

affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app.kubernetes.io/name: traefik
            app.kubernetes.io/instance: traefik-traefik
        topologyKey: kubernetes.io/hostname

anti-affinity를 required로 둔 이유는, 두 파드가 같은 워커에 몰리면 이중화 의미가 없어지기 때문입니다. PDB는 drain이 두 파드를 동시에 축출하지 못하게 막습니다.

적용은 helm upgrade로 했고, 차트의 롤아웃 전략이 이미 maxUnavailable: 0 / maxSurge: 1이라 기존 파드를 유지한 채 새 파드가 먼저 올라옵니다. 0.5초 간격 프로브로 278회를 측정했는데 전부 HTTP 200이었습니다.

3. Calico 업그레이드 — server-side apply

Calico는 매니페스트 방식으로 설치되어 있어 업스트림 calico.yaml을 적용합니다. 공식 문서가 --server-side --force-conflicts를 권장하는데, 일반 kubectl apply는 CRD 어노테이션 크기 제한(262144 bytes)에 걸릴 수 있기 때문입니다.

curl -fsSLO https://raw.githubusercontent.com/projectcalico/calico/v3.32.1/manifests/calico.yaml

# 커스터마이즈가 사라지지 않는지 먼저 확인
kubectl diff -f calico.yaml | head -100

kubectl apply --server-side --force-conflicts -f calico.yaml
kubectl -n kube-system rollout status ds/calico-node --timeout=600s

diff에서 확인해야 할 것은 환경변수입니다. IPIP 모드나 IPPool CIDR이 매니페스트 기본값으로 덮이면 네트워크가 끊깁니다. 두 번의 Calico 업그레이드 모두 환경변수 변경이 없었고, 바뀐 것은 이미지 태그와 init 컨테이너 이름, CRD 스키마, ClusterRole의 가산적 권한뿐이었습니다.

한 가지 주의할 점은 v3.31부터 이미지 레지스트리가 docker.io에서 quay.io로 바뀐다는 것입니다. 노드에서 quay.io 도달이 안 되면 롤아웃이 중간에 멈춥니다.

DaemonSet은 노드 1대씩 롤링되며 6노드 기준 약 4분이 걸렸습니다. 이 구간에 노드 Ready 상태는 6/6을 유지했고, 기존 파드의 네트워크 경로는 Calico 재시작 중에도 끊기지 않았습니다.

4. MetalLB v0.16.0 — nodeSelector 재적용이 핵심

MetalLB는 표준 metallb-native.yaml을 적용합니다. 그런데 이 클러스터는 speaker DaemonSet과 controller Deployment에 nodeSelector: type=lb를 걸어 워커 3대에만 배치하도록 커스터마이즈해두었습니다. 표준 매니페스트에는 이 설정이 없습니다.

즉 그대로 적용하면 nodeSelector가 사라집니다. speaker DaemonSet은 control-plane taint에 대한 toleration을 가지고 있어서, 셀렉터가 없어지면 컨트롤플레인 노드에도 배치됩니다.

# 적용 전 반드시 확인 — 제거되는 줄(-)이 있는지
kubectl diff -f metallb-native.yaml | grep -E "^[+-].*(nodeSelector|type: lb)"
#   -        type: lb
#   -        type: lb        ← speaker, controller 두 곳에서 제거됨

kubectl apply -f metallb-native.yaml

# 적용 직후 즉시 복구 (창을 최소화)
kubectl -n metallb-system patch ds speaker \
  -p '{"spec":{"template":{"spec":{"nodeSelector":{"type":"lb"}}}}}'
kubectl -n metallb-system patch deploy controller \
  -p '{"spec":{"template":{"spec":{"nodeSelector":{"type":"lb"}}}}}'

IPAddressPool과 L2Advertisement 같은 CR은 별도 리소스이므로 매니페스트 적용으로 사라지지 않습니다. 이 부분은 아래 트러블슈팅에서 다시 설명하겠습니다.

적용과 patch를 이어서 실행하고 롤아웃을 기다린 결과, VIP 3개가 모두 유지되고 speaker는 워커 3대에만 배치됐습니다. 이 구간에도 서비스 단절은 없었습니다.

5. metrics-server v0.9.0 — 이미지만 교체

metrics-server는 표준 components.yaml을 적용하지 않았습니다. 현재 배포에는 --kubelet-insecure-tls의도적으로 빠져 있는데, 표준 매니페스트는 이 플래그를 포함하고 있어 적용하면 보안 수준이 낮아지기 때문입니다.

# 현재 인자 확인 — --kubelet-insecure-tls 가 없는 상태를 지켜야 함
kubectl -n kube-system get deploy metrics-server \
  -o jsonpath='{.spec.template.spec.containers[0].args}'

# 이미지만 교체
kubectl -n kube-system set image deploy/metrics-server \
  metrics-server=registry.k8s.io/metrics-server/metrics-server:v0.9.0

kubectl -n kube-system rollout status deploy/metrics-server --timeout=180s
kubectl top nodes

인자 5개가 그대로 유지되는지 적용 후 다시 확인했고, kubectl top nodes로 6노드 메트릭이 정상 수집되는 것을 검증했습니다.

트러블슈팅

externalTrafficPolicy 때문에 VIP가 22초 사라진 문제

현상 — 워커 노드를 drain했을 때 데이터베이스 VIP가 22초간 응답하지 않았습니다. 그런데 해당 VIP가 가리키는 파드는 다른 노드에 있었고 정상 동작 중이었습니다. 데이터베이스 클러스터도 쿼럼을 유지하고 있었습니다.

원인 — MetalLB speaker 로그가 답을 줬습니다.

kubectl -n metallb-system logs -l component=speaker --since=5m \
  | grep -E "serviceAnnounced|serviceWithdrawn"

# serviceAnnounced  ips=[192.168.x.x]
# serviceWithdrawn  reason="notOwner"     ← 소유권 상실
# serviceAnnounced  ips=[192.168.x.x]     ← 22초 후 재광고

해당 Service의 externalTrafficPolicy가 기본값인 Cluster였습니다. 이 설정에서는 엔드포인트가 없는 노드도 VIP를 광고할 수 있습니다. 광고 중이던 노드를 cordon하자 MetalLB가 L2 소유권을 재선출했고, 그 사이 LAN에 VIP가 존재하지 않았던 것입니다.

즉 데이터베이스 계층의 문제가 아니라 로드밸런서 계층의 문제였습니다. 파드 상태만 확인했다면 원인을 엉뚱한 곳에서 찾았을 것입니다.

해결externalTrafficPolicy: Local로 변경했습니다. 이 값이면 엔드포인트를 가진 노드만 VIP를 광고하므로, 다른 노드를 drain해도 소유권이 흔들리지 않습니다.

kubectl -n  patch svc  \
  -p '{"spec":{"externalTrafficPolicy":"Local"}}'

kubectl -n  get svc  \
  -o jsonpath='{.spec.externalTrafficPolicy} {.spec.healthCheckNodePort}'
# Local 30797     ← healthCheckNodePort 가 새로 할당됨

변경 후 다른 워커를 drain해봤을 때 VIP 단절은 0초였습니다. 부수 효과로 SNAT가 사라져 클라이언트 소스 IP가 보존되는데, 접속 제한이나 접속 로그를 소스 IP 기준으로 운영한다면 이 변화도 함께 고려해야 합니다.

참고로 오퍼레이터가 관리하는 Service였는데, 커스텀 리소스에 이 필드를 넣어도 이미 존재하는 Service에는 반영되지 않았습니다. 매니페스트에 선언해두고 실행 중인 Service는 직접 patch하는 두 가지 작업이 모두 필요했습니다.

Helm values 병합이 만든 중복 키

현상 — 병합 요청에서 충돌 1건이 보고됐고, 충돌 자체는 같은 값에 주석만 다른 사소한 것이었습니다. 그런데 병합 결과를 검사해보니 traefik-values.yaml에 최상위 deployment: 키가 두 개 있었습니다.

14: deployment:
      replicas: 2                  # 이중화를 위해 추가한 설정

65: deployment:
      revisionHistoryLimit: 5      # 다른 브랜치에서 추가한 공통 정책

원인 — 두 변경이 파일의 다른 위치에 있었기 때문에 Git이 텍스트 기준으로 충돌 없이 병합했습니다. 그러나 YAML은 같은 키가 중복되면 뒤에 나온 값이 앞을 덮습니다. 결과적으로 replicas: 2가 무효화되어 Traefik이 다시 1개로 돌아갈 상황이었습니다.

업그레이드 무중단을 위해 일부러 넣은 설정이 병합 과정에서 조용히 사라지는 셈입니다. 다음 유지보수 때 원인 모를 Ingress 단절로 나타났을 것입니다.

해결 — 두 블록을 하나로 합쳤습니다. 그리고 이런 유형을 자동으로 잡기 위해 중복 키를 오류로 처리하는 YAML 로더로 브랜치 전체를 검사했습니다. 표준 파서는 중복 키를 조용히 허용하므로 일반 문법 검사로는 발견되지 않습니다.

python3 - <<'EOF'
import yaml
class Dup(yaml.SafeLoader): pass
def nodup(loader, node, deep=False):
    m = {}
    for k, v in node.value:
        key = loader.construct_object(k, deep=deep)
        if key in m:
            raise ValueError(f"중복 키: {key!r} (line {k.start_mark.line+1})")
        m[key] = loader.construct_object(v, deep=deep)
    return m
Dup.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, nodup)

for f in ['helm/traefik-values.yaml', 'helm/monitoring-values.yaml']:
    try:
        list(yaml.load_all(open(f), Loader=Dup))
        print(f"OK   {f}")
    except Exception as e:
        print(f"FAIL {f}  {e}")
EOF

Helm values는 여러 사람이 서로 다른 섹션을 건드리기 쉬운 파일입니다. 병합 후 이 검사를 한 번 돌리는 것만으로 상당한 위험을 줄일 수 있습니다.

메트릭 포트 변경이 관측성을 조용히 끊는 경우

현상 — MetalLB 0.16의 diff를 보다가 메트릭 포트가 바뀐 것을 발견했습니다.

-        prometheus.io/port: "7472"
+        prometheus.io/port: "9120"
-        - --port=7472
+        - --port=9120
-        - containerPort: 7472
-          name: monitoring
+        - containerPort: 9120
+          name: metricshttps

원인 — 0.16부터 kube-rbac-proxy가 네이티브 TLS로 교체되면서 포트 번호와 포트 이름이 함께 바뀌었습니다. ServiceMonitor는 보통 포트 이름으로 타깃을 지정하므로, monitoring을 참조하던 설정은 매칭에 실패합니다.

문제는 이 실패가 오류로 드러나지 않는다는 점입니다. 스크레이프 타깃이 사라져도 파드는 정상이고 애플리케이션도 정상이며, 그저 그래프가 빈칸이 됩니다.

해결 — 이 클러스터에는 MetalLB용 ServiceMonitor가 없어 실제 영향은 없었습니다. 다만 애드온을 올릴 때 스크레이프 설정 존재 여부를 먼저 확인하는 절차를 추가했습니다.

# 해당 애드온을 스크레이프하는 설정이 있는지 먼저 확인
kubectl get servicemonitor,podmonitor -A | grep -i metallb

# 있다면 diff 에서 포트 이름 변경을 확인하고 함께 수정
kubectl diff -f metallb-native.yaml | grep -E "^[+-].*(port|name: metric|name: monitoring)"

CRD와 CR을 grep으로 구분하려다 오판한 경우

현상 — 적용 전 안전성을 확인하려고 매니페스트에서 우리 CR이 정의되어 있는지 검색했습니다.

grep -cE "kind: (IPAddressPool|L2Advertisement)" metallb-native.yaml
#   2      ← "인스턴스가 2개 들어 있다"고 오판

원인 — 매칭된 것은 CustomResourceDefinition의 spec.names.kind 필드였습니다. CRD는 자신이 정의하는 리소스의 종류 이름을 이 필드에 담고 있어서, 들여쓰기를 무시한 검색으로는 실제 인스턴스와 구분되지 않습니다.

해결 — 행 시작 앵커를 붙여 최상위 문서의 kind:만 세면 됩니다.

grep -c "^kind: IPAddressPool" metallb-native.yaml
#   0      ← 인스턴스 없음. 우리 CR 은 안전

# 매니페스트에 포함된 리소스 종류 전체를 보는 편이 더 확실
grep -E "^kind:" metallb-native.yaml | sort | uniq -c | sort -rn

실제로 metallb-native.yaml에는 CRD 9개, RBAC, Deployment, DaemonSet만 들어 있고 IPAddressPool·L2Advertisement 인스턴스는 없습니다. 따라서 IP 풀 설정은 매니페스트 적용으로 사라지지 않습니다.

Git과 클러스터가 어긋난 것을 나중에 발견한 경우

현상 — 병합 후 매니페스트와 클러스터를 대조했더니 Traefik의 revisionHistoryLimit이 저장소에는 5, 클러스터에는 10으로 달랐습니다.

원인 — 다른 브랜치가 이 값을 저장소에 커밋했지만, 이 작업 브랜치에서 helm upgrade를 두 번 실행할 때 그 필드가 없는 버전의 values 파일을 사용했습니다. 결과적으로 클러스터를 차트 기본값으로 되돌린 셈이 됐습니다.

kubectl apply로 관리하는 리소스는 파일이 곧 선언이라 이런 드리프트가 잘 드러납니다. 반면 Helm은 실행 시점에 넘긴 values가 반영되므로, 저장소 파일이 최신이어도 마지막 helm upgrade가 구버전 파일이었다면 클러스터는 뒤처집니다.

해결 — 병합된 values로 helm upgrade를 다시 실행해 맞췄습니다. revisionHistoryLimit은 파드 템플릿이 아니라 Deployment 스펙 필드라 롤아웃이 발생하지 않고, 파드는 재시작조차 하지 않았습니다.

# 매니페스트 파싱값과 클러스터 실제값을 대조
python3 -c "import yaml; d=yaml.safe_load(open('helm/traefik-values.yaml')); \
  print('manifest:', d['deployment'])"
kubectl -n traefik get deploy traefik \
  -o jsonpath='cluster: replicas={.spec.replicas} rhl={.spec.revisionHistoryLimit}'

애드온 작업 후에는 파일만 확인하지 말고 클러스터 실제값과 한 번 대조하는 편이 안전합니다.

결과 확인

애드온 4종의 최종 상태입니다. 모든 구성요소가 목표 버전에 도달했고 정상 동작합니다.

kubectl -n kube-system get ds calico-node \
  -o jsonpath='calico     : {.spec.template.spec.containers[0].image} {.status.numberReady}/{.status.desiredNumberScheduled}{"\n"}'
kubectl -n metallb-system get ds speaker \
  -o jsonpath='metallb    : {.spec.template.spec.containers[0].image} {.status.numberReady}/{.status.desiredNumberScheduled}{"\n"}'
kubectl -n kube-system get deploy metrics-server \
  -o jsonpath='metrics    : {.spec.template.spec.containers[0].image}{"\n"}'
kubectl -n traefik get deploy traefik \
  -o jsonpath='traefik    : {.spec.template.spec.containers[0].image} {.status.readyReplicas}/{.status.replicas}{"\n"}'

# calico     : quay.io/calico/node:v3.32.1  6/6
# metallb    : quay.io/metallb/speaker:v0.16.0  3/3
# metrics    : registry.k8s.io/metrics-server/metrics-server:v0.9.0
# traefik    : docker.io/traefik:v3.7.5  2/2

커스터마이즈 보존 여부도 함께 확인했습니다. Calico의 IPIP 모드와 IPPool CIDR, MetalLB의 nodeSelector와 IP 풀, metrics-server의 인자 5개가 모두 그대로입니다.

kubectl get ippools.crd.projectcalico.org \
  -o custom-columns=NAME:.metadata.name,CIDR:.spec.cidr,IPIP:.spec.ipipMode --no-headers
kubectl -n metallb-system get ds speaker \
  -o jsonpath='{.spec.template.spec.nodeSelector}{"\n"}'
kubectl get svc -A --field-selector spec.type=LoadBalancer \
  -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,IP:.status.loadBalancer.ingress[0].ip --no-headers

Kubernetes 애드온 업그레이드 구간의 실측 단절 시간은 다음과 같습니다. 외부에서 1초 간격으로 HTTP 응답과 VIP TCP 도달성을 측정했습니다.

작업 실측 단절 측정 방식
Traefik 이중화 (replica 1 → 2) 0초 0.5초 간격 278회, 전부 200
Calico v3.31.6 0초 174회, 노드 Ready 6/6 유지
Calico v3.32.1 0초 DB 클러스터 크기 이탈 0회
MetalLB v0.16.0 0초 VIP 3개 유지
metrics-server v0.9.0 0초 인자 보존 확인
Traefik values 정렬 0초 150회, 파드 재시작 없음

애드온 업그레이드 자체는 전부 무중단이었습니다. 실제 단절이 발생한 것은 클러스터 노드를 drain하는 구간이었고, 그중 22초는 위에서 다룬 externalTrafficPolicy 문제였습니다.

결론

이번 작업에서 가장 유용했던 습관은 kubectl diff제거되는 줄을 눈으로 확인하는 것이었습니다. 업스트림 매니페스트는 우리 환경의 커스터마이즈를 알지 못하므로, 추가되는 내용보다 사라지는 내용이 더 위험합니다.

두 번째는 "성공했다는 신호"를 그대로 믿지 않는 것입니다. Git이 충돌 없이 병합했다고 결과가 올바른 것은 아니었고, YAML 파서가 통과시켰다고 설정이 의도대로 남은 것도 아니었습니다. 적용 후 실제 값을 다시 조회해 확인해야 발견되는 문제들이었습니다.

세 번째는 단절의 원인을 계층별로 분리해 보는 것입니다. VIP가 끊겼을 때 데이터베이스를 먼저 의심했지만 실제 원인은 로드밸런서의 소유권 재선출이었습니다. 파드 상태만 확인했다면 찾지 못했을 원인이고, speaker 로그가 결정적인 단서를 줬습니다.

애드온마다 지원하는 Kubernetes 버전 범위가 다르다는 점도 미리 확인할 가치가 있었습니다. Calico를 두 단계로 나눈 덕분에 모든 중간 상태가 공식 테스트 범위 안에 머물렀습니다.

참고

관련 포스트:

참고 문서: Calico Upgrade · MetalLB Release Notes · Kubernetes Source IP and externalTrafficPolicy · metrics-server