TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Closed-network k0s multi-node actual measurement 3: Rook-Ceph and CloudNativePG on 3 HA nodes

Status: Measured (2026-10-31). These values were measured by setting up k0s v1.37.1+k0s.0 inside TT Lab practice VMs (three 8 vCPU / 8GiB nodes and one import server). We built the cluster from scratch four times modifying the same answer sheet; the first run was marked unmeasured because the answer sheet was cut off in the middle, mixing up the steps. The dispersion of the numbers reflects the actual variance in this environment. The VM disks were on an HDD. Node addresses have been changed to an example block (192.0.2.0/24). Writing convention: Measured data is the default; unmeasured items are marked as "Unmeasured", and interpretations whose causes could not be verified are marked as "Estimated".

Table of Contents

1. Starting with the Conclusion

2. How to Split Three Nodes

There are two ways to divide three nodes.

(a) Three controller-cum-workers (this week's scenario) (b) 1 controller + 2 workers
etcd 3 members, tolerates 1 node failure 1 member, control plane is a single point of failure
Nodes to place workloads 3 nodes 2 nodes
Ceph (3 mons, 3-way replication), 3 PostgreSQL instances One per node, tolerates 1 node failure Only 2 storage nodes, cannot do 3-way replication
API address CPLB VIP + NLLB, external load balancer unnecessary Single controller address
Cost Control plane and workloads share memory. 8GiB nodes, reduced Ceph daemon memory Simple
Test Entire article Part 2 actually sets it up in Section 5

Approach (b) does not achieve high availability for storage, which is the goal of this article. Ceph's default block pool places 3 replicas across different hosts (failureDomain: host), and mons are placed on distinct nodes to secure a majority. With only two storage nodes, there is nowhere to place 3 replicas, leaving data as undersized+degraded . Lowering it to 2 replicas causes writes to halt during a single node failure (min_size 2) or forces survival with a single data replica (min_size 1). For the same reason, Ceph and PostgreSQL sets were not deployed on 2 or fewer nodes. Ceph's placement principles are detailed in the Rook-Ceph Guide series.

2.1 Three Ways to Bind the API Address

When there are three controllers, you must decide which address kubectl and nodes will use to call the API.

  1. CPLB(control plane load balancing). k0s runs keepalived to assign a virtual IP to a node via VRRP, and moves it to another node if that node dies. CPLB documentationexplains this is for "access originating from outside the cluster". Instead of increasing things to import, nodes must be on the same L2 network (documentation: virtual router ID must be unique within the broadcast domain). Controller-cum-worker setups only support k0s's userspace reverse proxy method.
  2. NLLB(node-local load balancing). Runs envoy on the loopback (127.0.0.1:7443) of each worker so that node components like kubelet evenly connect to the set of running apiservers. NLLB documentationnotes that NLLB is for fault tolerance inside the cluster and does not assist with external access.
  3. External Load Balancer(HAProxy, etc.). The example in the HA documentationcombines 6443 (API), 8132 (konnectivity), and 9443 (join API). That load balancer must be redundantly configured in turn, increasing the packages to import. We did not measure this time (unmeasured).

The CPLB documentation recommends using CPLB and NLLB together if there is no external load balancer. This configuration adopts that approach.

외부 로드밸런서 없이 컨트롤러 셋 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대가 멈추면 쓰기가 멈춘다
Figure: Controller set without an external load balancer: VIP floats on only one node, and kubelet goes through its node's envoy to reach the three apiservers

2.2 k0sctl Configuration

Only one host is listed. The other two differ only in address.

  - 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}

Here too, the fact that there are two NICs appears in three places.

authPass As the documentation states, k0sctl kubeconfig is a value to prevent accidental mixing between clusters, not for encryption. Set it to avoid overlapping with other clusters on the same network. After installation, we changed the server address in the kubeconfig provided by

to the VIP. Pointing to a single controller causes kubectl to die along with it when that node dies.

3. Mirroring Images to the In-house Registry

Image Bundles Count Example
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, 5 sig-storage sidecars
CloudNativePG 4 operator 1.30.1, postgresql 18.6-minimal-trixie, barman-cloud plugin and sidecar v0.15.1
cert-manager 3 controller, webhook, and cainjector for v1.21.3
Apps 2 nginx 1.30.6-alpine, busybox 1.36.1-1

The import server placed them with skopeo copy by 192.0.2.2:5000/quay.io/ceph/ceph:v20.2.4 , prepending the original registry name to the path . containerd on the node side points to this path using a single hosts.toml per registry. According to containerd's hosts.md, override_path = true means "the root of the API is specified in the URL path", so containerd requests it like /v2/ without re-appending http://192.0.2.2:5000/v2/quay.io/ceph/ceph/manifests/... . Therefore, we did not modify a single image name in the charts and manifests. Changing image names one by one in values files in a closed network invariably leads to missing one, which reveals itself minutes later as ImagePullBackOff.

The drop-in file layout followed the k0s runtime documentation. The documentation notes that this version of k0s must use the version = 3 and io.containerd.cri.v1.images plugin paths, and using version = 2 or old plugin paths will cause pre-checks to halt.

The list of images to mirror was based on deploy/examples/images.txtin the Rook repository, excluding unused COSI, csi-addons sidecars, and snapshot-metadata. Any missing items immediately manifest as ImagePullBackOff, so there is no need to fear trimming the list.

Item Measured
Import (tools 27s -> binary/bundle -> helm/charts/manifests 57s -> mirror 18 images -> seal) 182 seconds, registry 1.8GB
46 containers of rook-ceph All images are in the mirror list
Registry access logs 96-104 entries per node (all node sets pulled from the mirror)

I will also note the limitations. The test registry was HTTP within the internal network. In the field, TLS is used and hosts.toml is given the internal CA (ca = ...) (unmeasured). The method of pulling even the k0s system images from the registry (spec.images.repository) was also not measured (unmeasured). The reason for keeping system images as a bundle is to ensure that CNI, kube-proxy, and coredns come up when nodes restart even if the import server's registry dies.

4. Installation: k0sctl does not wait for Controller-cum-Worker Ready

Run k0sctl apply(k0sctl report) All three Ready (from apply start)
1 89 seconds Unmeasured (answer key interrupted, steps mixed up)
2 93 seconds Node registration at apply end +5s, +104s, +125s
3 72 seconds 217 seconds
4 109 seconds 324 seconds

Just because apply finished did not mean the cluster was fully up. In run 2, apply finished successfully at 93 seconds, but two nodes left kubelet's 「Successfully registered node」 104 and 125 seconds later, respectively.

Looking at the k0sctl source code, the reason was obvious. install_workers.go's 「waiting for node to become ready」 runs only on Workers() , and this function selects only hosts whose role is strictly worker . controller+worker finishes during the controller installation phase, so it does not wait for the kubelet to become Ready. Therefore, automation following apply must independently verify whether the node set is Ready. If you proceed directly to the next step without doing so, Ceph will be installed with one node missing.

5. Stopping one or two controllers

5.1 Measurement method

Measured two things every second against the VIP from the import server.

First, we found the node holding the VIP (the node with the VIP attached to its internal network NIC), stopped k0scontroller , and measured the time from the stop until API and writes recovered. Next, we also stopped the second controller, observed whether writes succeeded even once for 30 seconds, and measured the time until writes recovered after turning both back on.

5.2 Results

멈추고 다시 서기까지(실측) 같은 정답지로 처음부터 네 번 세운 결과 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
Figure: API and write recovery times when stopping the controller holding the VIP (4 runs) and time from CNPG primary deletion to writes on the new primary (4 runs)
Run VIP move API recovery Write recovery Stop two (1/3) Write after turning back on
1 Node 3 → Node 2 0s 1s All failed for 30s 1s
2 Node 1 → Node 3 3s 3s All failed for 30s 1s
3 Node 1 → Node 3 0s Not recorded All failed for 30s 2s
4 Node 1 → Node 3 1s Not recorded All failed for 30s 1s

When one node was stopped, the live node's k0s etcd member-list still contained the member set. Stopping one does not remove it from the members, so the quorum is still 2, and the two surviving nodes met that quorum to accept writes. When two are stopped, the remaining one cannot meet the quorum, so writes stop. This matches the etcd table in Part 2.

One thing to watch out for: even if you stop kubelet, the Node object's Ready remains at its old value for a while. This is because there is a timeout (--node-monitor-grace-period, documentationdefault is 50 seconds) before the controller manager changes the node to 「Not Ready」. Saying 「the node was Ready even during the outage」 is not proof of high availability. Therefore, the table above was measured using actual requests rather than Node status.

6. Rook-Ceph

6.1 What changes because of k0s: kubelet path

k0s's kubelet uses /var/lib/kubelet as its working directory instead of /var/lib/k0s/kubelet (k0s storage documentation). CSI node plugins mount volumes by attaching the kubelet directory via hostPath, so if this path is incorrect, PVCs will bind but will not attach to pods.

Rook v1.21 separated the CSI driver into ceph-csi-operator and a separate chart (ceph-csi-drivers). The kubelet path was not found in the rook-ceph chart values, so it was placed in the driver chart values.

# ceph-csi-drivers 1.1.0 의 values(Rook 이 권하는 값에 이 줄만 더함)
operatorConfig:
  driverSpecDefaults:
    nodePlugin:
      kubeletDirPath: /var/lib/k0s/kubelet

Another way is to symlink the k0sctl host's kubeletRootDir to the kubelet side's /var/lib/kubelet , but this was not measured (unmeasured). Because the driver DaemonSet is created by the operator inspecting the Driver object, it did not yet exist immediately after chart installation (23 seconds). Automation checking for the DaemonSet immediately after installation must wait until it appears.

6.2 Measurements

The chart set (rook-ceph, ceph-csi-drivers, rook-ceph-cluster v1.21.1, Ceph v20.2.4) was installed from tgz imported via helm. One empty 17GiB disk per node was used as an OSD, and since nodes also bear workloads, the OSD's osd_memory_target was lowered to 1GiB.

Item Value
Chart installation → HEALTH_OK (3 OSDs up & in, 3 mons) 546s · 605s · 437s (Runs 2, 3, and 4)
Raw capacity / Usable capacity of 3-replica block pool 51GiB / 15GiB
Pools and PGs 10 pools (1 block, 8 RGW object storage, .mgr), 121 active+clean PGs

HEALTH_OK is the value after investigating and silencing the cephx key format warnings raised by Ceph v20 (AUTH_INSECURE_*). After running all steps to the end, it became HEALTH_WARN due to BLUESTORE_SLOW_OP_ALERT(slow disk warning). This is the result of placing a PostgreSQL workload on a VM running on HDDs. On field hardware, this is a value that must be re-evaluated from the disks up.

7. CloudNativePG

7.1 Installation order and backup method

Imported manifests were applied in the order of cert-manager → CNPG operator (1.30.1) → barman-cloud plugin (v0.15.1). The CNPG manifest was applied using installation documentationas kubectl apply --server-side .

The reason for performing backups via the plugin is due to version lifecycles. CNPG 1.30's original backup documentationstates that the built-in barmanObjectStore method has been deprecated since 1.26, 1.31 is the last version to support it, and it will be removed in 1.32. There is no reason to start a newly built cluster with a method that will soon disappear. The plugin requires cert-manager (plugin installation documentation). Because the backup tool is included in the plugin sidecar, a small PostgreSQL image ( 18.6-minimal-trixie ) was sufficient.

The backup destination is the Ceph object storage (RGW) in the same cluster. When you create a bucket with an ObjectBucketClaim, Rook provides a ConfigMap (bucket name and address) and a Secret (keys) with the same name. However, RGW in the same cluster is not a separate failure domain. If you lose the cluster entirely, you lose the backups as well, so in production, you must send them to an external storage once more.

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}

In run 2, it took about 13 minutes and 40 secondsfor all instance sets to become Ready (initdb 70 seconds, the rest for replica image pulling and joining). While this value is heavily influenced by the condition of being a VM on HDDs and should not be generalized as-is, it is clear that when setting upper bounds for automation waits, you should aim for 「tens of minutes」 rather than 「a few minutes」.

7.2 Pitfall: Creating ObjectStore before the bucket name is generated

In the first attempt, the ObjectBucketClaim did not become Bound within 2 minutes, but the script proceeded directly to the next step once the wait ended. Consequently, ObjectStore was created with an empty bucket name, causing destinationPath to become s3:/// . WAL archiving continuously failed with Invalid s3 URL address .

Deleting the primary pod in this state made things much worse. The new primary came up in 39.8 seconds, but holding onto WALs that the old primary failed to preserve, it remained Terminating for over 18 minutes . The CNPG pod's terminationGracePeriodSeconds is 1800 seconds, so Kubernetes waited that long. Now, we made it wait until the ConfigMap's BUCKET_NAME is generated and the OBC is Bound, and if it ultimately remains empty, it stops.

7.3 When the primary is deleted

When all instance sets were Ready, we deleted the primary pod and queried select pg_is_in_recovery() on the new primary every 0.5 seconds to measure until f(writable) appeared. It is crucial to start only when all three are Ready. There was a run where we deleted the primary when no replicas had come up yet behind a broken answer key, resulting in the loss of the cluster.

Run Time to write on new primary Row Old primary
2 a (archiving broken state, 7.2) 39.8s Not recorded Terminating for over 18 minutes
2 b 49.5s Maintained 1001 rows Returned as a replica
3 62.6s Not recorded Terminating for over 1418s (23 minutes)
4 35.4s Maintained 1001 rows Terminating for over 320s (measured up to 5 minutes only)

There were no issues with the promotion of the new primary and writing. The problem is the old primary. In two out of the three times archiving was normal, it was also Terminating for over 5 minutes, and that pod's logs ended like this.

"msg":"unable to run controller-runtime manager",
"error":"failed waiting for all runnables to end within grace period of 30s: context deadline exceeded"

Although PostgreSQL already shut down normally with server stopped , the process did not end even after leaving an error that CNPG's instance manager failed to finish all internal tasks within 30 seconds. The child postgres process remained as a zombie (<defunct>), and the pod headed toward consuming the entire grace period of 1800 seconds. The cause could not be determined. Although it seems related to the issue where shutting down the primary pod takes about 3 minutes when the barman-cloud sidecar is enabled ( plugin-barman-cloud#1039(open)), this observation is vastly longer (estimated).

Why should we care? While the old primary is Terminating, the cluster survives with 2 Ready instances. If another node dies during that time, it enters a state with only one replica. Even if you try to drain the node where that pod sits, it does not finish. After a failover, you must verify even whether the old primary actually returned as a replica. Settings to mitigate this phenomenon (such as adjusting the grace period) were not tested (unmeasured).

7.4 Backup, recovery, and how to wait for 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}

After the backup finished, I inserted one more row (1002), rolled over the WAL file, confirmed that the WAL was uploaded to the bucket, and bootstrap.recovery created a new cluster pg-restore using it. If we recover up to row 1002, which was inserted after the backup point (row 1001), it means the WAL recovery was successful.

Attempt Backup Create restoration cluster → Ready Restoration source count, max(id)
2 (First attempt) 53 seconds 4 minutes 6 seconds 1001, 1001 (waited only 15 seconds for WAL)
2 (Retry) 4 seconds 3 minutes 15 seconds 1002, 1002
3 Not recorded 2 minutes 0 seconds 1002, 1002
4 37 seconds 1 minute 54 seconds 1002, 1002

In the first attempt, I passed the WAL and waited for only 15 seconds, and the restoration source stopped at the backup point (1001). This is because restoration only reads WALs uploaded to the bucket up to that point. So, I added "Wait until the passed WAL is archived", but this time the wait did not end for 300 seconds.

넘긴 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 이상이 되면 복원
Figure: The position returned by pg_switch_wal is a file boundary, and starting from PostgreSQL 17, the file name at that boundary is the next file

The code I wrote initially used pg_walfile_name(pg_switch_wal()) to get the name of the file to wait for. According to the PostgreSQL documentation, pg_switch_wal() returns "end position of the WAL file just finished + 1", which is the boundary where the next file starts. Depending on the version, what name comes out when you pass the boundary position to pg_walfile_name varies.

Therefore, in version 17 and later, pg_walfile_name(pg_switch_wal()) is the name of the next file that has not even started writing yet, and that file is not archived until the next switch occurs. This is why the wait did not end. The fix was to change the order.

-- 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;

After making this fix, all three attempts successfully recovered up to row 1002. It is also worth noting that the instance restarts once after recovery finishes ("Primary instance is being restarted without a switchover"). If you finish just by seeing Ready once, you immediately encounter a moment where Ready is empty. I finished after seeing "healthy" twice at 10-second intervals.

8. How to run long-running steps

apply, Ceph, CNPG, chaos testing, backups, and restorations take anywhere from a few minutes to over ten minutes. To avoid sitting and waiting tied to the terminal, I ran them in the background using systemd-run --property Type=oneshot and queried separately whether they finished. Here I encountered one trap and two small surprises.

9. Resources

This is a set of three nodes (8 vCPU and 8GiB) after everything is up.

Item Value
Memory usage 2.2~2.6 / 7.9GiB
/var/lib/k0s 6.4~6.9GiB (working disk)
Root disk (2.4GiB) 74~94%

/var/lib/k0s and pod logs were moved to the working disk, but journald and k0s logs accumulated on the root. If the root disk is small, you must set an upper limit for journald first. We have not run it for a long time with a limit set (unmeasured).

10. Limitations and Unmeasured Items

11. Conclusion

Checklist Rationale (Actual measurements in this post)
Three nodes acting as both controllers and workers etcd with 3 members to withstand a 1-node failure, with one Ceph and PG instance per node
VRRP interface and sans write if left empty, the VIP appears on the default route NIC, and if sans is missing, TLS fails
kubeconfig to VIP API 0-3s, writes 1-3s even when stopping the VIP-holding node
check node Ready directly after apply k0sctl does not wait for controller-cum-workers (+104-125s)
image name stays the same, hosts.toml mirrored to pulled 18 items, 1.8GB without modifications
Ceph CSI kubelet path k0s is /var/lib/k0s/kubelet
backup with barman-cloud plugin built-in method removed in 1.32
ObjectStore after waiting for bucket name if empty s3:///, archiving broke and the old primary remained for over 18 minutes
write the WAL name before handing over starting from PostgreSQL 17 pg_walfile_name(pg_switch_wal()) is the next file
check even the old primary after failover Terminating for over 5 minutes in three out of four times
oneshot unit as ActiveState to is-active answers 「no」 while running

Measurements in this post can be reproduced in the TT Lab hands-on course 「Air-gapped k0s: 1, 2, and 3-node clusters with Ceph and PostgreSQL」 (site path /course/k0s-airgap-multinode), module 3 「3-Node HA」, using the same version and manifest list. VIP migration, Rook-Ceph, and CNPG failover, backup, and restore are performed on real VMs, with a grader checking the results at each step.

TT's word. If you elect three class presidents, even if one gets sick, the other two can discuss and decide. But if two get sick, a single person can't decide anything. Also, the moving president (the old primary) might stand at the door for 30 minutes without finishing packing, so don't just finish when the new president takes office—make sure to check whether the old president has returned to their seat!

Sign in to like

Comments

No comments yet.

Sign in to leave a comment