태그 보관물: Docker

Fixing Container Log Rotation on cri-dockerd and a Rolling Kernel Reboot

cri-dockerd 로그 로테이션 에러를 발견한 경위

노드 유지보수를 준비하다가 kubelet이 하루에 노드당 3만~8만 줄의 동일한 에러를 남기고 있는 것을 발견했습니다. 원인은 cri-dockerd 로그 로테이션 요청이 구조적으로 성공할 수 없는 구성에 있었습니다. kubelet은 컨테이너 로그가 임계값을 넘으면 로테이션을 요청하는데, cri-dockerd는 그 요청을 처리할 수 없어 10초마다 거부하고 kubelet은 10초마다 다시 요청했습니다.

이 글은 그 무한 재시도를 어떻게 측정하고 끊었는지, 그리고 같은 작업 창에서 함께 처리한 커널 롤링 재부팅 기록입니다. 재부팅 과정에서 문서에 “영구화 완료”로 적혀 있던 부팅 스크립트가 실은 한 번도 동작한 적이 없었다는 사실도 드러났는데, 그 쪽이 더 값진 수확이었습니다.

환경

구성 요소 내용
Kubernetes v1.36.3 (kubeadm HA, control plane 3대 + worker 3대)
컨테이너 런타임 Docker 29.5.3 + cri-dockerd
노드 OS Ubuntu (kernel 6.8.0-124 → 6.8.0-146)
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)
노드 사양 워커 allocatable 약 7.8 GiB, swap 없음

로그 로테이션 에러를 끊은 과정

1. 에러 규모를 먼저 측정했습니다

로그가 시끄럽다는 인상만으로는 우선순위를 정할 수 없으므로 노드별로 발생량을 세었습니다. 함께 컨테이너 로그 총량과 journald 점유량도 같이 확인했습니다.

journalctl -u kubelet --since "24 hours ago" --no-pager \
  | grep -ci "ReopenContainerLog\|Failed to rotate log"
du -sh /var/lib/docker/containers
journalctl --disk-usage

결과는 예상보다 컸습니다. 4대만 추려도 하루치 에러가 아래와 같았고, journald는 노드당 2.6~4.0 GB를 점유하고 있었습니다. 디스크가 50 GB인 노드에서 무시할 양이 아닙니다.

노드 24시간 에러 줄 수 컨테이너 로그 journald
<master-node-1> 34,548 110 M 2.6 G
<worker-node-1> 45,422 99 M 3.9 G
<worker-node-2> 79,940 245 M 4.0 G
<worker-node-3> 76,334 126 M 3.9 G

2. 에러 본문에서 원인이 그대로 드러났습니다

발생 빈도가 10초 간격이라는 점이 단서였습니다. 크기 기반 로테이션이라면 이렇게 잦을 수 없으므로 실제 메시지를 확인했습니다.

E1011 11:39:41 container_log_manager.go:266] "Failed to rotate log for container"
  err="failed to rotate log \"/var/log/pods/.../csi-snapshotter/0.log\":
       failed to reopen container log \"bcce9138ed39\":
       rpc error: code = Unknown desc = docker does not support reopening container log files"
  currentSize=65818371 maxSize=10485760

두 숫자가 전부를 설명합니다. maxSize=10485760은 kubelet의 containerLogMaxSize 기본값 10 Mi이고, currentSize는 62.8 MiB로 이미 그 임계를 한참 넘긴 상태입니다. kubelet은 임계를 넘은 파일을 발견하면 CRI의 ReopenContainerLog를 호출해 로테이션을 수행하는데, cri-dockerd는 이 RPC를 구현하지 않습니다.

즉 cri-dockerd 환경에서 kubelet이 보내는 로그 로테이션 요청은 영원히 성공할 수 없었고, kubelet은 그 요청을 10초마다 재시도하고 있었습니다. 파일이 임계 아래로 내려갈 일이 없으므로 이 루프는 컨테이너가 재생성될 때까지 멈추지 않습니다. 로그 용량 문제가 아니라 끝나지 않는 재시도가 본질이었습니다.

3. 로그 로테이션 주체를 docker로 넘겼습니다

기존 설정은 max-size가 100m이고 max-file이 없었습니다. json-file 드라이버에서 max-file을 생략하면 기본값이 1이므로 이력이 남지 않고, 100 MiB는 kubelet 임계값 10 Mi보다 훨씬 커서 위 루프를 항상 유발합니다.

해결 방향은 두 가지가 있었습니다. kubelet의 containerLogMaxSize를 키워 로테이션 시도 자체를 막는 방법과, docker의 상한을 kubelet 임계값보다 작게 두는 방법입니다. 후자를 택했습니다. 전자는 kubelet 설정을 바꿔야 하는데 그 값은 클러스터 전역 ConfigMap에서 내려오므로 변경 범위가 노드 밖으로 커집니다.

항목 변경 전 변경 후
max-size 100m 8m (kubelet 임계 10 Mi 미만)
max-file 미지정 (= 1, 이력 없음) 3
컨테이너당 상한 100 MiB 24 MiB

로그 파일이 8 MiB에서 회전하므로 10 Mi에 도달하는 일 자체가 없어지고, kubelet은 로테이션을 시도할 이유를 잃습니다. 실제 회전은 docker가 수행하며 직전 2세대가 남습니다. 결과적으로 cri-dockerd 로그 로테이션 경로는 더 이상 호출되지 않습니다.

cri-dockerd 로그 로테이션 재시도 루프 개념도: 변경 전 docker max-size 100m에서는 kubelet의 ReopenContainerLog 요청이 10초마다 실패하고, 변경 후 max-size 8m에서는 요청 자체가 사라짐
cri-dockerd 로그 로테이션 재시도 루프가 생기고 사라지는 구조
# /etc/docker/daemon.json
{
  "exec-opts": ["native.cgroupdriver=systemd"],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "8m",
    "max-file": "3"
  },
  "storage-driver": "overlay2"
}

적용 전 반드시 검증해야 합니다. daemon.json이 깨지면 재부팅 후 docker가 기동하지 못해 노드가 그대로 죽습니다. JSON 구문 검사만으로는 부족하므로 dockerd 자체의 검증기를 통과시킨 뒤 설치했습니다.

sudo cp -a /etc/docker/daemon.json /etc/docker/daemon.json.bak-$(date +%Y%m%d)
python3 -m json.tool /tmp/dj.new > /dev/null          # 구문
sudo dockerd --validate --config-file /tmp/dj.new     # dockerd 레벨 → "configuration OK"
sudo install -m 644 -o root -g root /tmp/dj.new /etc/docker/daemon.json

docker는 재시작하지 않았습니다. 재시작하면 해당 노드의 모든 컨테이너가 재생성되므로, 어차피 진행할 롤링 재부팅에서 자연히 반영되도록 두었습니다.

4. journald 상한을 고정했습니다

SystemMaxUse는 기본적으로 주석 처리되어 있고, 그 경우 파일시스템의 10 %를 쓸 수 있습니다. 50 GB 디스크에서 약 5 GB이며 실제로 4 GB에 근접해 있었습니다. 에러 폭주를 끊어도 상한 자체는 남겨두는 편이 안전합니다.

sudo sed -i -E 's|^[[:space:]]*#?[[:space:]]*SystemMaxUse=.*|SystemMaxUse=1G|' \
  /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
sudo journalctl --vacuum-size=1G

6대 합계로 약 15 GB를 회수했습니다. 노드당 여유 공간이 마스터는 34 → 36 GB, 워커는 23 → 26 GB로 늘었습니다.

cri-dockerd 로그 로테이션 정리 작업 중 journald 상한 1 GB 적용 직후 노드별 루트 파일시스템 여유 공간이 컨트롤 플레인 약 2 GiB, 워커 약 3 GiB씩 늘어난 그래프
journald 상한 적용 전후 노드별 루트 파일시스템 여유 공간. 10월 11일 정오 무렵 6대가 동시에 늘었고 합계는 약 15 GiB입니다(node_exporter 실측).

5. 롤링 재부팅으로 설정과 커널을 함께 반영했습니다

때마침 커널 드리프트가 쌓여 있었습니다. 6대 전부 6.8.0-146이 설치만 된 상태였고 1대만 그 커널로 가동 중이었습니다. 나머지 5대는 6.8.0-124로 돌고 있어, 다음 재부팅에서 커널이 바뀐다는 뜻입니다. 계획된 창에서 바꾸는 편이 장애 상황에서 마주치는 것보다 낫습니다.

마스터 3대 → 워커 3대 순으로 한 대씩 진행했습니다. 워커 구간은 OSD 하나가 내려가는 동안 Ceph가 리밸런싱을 시작하지 않도록 감쌌습니다.

kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd set noout   # 워커 구간 시작

kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --timeout=600s
ssh <admin-user>@<node-ip> 'sudo systemctl reboot'

until kubectl get node <node> \
      -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' | grep -q True;
do sleep 10; done
kubectl uncordon <node>

kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph osd unset noout # 워커 구간 종료

노드마다 다음 게이트를 모두 통과한 뒤에만 다음 노드로 넘어갔습니다. 커널 버전, ceph 커널 모듈 적재, docker 로그 설정, kubelet·docker 활성 상태, 그리고 마스터는 etcd 쿼럼, 워커는 Ceph의 전체 PG가 active+clean으로 복귀했는지입니다.

노드 재부팅 전 커널 Ready 복귀 관찰
<master-node-1> 6.8.0-124 20초 rc.local 결함 발견
<master-node-2> 6.8.0-124 40초 rc.local 중복 기재 발견
<master-node-3> 6.8.0-124 40초 수정된 rc.local 실동작 확인
<worker-node-1> 6.8.0-146 20초 커널은 이미 최신, 설정 반영 목적
<worker-node-2> 6.8.0-124 30초 OSD PDB가 축출을 1회 지연
<worker-node-3> 6.8.0-124 80초 –

전체 약 40분이 걸렸고 서비스 중단은 없었습니다. Ingress 뒤의 웹 워크로드는 작업 내내 HTTP 200을 유지했습니다.

재부팅하면서 드러난 문제

부팅 스크립트가 매번 조용히 실패하고 있었습니다

현상 – 첫 마스터를 재부팅한 직후, ClusterIP를 HAProxy로 넘기는 DNAT 규칙이 0건이었습니다. 운영 문서에는 /etc/rc.local로 영구화했다고 적혀 있었고 파일도 실행 권한을 갖고 있었습니다.

systemctl status rc-local.service

  rc-local.service: Failed to execute /etc/rc.local: Exec format error
  Process: 822 ExecStart=/etc/rc.local start (code=exited, status=203/EXEC)

원인 – 파일에 shebang이 없었습니다. 실행 권한만으로는 systemd가 인터프리터를 결정할 수 없어 203/EXEC로 떨어집니다. 그 결과 “재부팅 영구화”라고 기록된 장치가 실은 한 번도 동작한 적이 없었습니다. 마스터를 재부팅한 적이 없어서 드러나지 않았을 뿐입니다.

해결 – shebang을 추가하고, 규칙을 넣기 전에 -C로 존재를 확인하도록 다시 썼습니다. 재실행해도 중복이 쌓이지 않습니다.

#!/bin/sh -e
for CHAIN in OUTPUT PREROUTING; do
  iptables -t nat -C "$CHAIN" -d 10.96.0.1 -p tcp --dport 443 \
    -j DNAT --to-destination 192.168.x.x:6443 2>/dev/null ||
  iptables -t nat -I "$CHAIN" -d 10.96.0.1 -p tcp --dport 443 \
    -j DNAT --to-destination 192.168.x.x:6443
done
exit 0

다음 마스터를 재부팅했을 때 규칙이 정확히 2건으로 복원되어 실동작을 확인했습니다.

한 가지 덧붙이면 이 결함의 기능적 영향은 없었습니다. 규칙이 0건인 상태에서도 https://10.96.0.1:443/healthz가 HTTP 200을 반환했습니다. 정상 가동 중에는 kube-proxy가 ClusterIP를 처리하므로, 이 DNAT는 kube-proxy가 아직 준비되지 않은 control plane 조인 시점에만 필요합니다. 그래서 결함이 오래 숨어 있을 수 있었습니다.

같은 파일에 규칙이 중복 기재되어 있었습니다

현상 – 수정 후 서비스를 재시작했더니 한 노드는 규칙이 2건인데 다른 두 노드는 6건이 됐습니다.

원인 – 두 노드의 rc.local 안에 iptables 두 줄이 각각 두 번 적혀 있었습니다. 실행마다 4건이 삽입되던 셈입니다. 동일한 DNAT 규칙이 중복되면 첫 매칭만 평가되므로 기능상 무해하지만, 규칙 수로 상태를 판단할 수 없게 됩니다.

해결 – 중복 줄을 제거해 3대의 파일을 통일하고, 라이브 테이블에서도 체인별 1건만 남도록 정리했습니다. 위의 -C 검사가 재발을 막습니다.

CephFS 커널 모듈이 부팅 시 적재되지 않았습니다

현상 – 앞서 한 워커가 커널 교체와 함께 재부팅된 뒤, RWX 볼륨을 쓰는 워크로드가 마운트에 실패했습니다.

MountVolume.MountDevice failed ... an error (exit status 1) occurred
while running modprobe args: [ceph]

원인 – 모듈 파일은 새 커널 트리에 정상적으로 존재했고 수동 modprobe ceph도 성공했습니다. 모듈 부재가 아니라 부팅 시 자동 적재가 되지 않은 것이었습니다.

해결 – /etc/modules-load.d/ceph.conf로 영구화하고 6대 전부에 배포했습니다. 설정 파일이 존재한다는 사실만으로는 검증이 안 되므로, 모듈을 내린 뒤 적재 서비스를 재시작해 부팅을 재현했습니다.

sudo modprobe -r ceph                       # 사용 중이면 실패하는데 그건 정상
sudo systemctl restart systemd-modules-load
lsmod | grep '^ceph '                       # 적재돼 있어야 한다

이후 6대 재부팅 전부에서 자동 적재를 확인했습니다. 블록 스토리지(RBD)는 이 문제와 무관하며 CephFS를 쓰는 워크로드만 영향을 받습니다.

로그 로테이션 설정 적용 후에도 큰 파일이 남아 있었습니다

현상 – 재부팅을 마치고 확인했더니 8 MiB를 넘는 로그 파일이 노드당 1~3건 남아 있었습니다. 설정이 먹지 않은 것처럼 보였습니다.

원인 – 해당 파일들의 소유 컨테이너를 역추적하니 모두 exited 상태였고, 마지막 수정 시각이 재부팅 시점이었습니다. 구 100m 설정으로 돌던 컨테이너들의 잔존 로그였습니다. 더 커지지 않으며 docker가 해당 컨테이너를 정리할 때 함께 사라집니다.

확인 – 신규 컨테이너에 설정이 실제로 적용됐는지는 파일 크기가 아니라 컨테이너의 로그 설정을 직접 조회해 판단해야 합니다.

docker inspect $(docker ps -q | head -1) \
  --format '{{json .HostConfig.LogConfig}}'

{"Type":"json-file","Config":{"max-file":"3","max-size":"8m"}}

파드가 원래 노드로 돌아오지 않습니다

현상 – 6대를 모두 순회한 뒤 메모리 사용률이 한쪽으로 쏠렸습니다. 한 워커는 68 %인데 다른 워커는 25 %였습니다.

원인 – drain으로 옮겨진 파드는 노드가 복귀해도 자동으로 되돌아오지 않습니다. 순차 재부팅에서는 뒤에 처리한 노드의 파드가 앞서 복귀한 노드로 모이기 쉽습니다.

대응 – 기능 문제는 아니지만 노드 메모리 여유가 빠듯한 환경에서는 지켜볼 값입니다. 작업 직후 kubectl top nodes로 분포를 확인하고, 필요하면 해당 디플로이먼트를 재시작해 재배치를 유도하는 편이 안전합니다. 스케줄러가 선언된 requests를 기준으로 판단하므로, 실사용이 requests보다 큰 워크로드에서는 스케줄러의 여유 표시를 그대로 믿기 어렵습니다.

로그 로테이션 조치 결과

cri-dockerd 로그 로테이션 요청의 무한 재시도는 완전히 멈췄습니다. 재부팅 이후 수집 구간에서 6대 모두 해당 에러가 0건입니다.

journalctl -b -u kubelet --no-pager | grep -c "ReopenContainerLog"
# 6대 전부 0
항목 작업 전 작업 후
kubelet 로테이션 에러 노드당 하루 3.4만~8만 줄 0건
journald 점유 노드당 2.6~4.0 GB (상한 미설정) 노드당 약 950 MB (상한 1 GB)
컨테이너 로그 상한 100 MiB × 1세대 8 MiB × 3세대
커널 6대 중 5대가 6.8.0-124 6대 전부 6.8.0-146
디스크 여유 마스터 34 GB / 워커 23 GB 마스터 36 GB / 워커 26 GB

클러스터 상태도 함께 확인했습니다. 6대 전부 Ready이고 스케줄 가능 상태이며, etcd 3개 엔드포인트가 모두 정상, Ceph는 HEALTH_OK에 mon 3/3, OSD 3/3, 전체 PG active+clean입니다.

kubectl get nodes -o custom-columns=\
'NODE:.metadata.name,STATUS:.status.conditions[-1].status,KERNEL:.status.nodeInfo.kernelVersion'

kubectl get pods -A | grep -v Running | grep -v Completed
kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph -s

정리하면 이번 작업의 핵심은 두 가지입니다. 하나는 런타임이 지원하지 않는 기능을 요청하는 구성은 조용히 자원을 태운다는 점입니다. cri-dockerd 로그 로테이션 문제는 로테이션 주체를 docker로 옮겨 해결했습니다. 다른 하나는 부팅 시 동작하는 설정은 실제로 재부팅해 보기 전까지 검증된 것이 아니라는 점입니다. 이번에 드러난 rc.local 결함과 커널 모듈 자동 적재 문제 모두 재부팅 없이는 알 수 없었습니다.

확인하지 않은 범위도 남겨둡니다. kubelet의 containerLogMaxSize를 직접 조정하는 방식은 시도하지 않았고, max-size를 10 Mi 이상으로 되돌리면 같은 에러가 재발하는지도 실측하지 않았습니다. 동작 원리상 재발이 예상되지만 측정한 결과는 아닙니다.

참고

관련 포스트:

참고 문서: Kubernetes Logging Architecture · Docker json-file logging driver · cri-dockerd · journald.conf

Enabling GitLab Container Registry over HTTPS with a Custom TLS Certificate

Kaniko 이미지를 둘 곳으로 GitLab Container Registry 선택

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 서비스 전체가 멈추는 이유가 여기에 있습니다.

Omnibus GitLab에서 GitLab Container Registry를 켤 때 registry_nginx가 커스텀 인증서 경로를 상속하지 않아 nginx 전체가 멈추고, 같은 경로를 명시해 복구하는 흐름을 그린 구성도입니다.
registry_nginx는 웹용 nginx의 인증서 설정을 물려받지 않아, 검증 실패 한 번에 웹 UI까지 함께 내려갑니다.

해결

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_url과 registry_nginx의 인증서 설정을 처음부터 같이 추가하는 것이 안전합니다. 두 항목을 한 번에 넣고 reconfigure를 한 번만 수행하면 다운타임 없이 마칠 수 있었던 작업이었습니다.

401 응답이 정상인 이유와 파드에서의 도달 확인

외부에서 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