TT Lab
블로그 시작하기
배우기 러닝패스 코스

폐쇄망 k0s — 노드 1·2·3대와 그 위의 Ceph·PostgreSQL

노드 3대 HA — CPLB·NLLB, 그 위의 Rook-Ceph 와 CloudNativePG

TT Lab 에서 이어서 보기

목표

봉인된 노드 셋(ctrl01~03, 8GiB, 빈 디스크 17GiB 씩)을 모두 컨트롤러+워커로 묶은 k0s 고가용성 클러스터를 반입 서버의 k0sctl 로 세웁니다. 컨트롤러를 하나·둘 내려 API 와 etcd 를 재고, 사내 레지스트리 미러만으로 Rook-Ceph 와 CloudNativePG 를 올려 PostgreSQL 장애 조치와 RGW 백업·복원까지 확인합니다.

왜 중요한가

3대부터 etcd 가 과반(2)을 지키며 한 대를 잃을 수 있습니다. 그 대신 「API 주소를 어디에 두나」 가 새 문제가 됩니다 — 컨트롤러 하나의 주소를 쓰면 그 노드가 죽을 때 함께 닿지 않습니다. k0s 는 바깥 부하 분산기 없이 이것을 푸는 장치 둘(CPLB 의 VIP, 워커 쪽 NLLB)을 갖고 있습니다. 그리고 진짜 서비스는 스토리지와 DB 를 함께 올려야 의미가 있습니다. 폐쇄망에서는 그 이미지 수십 개를 미러로 들여오는 일이 설치의 절반입니다.

단계

  1. 반입 서버가 반입을 끝낼 때까지 기다리세요(/opt/airgap/INTAKE_DONE 이 생깁니다, 진행은 tail -f /var/log/airgap-intake.log). 그다음 bastion·ctrl01·ctrl02·ctrl03 각각에서 curl -s -o /dev/null -m 5 -w '%{http_code}' https://<호스트> 로 github.com·quay.io·registry-1.docker.io 에 닿는지 재고, /root/k0s/egress.txt 에 노드 호스트 상태코드 를 한 줄씩 적으세요. 모두 000(연결 자체가 안 됨)이어야 합니다. 이 실습의 반입에는 Rook·Ceph·CNPG 이미지 미러가 들어 있어 수 분이 더 걸립니다(/opt/airgap/images/mirror-manifest.tsv).
  2. /root/k0s/k0sctl.yaml 에 세 노드(ctrl01·ctrl02·ctrl03, 172.30.1.3·172.30.1.4·172.30.1.5)를 모두 controller+worker 와 noTaints: true 로 적으세요. k0s 설정에 CPLB(Keepalived, VIP 172.30.1.100/24, interface: eth1)와 NLLB(EnvoyProxy)를 켜고 spec.api.sans 에 VIP 를, 2실습처럼 spec.api.onlyBindToAddress: true 를 넣습니다. files 로 airgap 묶음·앱 묶음과 함께, containerd 가 docker.io·quay.io·ghcr.io·registry.k8s.io·gcr.io 를 사내 레지스트리 http://172.30.1.2:5000/v2/<레지스트리> 로 돌리는 /etc/k0s/containerd.d/cri-registry.toml 과 certs.d/<레지스트리>/hosts.toml 을 올리세요.
  3. apply 출력은 /root/k0s/apply-ha.log, 걸린 초는 /root/k0s/apply-ha.time, apply 를 시작해 세 노드가 모두 Ready 가 되기까지의 초는 /root/k0s/ha-ready.time 에 남기고, kubeconfig 의 server 를 VIP(https://172.30.1.100:6443) 로 두세요. 세 노드가 Ready, 컨트롤 플레인 테인트가 없고, etcd 멤버가 셋이어야 합니다.
  4. VIP 를 가진 컨트롤러의 k0scontroller 를 멈추고 VIP 로 API 가 돌아오기까지의 초, VIP 를 넘겨받은 노드, 2/3 상태에서 쓰기(ConfigMap 만들기)의 성공 여부, 살아 있는 컨트롤러의 k0s etcd member-list 를 /root/k0s/ha-failover.txt 에 적으세요. 이어서 둘째 컨트롤러도 멈춰 1/3 에서 쓰기가 실패하는 것을 적고, 둘을 다시 켜 쓰기가 돌아오기까지의 초를 마지막 줄 restored <초>s 로 적습니다. 세 노드가 다시 Ready 여야 합니다.
  5. 반입한 차트(/opt/airgap/charts/)로 rook-ceph·ceph-csi-drivers·rook-ceph-cluster(v1.21.1·1.1.0·v1.21.1)를 네임스페이스 rook-ceph 에 설치하세요. CSI values 는 반입한 Rook 권장 values 에 operatorConfig.driverSpecDefaults.nodePlugin.kubeletDirPath: /var/lib/k0s/kubelet 를 더한 /root/k0s/csi-drivers-values.yaml, 클러스터 values 는 /root/k0s/cluster-values.yaml(mon 3·mgr 1·OSD 메모리 낮춤, 빈 디스크 by-id, 블록 풀 3벌 ceph-block, RGW ceph-objectstore)입니다. 시작 시각(초)을 /root/k0s/rook-start.txt 에 남기세요.
  6. Ceph 가 HEALTH_OK(mon 3 정족수, OSD 3 up·in)가 될 때까지 기다리세요. 커널(6.8) 때문에 생기는 AUTH_INSECURE_ 경고 셋만 ceph health mute <코드> --sticky 로 묵음 처리합니다. 5단계 시작부터 HEALTH_OK 까지의 초를 /root/k0s/ceph-ready.txt 에 적으세요. 이미지가 전부 사내 레지스트리에서 왔는지도 보세요.
  7. 반입한 매니페스트(/opt/airgap/manifests/)로 cert-manager v1.21.3, CloudNativePG 1.30.1, barman-cloud 플러그인 v0.15.1 을 설치하세요. 네임스페이스 db 에 RGW 버킷 ObjectBucketClaim pg-backup, 그것을 가리키는 ObjectStore ceph-rgw, Cluster pg(인스턴스 3, 이미지 ghcr.io/cloudnative-pg/postgresql:18.6-minimal-trixie, StorageClass ceph-block 2Gi, barman-cloud 플러그인을 WAL 아카이버로)를 만들고, primary 에 표 t 를 만들어 1~1000 을 넣으세요.
  8. Cluster pg 의 primary 파드를 지우고, 새 primary 가 쓰기 가능(pg_is_in_recovery() = f)이 되기까지의 초와 행 수를 /root/k0s/pg-failover.txt 에 적으세요(old-primary …·new-primary <파드> writable after <초>s·rows <수>). 새 primary 에 1001 을 넣어 행이 1001 이어야 합니다. 마지막으로 옛 primary 가 복제본으로 돌아왔는지 5분까지 재서 old-primary-rejoined after <초>s 또는 old-primary-still-terminating after <초>s 로 적으세요.
  9. 플러그인 방식의 Backup pg-b1 을 받아 completed 가 되게 하고, 백업 뒤에 1002 를 한 줄 더 넣고 WAL 을 넘기세요(select pg_switch_wal()). 그다음 같은 ObjectStore 에서 복원하는 Cluster pg-restore(인스턴스 1)를 만들고, 그 안의 select count(*), max(id) from t 를 /root/k0s/restore.txt 에 남기세요. 1002 까지 보여야 합니다(백업 + WAL).

참고

반입이 끝났고 네 대 모두 봉인됐다

반입 서버가 반입을 끝낼 때까지 기다리세요(/opt/airgap/INTAKE_DONE 이 생깁니다, 진행은 tail -f /var/log/airgap-intake.log). 그다음 bastion·ctrl01·ctrl02·ctrl03 각각에서 curl -s -o /dev/null -m 5 -w '%{http_code}' https://<호스트> 로 github.com·quay.io·registry-1.docker.io 에 닿는지 재고, /root/k0s/egress.txt 에 노드 호스트 상태코드 를 한 줄씩 적으세요. 모두 000(연결 자체가 안 됨)이어야 합니다. 이 실습의 반입에는 Rook·Ceph·CNPG 이미지 미러가 들어 있어 수 분이 더 걸립니다(/opt/airgap/images/mirror-manifest.tsv).

폐쇄망 일의 첫 질문은 「정말 막혔나」 입니다. 막혔다고 믿고 설치했는데 사실은 바깥에서 받아 온 것이면 진짜 폐쇄망에서 같은 절차가 실패합니다. 다른 노드에는 ssh <노드> '<명령>' 으로 들어갑니다(세션망 열쇠가 이미 깔려 있습니다). 이 망은 이름 풀이는 되지만 연결은 거절됩니다 — nft list table inet labhub_airgap 이 무엇을 남겼는지 보여 줍니다.

k0sctl.yaml — 컨트롤러+워커 셋, CPLB·NLLB, 미러

/root/k0s/k0sctl.yaml 에 세 노드(ctrl01·ctrl02·ctrl03, 172.30.1.3·172.30.1.4·172.30.1.5)를 모두 controller+worker 와 noTaints: true 로 적으세요. k0s 설정에 CPLB(Keepalived, VIP 172.30.1.100/24, interface: eth1)와 NLLB(EnvoyProxy)를 켜고 spec.api.sans 에 VIP 를, 2실습처럼 spec.api.onlyBindToAddress: true 를 넣습니다. files 로 airgap 묶음·앱 묶음과 함께, containerd 가 docker.io·quay.io·ghcr.io·registry.k8s.io·gcr.io 를 사내 레지스트리 http://172.30.1.2:5000/v2/<레지스트리> 로 돌리는 /etc/k0s/containerd.d/cri-registry.toml 과 certs.d/<레지스트리>/hosts.toml 을 올리세요.

controller+worker 는 기본으로 컨트롤 플레인 테인트가 붙어 일반 파드가 앉지 못합니다. 노드가 셋뿐이고 Ceph·DB 를 셋에 나눠 앉혀야 하므로 noTaints 로 풉니다. CPLB 의 VRRP 는 기본 경로 NIC 를 고르므로 interface 를 적어야 VIP 가 세션망에 뜹니다. NLLB 는 워커의 kubelet 이 컨트롤러 셋 중 살아 있는 쪽으로 붙게 합니다. k0s 1.36 부터 containerd 2 라 drop-in 은 version = 3 과 io.containerd.cri.v1.images 경로를 씁니다. override_path 는 미러 쪽 경로를 그대로 쓰라는 뜻입니다.

k0sctl apply — HA 3대가 서는 시간

apply 출력은 /root/k0s/apply-ha.log, 걸린 초는 /root/k0s/apply-ha.time, apply 를 시작해 세 노드가 모두 Ready 가 되기까지의 초는 /root/k0s/ha-ready.time 에 남기고, kubeconfig 의 server 를 VIP(https://172.30.1.100:6443) 로 두세요. 세 노드가 Ready, 컨트롤 플레인 테인트가 없고, etcd 멤버가 셋이어야 합니다.

k0sctl 은 첫 컨트롤러를 세운 뒤 VIP 로 API 에 닿는지 확인하고 나머지 컨트롤러를 붙입니다. k0sctl kubeconfig 가 컨트롤러 하나의 주소를 적으면 그 노드가 죽을 때 kubectl 도 죽으니 VIP 로 바꿉니다. etcd 멤버는 컨트롤러에서 k0s etcd member-list 로 봅니다.

컨트롤러 하나, 그리고 둘을 내린다

VIP 를 가진 컨트롤러의 k0scontroller 를 멈추고 VIP 로 API 가 돌아오기까지의 초, VIP 를 넘겨받은 노드, 2/3 상태에서 쓰기(ConfigMap 만들기)의 성공 여부, 살아 있는 컨트롤러의 k0s etcd member-list 를 /root/k0s/ha-failover.txt 에 적으세요. 이어서 둘째 컨트롤러도 멈춰 1/3 에서 쓰기가 실패하는 것을 적고, 둘을 다시 켜 쓰기가 돌아오기까지의 초를 마지막 줄 restored <초>s 로 적습니다. 세 노드가 다시 Ready 여야 합니다.

etcd 3멤버의 과반은 2라 하나를 잃어도 씁니다. 둘을 잃으면 남은 하나는 읽기 요청도 대부분 거절합니다. CPLB 의 keepalived 는 VIP 를 가진 노드가 사라지면 수 초 안에 다른 노드에 VIP 를 띄웁니다. 줄 모양은 vip-holder-before·api-back-after <초>s via VIP·write-back-after <초>s·vip-holder-after·write-with-2-of-3 ok|fail·write-with-1-of-3 ok|fail·restored <초>s 입니다. 쓰기 시험의 ConfigMap 이름은 매번 새로 지으세요 — 같은 이름을 다시 만들면 AlreadyExists 로 실패해 정족수 실패와 구별되지 않습니다. 2~4분 걸리므로 뒤에서(systemd-run) 돌리세요.

반입한 차트 셋으로 Rook-Ceph — kubelet 경로를 k0s 에 맞춰

반입한 차트(/opt/airgap/charts/)로 rook-ceph·ceph-csi-drivers·rook-ceph-cluster(v1.21.1·1.1.0·v1.21.1)를 네임스페이스 rook-ceph 에 설치하세요. CSI values 는 반입한 Rook 권장 values 에 operatorConfig.driverSpecDefaults.nodePlugin.kubeletDirPath: /var/lib/k0s/kubelet 를 더한 /root/k0s/csi-drivers-values.yaml, 클러스터 values 는 /root/k0s/cluster-values.yaml(mon 3·mgr 1·OSD 메모리 낮춤, 빈 디스크 by-id, 블록 풀 3벌 ceph-block, RGW ceph-objectstore)입니다. 시작 시각(초)을 /root/k0s/rook-start.txt 에 남기세요.

k0s 의 kubelet 은 /var/lib/kubelet 이 아니라 /var/lib/k0s/kubelet 을 씁니다. CSI 노드 플러그인은 kubelet 디렉터리에 소켓과 마운트를 만들므로 경로가 다르면 PVC 가 붙지 않습니다(k0sctl 의 kubeletRootDir 로 kubelet 쪽을 바꾸는 길도 있습니다). 이미지는 차트에 적힌 이름 그대로 당겨지고, 노드의 containerd 가 사내 레지스트리로 돌립니다. 차트는 helm install <이름> <tgz 경로> 로 저장소 없이 설치합니다.

HEALTH_OK — 폐쇄망 Ceph 가 서는 시간

Ceph 가 HEALTH_OK(mon 3 정족수, OSD 3 up·in)가 될 때까지 기다리세요. 커널(6.8) 때문에 생기는 AUTH_INSECURE_ 경고 셋만 ceph health mute <코드> --sticky 로 묵음 처리합니다. 5단계 시작부터 HEALTH_OK 까지의 초를 /root/k0s/ceph-ready.txt 에 적으세요. 이미지가 전부 사내 레지스트리에서 왔는지도 보세요.

toolbox(kubectl -n rook-ceph exec deploy/rook-ceph-tools -- ceph status)가 상태를 말합니다. OSD 가 아직 없을 때도 mon 만으로 HEALTH_OK 가 잠깐 뜨므로 OSD 셋이 up 인지 함께 봅니다. ImagePullBackOff 가 보이면 미러 목록에 없는 이미지입니다 — kubectl describe pod 의 이미지 이름을 반입 목록과 견줘 보세요.

CloudNativePG — 인스턴스 셋을 Ceph RBD 위에

반입한 매니페스트(/opt/airgap/manifests/)로 cert-manager v1.21.3, CloudNativePG 1.30.1, barman-cloud 플러그인 v0.15.1 을 설치하세요. 네임스페이스 db 에 RGW 버킷 ObjectBucketClaim pg-backup, 그것을 가리키는 ObjectStore ceph-rgw, Cluster pg(인스턴스 3, 이미지 ghcr.io/cloudnative-pg/postgresql:18.6-minimal-trixie, StorageClass ceph-block 2Gi, barman-cloud 플러그인을 WAL 아카이버로)를 만들고, primary 에 표 t 를 만들어 1~1000 을 넣으세요.

barman-cloud 플러그인은 CNPG 1.26 부터 권하는 백업 방식입니다(본체 안의 barmanObjectStore 는 1.26 에서 지원 중단, 1.32 에서 제거). 플러그인은 cert-manager 로 인증서를 만들므로 cert-manager 가 먼저입니다. CNPG 매니페스트는 CRD 가 커서 kubectl apply --server-side 로 넣습니다. primary 는 kubectl -n db get cluster pg -o jsonpath='{.status.currentPrimary}'.

primary 파드를 지운다 — 자동 장애 조치

Cluster pg 의 primary 파드를 지우고, 새 primary 가 쓰기 가능(pg_is_in_recovery() = f)이 되기까지의 초와 행 수를 /root/k0s/pg-failover.txt 에 적으세요(old-primary …·new-primary <파드> writable after <초>s·rows <수>). 새 primary 에 1001 을 넣어 행이 1001 이어야 합니다. 마지막으로 옛 primary 가 복제본으로 돌아왔는지 5분까지 재서 old-primary-rejoined after <초>s 또는 old-primary-still-terminating after <초>s 로 적으세요.

CNPG 는 primary 가 사라지면 가장 앞선 복제본을 승격합니다. 복제본은 Ceph RBD 볼륨을 각자 가지고 있으므로 지운 파드의 볼륨은 그 파드가 다시 뜰 때 복제본으로 돌아옵니다. 승격 시간은 primary 를 잃었다고 판단하는 시간과 승격 자체의 합입니다.

RGW 로 백업하고 새 클러스터로 복원한다

플러그인 방식의 Backup pg-b1 을 받아 completed 가 되게 하고, 백업 뒤에 1002 를 한 줄 더 넣고 WAL 을 넘기세요(select pg_switch_wal()). 그다음 같은 ObjectStore 에서 복원하는 Cluster pg-restore(인스턴스 1)를 만들고, 그 안의 select count(*), max(id) from t 를 /root/k0s/restore.txt 에 남기세요. 1002 까지 보여야 합니다(백업 + WAL).

백업은 베이스 백업이고, 그 뒤의 변경은 아카이브된 WAL 에 있습니다. 복원 클러스터는 bootstrap.recovery 의 source 로 externalClusters 의 plugin(barmanObjectName·serverName)을 가리킵니다. serverName 은 원래 클러스터의 이름(pg)입니다 — 버킷 안의 폴더가 그 이름입니다.