상태: 실측 (2026-10-31). TT Lab 실습 VM(8 vCPU·8GiB 세 대와 반입 서버 한 대) 안에 k0s v1.37.1+k0s.0 을 세워 잰 값입니다. 같은 정답지를 고쳐 가며 처음부터 네 번 세웠고, 1회차는 중간에 정답지가 끊겨 단계가 섞인 부분을 미실측으로 두었습니다. 숫자가 흩어진 폭이 이 환경의 실제 폭입니다. VM 디스크는 HDD 위에 있었습니다. 노드 주소는 예시 대역(192.0.2.0/24)으로 바꿔 적었습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 원인을 확인하지 못한 해석은 「추정」 으로 표시합니다.
목차
- 1. 결론부터
- 2. 3대를 어떻게 나누나
- 3. 이미지를 사내 레지스트리로 미러하기
- 4. 설치: k0sctl 은 컨트롤러 겸 워커의 Ready 를 기다리지 않는다
- 5. 컨트롤러를 하나, 둘 멈추면
- 6. Rook-Ceph
- 7. CloudNativePG
- 8. 오래 걸리는 단계를 돌리는 법
- 9. 자원
- 10. 한계와 미실측
- 11. 정리
1. 결론부터
- 3대는 셋 다 컨트롤러 겸 워커로 두었습니다. etcd 3멤버는 1대 장애를 견딥니다. API 주소는 바깥 로드밸런서 없이 k0s 에 든 CPLB(keepalived 가상 IP)와 NLLB(노드마다 envoy)로 묶었습니다.
- 가상 IP(VIP)를 가진 노드를 멈추면 네 번 모두 0~3초 안에 API 가 돌아왔습니다. 쓰기를 잰 두 번은 1초와 3초였습니다. 두 대를 멈추면 30초 동안 쓰기가 전부 실패했고, 다시 켜자 1~2초 만에 돌아왔습니다.
- 이미지 18개(1.8GB)를 사내 레지스트리로 미러하고 containerd 의
hosts.toml로 돌렸습니다. 차트와 매니페스트의 이미지 이름은 하나도 고치지 않았습니다. - Rook-Ceph 는 차트 설치에서 HEALTH_OK 까지 437~605초(7~10분)가 걸렸습니다. k0s 의 kubelet 경로 하나만 맞추면 되었습니다.
- CloudNativePG 는 primary 파드를 지운 뒤 35.4~62.6초 만에 새 primary 가 쓰기를 받았고, 행을 센 두 번은 모두 그대로였습니다. 다만 지운 옛 primary 는 네 번 중 세 번 5분 넘게 Terminating 에 머물렀습니다. Ceph 의 오브젝트 저장소로 백업(4~53초)한 뒤 새 클러스터로 복원(1분54초~4분6초)했고, 백업 뒤에 넣은 행까지 WAL 로 되살아났습니다.
- 함정 넷: k0sctl 은 컨트롤러 겸 워커의 Ready 를 기다리지 않습니다. PostgreSQL 17 부터
pg_walfile_name의 경계 규칙이 바뀌어 WAL 보관을 기다리는 스크립트가 끝나지 않았습니다. 버킷 이름이 생기기 전에 백업 설정을 만들면 WAL 아카이빙이 깨집니다.systemctl is-active는 도는 중인 oneshot 유닛을 「아니다」 로 답합니다.
2. 3대를 어떻게 나누나
노드 3대를 나누는 길은 둘입니다.
| (a) 컨트롤러 겸 워커 셋(이번 주 시나리오) | (b) 컨트롤러 1 + 워커 2 | |
|---|---|---|
| etcd | 3멤버, 1대 장애를 견딘다 | 1멤버, 제어면이 단일 장애점 |
| 워크로드를 놓을 노드 | 3대 | 2대 |
| Ceph(mon 3, 3벌 복제), PostgreSQL 셋 | 노드마다 하나씩, 1대 장애를 견딘다 | 저장 노드가 2대뿐이라 3벌 복제를 못 한다 |
| API 주소 | CPLB VIP + NLLB, 바깥 로드밸런서 불필요 | 컨트롤러 주소 하나 |
| 대가 | 제어면과 워크로드가 메모리를 나눈다. 노드 8GiB, Ceph 데몬 메모리를 낮춤 | 단순하다 |
| 시험 | 이 글 전체 | 2편 5절에서 실제로 세움 |
(b) 로는 이 글의 목적인 저장소 고가용성이 안 됩니다. Ceph 의 기본 블록 풀은 서로 다른 호스트에 3벌을 두고(failureDomain: host), mon 은 과반을 위해 셋을 서로
다른 노드에 둡니다. 저장 노드가 둘이면 3벌을 둘 곳이 없어 데이터가 undersized+degraded 로 남습니다. 2벌로 낮추면 1대 장애 때 쓰기가 멈추거나(min_size 2)
데이터 1벌로 버티게 됩니다(min_size 1). 같은 이유로 2대 이하에는 Ceph 와 PostgreSQL 셋을 올리지 않았습니다. Ceph 의 배치 원리는
Rook-Ceph 해설 시리즈에 자세히 있습니다.
2.1 API 주소를 하나로 묶는 세 길
컨트롤러가 셋이면 kubectl 과 노드가 어느 주소로 API 를 부를지 정해야 합니다.
- CPLB(control plane load balancing). k0s 가 keepalived 를 띄워 VRRP 로 가상 IP 를 한 노드에 올리고, 그 노드가 죽으면 다른 노드로 옮깁니다. CPLB 문서는 이것을 「클러스터 바깥에서 들어오는 접근」 용이라고 설명합니다. 반입할 것이 늘지 않는 대신 노드들이 같은 L2 망에 있어야 합니다(문서: virtual router ID 는 브로드캐스트 도메인 안에서 유일해야 함). 컨트롤러 겸 워커에서는 k0s 의 사용자 공간 리버스 프록시 방식만 지원합니다.
- NLLB(node-local load balancing). 워커마다 루프백(127.0.0.1:7443)에 envoy 를 띄워 kubelet 같은 노드 구성 요소가 살아 있는 apiserver 셋에 고르게 붙게 합니다. NLLB 문서는 NLLB 가 클러스터 안쪽의 장애 내성용이고 바깥 접근은 돕지 않는다고 적습니다.
- 바깥 로드밸런서(HAProxy 등). HA 문서의 예시는 6443(API)·8132(konnectivity)·9443(조인 API)을 묶습니다. 그 로드밸런서를 다시 이중화해야 하고 반입할 패키지가 늘어납니다. 이번에는 재지 않았습니다(미실측).
CPLB 문서는 바깥 로드밸런서가 없으면 CPLB 와 NLLB 를 함께 쓰라고 권합니다. 이번 구성이 그것입니다.
2.2 k0sctl 설정
호스트 하나만 적습니다. 나머지 둘은 주소만 다릅니다.
- role: controller+worker
noTaints: true # 컨트롤러에도 워크로드를 놓는다
ssh: {address: 192.0.2.3, user: root, keyPath: /root/.ssh/id_ed25519}
hostname: ctrl01
privateInterface: eth1
privateAddress: 192.0.2.3
uploadBinary: true
k0sBinaryPath: /opt/airgap/bin/k0s-v1.37.1+k0s.0-amd64
installFlags: [--kubelet-extra-args=--node-ip=192.0.2.3]
files:
- {name: k0s-airgap-bundle, src: /opt/airgap/images/k0s-airgap-bundle-v1.37.1+k0s.0-linux-amd64.tar, dstDir: /var/lib/k0s/images/, perm: "0644"}
- {name: apps-bundle, src: /opt/airgap/images/apps-bundle.tar, dstDir: /var/lib/k0s/images/, perm: "0644"}
- name: cri-registry
dst: /etc/k0s/containerd.d/cri-registry.toml
data: |
version = 3
[plugins."io.containerd.cri.v1.images".registry]
config_path = "/etc/k0s/containerd.d/certs.d"
- name: mirror-quay.io # docker.io·ghcr.io·registry.k8s.io·gcr.io 도 같은 모양
dst: /etc/k0s/containerd.d/certs.d/quay.io/hosts.toml
data: |
server = "https://quay.io"
[host."http://192.0.2.2:5000/v2/quay.io"]
capabilities = ["pull", "resolve"]
override_path = true
k0s:
version: v1.37.1+k0s.0
config:
spec:
api: {onlyBindToAddress: true, sans: [192.0.2.100]}
network:
provider: kuberouter
podCIDR: 172.20.0.0/16
serviceCIDR: 172.21.0.0/16
controlPlaneLoadBalancing:
enabled: true
type: Keepalived
keepalived:
vrrpInstances:
- {virtualIPs: ["192.0.2.100/24"], authPass: <클러스터마다 다른 값>, interface: eth1}
nodeLocalLoadBalancing: {enabled: true, type: EnvoyProxy}
images: {default_pull_policy: Never}
telemetry: {enabled: false}
여기서도 NIC 가 둘이라는 사실이 세 군데에 나옵니다.
onlyBindToAddress: true는 2편 3.1절의 이유로 넣었습니다. 컨트롤러가 셋이면 호스트마다address를 적을 자리가 없으므로 이것이 유일한 길입니다.- VRRP 의
interface: eth1도 적어야 합니다. CPLB 문서에 따르면 비워 두면 k0s 가 기본 경로를 가진 NIC 를 고릅니다. 이 VM 에서는 NAT NIC 이고, 그곳에 VIP 를 올려 봐야 다른 노드가 닿지 못합니다. sans에 VIP 를 넣습니다. 넣지 않으면 VIP 로 붙을 때 API 서버 인증서에 그 주소가 없어 TLS 가 실패합니다.
authPass 는 문서가 밝히듯 암호화가 아니라 클러스터끼리 실수로 섞이는 것을 막는 값입니다. 같은 망의 다른 클러스터와 겹치지 않게 정합니다. 설치 뒤에는
k0sctl kubeconfig 가 준 kubeconfig 의 서버 주소를 VIP 로 바꿨습니다. 컨트롤러 하나를 가리키면 그 노드가 죽을 때 kubectl 도 함께 죽습니다.
3. 이미지를 사내 레지스트리로 미러하기
k0s 시스템 이미지는 1편처럼 묶음으로 넣었습니다. 그런데 Rook-Ceph, CSI 드라이버, CloudNativePG, cert-manager 의 이미지는 수가 많고 큽니다. 노드마다 묶음 파일을 나르는 대신 반입 서버에 레지스트리를 하나 두고 미러했습니다.
| 이미지 묶음 | 개수 | 예 |
|---|---|---|
| Rook·Ceph | 2 | rook/ceph:v1.21.1(565MB), ceph/ceph:v20.2.4(543MB) |
| Ceph CSI | 7 | ceph-csi-operator v1.1.0, cephcsi v3.18.1, sig-storage 사이드카 5개 |
| CloudNativePG | 4 | 오퍼레이터 1.30.1, postgresql 18.6-minimal-trixie, barman-cloud 플러그인과 사이드카 v0.15.1 |
| cert-manager | 3 | v1.21.3 의 controller·webhook·cainjector |
| 앱 | 2 | nginx 1.30.6-alpine, busybox 1.36.1-1 |
반입 서버가 skopeo copy 로 192.0.2.2:5000/quay.io/ceph/ceph:v20.2.4 처럼 원래 레지스트리 이름을 경로 앞에 붙여 넣었습니다. 노드 쪽 containerd 는
레지스트리마다 hosts.toml 하나로 이 경로를 가리킵니다. containerd 의 hosts.md에 따르면
override_path = true 는 「API 의 뿌리가 URL 경로에 적혀 있다」 는 뜻이라서, containerd 는 /v2/ 를 다시 붙이지 않고 http://192.0.2.2:5000/v2/quay.io/ceph/ceph/manifests/...
처럼 요청합니다. 그래서 차트와 매니페스트의 이미지 이름은 하나도 고치지 않았습니다. 폐쇄망에서 값 파일의 이미지 이름을 하나씩 바꾸면 반드시 하나를
빠뜨리고, 그 하나가 ImagePullBackOff 로 몇 분 뒤에 드러납니다.
드롭인 파일의 모양은 k0s 런타임 문서를 따랐습니다. 이 판의 k0s 는 version = 3 과
io.containerd.cri.v1.images 플러그인 경로를 써야 하고, version = 2 나 옛 플러그인 경로를 쓰면 사전 점검에서 멈춘다고 문서가 적습니다.
미러할 목록은 Rook 저장소의 deploy/examples/images.txt를 기준으로 했고, 쓰지 않는
COSI, csi-addons 사이드카, snapshot-metadata 는 뺐습니다. 빠진 것이 있으면 ImagePullBackOff 로 바로 보이므로, 목록을 줄일 때 겁낼 필요는 없습니다.
| 항목 | 실측 |
|---|---|
| 반입(도구 27초 → 바이너리·묶음 → helm·차트·매니페스트 57초 → 이미지 18개 미러 → 봉인) | 182초, 레지스트리 1.8GB |
| rook-ceph 의 컨테이너 46개 | 이미지가 모두 미러 목록 안 |
| 레지스트리 접근 기록 | 노드마다 96~104건(노드 셋 모두 미러에서 받음) |
한계도 적어 둡니다. 시험의 레지스트리는 사내망 안의 HTTP 였습니다. 현장에서는 TLS 를 쓰고 hosts.toml 에 사내 CA(ca = ...)를 줍니다(미실측). k0s 시스템
이미지까지 레지스트리에서 받는 방식(spec.images.repository)도 재지 않았습니다(미실측). 시스템 이미지를 묶음으로 둔 까닭은, 반입 서버의 레지스트리가 죽어도
노드가 재시작할 때 CNI·kube-proxy·coredns 가 뜨게 하려는 것입니다.
4. 설치: k0sctl 은 컨트롤러 겸 워커의 Ready 를 기다리지 않는다
| 회차 | k0sctl apply(k0sctl 보고) |
셋 다 Ready(apply 시작부터) |
|---|---|---|
| 1 | 89초 | 미실측(정답지가 끊겨 단계가 섞임) |
| 2 | 93초 | 노드 등록이 apply 끝 뒤 +5초·+104초·+125초 |
| 3 | 72초 | 217초 |
| 4 | 109초 | 324초 |
apply 가 끝났다고 클러스터가 다 선 것이 아니었습니다. 2회차에서 apply 는 93초에 성공으로 끝났는데, 노드 두 대는 그 뒤 104초와 125초가 지나서야 kubelet 이 「Successfully registered node」 를 남겼습니다.
k0sctl 소스를 보니 이유가 분명했습니다. install_workers.go의 「waiting for node to
become ready」 는 Workers() 에만 돌고, 이 함수는 역할이 정확히 worker 인 호스트만 고릅니다. controller+worker 는 컨트롤러 설치 단계에서 끝나므로 kubelet 이
Ready 가 되기를 기다리지 않습니다. 그래서 apply 뒤에 이어지는 자동화는 노드 셋이 Ready 인지를 스스로 확인해야 합니다. 그대로 다음 단계로 넘어가면 노드 하나가
빠진 채로 Ceph 를 깔게 됩니다.
5. 컨트롤러를 하나, 둘 멈추면
5.1 재는 방법
반입 서버에서 VIP 로 두 가지를 1초마다 쟀습니다.
- API:
kubectl get --raw /readyz(4초 제한). - 쓰기: ConfigMap 만들기(6초 제한). 이름을 매번 새로 짓습니다. 같은 이름을 다시 만들면
AlreadyExists로 실패해 「과반을 잃어서 실패」 와 구별되지 않습니다. 첫 시도가 실제로 그렇게 거짓 실패를 남겼습니다.
먼저 VIP 를 가진 노드(사내망 NIC 에 VIP 가 붙은 노드)를 찾아 k0scontroller 를 멈추고, 멈춘 시각부터 API 와 쓰기가 돌아오기까지를 쟀습니다. 이어서 둘째
컨트롤러도 멈추고 30초 동안 쓰기가 한 번이라도 되는지 보았고, 둘을 다시 켜 쓰기가 돌아오기까지를 쟀습니다.
5.2 결과
| 회차 | VIP 이동 | API 복귀 | 쓰기 복귀 | 둘 정지(1/3) | 다시 켠 뒤 쓰기 |
|---|---|---|---|---|---|
| 1 | 노드 3 → 노드 2 | 0초 | 1초 | 30초 동안 전부 실패 | 1초 |
| 2 | 노드 1 → 노드 3 | 3초 | 3초 | 30초 동안 전부 실패 | 1초 |
| 3 | 노드 1 → 노드 3 | 0초 | 기록 안 함 | 30초 동안 전부 실패 | 2초 |
| 4 | 노드 1 → 노드 3 | 1초 | 기록 안 함 | 30초 동안 전부 실패 | 1초 |
한 대를 멈췄을 때 살아 있는 노드의 k0s etcd member-list 에는 멤버 셋이 그대로 있었습니다. 멈춘 것은 멤버를 뺀 것이 아니므로 과반은 여전히 2이고, 살아
있는 둘이 그 과반을 채워 쓰기를 받았습니다. 둘을 멈추면 남은 하나로는 과반을 못 채우므로 쓰기가 멈춥니다. 2편의 etcd 표 그대로입니다.
하나 조심할 것이 있습니다. kubelet 을 멈춰도 Node 객체의 Ready 는 한동안 옛값으로 남습니다. 컨트롤러 매니저가 노드를 「응답 없음」 으로 바꾸기까지 기다리는
시간(--node-monitor-grace-period, 문서상 기본 50초)이 있기
때문입니다. 「장애 중에도 노드가 Ready 였다」 는 고가용성의 증거가 못 됩니다. 그래서 위 표는 Node 상태가 아니라 실제 요청으로 쟀습니다.
6. Rook-Ceph
6.1 k0s 라서 달라지는 것: kubelet 경로
k0s 의 kubelet 은 작업 디렉터리로 /var/lib/kubelet 이 아니라 /var/lib/k0s/kubelet 을 씁니다(k0s 스토리지 문서).
CSI 노드 플러그인은 kubelet 디렉터리를 hostPath 로 붙여 볼륨을 마운트하므로, 이 경로가 틀리면 PVC 는 바인드되어도 파드에 붙지 않습니다.
Rook v1.21 은 CSI 드라이버를 ceph-csi-operator 와 별도 차트(ceph-csi-drivers)로 나눴습니다. rook-ceph 차트의 값에서는 kubelet 경로를 찾지 못했고, 드라이버
차트의 값에 넣었습니다.
# ceph-csi-drivers 1.1.0 의 values(Rook 이 권하는 값에 이 줄만 더함)
operatorConfig:
driverSpecDefaults:
nodePlugin:
kubeletDirPath: /var/lib/k0s/kubelet
다른 길은 k0sctl 호스트의 kubeletRootDir 로 kubelet 쪽을 /var/lib/kubelet 으로 옮기는 것인데 재지 않았습니다(미실측). 드라이버 데몬셋은 operator 가
Driver 객체를 보고 만들기 때문에 차트 설치 직후(23초)에는 아직 없었습니다. 설치 직후에 데몬셋을 확인하는 자동화는 생길 때까지 기다려야 합니다.
6.2 실측
차트 셋(rook-ceph, ceph-csi-drivers, rook-ceph-cluster v1.21.1, Ceph v20.2.4)을 helm 으로 반입한 tgz 에서 깔았습니다. 노드마다 빈 디스크 17GiB 하나를 OSD 로
썼고, 노드가 워크로드도 함께 지므로 OSD 의 osd_memory_target 을 1GiB 로 낮췄습니다.
| 항목 | 값 |
|---|---|
| 차트 설치 → HEALTH_OK(OSD 3 up·in, mon 3) | 546초 · 605초 · 437초(2·3·4회차) |
| 원시 용량 / 3벌 블록 풀의 쓸 수 있는 양 | 51GiB / 15GiB |
| 풀과 PG | 풀 10개(블록 1, RGW 오브젝트 저장소 8, .mgr), PG 121개 active+clean |
HEALTH_OK 는 Ceph v20 이 내는 cephx 키 형식 경고 셋(AUTH_INSECURE_*)을 이유를 확인하고 묵음 처리한 뒤의 값입니다. 끝까지 모든 단계를 돌린 뒤에는
BLUESTORE_SLOW_OP_ALERT(느린 디스크 경고)로 HEALTH_WARN 이 되었습니다. HDD 위의 VM 에 PostgreSQL 부하를 얹은 결과입니다. 현장 장비에서는 디스크부터
다시 봐야 할 값입니다.
7. CloudNativePG
7.1 설치 순서와 백업 방식
cert-manager → CNPG 오퍼레이터(1.30.1) → barman-cloud 플러그인(v0.15.1) 순서로 반입한 매니페스트를 넣었습니다. CNPG 매니페스트는
설치 문서대로 kubectl apply --server-side 로 넣습니다.
백업을 플러그인으로 한 까닭은 판의 흐름 때문입니다. CNPG 1.30 의 백업 문서 원문은
본체에 든 barmanObjectStore 방식이 1.26 부터 지원 중단되었고, 1.31 이 그것을 지원하는 마지막 판이며, 1.32 에서 빠진다고 적습니다. 새로 세우는 클러스터를
곧 사라질 방식으로 시작할 이유가 없습니다. 플러그인은 cert-manager 가 있어야 합니다(플러그인 설치 문서).
백업 도구가 플러그인 사이드카에 들어 있으므로 PostgreSQL 이미지는 작은 18.6-minimal-trixie 로 충분했습니다.
백업을 받을 곳은 같은 클러스터의 Ceph 오브젝트 저장소(RGW)입니다. ObjectBucketClaim 으로 버킷을 만들면 Rook 이 같은 이름의 ConfigMap(버킷 이름·주소)과 Secret(키)을 내 줍니다. 다만 같은 클러스터의 RGW 는 다른 장애 영역이 아닙니다. 클러스터를 통째로 잃으면 백업도 함께 잃으므로, 운영에서는 바깥 저장소로 한 번 더 보내야 합니다.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata: {name: pg, namespace: db}
spec:
instances: 3
imageName: ghcr.io/cloudnative-pg/postgresql:18.6-minimal-trixie
storage: {size: 2Gi, storageClass: ceph-block}
plugins:
- name: barman-cloud.cloudnative-pg.io
isWALArchiver: true
parameters: {barmanObjectName: ceph-rgw}
인스턴스 셋이 모두 Ready 가 되기까지 2회차에서 약 13분 40초가 걸렸습니다(initdb 70초, 나머지는 복제본의 이미지 받기와 합류). HDD 위의 VM 이라는 조건이 크게 작용한 값이라 그대로 일반화하면 안 되지만, 자동화의 기다림 상한을 정할 때 「몇 분」 이 아니라 「십수 분」 을 잡아야 한다는 것은 분명합니다.
7.2 함정: 버킷 이름이 생기기 전에 ObjectStore 를 만들면
처음 시도에서는 ObjectBucketClaim 이 2분 안에 Bound 가 되지 않았는데, 스크립트는 기다림이 끝나자 그대로 다음으로 넘어갔습니다. 그래서 버킷 이름이 빈 채로
ObjectStore 를 만들었고 destinationPath 가 s3:/// 가 되었습니다. WAL 아카이빙이 Invalid s3 URL address 로 계속 실패했습니다.
이 상태에서 primary 파드를 지우자 일이 커졌습니다. 새 primary 는 39.8초 만에 섰지만, 옛 primary 가 보관하지 못한 WAL 을 붙든 채 18분 넘게 Terminating 에
머물렀습니다. CNPG 파드의 terminationGracePeriodSeconds 가 1800초라 쿠버네티스가 그만큼 기다려 준 것입니다. 지금은 ConfigMap 의 BUCKET_NAME 이 생기고
OBC 가 Bound 일 때까지 기다리고, 끝내 비어 있으면 멈추게 했습니다.
7.3 primary 를 지우면
인스턴스 셋이 모두 Ready 일 때 primary 파드를 지우고, 0.5초마다 새 primary 에 select pg_is_in_recovery() 를 물어 f(쓰기 가능)가 나오기까지를 쟀습니다.
셋이 모두 Ready 일 때만 시작하는 것이 중요합니다. 끊긴 정답지 뒤에 복제본이 하나도 서지 않은 primary 를 지웠다가 클러스터를 잃은 회차가 있었습니다.
| 회차 | 새 primary 에 쓰기까지 | 행 | 옛 primary |
|---|---|---|---|
| 2 a(아카이빙이 깨진 상태, 7.2) | 39.8초 | 기록 안 함 | 18분 넘게 Terminating |
| 2 b | 49.5초 | 1001행 유지 | 복제본으로 돌아옴 |
| 3 | 62.6초 | 기록 안 함 | 1418초(23분) 넘게 Terminating |
| 4 | 35.4초 | 1001행 유지 | 320초 넘게 Terminating(5분까지만 잼) |
새 primary 의 승격과 쓰기에는 문제가 없었습니다. 문제는 옛 primary 입니다. 아카이빙이 정상이었던 세 번 중 두 번도 5분 넘게 Terminating 이었고, 그 파드의 로그는 이렇게 끝났습니다.
"msg":"unable to run controller-runtime manager",
"error":"failed waiting for all runnables to end within grace period of 30s: context deadline exceeded"
PostgreSQL 은 이미 server stopped 로 정상 종료했는데, CNPG 의 instance manager 가 30초 안에 내부 작업을 다 끝내지 못했다는 오류를 남기고도 프로세스가 끝나지
않았습니다. 자식 postgres 프로세스는 좀비(<defunct>)로 남았고, 파드는 grace period 1800초를 다 쓰는 쪽으로 갔습니다. 원인은 확정하지 못했습니다. barman-cloud
사이드카를 켜면 primary 파드 종료가 약 3분 걸린다는 이슈 plugin-barman-cloud#1039(열림)와
같은 계열로 보이지만, 이번 관측은 그보다 훨씬 깁니다(추정).
왜 신경 써야 하나. 옛 primary 가 Terminating 인 동안 클러스터는 Ready 인스턴스 2개로 버팁니다. 그 사이에 노드가 하나 더 죽으면 복제본이 하나뿐인 상태가 됩니다. 그 파드가 앉은 노드를 drain 하려 해도 끝나지 않습니다. 장애 조치 뒤에는 옛 primary 가 실제로 복제본으로 돌아왔는지까지 확인해야 합니다. 이 현상을 줄이는 설정(grace period 조정 등)은 시험하지 않았습니다(미실측).
7.4 백업과 복원, 그리고 WAL 을 기다리는 법
apiVersion: postgresql.cnpg.io/v1
kind: Backup
metadata: {name: pg-b1, namespace: db}
spec:
cluster: {name: pg}
method: plugin
pluginConfiguration: {name: barman-cloud.cloudnative-pg.io}
백업이 끝나면 행 하나(1002)를 더 넣고 WAL 파일을 넘긴 뒤, 그 WAL 이 버킷에 올라간 것을 확인하고 bootstrap.recovery 로 새 클러스터 pg-restore 를 만들었습니다.
백업 시점(1001행) 뒤에 넣은 1002행까지 돌아오면 WAL 복원까지 된 것입니다.
| 회차 | 백업 | 복원 클러스터 생성 → Ready | 복원본의 count, max(id) |
|---|---|---|---|
| 2(첫 시도) | 53초 | 4분 6초 | 1001, 1001(WAL 을 15초만 기다림) |
| 2(다시) | 4초 | 3분 15초 | 1002, 1002 |
| 3 | 기록 안 함 | 2분 0초 | 1002, 1002 |
| 4 | 37초 | 1분 54초 | 1002, 1002 |
첫 시도는 WAL 을 넘기고 15초만 기다렸고, 복원본은 백업 시점(1001)에서 멈췄습니다. 복원은 그때까지 버킷에 올라간 WAL 까지만 읽기 때문입니다. 그래서 「넘긴 WAL 이 보관될 때까지 기다리기」 를 넣었는데, 이번에는 그 기다림이 300초 동안 끝나지 않았습니다.
처음 쓴 코드는 pg_walfile_name(pg_switch_wal()) 로 기다릴 파일 이름을 얻었습니다. PostgreSQL 문서에
따르면 pg_switch_wal() 은 「방금 끝낸 WAL 파일의 끝 위치 + 1」, 곧 다음 파일이 시작하는 경계를 돌려줍니다. 경계 위치를 pg_walfile_name 에 넣으면
어떤 이름이 나오는지는 판에 따라 다릅니다.
- PostgreSQL 16 문서까지는 「위치가 정확히 경계이면 앞 파일의 이름을 돌려준다, 보관을 다룰 때는 보통 이것이 바라는 동작」 이라고 적었습니다. 그래서 예전 글들의 이 관용구가 맞았습니다.
- PostgreSQL 17 릴리스 노트의 「17 로 옮길 때」 절은 이 두 함수가 경계에서 앞 파일이 아니라 지금 파일을 돌려주도록 바뀌었다고 적습니다. 이번 시험의 PostgreSQL 은 18.6 이었습니다.
그러니 17 이상에서 pg_walfile_name(pg_switch_wal()) 은 아직 쓰기 시작도 안 한 다음 파일의 이름이고, 그 파일은 다음 전환이 일어나기 전에는 보관되지 않습니다.
기다림이 끝나지 않은 이유입니다. 고친 방법은 순서를 바꾸는 것입니다.
-- 1002 를 넣은 뒤, 넘기기 「전에」 지금 파일의 이름을 적는다
select pg_walfile_name(pg_current_wal_lsn()); -- 예: ...0B
select pg_switch_wal();
-- 그다음 last_archived_wal 이 적어 둔 이름 이상이 될 때까지 기다린다
select last_archived_wal from pg_stat_archiver;
이렇게 고친 뒤 세 번 모두 1002행까지 복원되었습니다. 복원이 끝나면 인스턴스가 한 번 다시 시작하는 점도 기억해 둘 만합니다(「Primary instance is being restarted without a switchover」). Ready 를 한 번 본 것으로 끝내면 바로 뒤에 Ready 가 비어 있는 순간을 만납니다. 「healthy」 를 10초 간격으로 두 번 보고 끝냈습니다.
8. 오래 걸리는 단계를 돌리는 법
apply, Ceph, CNPG, 장애 시험, 백업과 복원은 몇 분에서 십수 분이 걸립니다. 터미널을 붙들고 기다리지 않게 systemd-run --property Type=oneshot 으로 뒤에서
돌리고, 끝났는지를 따로 물었습니다. 여기서 함정 하나와 작은 놀라움 둘을 만났습니다.
systemctl is-active는 도는 중인 oneshot 유닛을 「아니다」 로 답합니다. systemd.service 문서에 따르면Type=oneshot유닛은 프로그램이 끝날 때까지 「activating」 상태입니다.is-active는 「active」 일 때만 참이므로 「아직 도는 중」 판정이 걸리지 않았고, 기다려야 할 곳에서 실패로 떨어졌습니다.systemctl show -p ActiveState --value <유닛>이activating인지를 봅니다.- helm v4.3.0 에서는
helm list -a의-a가 없어unknown shorthand flag: 'a'로 끝납니다. - 같은 판의
helm status -o json은 릴리스마다 19초가 걸렸습니다. 상태 확인에는helm list -o json한 번이 낫습니다.
9. 자원
모든 것을 올린 뒤의 노드(8 vCPU·8GiB) 셋입니다.
| 항목 | 값 |
|---|---|
| 메모리 사용 | 2.2~2.6 / 7.9GiB |
/var/lib/k0s |
6.4~6.9GiB(작업 디스크) |
| 루트 디스크(2.4GiB) | 74~94% |
/var/lib/k0s 와 파드 로그는 작업 디스크로 옮겼지만 journald 와 k0s 의 로그가 루트에 쌓였습니다. 루트가 작은 장비라면 journald 의 상한부터 정해야 합니다.
상한을 두고 오래 돌려 보지는 않았습니다(미실측).
10. 한계와 미실측
- 환경: KubeVirt VM 안의 k0s 라 절대 시간은 물리 서버보다 느릴 수 있고, 디스크가 HDD 였습니다. 특히 Ceph 와 CNPG 의 시간은 디스크에 크게 좌우됩니다.
- 반복 수: 3대 시나리오 네 번, 장애 조치 네 번입니다. 분포가 아니라 사례로 읽어 주십시오.
- 재지 않은 것: 바깥 HAProxy 로 API 를 묶는 구성,
spec.images.repository로 시스템 이미지까지 사내 레지스트리에서 받기,k0sctl apply --restore-from복원, k0s 판 올리기, 노드 재부팅 뒤의 동작, TLS 레지스트리,kubeletRootDir로 kubelet 경로 바꾸기, 옛 primary 의 Terminating 을 줄이는 설정, journald 상한을 둔 장시간 운전.
11. 정리
| 점검 | 이유(이 글의 실측) |
|---|---|
| 3대면 셋 다 컨트롤러 겸 워커 | etcd 3멤버로 1대 장애를 견디고 Ceph·PG 셋을 노드마다 하나씩 |
VRRP interface 와 sans 를 적는다 |
비우면 기본 경로 NIC 에 VIP 가 뜨고, sans 가 없으면 TLS 가 실패한다 |
| kubeconfig 는 VIP 로 | VIP 보유 노드를 멈춰도 API 0~3초, 쓰기 1~3초 |
| apply 뒤 노드 Ready 를 직접 확인 | k0sctl 은 컨트롤러 겸 워커를 기다리지 않는다(+104·+125초) |
이미지 이름은 그대로, hosts.toml 로 미러 |
18개, 1.8GB 를 고친 곳 없이 받았다 |
| Ceph CSI 의 kubelet 경로 | k0s 는 /var/lib/k0s/kubelet |
| 백업은 barman-cloud 플러그인으로 | 본체 방식은 1.32 에서 빠진다 |
| 버킷 이름을 기다린 뒤 ObjectStore | 비면 s3:///, 아카이빙이 깨지고 옛 primary 가 18분 넘게 남았다 |
| 넘기기 전 WAL 이름을 적는다 | PostgreSQL 17 부터 pg_walfile_name(pg_switch_wal()) 은 다음 파일 |
| 장애 조치 뒤 옛 primary 까지 확인 | 네 번 중 세 번 5분 넘게 Terminating |
oneshot 유닛은 ActiveState 로 |
is-active 는 도는 중을 「아니다」 로 답한다 |
티티 한마디. 반장을 셋 뽑으면 한 명이 아파도 둘이 의논해서 정할 수 있어요. 그런데 둘이 아프면 혼자서는 아무것도 못 정해요. 그리고 이사 간 반장(옛 primary)이 짐을 다 못 싸고 문 앞에 30분 서 있을 수도 있으니, 새 반장이 섰다고 끝내지 말고 옛 반장이 자리로 돌아왔는지도 꼭 봐 주세요!