카테고리 보관물: IT

Diagnosing a 9-Second Node Blackout: Rook Tolerations and Control Plane Metrics

개요

워커 노드 한 대가 약 9초 동안 네트워크에서 사라졌습니다. 그 쿠버네티스 노드 순단 하나가 스토리지 컨트롤 파드의 즉시 축출로 번졌고, 결국 상위 워크로드까지 영향을 받았습니다. 정작 처음 눈에 띈 증상은 Ceph 파드가 무더기로 재기동된 것이어서, 저장소 쪽 문제로 오인하기 쉬운 상황이었습니다.

이 글은 그 9초를 어떻게 특정했는지, 그리고 같은 일이 반복되어도 피해가 번지지 않도록 적용한 두 가지 조치를 정리한 운영 기록입니다. 관측 도구 자체가 장애 노드 위에 있을 때 생기는 판단 착오와, 그것을 걷어내기 위해 클러스터 바깥의 관측자를 동원한 과정이 핵심입니다.

환경

구성 요소 내용
Kubernetes v1.36.3 (kubeadm HA, control plane 3대 + worker 3대)
컨테이너 런타임 Docker + cri-dockerd
CNI / LB Calico, MetalLB (L2 모드)
스토리지 Rook-Ceph v1.13.10 (mon 3, OSD 3, MDS active+standby)
API 로드밸런서 HAProxy (클러스터 외부 VM, 192.168.x.x:6443)
모니터링 kube-prometheus-stack
가상화 VMware (전 노드 VM)

단계별 절차

1. 최초 관측과 오진

Prometheus에서 장애 시각의 스크레이프 상태를 조회했더니 55개 타깃 중 26개가 같은 1분 구간에 up=0이었습니다. API 서버 3대 전부, kubelet 6대 전부가 포함되어 있어 처음에는 클러스터 전역 네트워크 장애로 판단했습니다.

kubectl exec -n rook-ceph deploy/rook-ceph-tools -- \
  curl -sG "http://<prometheus-svc>:9090/api/v1/query_range" \
  --data-urlencode 'query=up' \
  --data-urlencode "start=<epoch>" --data-urlencode "end=<epoch>" \
  --data-urlencode "step=15"

그런데 이 판단에는 결함이 있었습니다. Prometheus 파드 자체가 문제가 의심되는 워커 노드 위에서 동작하고 있었기 때문입니다. 관측자가 장애 대상 안에 있으면 “모든 타깃이 죽었다”는 결과는 타깃의 상태가 아니라 관측자의 상태를 반영합니다. 게다가 query_range는 직전 값을 최대 5분까지 끌어와 채우므로, 스크레이프가 아예 누락된 구간이 정상값 1로 보이기도 합니다.

2. 클러스터 바깥의 독립 관측자 확보

편향을 걷어내기 위해 클러스터 외부에 있는 HAProxy의 로그를 확인했습니다. HAProxy는 API 서버 3대를 TCP 체크로 상시 감시하고 있으므로, 물리망이나 마스터에 문제가 있었다면 반드시 흔적이 남습니다.

# API 백엔드는 하루 종일 DOWN 이력이 없었음
journalctl -t haproxy --since "YYYY-MM-DD 00:00:00" | grep -iE "DOWN|UP|no server"

Server www.example.com/app1 is DOWN, reason: Layer6 timeout, check duration: 2001ms.
backend 'www.example.com' has no server available!
Server www.example.com/app1 is UP, reason: Layer6 check passed, check duration: 1ms.

결과는 명확했습니다. 6443 백엔드는 DOWN 기록이 한 건도 없었고 NIC 에러·드롭·carrier 카운터도 모두 0이었습니다. 즉 물리망과 마스터 3대는 사건 내내 정상이었습니다. 유일한 이벤트는 Ingress 컨트롤러 백엔드가 9초 동안 Layer6 타임아웃을 낸 것뿐이었습니다.

3. ARP 테이블로 MetalLB L2 소유자 특정

그 백엔드 주소는 MetalLB가 L2로 광고하는 LoadBalancer IP입니다. L2 모드에서는 speaker 하나가 해당 IP를 소유하므로, 그 speaker가 있는 노드가 사라지면 IP도 함께 사라집니다. HAProxy의 ARP 테이블에서 소유자를 바로 확인할 수 있었습니다.

ip neigh show | grep -E "192.168.x."

192.168.x.112 dev ens33 lladdr 00:0c:29:xx:xx:xx REACHABLE   # worker-node
192.168.x.120 dev ens33 lladdr 00:0c:29:xx:xx:xx REACHABLE   # LoadBalancer IP - 동일 MAC

LoadBalancer IP의 MAC이 특정 워커 노드의 MAC과 동일했습니다. 여기에 kube-controller-manager 로그가 같은 시각 그 노드의 파드를 축출한 기록이 더해지면서, 서로 독립된 두 관측자가 같은 노드를 지목하게 되었습니다.

taint_eviction.go:111] "Deleting pod" controller="taint-eviction-controller" pod="rook-ceph/rook-ceph-operator-..."
taint_eviction.go:111] "Deleting pod" controller="taint-eviction-controller" pod="rook-ceph/rook-ceph-mgr-a-..."
taint_eviction.go:111] "Deleting pod" controller="taint-eviction-controller" pod="rook-ceph/rook-ceph-mds-myfs-b-..."

4. 게스트 내부에는 증거가 없음을 확인

노드가 네트워크에서 사라졌다면 게스트 안에도 흔적이 남아야 합니다. 그런데 어느 지표에도 흔적이 없었습니다.

확인 항목 결과
node_network_carrier_changes_total 전 노드 불변 — 링크 플랩 없음
node_network_up 1 유지
node_boot_time_seconds 불변 — 노드 재부팅 없음
kubelet process_start_time_seconds 불변 — kubelet 재시작 없음
calico-node / kube-proxy / CoreDNS 재시작 0회
HAProxy NIC 카운터 error / dropped / carrier 모두 0

링크는 끊기지 않았는데 네트워크에서는 사라졌고, 물리망과 다른 VM은 멀쩡했습니다. 이 조합은 게스트 계층이 아니라 그 바깥, 즉 하이퍼바이저 계층의 순간적인 정지를 시사합니다. 다만 해당 계층의 이벤트 로그에 접근하지 못해 근본 원인 확정까지는 이르지 못했다는 점을 분명히 해 둡니다. 스냅샷이나 백업 스케줄은 없다는 것이 확인되어, 정기 작업 가설은 배제되었습니다.

5. 조치 1 — Rook toleration 완화

9초짜리 순단이 큰 사고가 된 직접적인 이유는 Rook이 자신의 파드에 붙이는 축출 유예 시간이 5초였기 때문입니다. 노드에 unreachable 테인트가 붙자마자 operator·mgr·MDS가 즉시 축출되어 재배치되었습니다.

다행히 데이터 경로인 mon과 OSD에는 이 설정이 적용되지 않습니다. 이들은 쿠버네티스 기본값 300초를 쓰기 때문에 축출되지 않았고, 그래서 Ceph 자체는 HEALTH_OK를 유지했습니다. 유예 시간을 60초로 올렸습니다.

# operator가 관리하는 데몬(mgr, MDS, exporter, crashcollector)에 적용
kubectl set env deploy/rook-ceph-operator -n rook-ceph \
  ROOK_UNREACHABLE_NODE_TOLERATION_SECONDS=60

# operator와 tools 자신의 toleration은 설치 매니페스트가 소유하므로 별도 패치
for d in rook-ceph-operator rook-ceph-tools; do
  kubectl patch deploy -n rook-ceph $d --type=json \
    -p='[{"op":"replace","path":"/spec/template/spec/tolerations/0/tolerationSeconds","value":60}]'
done

이 변경은 operator 파드를 재기동시키고, 이어서 mgr과 MDS 배포본이 순차적으로 롤링됩니다. MDS는 hot standby가 있어 전환이 수 초에 그쳤고, CephFS를 사용하는 워크로드 파드는 재시작 없이 그대로 유지되었습니다.

6. 조치 2 — 컨트롤 플레인 메트릭 노출

사후 분석 과정에서 더 불편했던 것은 etcd·scheduler·controller-manager 지표가 통째로 비어 있었다는 점입니다. 28시간 치를 조회해도 이 세 잡의 스크레이프 성공률이 0%였습니다. 장애 순간 컨트롤 플레인이 어떤 상태였는지 확인할 수단이 아예 없었던 셈입니다.

원인은 단순했습니다. kubeadm 기본값이 세 컴포넌트를 모두 루프백에만 바인딩하기 때문에, 노드 IP로 스크레이프하면 구조적으로 연결이 거부됩니다.

kube-controller-manager   --bind-address=127.0.0.1
kube-scheduler            --bind-address=127.0.0.1
etcd                      --listen-metrics-urls=http://127.0.0.1:2381

마스터 3대의 static pod 매니페스트를 한 대씩 수정했습니다. etcd가 포함되므로 반드시 한 대씩 진행하고, 다음 노드로 넘어가기 전에 쿼럼을 확인해야 합니다.

# 마스터 1대에서 실행 후 검증, 그 다음 노드로 이동
sudo sed -i 's|--bind-address=127.0.0.1|--bind-address=0.0.0.0|' \
  /etc/kubernetes/manifests/kube-controller-manager.yaml \
  /etc/kubernetes/manifests/kube-scheduler.yaml

sudo sed -i -E 's#(- --listen-metrics-urls=).*#\1http://0.0.0.0:2381#' \
  /etc/kubernetes/manifests/etcd.yaml

여기서 한 가지 함정이 있는데, 아래 트러블슈팅에서 따로 다루겠습니다. 그리고 이 수정만으로는 kubeadm upgrade 시점에 매니페스트가 재생성되면서 모두 되돌아갑니다. kubeadm-config ConfigMap에도 같은 인자를 넣어야 영구적으로 유지됩니다.

controllerManager:
  extraArgs:
  - name: bind-address
    value: 0.0.0.0
scheduler:
  extraArgs:
  - name: bind-address
    value: 0.0.0.0
etcd:
  local:
    dataDir: /var/lib/etcd
    extraArgs:
    - name: listen-metrics-urls
      value: http://0.0.0.0:2381

이 ConfigMap은 업그레이드와 join 시점에만 읽히므로, 반영해도 실행 중인 컴포넌트에는 영향이 없고 노드 재기동도 발생하지 않습니다.

트러블슈팅

etcd가 기동하지 않고 마스터 노드가 NotReady로 빠짐

현상 — 첫 마스터에 위 수정을 적용한 직후 해당 노드가 NotReady가 되었습니다. 2379와 6443 포트가 모두 닫혔고, etcd 쿼럼은 2/3로 떨어졌습니다. 호스트 자체는 살아 있어 SSH와 kubelet 포트는 정상이었습니다.

원인 — 처음에 liveness probe를 배려한다는 이유로 루프백을 남긴 채 와일드카드를 덧붙였습니다. 그런데 0.0.0.0은 루프백을 이미 포함하므로 같은 주소를 두 번 바인딩하게 되어 etcd가 기동에 실패했습니다.

# 잘못된 설정
--listen-metrics-urls=http://127.0.0.1:2381,http://0.0.0.0:2381

# etcd 로그
{"level":"fatal","msg":"discovery failed",
 "error":"listen tcp 127.0.0.1:2381: bind: address already in use"}

해결 — 중복을 제거하고 와일드카드만 남겼습니다. 0.0.0.0이 루프백도 커버하므로 127.0.0.1:2381을 보는 liveness probe는 그대로 동작합니다. 수정 후 kubelet이 약 20초 만에 etcd를 재기동했고, 이어서 API 서버와 kubelet이 차례로 붙으면서 노드가 Ready로 복귀했습니다. etcd는 raft term 변화 없이 같은 인덱스로 깨끗하게 재합류했습니다.

sudo sed -i -E 's#(- --listen-metrics-urls=).*#\1http://0.0.0.0:2381#' \
  /etc/kubernetes/manifests/etcd.yaml

kubectl exec -n kube-system etcd-<master-node> -- etcdctl \
  --endpoints=https://192.168.x.101:2379,https://192.168.x.102:2379,https://192.168.x.103:2379 \
  --cacert=/etc/kubernetes/pki/etcd/ca.crt \
  --cert=/etc/kubernetes/pki/etcd/server.crt \
  --key=/etc/kubernetes/pki/etcd/server.key endpoint health

이 작업 중 백업 파일을 /etc/kubernetes/manifests/ 안에 만들지 않도록 주의해야 합니다. kubelet이 그 디렉토리를 감시하므로 백업 파일까지 또 하나의 static pod로 읽어 중복 기동을 일으킵니다.

모든 스크레이프 실패를 클러스터 전역 장애로 오독

현상 — 장애 구간에 Prometheus 타깃이 대량으로 up=0이 되어, 마스터를 포함한 클러스터 전체가 끊긴 것처럼 보였습니다.

원인 — Prometheus 파드가 장애 노드 위에 있었습니다. 관측자가 장애 범위 안에 포함되면 “전부 죽었다”는 결과는 타깃이 아니라 관측자의 상태입니다. 여기에 query_range의 lookback 동작이 겹쳐, 스크레이프가 누락된 구간과 정상 구간을 구분할 수 없었습니다.

해결 — 클러스터 밖의 HAProxy 로그와 ARP 테이블을 독립 관측자로 사용해 범위를 다시 그렸습니다. 모니터링 스택이 감시 대상과 같은 장애 도메인에 있으면 그 스택의 데이터만으로는 범위를 확정할 수 없다는 점이 이번 분석의 가장 큰 교훈이었습니다.

결과 확인

조치 이후 쿠버네티스 노드 순단이 발생해도 스토리지 컨트롤 파드가 즉시 축출되지 않으며, 컨트롤 플레인 지표도 사후 분석이 가능한 수준으로 확보되었습니다.

항목 조치 전 조치 후
Rook 파드 축출 유예 5초 60초
etcd / scheduler / controller-manager 스크레이프 0 / 9 성공 9 / 9 성공
kubeadm upgrade 후 설정 유지 되돌아감 유지됨
etcd 쿼럼 3 / 3 3 / 3

변경 직후 뜻하지 않게 실전 검증도 이루어졌습니다. 마지막 마스터를 수정하면서 etcd 리더 선출이 일어났고, 그 여파로 노드 3대가 잠시 NotReady로 표시되었습니다. 기존 5초 설정이었다면 operator와 mgr, MDS가 그대로 축출되었을 상황입니다.

kubectl get pods -n rook-ceph

# 축출된 파드 없음 - AGE가 모두 toleration 변경 시점이거나 그 이전
rook-ceph-operator-...     1/1  Running  0  26m   # 설정 변경 시점
rook-ceph-mgr-a-...        3/3  Running  0  25m
rook-ceph-mds-myfs-a-...   2/2  Running  0  25m
rook-ceph-mon-a-...        2/2  Running  0  35d   # 무변동
rook-ceph-osd-0-...        2/2  Running  0  35d   # 무변동

Ceph 파드 축출은 한 건도 발생하지 않았고, 노드는 자동으로 Ready로 복귀했습니다. 최종 상태는 노드 6대 Ready, 비정상 파드 0, etcd 쿼럼 3/3, Ceph HEALTH_OK입니다.

다만 정직하게 남겨둘 부분이 있습니다. 노드가 왜 9초간 사라졌는지는 아직 확정하지 못했습니다. 이번 조치는 원인 제거가 아니라 피해 범위를 줄이는 완화책이며, 하이퍼바이저 계층의 이벤트 로그를 확보하기 전까지는 재발 가능성이 남아 있습니다.

참고

관련 포스트:

참고 문서: Kubernetes Taints and Tolerations · kubeadm ClusterConfiguration v1beta4 · etcd Monitoring · Rook Ceph Operator Helm Chart

Diagnosing a WordPress Plugin Update Notice That Never Clears

개요

WordPress 관리 화면에서 플러그인을 업데이트해도 업데이트 알림이 사라지지 않고 계속 반복되는 문제를 처리한 기록입니다. 업데이트는 성공했다고 표시되는데 화면을 새로고침하면 같은 플러그인의 업데이트 알림이 다시 떠 있었습니다.

처음에는 Kubernetes로 이전하면서 생긴 파일 권한이나 스토리지 문제로 의심했습니다. 확인해 보니 서로 무관한 두 가지 원인이 겹쳐 있었고, 두 번째 원인은 이쪽 환경 문제가 아니라 배포된 플러그인 패키지 자체의 문제였습니다. 같은 증상을 겪는 경우 진단 순서를 줄일 수 있도록 정리합니다.

환경

구성 요소 내용
플랫폼 Kubernetes 자체 관리형 클러스터
애플리케이션 WordPress (Apache + PHP 8.4, 커스텀 이미지)
스토리지 Rook CephFS (RWX), /var/www/html 전체 마운트
변경 전 리소스 request 100m / 256Mi, limit 500m / 512Mi
변경 전 PHP memory_limit = 128M (이전 서버에서 그대로 승계)

원인 ① — 업데이트 작업을 버틸 리소스 여유분 부족

먼저 파드의 실제 사용량을 확인했습니다.

kubectl top pod -n wordpress-system

아무 작업도 하지 않는 유휴 상태에서 이미 330~390Mi를 사용하고 있었습니다. 활성화된 플러그인이 여러 개인데 지속형 오브젝트 캐시를 쓰지 않는 구성이라 기본 사용량 자체가 높은 상태였습니다. 그런데 메모리 limit은 512Mi였습니다.

플러그인 업데이트는 순간적으로 부하가 몰리는 작업입니다. ZipArchive로 압축을 풀고 Plugin_Upgrader가 파일을 교체하는 동안 메모리와 CPU를 함께 사용합니다. 남은 여유가 120Mi 남짓인 상태에서는 이 작업을 안정적으로 끝내기 어렵습니다. 실제로 업데이트를 시도한 시점에 무관한 정적 자원 요청이 503으로 실패한 것이 같은 시간대에 관측되었습니다.

PHP 쪽도 이전 서버에서 memory_limit = 128M을 그대로 가져온 상태였습니다. 컨테이너의 cgroup 제한과 별개로 PHP 프로세스 자체가 자기 한도에 먼저 걸릴 수 있는 값입니다.

양쪽을 함께 올렸습니다.

# docker/wordpress/php-custom.ini
memory_limit = 256M
# manifests/wordpress/wordpress.yaml
resources:
  requests:
    cpu: 150m
    memory: 384Mi
  limits:
    cpu: "1"
    memory: 1Gi

PHP 설정이 이미지에 포함되어 있어 이미지를 다시 빌드해 태그를 올린 뒤 배포했습니다. 이후 업데이트 도중 503이 발생하는 현상은 사라졌습니다.

원인 ② — 업스트림 패키지의 버전 헤더 불일치

리소스를 확보한 뒤 업데이트는 깨끗하게 완료되었습니다. 그런데도 업데이트 알림은 여전히 다시 나타났습니다.

여기서부터는 이쪽 환경 문제가 아니었습니다. WordPress가 설치된 플러그인의 버전을 어디에서 읽는지 확인하면 원인이 드러납니다. WordPress 코어는 플러그인 메인 PHP 파일 상단의 헤더 주석에 적힌 Version:을 설치된 버전으로 인식합니다. readme.txtStable tag가 아닙니다.

설치된 파일의 헤더와 readme.txt를 직접 비교했습니다.

# 플러그인 메인 파일의 헤더 확인
grep -m1 "Version:" wp-content/plugins/<plugin-dir>/<plugin>.php
# Version: 2.11.2        ← 설치된 버전으로 인식되는 값

# 같은 패키지의 readme.txt
grep -m1 "Stable tag:" wp-content/plugins/<plugin-dir>/readme.txt
# Stable tag: 2.12.0     ← 배포 저장소가 최신으로 인식하는 값

공식 배포 zip 안에서 두 값이 어긋나 있었습니다. 릴리스를 내면서 readme.txtStable tag는 올렸는데 플러그인 파일의 헤더 주석은 이전 버전 문자열이 그대로 남아 있는 상태였습니다.

이 경우 알림은 구조적으로 사라질 수 없습니다. WordPress는 저장소가 알려주는 최신 버전과 헤더에서 읽은 설치 버전을 비교하는데, 업데이트를 아무리 반복해도 같은 헤더 문자열이 다시 설치되므로 두 값은 영원히 불일치합니다. 업데이트가 실패한 것이 아니라 성공해도 상태가 갱신되지 않는 것입니다.

공식 릴리스 내용으로 파일을 교체한 뒤 헤더의 버전 문자열만 실제 릴리스 버전에 맞게 직접 수정해 해소했습니다. 동작에는 영향이 없고 WordPress가 비교하는 문자열만 맞추는 일회성 조치입니다. 이 사례는 2026년 8월 시점에 특정 플러그인에서 관측한 것으로, 이후 배포본에서는 수정되었을 수 있습니다.

진단 순서

같은 증상을 만났을 때 확인 순서를 정리하면 다음과 같습니다.

단계 확인 내용 해당하면
1 kubectl top pod으로 유휴 사용량과 limit 사이 여유분 리소스 상향
2 업데이트 시점 전후의 503·타임아웃 로그 리소스 상향
3 설치된 플러그인의 헤더 Version: vs readme.txtStable tag 업스트림 패키징 문제
4 파일 소유자·권한, 스토리지 쓰기 가능 여부 권한 문제

1~2단계가 정상인데 알림이 계속 반복된다면 3단계를 먼저 보시기를 권합니다. 권한이나 스토리지를 의심하며 시간을 쓰기 쉬운 지점인데, 헤더 한 줄만 비교하면 바로 판별됩니다.

결과 확인

# 파드 리소스 반영 확인
kubectl get deploy -n wordpress-system wordpress \
  -o jsonpath='{.spec.template.spec.containers[0].resources}'

# 적용된 PHP memory_limit 확인
kubectl exec -n wordpress-system deploy/wordpress -- php -i | grep memory_limit

# 유휴 사용량과 limit 사이 여유분 재확인
kubectl top pod -n wordpress-system

리소스 상향 후 유휴 사용량 대비 여유가 확보되었고, 헤더 수정 후 관리 화면의 업데이트 알림이 더 이상 나타나지 않는 것을 확인했습니다.

정리

이전 작업에서 승계한 리소스 설정은 한 번 점검할 가치가 있습니다. 기존 서버에서 문제없이 돌던 값이라도 컨테이너 환경에서는 limit이 곧 하드 제한이라 여유분의 의미가 달라집니다. 특히 유휴 상태 사용량이 limit의 70%를 넘고 있다면 순간 부하를 견딜 여지가 거의 없는 상태입니다.

그리고 모든 증상이 내 환경 탓은 아닙니다. 이번처럼 배포된 패키지 자체의 메타데이터가 어긋난 경우, 인프라를 아무리 고쳐도 증상은 사라지지 않습니다. 재현되는 문제를 계속 자기 쪽에서만 찾고 있다면 업스트림이 보내온 것을 직접 열어보는 단계를 진단 순서에 넣어두는 편이 좋습니다.

참고

관련 포스트:

참고 문서: WordPress — Plugin Header Requirements · Kubernetes — Managing Resources for Containers · PHP — memory_limit

Preserving the Real Client IP Behind Traefik: externalTrafficPolicy and mod_remoteip

개요

Kubernetes로 이전한 WordPress에서 방문자 IP가 실제 접속자가 아니라 리버스 프록시의 주소로 기록되는 문제를 처리한 기록입니다. 트래픽 통계 플러그인에 찍히는 방문자 IP가 모두 동일한 값이었고, 확인해 보니 Traefik 파드의 IP였습니다.

리버스 프록시 뒤에서 클라이언트 IP가 사라지는 것은 흔한 증상이라 mod_remoteip 설정만 점검하면 될 것으로 예상했지만, 실제로는 서로 다른 계층에서 두 가지 원인이 겹쳐 있었습니다. 하나는 Kubernetes Service의 SNAT 동작이고, 다른 하나는 mod_remoteip가 사설 IP 대역을 신뢰하지 않는 하드코딩된 동작이었습니다. 후자는 설정으로 우회할 수 없어 애플리케이션 계층에서 보정해야 했습니다.

환경

구성 요소 내용
Ingress Traefik v3.7.5 (LoadBalancer, MetalLB 할당)
애플리케이션 WordPress (Apache + PHP 8.4), 파드 2개
앞단 프록시 HAProxy → Traefik (내부망) / CloudFront (외부)
내부망 대역 RFC1918 사설 대역
파드 네트워크 Calico, 172.16.0.0/16

증상

트래픽 통계 플러그인에 기록된 방문자 IP가 전부 같은 값이었고, 그 값은 Traefik 파드의 IP였습니다. 특이한 점은 모든 방문자가 아니라 내부망에서 접속한 방문자만 해당된다는 것이었습니다. CloudFront를 거쳐 들어오는 외부 인터넷 방문자의 IP는 정상적으로 기록되고 있었습니다.

이 차이가 원인을 좁히는 단서가 되었습니다. 프록시 설정이 전면적으로 잘못되었다면 외부 방문자도 같이 깨져야 하는데 그렇지 않았기 때문입니다. 내부망 방문자와 외부 방문자를 가르는 차이는 실제 클라이언트 IP가 사설 대역인지 공인 대역인지였습니다.

원인 ① — externalTrafficPolicy의 SNAT

먼저 Kubernetes 계층을 확인했습니다. Traefik의 Service가 기본값인 externalTrafficPolicy: Cluster로 동작하고 있었습니다.

이 정책에서는 트래픽을 받은 노드가 반드시 자기 노드의 파드로 전달하지 않습니다. 다른 노드의 파드로 라우팅할 수 있고, 이때 kube-proxy가 SNAT을 수행하면서 원본 클라이언트 IP를 자기 노드 주소로 바꿉니다. Apache가 요청을 받기도 전에 출발지 IP가 이미 손실되는 것입니다.

# helm/traefik-values.yaml
service:
  annotations:
    metallb.universe.tf/loadBalancerIPs: "192.168.x.x"
  spec:
    externalTrafficPolicy: Local

Local로 바꾸면 각 노드가 자기 노드의 파드로만 전달하므로 SNAT이 발생하지 않고 원본 IP가 보존됩니다. 다만 로컬 엔드포인트가 없는 노드로 트래픽이 가면 응답이 끊기는데, MetalLB가 로컬 엔드포인트가 없는 노드를 광고 대상에서 자동으로 제외하므로 이 구성에서는 안전합니다. Traefik 파드를 2개로 운영하고 있어 가용성 측면의 여유도 확보된 상태였습니다.

같은 이유로 mariadb-galera-primary Service에도 이미 동일한 설정을 적용해 둔 상태였습니다. MetalLB와 kube-proxy가 얽힌 같은 원인이므로, LoadBalancer로 노출하면서 출발지 IP가 의미 있는 서비스라면 함께 점검할 항목입니다.

원인 ② — mod_remoteip가 사설 IP를 무시함

Service 정책을 고친 뒤에도 내부망 방문자의 IP는 여전히 Traefik 파드 IP로 기록되었습니다. Apache 계층에 두 번째 원인이 있었습니다.

mod_remoteip 설정 자체는 정상이었습니다.

# remoteip.conf
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 172.16.0.0/16

모듈의 디버그 로그를 켜서 실제 동작을 확인했습니다.

# Apache 설정에 추가
LogLevel remoteip:trace8

# 로그 출력
"appears to be a private IP or nonsensical. Ignored"

mod_remoteipX-Forwarded-For 값이 사설 IP로 보이면 이를 신뢰하지 않고 버립니다. 스푸핑 방지를 위한 안전장치인데, 문제는 이 판단이 모듈에 하드코딩되어 있어 RemoteIPTrustedProxyRemoteIPInternalProxy로 우회할 수 없다는 점입니다. 두 지시자는 어떤 프록시를 신뢰할지 지정할 뿐, 전달받은 값이 사설 대역일 때의 거부 동작에는 관여하지 않습니다.

이 환경은 LAN과 파드 네트워크가 모두 RFC1918 사설 대역입니다. 따라서 내부망에서 접속하는 방문자의 실제 IP는 항상 사설 대역이고, mod_remoteip는 그 값을 예외 없이 버린 뒤 REMOTE_ADDR을 Traefik 파드 IP로 남겨둡니다. 외부 인터넷 방문자의 IP는 공인 대역이라 정상적으로 수용되므로, 앞서 관찰한 내부/외부 차이가 여기서 설명됩니다.

해결 — PHP 계층에서 보정

모듈 동작을 설정으로 바꿀 수 없으므로 애플리케이션 계층에서 처리했습니다. wp-config.php에서 REMOTE_ADDR이 신뢰하는 프록시 대역 안에 있고 X-Forwarded-For 헤더가 존재할 때만 직접 값을 덮어쓰는 방식입니다.

// mod_remoteip가 사설 IP로 보이는 X-Forwarded-For를 버리므로 PHP 레벨에서 보정
if ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) && isset( $_SERVER['REMOTE_ADDR'] ) ) {
    $trusted_proxy_long = ip2long( '172.16.0.0' );
    $trusted_proxy_mask = -1 << ( 32 - 16 ); // /16
    $remote_addr_long   = ip2long( $_SERVER['REMOTE_ADDR'] );

    if ( false !== $remote_addr_long
        && ( $remote_addr_long & $trusted_proxy_mask ) === ( $trusted_proxy_long & $trusted_proxy_mask ) ) {
        $forwarded_ips = array_map( 'trim', explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
        $client_ip     = $forwarded_ips[0];

        if ( filter_var( $client_ip, FILTER_VALIDATE_IP ) ) {
            $_SERVER['REMOTE_ADDR'] = $client_ip;
        }
    }
}

조건을 두 개 건 이유가 있습니다. REMOTE_ADDR이 파드 네트워크 대역일 때만 덮어쓰므로, 프록시를 거치지 않은 직접 요청이 X-Forwarded-For를 위조해 보내더라도 값이 반영되지 않습니다. 또한 FILTER_VALIDATE_IP로 형식을 검증해 잘못된 값이 들어가는 것을 막습니다.

이 방식은 같은 파일에 이미 적용되어 있던 HTTP_X_FORWARDED_PROTO$_SERVER['HTTPS'] 보정과 동일한 패턴입니다. 리버스 프록시 뒤에서 PHP가 요청 맥락을 잘못 인식하는 문제를 애플리케이션 진입점에서 한 번에 정리하는 구조입니다.

주의 — 이 보정이 적용되지 않는 경우

보정 코드가 wp-config.php에 있다는 점에서 오는 제약이 있습니다. wp-load.php를 거치지 않고 직접 호출되는 순수 PHP 스크립트는 이 보정을 보지 못합니다. Apache나 php.ini 레벨이 아니라 WordPress 부트스트랩 과정에서만 실행되기 때문입니다.

검증할 때 이 점 때문에 혼란을 겪을 수 있습니다. 테스트용 PHP 파일을 만들어 $_SERVER['REMOTE_ADDR']을 출력하면 보정 전 값이 나오므로 수정이 반영되지 않은 것처럼 보입니다. WordPress를 실제로 부트스트랩하는 경로에서 확인해야 정확합니다.

결과 확인

두 가지를 모두 적용한 뒤 내부망에서 접속해 통계 플러그인의 기록을 확인했습니다. 방문자 IP가 Traefik 파드 IP가 아니라 실제 접속 단말의 주소로 기록되는 것을 확인했습니다. 외부 방문자 기록은 기존과 동일하게 유지되었습니다.

# Service 정책 확인
kubectl get svc -n traefik traefik -o jsonpath='{.spec.externalTrafficPolicy}'
# 출력: Local

# Traefik 파드가 각 노드에 분산되어 있는지 확인
kubectl get pods -n traefik -o wide

# MetalLB 광고 상태 확인 (로컬 엔드포인트 없는 노드 제외 여부)
kubectl get svc -n traefik traefik -o wide

정리

리버스 프록시 뒤에서 클라이언트 IP가 사라질 때는 계층을 나눠서 확인하는 편이 빠릅니다. 이번 사례에서는 Kubernetes Service의 SNAT과 Apache 모듈의 거부 동작이 겹쳐 있었고, 한쪽만 고쳐서는 증상이 사라지지 않았습니다.

특히 mod_remoteip가 사설 IP를 버리는 동작은 사내망이나 VPN 환경처럼 클라이언트 IP 자체가 사설 대역인 구성에서 문제가 됩니다. 인터넷에 공개된 서비스에서는 클라이언트 IP가 공인 대역이라 드러나지 않으므로, 같은 구성이라도 접속 경로에 따라 증상이 갈립니다. 내부망 접속만 IP가 이상하다면 이 동작을 의심해 볼 만합니다.

참고

관련 포스트:

참고 문서: Apache — mod_remoteip · Kubernetes — External Traffic Policy · MetalLB — Traffic Policies

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

Migrating Standalone MariaDB to a Galera Cluster on Kubernetes

개요

MariaDB Galera 전환 작업을 진행했습니다. 그동안 단일 인스턴스(단일 replica + ReadWriteOnce 볼륨) 구조로 MariaDB를 운영해왔는데, Kubernetes 클러스터 업그레이드 도중 MariaDB가 떠 있는 워커 노드를 drain할 때마다 2~3분 정도 서비스가 끊기는 문제가 반복됐습니다. 단일 replica + RWO 볼륨 조합에서는 drain 시 기존 노드에서 볼륨이 detach되고 새 노드에 attach되는 동안의 지연을 구조적으로 피할 수 없었습니다.

이 글에서는 이 문제를 해결하기 위해 mariadb-operator 기반 3노드 Galera 클러스터로 전환한 전체 과정 — 사전 조사, 클러스터 구성, 데이터 마이그레이션, 최종 트래픽 전환(cutover), 그리고 그 과정에서 만난 트러블슈팅을 정리합니다.

환경

  • Kubernetes 클러스터 (Control-plane 3대, Worker 3대)
  • Storage: Rook-Ceph RBD (StorageClass, ReadWriteOnce)
  • 기존: MariaDB 10.11 단일 인스턴스(Deployment, replica 1)
  • 전환 후: MariaDB 10.11 Galera 3노드 (mariadb-operator 관리, StatefulSet)
  • 데이터 규모: 2개 DB, 총 143개 테이블, 약 2.3GB

단계별 절차

1. 전환 배경 조사

워커 drain 시 실제 단절 시간을 측정해보니, MariaDB가 떠 있는 노드를 drain할 때마다 매번 2~3분씩 서비스가 끊기는 것으로 확인됐습니다. 원인은 단일 replica라 drain되는 순간 파드가 다른 노드로 옮겨가야 하는데, RWO(ReadWriteOnce) 블록 볼륨이라 기존 노드에서 detach가 완료되어야 새 노드에서 attach가 시작되기 때문이었습니다. 이 대기 시간에 InnoDB 복구 시간까지 더해지면서 단절이 발생했습니다.

해결 방법을 두 갈래로 검토했습니다.

  • A안 — cordon-only 절차: drain 대신 cordon만 하고 kubelet을 재기동하는 방식. 컨테이너 런타임이 kubelet 재기동으로 기존 컨테이너를 죽이지 않는다는 점을 이용해, 계획된 업그레이드에 한해서는 단절을 0초로 만들 수 있음. 다만 노드 장애 등 예기치 못한 상황에는 도움이 안 됨
  • B안 — Galera 3노드 전환: 계획된 업그레이드는 물론 노드 장애 시에도 무중단이 되는 진짜 HA 구조

적합성을 미리 조사해보니, 전체 테이블이 InnoDB로 전환 가능했고(예외 1개, 아래에서 다룸), 쓰기 부하도 초당 1~2건 수준으로 매우 낮아 Galera의 동기 복제 오버헤드가 문제 될 수준이 아니었습니다. 워커 노드들의 메모리 여유도 충분해 3노드 확장에 무리가 없다고 판단해 B안으로 진행했습니다.

2. mariadb-operator 설치

OSS mariadb-operator는 Galera 클러스터의 부트스트랩·장애 감지·자동 복구를 대신 처리해주고, 무엇보다 현재 Primary 파드로만 트래픽을 라우팅하는 Service(primaryService)를 자체 제공합니다. Galera는 원래 멀티 마스터 구조라 여러 파드로 쓰기가 분산되면 오히려 인증(certification) 충돌 위험이 있는데, 이 기능 덕분에 별도의 쓰기 라우팅 설계나 MaxScale 도입 없이 “쓰기는 항상 Primary로” 요구사항을 해결할 수 있었습니다.

helm install mariadb-operator-crds oci://ghcr.io/mariadb-operator/charts/mariadb-operator-crds --version 26.6.0 -n mariadb-operator --create-namespace
helm install mariadb-operator oci://ghcr.io/mariadb-operator/charts/mariadb-operator --version 26.6.0 -n mariadb-operator

operator는 DB 워크로드와 별도 네임스페이스에 설치했습니다. 이미 이 클러스터에서 Ingress 컨트롤러, LoadBalancer 컨트롤러 등을 “컨트롤러는 자기 네임스페이스, 워크로드는 자기 네임스페이스”로 분리해온 컨벤션과 동일하며, operator를 재설치/업그레이드할 때 워크로드에 영향을 줄 위험을 줄여줍니다.

3. Galera 클러스터 구성 설계

CRD로 3노드 Galera 클러스터를 선언했습니다. 실제 사용량(메모리 약 1.4Gi)과 여유분을 고려해 request/limit을 산정했고, 워커 3대 = replica 3개인 구조라 topologySpreadConstraints로 반드시 노드당 1개씩 배치되도록 강제했습니다(그래야 노드 장애 내성이 실제로 성립합니다).

apiVersion: k8s.mariadb.com/v1alpha1
kind: MariaDB
metadata:
  name: mariadb-galera
spec:
  image: mariadb:10.11
  replicas: 3
  galera:
    enabled: true
    sst: mariabackup
  storage:
    size: 10Gi
    storageClassName: rook-ceph-block
  resources:
    requests: { cpu: 250m, memory: 1Gi }
    limits: { cpu: "1", memory: 2Gi }
  primaryService:
    type: LoadBalancer
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: kubernetes.io/hostname
      whenUnsatisfiable: DoNotSchedule
      labelSelector:
        matchLabels:
          app.kubernetes.io/instance: mariadb-galera

4. 데이터 이관 준비

Galera는 InnoDB 테이블만 복제합니다. 기존 DB를 점검해보니 전체 143개 테이블 중 보안 플러그인이 쓰는 캐시 테이블 1개만 MEMORY 엔진이었고, 그마저도 항상 빈 테이블이라 InnoDB로 즉시 전환했습니다.

계정/권한 이관은 평문 비밀번호를 몰라도 되는 방법을 썼습니다. SHOW GRANTS FOR 'user'@'%'로 확인되는 IDENTIFIED BY PASSWORD '*HASH' 값을 그대로 새 클러스터에 CREATE USER ... IDENTIFIED BY PASSWORD '*HASH'로 재생성한 것입니다. 외부 애플리케이션이 쓰는 계정처럼 평문 비밀번호를 알 수 없는 계정도 문제없이 동일하게 이관할 수 있었습니다.

5. 데이터 마이그레이션

논리 덤프(mysqldump)로 실제 데이터를 옮겼습니다. 이 과정에서 트러블슈팅이 하나 있었는데, 아래 트러블슈팅 섹션에서 자세히 다룹니다. 최종적으로는 클러스터 내부에서 실행되는 일회성 Job으로 소스와 타겟 DB를 직접 연결하는 방식으로 안정적으로 완료했습니다.

6. 최종 컷오버

실제 트래픽 전환 직전에는, 쓰기 중인 애플리케이션을 짧게 멈추고 최종 재동기화를 한 번 더 돌려 모든 테이블에서 정확히 0건 차이임을 확인한 뒤 진행했습니다. 외부에서 접속하는 클라이언트가 있었기 때문에, 기존에 쓰던 LoadBalancer IP를 그대로 새 클러스터의 primaryService로 이전해서 그 클라이언트 쪽 설정은 전혀 바꿀 필요가 없도록 했습니다. 클러스터 내부에서 DNS 이름으로 접속하던 애플리케이션(WordPress 등)만 새 Service 이름으로 연결 문자열을 바꿔주면 됐습니다.

트러블슈팅

Galera 설정 볼륨이 StorageClass 미지정으로 Pending

현상: 클러스터를 처음 생성했을 때 3개 파드 모두 Pending 상태로 멈췄습니다. 이벤트를 보니 pod has unbound immediate PersistentVolumeClaims, PVC 쪽에는 no persistent volumes available for this claim and no storage class is set 에러가 나 있었습니다.

원인: 데이터 볼륨 외에 Galera 설정 파일 전용으로 별도 PVC가 하나 더 자동 생성되는데, 이 PVC 템플릿에는 StorageClass가 지정되지 않았고, 클러스터에 기본(default) StorageClass도 지정되어 있지 않아 바인딩될 곳이 없었습니다.

해결: 설정 파일용 볼륨을 따로 만들지 않고 데이터 볼륨을 재사용하도록 옵션을 켰습니다. 다만 이 옵션은 리소스 생성 후에는 변경 불가능한(immutable) 필드라, 처음부터 이 옵션을 켠 상태로 리소스를 삭제 후 재생성해야 했습니다.

  galera:
    enabled: true
    config:
      reuseStorageVolume: true   # 생성 후 변경 불가 - 처음부터 켜야 함

대용량 마이그레이션 중 kubectl exec 스트림 단절

현상: 소스 파드에서 mysqldump를 실행해 그 표준출력을 타겟 파드의 mysql 클라이언트로 바로 파이프 연결하는 방식으로 마이그레이션을 시도했는데, 수백만 줄이 넘어간 시점에 unexpected EOF로 스트림이 끊겼습니다. 타겟 쪽에서는 끊긴 지점의 잘린 SQL 구문 때문에 문법 오류가 발생했습니다.

원인: 두 파드를 각각 별도의 exec 세션으로 열어 로컬 셸의 파이프로 연결하는 방식이라, 데이터가 API 서버를 두 번 거치는 이중 스트리밍 구조가 됩니다. 데이터량이 크고 전송 시간이 길어질수록 이 경로가 끊길 가능성이 커집니다.

해결: 클러스터 내부에서 직접 실행되는 일회성 Job으로 전환했습니다. Job 안에서 소스/타겟 Service의 DNS 이름으로 직접 연결해 mysqldump | mysql을 실행하니, API 서버를 거치지 않고 클러스터 네트워크 안에서 바로 전송되어 안정적으로 완료됐습니다.

apiVersion: batch/v1
kind: Job
metadata:
  name: mariadb-galera-migrate
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: mariadb:10.11
          command: ["sh", "-c"]
          args:
            - |
              mysqldump -h  -uroot -p"$SRC_PW" \
                --databases db1 db2 --single-transaction --routines --triggers --events \
              | mysql -h  -uroot -p"$DST_PW"

결과 확인

전환 후 다음 항목들을 확인했습니다.

  • 마이그레이션 후 모든 테이블에서 정확히 0건 차이 검증 완료
  • 4일 이상 무재시작·무단절 운영 확인 (Galera 3노드 전부 Synced)
  • 기존 인스턴스는 행 수가 더 이상 늘지 않음(트래픽 완전히 이전) vs 신규 클러스터는 계속 증가 — 컷오버가 실제로 적용됐음을 재확인
  • 관찰 기간 이후 기존 단일 인스턴스는 설정을 문서로 백업한 뒤 정리(삭제)

단일 인스턴스 대비 이번 MariaDB Galera 전환으로, 계획된 업그레이드는 물론 예기치 못한 노드 장애 상황에서도 서비스 단절 없이 운영할 수 있는 구조를 갖추게 됐습니다.

참고

관련 포스트:

참고 문서: mariadb-operator (GitHub) · Galera Cluster Documentation

Enabling GitLab Container Registry over HTTPS with a Custom TLS Certificate

개요

GitLab Container Registry를 자체 관리형 Omnibus GitLab에서 HTTPS로 활성화한 작업 기록입니다. WordPress를 Kubernetes로 이전하면서 커스텀 이미지를 저장할 레지스트리가 필요했는데, 로컬 환경에 docker나 podman이 없어 클러스터 안에서 Kaniko로 이미지를 빌드하고 곧바로 푸시할 대상이 있어야 했습니다. 이미 사내에서 운영 중인 GitLab이 있었으므로 별도 레지스트리를 구축하는 대신 GitLab에 내장된 Container Registry를 켜는 방향을 선택했습니다.

Omnibus GitLab의 Container Registry는 기본적으로 비활성 상태이며 /etc/gitlab/gitlab.rb에서 직접 켜야 합니다. 이 과정에서 registry용 nginx 블록이 기존 커스텀 인증서 경로를 상속하지 않는 문제로 GitLab 전체 nginx가 기동 중지되는 상황을 겪었습니다. 레지스트리만 실패한 것이 아니라 GitLab 웹 UI까지 함께 접속 불가가 되었기 때문에, 같은 구성을 계획하고 계신 분이라면 미리 알아두실 만한 지점이라 판단해 정리합니다.

환경

항목 내용
GitLab Omnibus GitLab CE (Ubuntu, 192.168.x.x)
기존 도메인 gitlab.sierracloud.dev (HTTPS 서비스 중)
기존 TLS 인증서 Let’s Encrypt *.sierracloud.dev 와일드카드, /test/cert/에 배치
Registry 목표 주소 gitlab.sierracloud.dev:5050
이미지 사용처 Kubernetes 클러스터 (Kaniko 빌드 → 파드 imagePullSecrets)

호스트 방식 선택 — 포트 분리와 서브도메인 분리

GitLab Container Registry의 주소는 두 가지 방식으로 구성할 수 있습니다. 어느 쪽을 선택하느냐에 따라 필요한 DNS 레코드와 인증서가 달라지므로 먼저 결정해야 합니다.

방식 주소 예시 DNS 인증서
단일 호스트 + 포트 gitlab.sierracloud.dev:5050 추가 불필요 기존 와일드카드 재사용
별도 서브도메인 registry.gitlab.sierracloud.dev 레코드 추가 필요 신규 발급 또는 SAN 추가 필요

이번에는 기존에 발급받아 둔 *.sierracloud.dev 와일드카드 인증서를 그대로 재사용할 수 있고 DNS 작업이 필요 없는 단일 호스트 + 포트 방식을 선택했습니다. 다만 와일드카드 인증서는 *.sierracloud.dev 형태로 한 단계 서브도메인만 포함하므로, 별도 서브도메인 방식을 택했다면 registry.gitlab.sierracloud.dev는 두 단계가 되어 기존 인증서로 커버되지 않는다는 점도 고려 대상이었습니다.

사전 확인

GitLab은 여러 사용자가 함께 쓰는 공유 시스템이고 gitlab-ctl reconfigure가 서비스 재기동을 동반하므로, 설정을 바꾸기 전에 현재 상태와 복구 지점을 먼저 확보했습니다.

# 설정 파일 백업 (가장 중요 - 되돌릴 지점 확보)
sudo cp /etc/gitlab/gitlab.rb /etc/gitlab/gitlab.rb.bak-$(date +%Y%m%d)

# 현재 registry 관련 설정 확인 (주석이 아닌 유효 설정만)
sudo grep -n -i 'registry|external_url|nginx\[.ssl' /etc/gitlab/gitlab.rb | grep -v '^\s*#'

# 레지스트리 저장 공간 확인 - 이미지가 쌓이는 경로
df -h /var/opt/gitlab

# 방화벽 상태 및 현재 리스닝 포트
sudo ufw status
sudo ss -tlnp | grep -E ':443|:80|:5050'

# 기존 인증서 위치 확인
sudo ls -la /etc/gitlab/ssl/
sudo find /etc/letsencrypt -maxdepth 3

이 단계에서 두 가지를 확인했습니다. 첫째로 /etc/gitlab/ssl/ 디렉터리에는 인증서가 없었고, GitLab이 /test/cert/의 커스텀 경로를 바라보도록 구성되어 있었습니다. 둘째로 registry 관련 설정은 모두 주석 처리된 기본 상태였습니다. 이 첫 번째 사실이 뒤에서 문제의 원인이 됩니다.

Container Registry 활성화

/etc/gitlab/gitlab.rb 하단에 registry 설정을 추가합니다. Omnibus GitLab은 이 파일을 읽어 nginx와 registry 서비스 설정을 생성하는 구조입니다.

# gitlab.rb에 추가할 내용
registry_external_url 'https://gitlab.sierracloud.dev:5050'
gitlab_rails['registry_enabled'] = true

설정을 반영합니다. reconfigure는 Chef 기반으로 설정 파일을 재생성하고 관련 서비스를 재기동하므로 GitLab 이용자에게 짧은 영향이 발생합니다.

sudo gitlab-ctl reconfigure

# 반영 후 서비스 상태 확인
sudo gitlab-ctl status | grep -iE "nginx|registry"

트러블슈팅 — reconfigure 이후 nginx 전체 기동 실패

reconfigure는 오류 없이 끝났지만 5050 포트가 열리지 않았고, 서비스 상태를 확인하니 registry는 떠 있는데 nginx가 down 상태였습니다.

$ sudo gitlab-ctl status | grep -iE "nginx|registry"
down: nginx: 0s, normally up, want up; run: log: (pid 1249167) 3169106s
run: registry: (pid 2639512) 481s; run: log: (pid 2639249) 540s

nginx가 내려갔다는 것은 레지스트리뿐 아니라 GitLab 웹 UI 전체가 접속 불가라는 의미입니다. 실제로 외부에서 gitlab.sierracloud.dev에 접속되지 않는 상태였습니다.

원인 진단

nginx 설정 파일을 검증하려 했으나 nginx 명령이 PATH에 없었습니다. Omnibus GitLab은 nginx를 자체 번들로 포함하므로 임베디드 바이너리의 전체 경로를 지정해야 합니다.

# PATH에 없으므로 임베디드 바이너리 직접 호출
sudo /opt/gitlab/embedded/sbin/nginx -t -c /var/opt/gitlab/nginx/conf/nginx.conf

검증 결과 원인이 명확히 드러났습니다.

nginx: [emerg] cannot load certificate "/etc/gitlab/ssl/gitlab.sierracloud.dev.crt":
  BIO_new_file() failed (SSL: error:02001002:system library:fopen:
  No such file or directory)
nginx: configuration file /var/opt/gitlab/nginx/conf/nginx.conf test failed

존재하지 않는 /etc/gitlab/ssl/gitlab.sierracloud.dev.crt를 찾고 있었습니다. 이 서버는 인증서를 /test/cert/에 두고 사용하도록 구성되어 있었는데, registry용 nginx가 그 경로를 알지 못한 것입니다.

근본 원인 — registry_nginx는 인증서 설정을 상속하지 않습니다

Omnibus GitLab에서 registry_nginx는 기존 GitLab 웹용 nginx별개의 설정 블록입니다. nginx['ssl_certificate']에 커스텀 경로를 지정해 두었더라도 registry_nginx는 이를 물려받지 않고, Omnibus의 기본 명명 규칙인 /etc/gitlab/ssl/<호스트명>.crt를 찾습니다.

인증서를 기본 경로에 두고 쓰는 환경에서는 이 규칙이 우연히 맞아떨어지기 때문에 문제가 드러나지 않습니다. 반면 이번처럼 인증서를 별도 경로에서 관리하는 구성에서는 파일을 찾지 못하고, nginx는 설정 검증 실패 시 부분 기동이 아니라 전체 기동을 중단합니다. 레지스트리 하나를 추가하려다 GitLab 서비스 전체가 멈추는 이유가 여기에 있습니다.

해결

registry_nginx에도 동일한 인증서 경로를 명시적으로 지정합니다.

# gitlab.rb에 추가
registry_nginx['ssl_certificate'] = "/test/cert/fullchain.pem"
registry_nginx['ssl_certificate_key'] = "/test/cert/privkey.pem"
sudo gitlab-ctl reconfigure

$ sudo gitlab-ctl status | grep -iE "nginx|registry"
run: nginx: (pid 2643061) 44s; run: log: (pid 1249167) 3170680s
run: registry: (pid 2639512) 2055s; run: log: (pid 2639249) 2114s

nginx가 정상 기동되었습니다. 커스텀 인증서 경로를 쓰는 환경이라면 registry_external_urlregistry_nginx의 인증서 설정을 처음부터 같이 추가하는 것이 안전합니다. 두 항목을 한 번에 넣고 reconfigure를 한 번만 수행하면 다운타임 없이 마칠 수 있었던 작업이었습니다.

결과 확인

외부에서 GitLab 본 사이트와 레지스트리 엔드포인트에 각각 요청해 응답 코드를 확인합니다.

$ curl -sk -o /dev/null -w "gitlab main site -> %{http_code}\n" https://gitlab.sierracloud.dev/
gitlab main site -> 302

$ curl -sk -o /dev/null -w "registry :5050/v2/ -> %{http_code}\n" https://gitlab.sierracloud.dev:5050/v2/
registry :5050/v2/ -> 401

여기서 401 Unauthorized는 오류가 아니라 정상 신호입니다. Docker Registry HTTP API V2 규격상 /v2/ 엔드포인트는 인증되지 않은 요청에 401을 반환하면서 WWW-Authenticate 헤더로 토큰 발급처를 안내합니다. 레지스트리가 살아 있고 인증 흐름이 동작한다는 뜻이므로, 200을 기대하고 401을 실패로 오인하지 않도록 주의합니다. 반대로 000이나 커넥션 거부가 나오면 포트가 열리지 않았거나 nginx가 내려간 상태입니다.

Kubernetes 파드에서도 같은 주소에 도달하는지 확인합니다. 노드에서는 되는데 파드에서 안 되는 경우가 있어 클러스터 내부 경로를 별도로 검증했습니다.

kubectl run regtest --image=curlimages/curl:8.5.0 --rm -i --restart=Never --command -- \
  sh -c "curl -sk -o /dev/null -w '%{http_code}\n' https://gitlab.sierracloud.dev:5050/v2/"

# 출력: 401  (클러스터 내부에서도 도달 확인)

Kubernetes에서 이미지 가져오기

파드가 레지스트리에서 이미지를 받으려면 인증 정보가 담긴 Secret이 필요합니다. 개인 계정 대신 프로젝트 단위 Deploy Token을 발급해 사용했습니다. GitLab 프로젝트의 Settings → Repository → Deploy tokens에서 read_registry 권한으로 생성합니다.

kubectl create secret docker-registry gitlab-registry-cred \
  -n wordpress-system \
  --docker-server=gitlab.sierracloud.dev:5050 \
  --docker-username='gitlab+deploy-token-1' \
  --docker-password='<REDACTED>'

# Deployment에서 참조
# spec:
#   template:
#     spec:
#       imagePullSecrets:
#       - name: gitlab-registry-cred

운영 시 참고 사항

  • 인증서 갱신 — registry_nginx가 기존 인증서와 같은 파일을 바라보므로, 갱신 스크립트가 해당 경로의 파일을 제자리에서 교체한다면 별도 작업이 필요 없습니다. 다만 갱신 후에는 nginx 재기동이 필요합니다.
  • 저장 공간 — 이미지는 /var/opt/gitlab/gitlab-rails/shared/registry에 누적됩니다. 태그를 계속 늘려가는 운영이라면 디스크 사용량 모니터링과 정리 정책을 함께 준비하는 편이 좋습니다.
  • 포트 개방 — 방화벽이나 앞단 프록시를 거치는 구조라면 5050 포트에 대한 규칙을 별도로 추가해야 합니다. GitLab 서버가 내부망에서 직접 라우팅되는지 먼저 확인하시기 바랍니다.
  • 변경 전 백업gitlab.rb는 3,500줄이 넘는 파일이라 수정 전 백업이 실질적인 안전장치가 됩니다. 이번에도 백업본 대비 줄 수를 비교해 설정이 실제로 반영되었는지 확인했습니다.

참고

관련 포스트:

참고 문서: GitLab — Container Registry administration · Omnibus GitLab — SSL settings · Docker Registry HTTP API V2