TT Lab
블로그

한국어

시작하기
블로그

블로그

폐쇄망 k0s 멀티노드 실측 3: 노드 3대 HA 위에 Rook-Ceph 와 CloudNativePG

상태: 실측 (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대를 나누는 길은 둘입니다.

(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 를 부를지 정해야 합니다.

  1. CPLB(control plane load balancing). k0s 가 keepalived 를 띄워 VRRP 로 가상 IP 를 한 노드에 올리고, 그 노드가 죽으면 다른 노드로 옮깁니다. CPLB 문서는 이것을 「클러스터 바깥에서 들어오는 접근」 용이라고 설명합니다. 반입할 것이 늘지 않는 대신 노드들이 같은 L2 망에 있어야 합니다(문서: virtual router ID 는 브로드캐스트 도메인 안에서 유일해야 함). 컨트롤러 겸 워커에서는 k0s 의 사용자 공간 리버스 프록시 방식만 지원합니다.
  2. NLLB(node-local load balancing). 워커마다 루프백(127.0.0.1:7443)에 envoy 를 띄워 kubelet 같은 노드 구성 요소가 살아 있는 apiserver 셋에 고르게 붙게 합니다. NLLB 문서는 NLLB 가 클러스터 안쪽의 장애 내성용이고 바깥 접근은 돕지 않는다고 적습니다.
  3. 바깥 로드밸런서(HAProxy 등). HA 문서의 예시는 6443(API)·8132(konnectivity)·9443(조인 API)을 묶습니다. 그 로드밸런서를 다시 이중화해야 하고 반입할 패키지가 늘어납니다. 이번에는 재지 않았습니다(미실측).

CPLB 문서는 바깥 로드밸런서가 없으면 CPLB 와 NLLB 를 함께 쓰라고 권합니다. 이번 구성이 그것입니다.

외부 로드밸런서 없이 컨트롤러 셋 controller+worker 셋, CPLB + NLLB, 주소는 예시 kubectl k0sctl VIP 192.0.2.100 한 노드에만(VRRP) 반입 서버 레지스트리 미러 노드 1 VIP 보유 etcd kube-apiserver envoy(NLLB) Ceph mon · osd PostgreSQL 노드 2 etcd kube-apiserver envoy(NLLB) Ceph mon · osd PostgreSQL 노드 3 etcd kube-apiserver envoy(NLLB) Ceph mon · osd PostgreSQL kubelet 은 자기 노드의 envoy(127.0.0.1:7443)를 거쳐 세 apiserver 로 이미지는 containerd hosts.toml 로 반입 서버의 미러에서 etcd 3멤버: 1대 장애는 견디고, 2대가 멈추면 쓰기가 멈춘다
그림: 외부 로드밸런서 없이 컨트롤러 셋: VIP 는 한 노드에만 뜨고, kubelet 은 자기 노드의 envoy 를 거쳐 세 apiserver 로 간다

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 가 둘이라는 사실이 세 군데에 나옵니다.

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초마다 쟀습니다.

먼저 VIP 를 가진 노드(사내망 NIC 에 VIP 가 붙은 노드)를 찾아 k0scontroller 를 멈추고, 멈춘 시각부터 API 와 쓰기가 돌아오기까지를 쟀습니다. 이어서 둘째 컨트롤러도 멈추고 30초 동안 쓰기가 한 번이라도 되는지 보았고, 둘을 다시 켜 쓰기가 돌아오기까지를 쟀습니다.

5.2 결과

멈추고 다시 서기까지(실측) 같은 정답지로 처음부터 네 번 세운 결과 VIP 를 가진 컨트롤러 정지 → 다시 되기까지 API(/readyz, VIP) 쓰기(ConfigMap 생성) 1회차 0초 1초 2회차 3초 3초 3회차 0초 · 쓰기 시각은 기록하지 않음 4회차 1초 · 쓰기 시각은 기록하지 않음 둘을 멈추면 30초 동안 쓰기 전부 실패, 다시 켜고 1~2초 CNPG primary 파드 삭제 → 새 primary 에 쓰기 1회차 미실측(정답지가 끊겨 단계가 섞임) 2회차 a 39.8초 2회차 b 49.5초 3회차 62.6초 4회차 35.4초 2회차 a 는 WAL 아카이빙이 깨진 상태(함정 2) 옛 primary 는 4번 중 3번이 5분 넘게 Terminating
그림: VIP 를 가진 컨트롤러를 멈췄을 때 API·쓰기 복귀 시간(4회)과 CNPG primary 삭제 뒤 새 primary 에 쓰기까지의 시간(4회)
회차 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초 동안 끝나지 않았습니다.

넘긴 WAL 이 올라갔는지 기다리는 법 PostgreSQL 17 부터 경계 위치의 파일 이름 규칙이 바뀌었다 1002 를 넣은 곳 WAL 파일 N WAL 파일 N+1 pg_switch_wal() 이 돌려준 위치 = 이 경계 pg_walfile_name(경계) 의 답 PostgreSQL 16 까지 → N(앞 파일) 기다릴 대상으로 맞다 PostgreSQL 17 부터 → N+1(지금 파일) 다음 전환 전엔 안 올라간다 넘기기 전에 pg_current_wal_lsn() 의 파일 이름(N)을 적고 pg_stat_archiver.last_archived_wal 이 N 이상이 되면 복원
그림: pg_switch_wal 이 돌려준 위치는 파일 경계이고, PostgreSQL 17 부터 그 경계의 파일 이름은 다음 파일이다

처음 쓴 코드는 pg_walfile_name(pg_switch_wal()) 로 기다릴 파일 이름을 얻었습니다. PostgreSQL 문서에 따르면 pg_switch_wal() 은 「방금 끝낸 WAL 파일의 끝 위치 + 1」, 곧 다음 파일이 시작하는 경계를 돌려줍니다. 경계 위치를 pg_walfile_name 에 넣으면 어떤 이름이 나오는지는 판에 따라 다릅니다.

그러니 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 으로 뒤에서 돌리고, 끝났는지를 따로 물었습니다. 여기서 함정 하나와 작은 놀라움 둘을 만났습니다.

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. 한계와 미실측

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분 서 있을 수도 있으니, 새 반장이 섰다고 끝내지 말고 옛 반장이 자리로 돌아왔는지도 꼭 봐 주세요!

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다