0. 이어받기
지난 편 끝에서 VM 세 대가 각자 온전한 서버로 섰다. Ubuntu가 깔리고, 고정 IP로 서고, 데스크탑에서 ssh로 다 붙는다. 그런데 이 셋은 아직 서로의 존재를 모른다. 같은 사설망에 나란히 놓인 판박이 서버 세 대일 뿐이다.
6부에서 이 셋을 하나로 묶는다. 할 일은 둘이다. 각 노드에 데이터 디스크를 붙이고, 그 위에 k3s를 세워 세 대를 한 클러스터로 합친다.
디스크를 k3s보다 먼저 붙인다. 노드가 아직 그냥 Ubuntu 서버로 단순할 때 마운트까지 굳혀두는 편이 낫다. k3s가 돌기 시작하면 마운트 하나 바꿔도 재시작과 재검증이 얽힌다.
끝나는 지점은 세 노드가 한 클러스터로 조인되고 etcd가 세 대에 복제돼 과반 합의로 HA를 이루는 상태다. 다만 파드 네트워크(CNI)는 다음 편에서 깔기 때문에, 이 편 끝에서 세 노드는 아직 NotReady로 서 있다. 제어면과 etcd는 HA로 살아 있지만 파드는 아직 못 뜬다.
1. 쿠버네티스에서 디스크를 쓴다는 것 — 그리고 무엇을 뺄 것인가
데이터 디스크를 노드마다 붙이는 것까지는 금방 정했다. 막힌 건 그 다음이었다 — 붙인 디스크 위에서 파드가 그걸 어떻게 찾아 쓰는지를 모르니, 정작 몇 개를 어떤 단위로 잘라야 할지도 못 정했다.
그런데 파보니 이건 "리눅스에서 디스크를 어떻게 붙이나"가 아니라 "쿠버네티스가 디스크를 어떻게 쓰나"의 문제였다. 그래서 그 기본 구조부터 보고, 내 환경에 대입한 뒤, 어떻게 자를지를 정했다. 그 과정을 순서대로 적으면 이렇다.
1.1 왜 멈췄나 붙이는 건 쉬운데, 몇 개를 어떻게 자를지가 안 정해졌다
1.2 디스크가 파드에 닿기까지 생 디스크에서 파드까지 여섯 마디 — 클라우드는 자동, 나는 수동
1.3 내 환경엔 뭐가 없나 여섯 마디를 내 노트북에 대입 — 앞마디가 전부 내 손으로 온다
1.4 무엇을 잘라야 하나 관측이 디스크 자르는 단위를 정한다
1.5 그래서 뭘 뺄까 PV를 누가 만들지 정하고, 마디마다 누가 하는지 못박는다
1.1 왜 멈췄나
노드는 지금 디스크가 부트 하나뿐이다 — 40GB짜리에 OS와 k3s가 깔린다.
여기에 올릴 건 정해뒀다 — MySQL(관계형 DB)·Kafka(메시지 큐)·LGTM(관측 스택 — 로그는 Loki, 트레이스는 Tempo, 메트릭은 Mimir)·MinIO(오브젝트 저장소).
넷 다 디스크에 데이터를 쌓는데, 이걸 부트 디스크에 같이 담을 순 없다. 이유가 둘이다.
- 격리 — 데이터가 부트 디스크를 채우면 OS까지 죽는다. 로그도 못 쓰고 복구도 어려워진다. 갈라두면 데이터 쪽만 터진다.
- 크기 — 40GB는 OS와 k3s 바이너리, 이미지 캐시의 몫이다. MinIO 데이터 100GB가 들어갈 자리가 아니다.
그래서 노드마다 데이터 디스크를 따로 붙인다. 여기까지는 명확했다. 붙이는 작업 자체는 두 층에서 나뉘어 일어난다.
- Proxmox 호스트 — 저장 공간에서 디스크 한 조각을 잘라 VM에 물린다. 웹UI 작업이다.
- 노드 안 — 그렇게 생긴 디스크를 쓸 수 있게 손질한다. 명령 몇 줄이다.
각각이 정확히 뭘 하는지 — 자른다는 게 뭐고, 손질이 뭔지 — 는 1.2에서 밑바닥부터 짚는다.
멈춘 건 그다음이었다. 디스크를 몇 개, 어떤 크기로, 어디에 마운트할지가 안 정해졌다.
MySQL·Kafka·LGTM·MinIO에 하나씩 줘야 하나, 노드당 하나면 되나. 마운트 지점 경로는 아무 데나 잡아도 되나. 그 위에서 파드는 그 디스크를 어떻게 찾아 쓰나. 이걸 모르면 자르는 개수도 크기도 못 정한다.
전부 쿠버네티스가 디스크를 어떻게 쓰느냐에 달린 문제였다. 리눅스 쪽 작업이 아니었다.
그러니 질문을 바꿔야 했다. "쿠버네티스는 원래 디스크를 어떻게 쓰지?" 생 디스크에서 파드까지, 밑바닥부터 한 층씩 따라가 본다.
1.2 디스크가 파드에 닿기까지
디스크 한 장이 파드 안에서 쓰이기까지, 맨 밑 생 디스크부터 한 층씩 쌓아 올려봤다. 여섯 마디다.
파드가 쓴다 (컨테이너가 자기 폴더로 봄)
▲
│ ⑥ kubelet이 파드 경로에 붙임 (bind mount) ┐ 어느 쪽이든
│ ⑤ 코어가 PVC와 PV를 짝지음 (바인딩) ┘ 자동
───────┼──────────────────────────────────────────
│ ④ 쿠버네티스가 PV로 등록 (이 노드 소속) ┐ 클라우드는
│ ③ 폴더에 붙임 (mount) │ provisioner가,
│ ② 파일시스템 새김 (mkfs) │ 나는 손으로
│ ① 호스트가 잘라 노드에 붙임 (생 블록) ┘
생 디스크 (아무 구조도 없음)
아래에서 위로, 한 마디씩 (자세한 손질은 §3·§4에서 직접 하고 여기선 마디만 잡는다):
- ① 붙는다 — 호스트가 저장 공간에서 디스크 한 조각(LV)을 잘라 VM에 물린다. 노드에선 /dev/sdb로 뜨지만, 아직 아무 구조도 없는 생 블록이다.
- ② 새긴다 — 생 블록엔 파일 둘 자리가 없다. mkfs로 파일시스템(파일을 담는 구조)을 새겨야 쓸 수 있다.
- ③ 붙인다 — 폴더 하나(/mnt/disks/mysqldata)에 mount하면 그 폴더가 디스크의 입구가 된다. 여기까지가 리눅스 몫이다.
- ④ 등록한다 — 쿠버네티스는 이 디스크를 그냥 못 본다. PV(디스크 한 조각을 나타내는 쿠버네티스 객체)로 등록해줘야 안다. 로컬이라 "k3s-1 소속" 꼬리표가 붙는다.
- ⑤ 짝짓는다 — 파드는 PVC(요청서 — "20Gi 하나"처럼 조건만)를 낸다. 코어(쿠버네티스 본체)가 조건 맞는 PV를 찾아 묶는다(바인딩).
- ⑥ 넣는다 — kubelet(노드마다 파드를 띄우는 일꾼)이 ③의 폴더를 파드 안 경로에 겹쳐 붙인다(bind mount). 컨테이너는 그걸 자기 디스크로 본다.
여섯 마디에서 갈리는 건 딱 하나 — ①~④를 누가 밟느냐다.
클라우드는 이 넷을 provisioner가 자동으로 밟는다. provisioner는 쿠버네티스에 늘 떠서 PVC를 지켜보다가, 새 PVC가 뜨면 스토리지에 볼륨을 만들고 PV까지 등록해주는 프로그램이다.
PVC 뜸 → provisioner가 감지 → 볼륨 생성 → PV 등록 → ⑤ 바인딩 → ⑥ 파드에
(실제 볼륨 만드는 손 = CSI 드라이버)
└──────────── 사람이 한 건 PVC 한 장 낸 것뿐 ────────────┘
볼륨을 실제로 만드는 손이 스토리지마다 다른 CSI 드라이버다(EBS용·Ceph용이 따로 — 내 환경은 안 쓰니 이름만 안다).
StorageClass는 "이런 볼륨은 이 provisioner로 뽑아라"라고 가리키는 설정이다. 사람은 ⑤의 PVC 한 장만 내면 되니 — 이게 동적이다. 그 provisioner가 없으면 ①~④를 사람이 손으로 밟아 PV까지 미리 만들어 둔다 — 이게 정적이다. 어느 쪽이든 ⑤ 바인딩·⑥ bind mount는 코어·kubelet이 자동으로 한다.
*쿠버네티스 관점에서 StorageClass는 볼륨 주문 구격 - 어떤 provisioner로 어떤 종류의 볼륨을 뽑을지 정해둔 것. PVC가 이 클래스로 줘 라고 가리키면 그 규격대로 만들어짐
이제 이 두 길 중 내 노트북이 어디 서는지 본다.
1.3 내 환경엔 뭐가 없나
내 노트북은 두 길 중 정적에 선다 — 동적을 돌릴 provisioner가 없어서다. provisioner는 "디스크 만들어달라"고 부를 클라우드 API에 기대는데, 내 홈랩엔 그게 없다. 그러니 클라우드에선 provisioner가 밟던 앞 네 마디(①~④)가 전부 내 손으로 넘어온다.
- ① 디스크 자르기 — 빈 볼륨을 찍어줄 클라우드 API가 없다.
- 대신 Proxmox 호스트에서 내가 직접 잘라 붙인다(§3).
- provisioner가 PV까지 자동으로 만들어주는 것도, 그게 빛나는 건 "PVC가 언제 몇 개 올지 모를 때"이다.
- 내가 설계한 PVC는 10개, 설계 시점에 전부 안다(MySQL 1·Kafka 3·MinIO 1·관측 5 — Mimir 인제스터 3·Loki·Tempo). 미리 다 아는 걸 자동으로 만들 이유가 약하다.
- ②·③ 파일시스템·mount — 생 디스크에 mkfs하고 마운트해줄 도구가 없다. 이것도 내가 노드에서 손으로 한다(§4).
- ④ PV 등록 — 자동으로 찍히던 PV를 내가 YAML로 써서 등록한다. StorageClass도 정적엔 공장이 없으니, 볼륨을 만드는 물건이 아니라 PVC와 PV를 짝지어 묶는 이름표로만 남는다.
여기 하나 덧붙이면 — 클라우드가 디스크를 노드 사이로 떼었다 붙였다 하려고 "만들기"와 "노드에 붙이기"를 별개로 두는데, 내 데이터 디스크는 외장 SSD 한 장이라 어느 노드에 붙든 같은 SSD고, 워크로드도 자기 디스크가 있는 노드에 고정이다(MySQL은 k3s-1, Loki·Tempo·MinIO는 k3s-2·3, Kafka와 인제스터는 세 노드 분산). 볼륨이 옮겨 다닐 일이 없으니 그 구분도 여기선 무의미하다.
반대로 ⑤ 바인딩과 ⑥ bind mount는 손 안 대도 그대로 굴러간다 — PVC를 내면 코어가 대기 중인 PV와 짝짓고, kubelet이 파드 경로에 붙인다. 정적이라고 전부 수동인 게 아니라, 뒤쪽 두 마디는 여전히 자동이다.
그럼 남는 건 이 사람 몫(①~④)을 어떻게 하느냐다 — 먼저 이 단계를 뭘 어떤 단위로 자를지(1.4), 그다음 그 PV를 누가 만들지(1.5).
1.4 무엇을 잘라야 하나 — 관측이 단위를 정한다
그럼 먼저 뭘 어떻게 자르느냐다. 노드당 큰 디스크 하나를 주고 그 안에서 나눠 쓸 수도 있고, 워크로드마다 따로 줄 수도 있다. 이 선택을 관측이 결정했다.
이유는 리눅스가 디스크 사용량을 재는 방식에 있다. 사용량을 알려주는 statfs는 파일시스템 단위로만 답한다 — "이 폴더가 얼마 썼나"는 하위를 전부 걸어가며 세야 나온다. 그리고 디스크 사용량 메트릭은 둘인데, 둘 다 결국 statfs를 부른다. 내는 주체와 붙는 라벨만 다르다.
| 메트릭 | 내는 곳 | 붙는 라벨 | 재는 대상 |
| node_filesystem_* | node-exporter | mountpoint·device | 그 마운트 지점의 파일시스템 |
| kubelet_volume_stats_* | kubelet | namespace·persistentvolumeclaim | PV 경로가 속한 파일시스템 |
- 같은 파일시스템을 하나는 마운트 지점 이름으로, 하나는 PVC 이름으로 본다. 뒤쪽이 쿠버네티스 객체 이름을 달고 나오니 대시보드에선 읽기 좋다.
- 다만 뒤쪽에 함정이 있다. PV 경로가 마운트 지점 자체면 그 디스크만 재지만, 마운트 지점 아래 폴더면 파일시스템 전체 값이 그대로 나온다. 폴더 여러 개가 한 파일시스템에 있으면 PVC 이름만 다를 뿐 숫자는 전부 같다.
- 그래서 파일시스템을 어떻게 나누느냐가 그대로 관측 분해능이 된다.
경우는 둘이다.
(가) 워크로드마다 디스크 하나 (나) 디스크 하나에 폴더로 나눠 담기
/mnt/disks/mysqldata ← sdb (FS) /var/lib/rancher/k3s/storage (FS 1개)
/mnt/disks/kafkadata ← sdc (FS) ├── pvc-… mysql
├── pvc-… kafka
└── pvc-… loki
파일시스템 2개 파일시스템 1개
→ 워크로드마다 자기 숫자 → 셋 다 같은 숫자
"MySQL이 15G 썼다"가 보임 "80% 찼다"까지만, "누가"는 안 보임
(나)가 k3s에 기본으로 들어 있는 local-path 방식이다. PVC가 오면 정해진 경로 아래에 폴더를 하나 파서 준다. 설치 직후부터 PVC가 뜨는 대신, 그 폴더들이 전부 한 파일시스템 위라 사용량이 안 갈린다.
스토리지를 어떻게 자르느냐가 관측 분해능을 미리 정한다. 나중에 쿼리나 대시보드로 못 뒤집는다. 부하를 걸고 "무엇이 디스크를 먹었나"를 보려면 그 답은 디스크를 자르는 지금 결정된다.
그래서 "(가) 워크로드마다 디스크 하나"로 간다. 디스크 하나 = 파일시스템 하나 = PV 하나. 워크로드마다 따로 자른다.
마운트 지점을 /mnt/disks 한 부모 아래로 모으는 것도 여기서 값을 한다. 이 한 줄로 데이터 디스크 전부를 훑고, 부트 디스크(/)는 패턴에 안 걸려 자연히 빠진다.
node_filesystem_avail_bytes{mountpoint=~"/mnt/disks/.*"}
1.5 그래서 뭘 뺄까 — 방식 확정
자를 단위는 정해졌다. 남은 갈림은 하나다. 그 디스크들 위의 PV를 누가 만드나 — 내가 손으로 쓰나, 도구에 시키나.
선택지는 셋이다.
| PV를 만드는 방식 | 격리 단위 | 자동화 범위 | |
| 경로 1 | 내가 YAML로 직접 | 호스트가 자른 디스크 | 없음 |
| 경로 2 | LVM CSI(TopoLVM)가 동적으로 | 노드가 다시 자른 LV | 볼륨 생성부터 전부 |
| 경로 3 | static-provisioner가 마운트 스캔 | 호스트가 자른 디스크 | PV 등록만 |
셋 다 "(가) 워크로드마다 디스크 하나" 를 만족한다. 경로 3 즉, 프로비저너를 사용하면 내가 마운트해둔 지점을 스캔해 PV를 자동 등록할 뿐이라 격리 단위가 직접 수행하는 경로 1과 같다.
경로 2만 자르는 층이 하나 더 있다 — 호스트가 준 디스크를 노드 안에서 볼륨 그룹으로 묶고, 그 안에서 PVC마다 LV를 다시 잘라낸다. 어느 쪽이든 PV 하나가 파일시스템 하나를 갖는 건 같아서, 관측 분해능으로는 셋이 갈리지 않는다.
그래서 경로 1(수동 정적 PV)을 고른다. 근거는 둘이다.
첫째, 이 홈랩의 목적이 "동작하는 결과물"이 아니라 스토리지 프로비저닝의 내부를 손으로 이해하는 것이다. 볼륨이 생겨나는 물리 과정(LV 절단 → mkfs → mount → PV 등록)을 자동화가 대신 밟으면 나는 그걸 못 본 채로 넘어간다. 경로 2·3은 그 미시를 덮는다.
둘째, 아낄 노동이 작다. PV는 10개고 설계 시점에 전부 안다. 이름·용량·경로·노드만 바꿔 복붙하는 수준이다. 자동화로 얻는 게 그 복붙을 줄이는 것뿐이라면, 상시 도는 도구를 하나 더 안고 갈 이유가 약하다.
경로 1 직접 수행을 고른다는 건 1.2의 여섯 마디에서 클라우드가 자동으로 밟던 앞 네 마디(디스크 자르고 부착, 파일시스템, 디렉토리 마운트)를 전부 사람이 밟는다는 뜻이다. 마디마다 누가 하는지 정리하면 이렇다.
| 마디 | 동적이면 (클라우드) | 경로 1에선 (내가) |
| ① 디스크 자르기·붙이기 | provisioner가 클라우드 API로 만들고 붙임 | Proxmox에서 잘라 VM에 붙임 — 한 동작 |
| ②·③ 파일시스템·mount | 드라이버가 mkfs + mount | 노드에서 mkfs + mount + fstab |
| ④ PV 등록 | provisioner가 자동 | YAML로 등록 |
| StorageClass | provisioner를 가리키는 설명서 | 껍데기 — PVC와 PV를 짝짓는 이름표 |
| ⑤ 바인딩 | 코어가 자동 | 코어가 자동 — 손 안 댐 |
| ⑥ bind mount | 드라이버·kubelet | kubelet이 자동 — 손 안 댐 |
| (상시 도구) | controller·sidecar·node plugin이 늘 떠 있음 | 전부 없음 |
① 디스크 자르기·붙이기 은 클라우드에선 두 동작이다 — "디스크 만들기"와 "노드에 붙이기"가 별개 API라 갈려 있다.
Proxmox에선 "VM에 20G 디스크 추가" 한 번이 둘을 동시에 하니 나에겐 한 동작이다.(UI로 5부에서, 6부인 지금은 명령어로) 볼륨이 노드 밖에 따로 존재할 이유가 없으니 나눌 이유도 없다.
그리고 표 맨 아래 — 동적으로 가면 그 자동화를 굴리려 controller·sidecar·node plugin이 클러스터에 상시 떠 있어야 하는데, 경로 1엔 그게 없다. 상시 도는 객체 하나를 덜었다는 뜻이기도 하다.
이게 이 편의 설계 확정안이다. 앞으로 §2 이하의 모든 작업은 이 표의 오른쪽 열을 실제로 하는 것이다. 쿠버네티스에 남는 자동화는 딱 둘 — 코어가 PV↔PVC를 짝지어주는 것(바인딩), 그리고 kubelet이 마지막에 bind mount 하는 것뿐이다.
2. 노드부터 손본다 — 현황 확인과 기본 정리
설계는 §1에서 정해졌다 — 워크로드마다 디스크 하나, 내가 손으로 잘라 정적 PV로 올린다. 그 첫 삽이 디스크 자르기다. 그런데 삽을 뜨기 전에, 디스크를 받을 노드 세 대부터 손봐야 한다.
노드가 아직 그냥 Ubuntu 서버로 단순한 지금 정돈해두면 이후 디스크·k3s 작업에서 변수가 준다. 여기 나오는 건 하나같이 k3s를 올린 뒤엔 고치기 번거롭거나, 틀려도 조용히 넘어가 원인 찾기 어려운 것들이라, 노드가 단순한 지금 미리 잡는다.
각 노드에 들어가 "현황 훑기 → 누구고 남과 통하나 → 시스템 요건 → 디스크 점검 → 마무리" 순으로 하나씩 확인한다.
현황 확인 — 지금 상태 스냅샷
- 디스크·마운트 (
lsblk·df·sudo cat /etc/fstab) — 노드에 들어가 제일 먼저 훑는다.- 디스크는 부트(
sda) 하나뿐이고sda2가 ext4로/에 붙어 있다(LVM 층 없음). - fstab엔 그 루트 말고
/swap.img가 swap으로 잡혀 있다 — 이게 아래 swap 끄기의 출발점이다. 데이터 디스크는 아직 없다(§3에서 붙인다).
- 디스크는 부트(
정체성·네트워크 — 이 노드가 누구고, 남과 통하나
- hostname (
hostnamectl) —k3s-1으로 잡혀 있다.- 설치 때 지정한 이름이 그대로고, Proxmox VM이라 Virtualization은
kvm, OS는 Ubuntu 24.04로 뜬다. - 이 이름이 중요한 건 뒤에서 PV가
kubernetes.io/hostname으로 "이 볼륨은 이 노드에 있다"를 못박기 때문이다 — 하나라도 어긋나면 그 노드 PV가 영원히 Pending인데 원인이 안 보인다.
- 설치 때 지정한 이름이 그대로고, Proxmox VM이라 Virtualization은
- 네트워크 (
sudo cat /etc/netplan/*.yaml) — 설치 때 cloud-init이 넣은 고정 IP 설정이 들어 있다.- k3s-1은 주소
192.168.0.201/24, 게이트웨이와 DNS는 둘 다192.168.0.1(공유기)이다. - 주소·GW·DNS 3종이 다 있어야 한다 — IP만 있고 GW/DNS가 비면 노드끼리 핑은 되는데
apt·이미지 pull은 먹통인 반쪽 장애가 나고, 원인이 잘 안 보인다.
- k3s-1은 주소
- 노드 간 ping — k3s-1에서
.202·.203으로 쏘면0% packet loss에0.7ms대로 온다. 같은 스위치에 물려 있어 라우터를 안 거치는 속도다. 설정이 있는 것과 실제로 통하는 건 별개라, 조인 전에 확인해야 나중에 타임아웃 원인을 안 헤맨다. - 바깥 통신 — 노드가 인터넷으로 나가는지는 아래
apt가 겸사로 증명한다. 패키지를 받아오면 GW·DNS·외부 경로가 다 정상이라는 뜻이다.
시스템 요건 — k3s가 요구하는 상태
- 시간 동기화 (
timedatectl) — 셋 다Etc/UTC에synchronized: yes·NTP: active다.- Ubuntu 기본
systemd-timesyncd가 알아서 맞춰준다. - 세 노드가 UTC로 통일된 건 오히려 좋다 — etcd 합의와 TLS 인증서는 노드 간 시각이 어긋나면 깨지는데, 다 같은 UTC면 그 위험이 없다.
- Ubuntu 기본
- swap 끄기 — 현황에서 봤듯 fstab에
/swap.img가 있다.- Ubuntu 설치가 기본으로 만들어둔 swap 파일이다.
- swap은 디스크의 일부를 가짜 RAM처럼 쓰는 공간이다.
- RAM이 꽉 차면 OS가 당장 안 쓰는 메모리 내용을 swap(디스크)으로 밀어내고 RAM을 비운다.
- 즉, 디스크의 한 켠을 RAM이 모자랄 때 임시로 빌려 쓸 자리라고 이해하고 넘어가자.
swapoff로 지금 끄고 fstab 그 줄을 주석 처리(재부팅 후에도 안 켜지게) 한다.- etcd가 swap으로 밀리면 쿼럼이 흔들리고 kubelet의 RAM 판단도 틀어지기 때문이다. 8GB 노드라 이득 없고 리스크만이라 끈다.
- ufw (
sudo ufw status) —inactive다. Ubuntu 기본이 방화벽 꺼짐이라 그대로 둔다. 켜져 있었다면 노드 간 포트(API·etcd·kubelet·CNI)를 막아 조인이 안 되는데 에러가 안 떠서 헤맬 뻔했다.
디스크 사전 점검 — 자르기 전에
- TRIM (
lsblk -D) —DISC-MAX가1G로 나온다. 0이 아니니 게스트에서 지운 블록이 외장 SSD의 thin 풀까지 회수된다는 뜻이다 — §3에서 붙일 디스크의discard=on이 헛일 안 하고 실효를 낸다. (디스크 현황 자체는 위 "현황 확인"에서 이미 봤다.)
마무리 — OS 업데이트
sudo apt update && sudo apt upgrade— 커널·패키지를 최신으로. 이게 성공하면 위에서 본 네트워크가 실제로 바깥까지 닿는다는 방증이기도 하다.- 재부팅은 여기서 하지 않는다. §3에서 디스크를 붙이고 §4에서 fstab에 등록한 뒤, 재부팅 한 번으로 swap 꺼짐·새 커널·데이터 디스크 마운트를 한꺼번에 검증한다. 지금 재부팅하면 §4 뒤에 또 해야 하니 미룬다.
여기까지 하면 노드 셋이 디스크를 받을 준비가 됐다. 이제 §3에서 호스트로 넘어가 실제로 디스크를 자른다.
3. 호스트에서 디스크를 자른다
노드 손질이 끝났으니 이제 데이터 디스크를 만들어 각 노드에 붙인다. §1에서 정한 대로 — 게스트 안에서 LVM을 다시 쌓지 않고, Proxmox 호스트의 thin 풀에서 LV를 잘라 VM에 디스크로 물리는 방식이다. 이 작업은 전부 호스트(pve)에서 한다.

pve에 SSH로 붙는다.(ssh root@~) 지금까지 Proxmox는 웹UI로 만졌는데, 여기선 같은 명령을 열 번 반복하니 터미널에서 한 줄씩 치는 게 빠르다.
자를 자리부터 본다. thin 풀에 10장(약 235G)을 얹을 여유가 있는지 확인한다.
lvs # data 풀의 Data%(실사용률)와 각 LV
pvesm status # local-lvm 총량·여유
thin(씬 프로비저닝)이라 자르는 순간엔 실사용이 거의 안 는다. 논리 크기만 정해두고 물리는 게스트가 쓰는 만큼만 주는 방식이라(반대인 thick은 만들 때 통째로 예약 — 5부에서 "40G가 지금 떼어지는 게 아니다"라던 그것), 데이터가 쌓이는 만큼만 소비된다. 그래서 논리 할당(지금 OS 120G + 데이터 235G = 355G)이 물리 풀(약 815G) 안에 들기만 하면 된다.
디스크 하나를 제대로 붙여본다(앞서 ssh로 들어간 200 pve에서) — mysqldata를 k3s-1(VMID 201)에:
qm set 201 --scsi1 local-lvm:20,discard=on,iothread=1,ssd=1,serial=mysqldata
이건 5부에서 한 작업의 명령판이다.
5부에선 부트 디스크(scsi0)를 웹 UI Disks 탭에서 만들며 Discard·SSD emulation·IO thread를 켰고, Confirm에 scsi0: local-lvm:40,discard=on,ssd=on,iothread=on으로 찍혔다.
데이터 디스크도 옵션은 똑같고, 열 장을 반복하니 웹UI 대신 명령으로 붙일 뿐이다. 한 줄을 세 덩어리로 뜯어보면 —
- 어디에·어떻게 만드나
qm set 201— VMID 201(k3s-1)의 설정을 바꾼다(qm은 Proxmox의 VM 관리 명령).--scsi1— SCSI 1번 슬롯에 붙인다. 5부에서 부트 디스크가 scsi0을 차지했으니 데이터 디스크는 1번부터다. 용어를 쪼개면:- SCSI — OS와 저장장치가 명령을 주고받는 표준 규격. 리눅스 디스크가
/dev/sda처럼sd로 시작하는 게 이것 때문이다. - 컨트롤러 — 그 SCSI 명령을 받아 실제 디스크로 중개하는 장치. VM에선 QEMU가 소프트웨어로 만든다.
- 슬롯(
scsi0/scsi1…) — 컨트롤러에 붙는 디스크 자리 번호.scsi0=OS(5부),scsi1부터=데이터. virtio-scsi-single— 컨트롤러가 VirtIO 방식(가상 전용이라 빠름)이고 "single"이라 디스크마다 전용 컨트롤러가 하나씩 생긴다. 그래서 한 디스크의 IO 폭주가 옆 디스크로 안 번지고,iothread를 디스크별로 걸 수 있다.
- SCSI — OS와 저장장치가 명령을 주고받는 표준 규격. 리눅스 디스크가
local-lvm:20— 여기가 핵심이다.스토리지:숫자형식은 "local-lvm(그 875GB thin 풀)에 20GiB 볼륨을 새로 만들어 이 슬롯에 물려라"는 뜻이다. 웹UI로 치면 Disks 탭의 Add에 해당한다. 실행하면 thin 풀에vm-201-disk-1LV가 생기고 VM에 붙는다.
- 5부에서 이미 켠 셋 (부트 디스크와 똑같이 — 각 옵션의 이유도 5부 Disks 탭에서 다뤘다)
discard=on— 게스트가 지운 블록을 thin 풀이 회수하게 한다(TRIM 전달). 875GB 풀을 세 노드가 나눠 쓰니 회수가 안 되면 풀 고갈 = 클러스터 전체 장애다. §2에서 TRIM이 실제로 통하는 것도 확인했다.ssd=1— 게스트에 "이건 SSD다"라고 보고한다. 회전식으로 보이면 TRIM 판단이 달라져서, 실물(외장 SSD)과 맞춰 discard를 거든다.iothread=1— 이 디스크 I/O를 전용 스레드가 처리한다. 한 디스크의 IO 폭주가 다른 디스크·메인 루프를 같이 끌고 내려가지 않게. 5부에서 컨트롤러를single로 둔 게 이 전제였다(스레드는 컨트롤러 단위로 붙는다).
- 6부에서 새로 붙는 것
serial=mysqldata— 노드 안에서 이 디스크를/dev/disk/by-id/…mysqldata로 이름 지목하게 해준다. 5부 부트 디스크엔 없던 옵션인데, 6부엔 같은 크기(5G) 디스크가 한 노드에 셋이라 크기로는 구분이 안 돼서 넣는다.
VM이 켜져 있어도 Proxmox 디스크 hotplug가 기본이라, 이 한 줄로 노드 안에 /dev/sdb가 바로 뜬다.
나머지 9장은 완전히 같은 명령에 VMID·슬롯 번호·크기·serial만 바꿔 반복한다:
# k3s-1 (201) — 위에서 mysqldata 붙였고, 나머지 둘
qm set 201 --scsi2 local-lvm:30,discard=on,iothread=1,ssd=1,serial=kafkadata
qm set 201 --scsi3 local-lvm:5,discard=on,iothread=1,ssd=1,serial=ingesterwal
# k3s-2 (202)
qm set 202 --scsi1 local-lvm:30,discard=on,iothread=1,ssd=1,serial=kafkadata
qm set 202 --scsi2 local-lvm:5,discard=on,iothread=1,ssd=1,serial=ingesterwal
qm set 202 --scsi3 local-lvm:5,discard=on,iothread=1,ssd=1,serial=lokiwal
qm set 202 --scsi4 local-lvm:5,discard=on,iothread=1,ssd=1,serial=tempowal
# k3s-3 (203)
qm set 203 --scsi1 local-lvm:30,discard=on,iothread=1,ssd=1,serial=kafkadata
qm set 203 --scsi2 local-lvm:5,discard=on,iothread=1,ssd=1,serial=ingesterwal
qm set 203 --scsi3 local-lvm:100,discard=on,iothread=1,ssd=1,serial=miniodata
슬롯 번호(--scsiN)만 노드마다 1부터 순서대로 올리고, 크기와 serial을 용도에 맞추면 된다. 총 10장이다.
다 붙었는지 확인한다:
lvs # vm-20x-disk-* LV가 10개 늘었나
qm config 201 # scsi1~3 항목이 붙었나 (202·203도 마찬가지로)
lvs를 보면 방금 자른 디스크가 20G인데 Data% 0.00으로 뜬다 — 앞서 말한 thin이 눈에 보이는 것이다. 논리는 20G지만 아직 아무것도 안 써서 물리 점유는 0이고, 게스트가 mkfs하고 데이터를 넣어야 그만큼 실제로 빠진다.


4. 노드에서 파일시스템을 얹는다
앞서 호스트에서 thin 풀을 잘라 노드에 디스크를 붙였다. 그런데 노드 안에서 보면 이건 아직 아무것도 안 새겨진 생 블록이다 — 붙어만 있고, 파일을 어디에 어떻게 둘지 구조가 없어 쓸 수는 없다.
지금이 ②·③(파일시스템을 새기고 폴더에 붙이는) 자리, 표준에선 드라이버가 자동으로 밟지만 provisioner 없는 내 홈랩에선 손으로 해야 하는 그 마디다. 여기부턴 호스트가 아니라 노드(k3s-1) 안에 SSH로 들어가서 한다. mysqldata 한 장을 처음부터 끝까지 따라가 보고, 나머지는 같은 걸 반복한다.
붙은 디스크 확인 — 근데 이름표가 없다
lsblk로 보면 §3에서 붙인 세 장이 sdb(20G)·sdc(30G)·sdd(5G)로 떠 있다.

붙긴 했는데 어느 게 mysqldata인지 이름표가 없다. 크기로 짐작은 되지만(20G니 sdb가 mysqldata겠거니), k3s-2는 5G가 셋이라 크기로는 구분이 안 된다. 그래서 §3에서 serial(디스크마다 붙는 고유 이름표-문자열)을 심어뒀고, 그게 /dev/disk/by-id/ 아래 이름으로 걸려 있다.
ls -l /dev/disk/by-id/ | grep QEMU로 보면 HARDDISK_mysqldata → ../../sdb 식으로 serial이 실제 디바이스에 이어진 게 보인다.

앞으로 mkfs·마운트는 sdb가 아니라 이 serial 경로로 지목한다 — 재부팅으로 sdb/sdc 순서가 뒤바뀌어도 serial은 안 변하니까.
파일시스템을 새긴다 (mkfs) — 이 한 번에 뭐가 만들어지나
생 블록에 sudo mkfs.ext4 <serial 경로>로 ext4를 새긴다.

출력 몇 줄이 각각 뭘 한 건지 뜯어보면 —
Discarding device blocks— mkfs가 시작하며 "이 블록들 안 쓴다"는 TRIM을 thin 풀에 날린다. §3에서 켠discard(빈 새 디스크라 통째로 반납).5242880 4k blocks— 디스크를 4KB짜리 블록 524만 개로 관리한다(524만 × 4KB ≈ 20G). ext4가 데이터를 담는 최소 단위가 이 4K다.1310720 inodes— inode를 131만 개 만든다.- inode는 파일 하나당 하나씩 붙는 "집"으로, 파일의 크기·권한·소유자·수정시간·실제 데이터가 디스크 어디 있는지를 담는다.
- 정작 파일 이름은 여기 없다 — 이름은 디렉터리가 "이름 → inode 번호"로 따로 관리한다. 그래서 만들 수 있는 파일 개수의 상한이 곧 이 inode 개수고, 용량이 남아도 inode가 바닥나면 파일을 못 만든다(용량은
df -h, inode는df -i로 따로 본다). - 이 개수는 mkfs 순간에 고정되고 나중에 못 늘린다. MySQL·Kafka·MinIO는 큰 파일 위주라 용량이 먼저 차서 이 함정은 드물지만, 로그처럼 작은 파일이 수백만 개면 여기서 막힌다.
Filesystem UUID— 이 파일시스템의 고유 ID다. 이따 fstab에 이 값을 쓴다(외울 필요 없다,blkid로 다시 본다).Superblock backups— 파일시스템의 핵심 메타데이터(슈퍼블록: 블록·inode 총개수 등)를 여러 군데 복사해둔다. 맨 앞 슈퍼블록이 깨져도 백업에서 복구하려는 안전장치다.inode tables/journal— inode를 담을 테이블과, 변경을 먼저 적어두는 저널을 만든다(저널 덕에 쓰다가 전원이 나가도 파일시스템이 덜 깨진다).
즉 mkfs 한 번에 블록 구획 + inode 집들 + UUID + 저널 + 백업이 한꺼번에 새겨진다. 이제야 이 디스크가 "파일을 담을 수 있는" 상태가 됐다.
폴더에 연결한다 (mount)
파일시스템은 만들었지만 아직 시스템 어디에도 연결이 안 됐다. 마운트는 그걸 폴더에 붙이는 것 — sudo mkdir -p /mnt/disks/mysqldata로 폴더를 만들고 거기에 sudo mount <serial 경로>하면, 그 폴더가 곧 sdb의 입구가 된다(폴더에 쓴 파일이 sdb에 저장). df -h로 확인한다.

df에 /dev/sdb가 /mnt/disks/mysqldata로 잡히면 연결된 거다. Size 20G인데 Avail 19G인 건, ext4 메타데이터(inode 테이블·저널)와 reserved block(기본 5%, root 예비)이 빠져서다. 단 mount는 지금 세션만이라, 노드를 재부팅하면 풀린다. 영구화하려면 fstab에 등록해야 한다.
재부팅에도 살아남게 (fstab)
mkfs가 만든 UUID를 sudo blkid로 확인

이 UUID를 fstab에 echo로 한 줄로 넣는다(§2에서 swap 끌 때 만졌던 그 파일). 장치명(sdb) 대신 UUID로 넣는 게 핵심이다 — 재부팅 때 sdb/sdc 순서가 바뀌어도 UUID는 안 변하니까.

echo 다음에 있는 여섯 필드는 장치(UUID) · 마운트지점 · 타입 · 옵션 · dump(백업 여부, 0=안 함) · pass(부팅 fsck 순서, 루트 1·데이터 2)다.
cat으로 보면 앞에서 노드설정으로 끈 swap이 #로 주석된 것과, 방금 추가한 mysqldata 줄이 같이 보인다.

넣었으면 재부팅 전에 sudo mount -a로 검증한다 — fstab에 적힌 걸 전부 마운트해보는 명령이라, 오타나 UUID가 틀리면 여기서 에러가 난다. 조용하면 정상 = 문법이 맞고 재부팅해도 자동으로 마운트된다는 뜻이다.

나머지는 똑같이
여기까지가 mysqldata 한 장의 전 과정이다 — 생 블록 확인 → serial로 정체 확인 → mkfs → mount → fstab → 검증. 나머지는 이걸 그대로 반복한다.
- k3s-1의 나머지 둘(kafkadata·ingesterwal) — 이름·크기·UUID만 다를 뿐 순서는 같다. UUID는 손으로 안 치고
blkid -s UUID -o value로 뽑아 넣으면 실수가 없다. - k3s-2(4장)·k3s-3(3장) — 노드만 옮겨 같은 걸 반복한다. k3s-2는 5G가 셋이라 여기서
serial이 진짜 값을 한다(크기로는 절대 못 가르니).
소유권 하나는 짚어둔다 — mkfs 직후 마운트 지점은 root:root다. MySQL(uid 1001)·MinIO(1000)·Loki(10001)는 non-root로 뜨니, 차트가 fsGroup을 안 걸면 그 디스크에 쓰기가 막힌다. 지금은 마운트까지만 하고, 이건 §5 배포 때 CrashLoop으로 드러날 수 있어 인지만 해둔다.
세 노드에 열 장을 다 얹고 나면, 마지막으로 재부팅 한 번으로 몰아서 검증한다 — mount -a로 오타를 잡고 reboot, 올라와서 df -h | grep /mnt/disks로 열 마운트가 살아있나 · swapon --show가 비었나 확인한다. fstab이 제대로 먹는지 + §2에서 끈 swap이 안 켜지는지 + 새 커널까지 한꺼번에 검증되는 셈이다.



5. k3s를 세워 세 대를 묶는다
디스크까지 얹으니 노드 세 대가 각자 온전한 서버로 섰다. 그런데 아직 셋은 남남이다. 여기서 할 일은 둘이다 — 셋을 하나의 클러스터로 묶는 것, 그리고 내가 그 클러스터를 관리할 접근을 확보하는 것.
두 번째가 이 절의 무게중심이다. 클러스터를 세우는 건 스크립트 몇 줄이면 끝나는데, 정작 "세우고 나서 내가 여기에 어떤 자격으로 붙어 명령하나"는 처음 해보는 일이었다. 그래서 이 절은 명령을 나열하기보다 그 접근이 어떻게 생기는지를 따라간다.
5.1 클러스터에 명령한다는 것 — 인증서·인증·인가
클러스터를 세우기 전에 짚고 갈 게 있었다. 세우고 나면 나는 그 클러스터에 어떤 자격으로 붙어 명령하나.
먼저 갈라야 할 두 층이 있다.
- 노드 접근 — 리눅스 서버에 로그인하는 것. SSH 키로 붙는다(5부에서 해둠).
- 클러스터 접근 — 쿠버네티스 API 서버에 명령하는 것. 여기가 지금 얘기다.
이 둘은 독립이다. SSH가 열려 있어도 클러스터 자격이 없으면 kubectl은 안 되고, 반대로 SSH를 다 막아도 자격만 있으면 API로 클러스터를 조작할 수 있다.
API 서버는 클러스터의 유일한 관문이다. kubectl도, 내부 컴포넌트도, 상태를 읽고 쓰려면 전부 여기를 거친다(etcd에 직접 손대는 건 API 서버뿐이다). 컨트롤 플레인에만 있는데, 나는 세 노드를 다 server로 세우니 셋 다 여기에 해당한다.
실체는 특별하지 않다. Go로 짠 HTTP 서버 프로그램이고, 일반 쿠버네티스에서는 컨트롤 플레인 노드에 파드로 떠서 6443 포트를 연다. 여느 웹 앱이 8080을 열고 요청을 받는 것과 같은 구조다. kubectl get pods도 사실 그 포트로 보내는 REST 요청이고, 돌아오는 JSON을 kubectl이 표로 보여줄 뿐이다. 그래서 클러스터를 다루는 모든 일이 결국 "이 주소로 요청을 보낼 수 있느냐"의 문제가 된다.
한 가지 차이는 짚어둔다. 일반 쿠버네티스에서는 API 서버·스케줄러·컨트롤러가 각각 파드로 뜨지만(셋 다 같은 Go 프로그램들이다), k3s는 이걸 한 바이너리에 묶어 k3s 서비스 하나로 돌린다. 그래서 클러스터에선 kube-apiserver 파드를 찾아도 안 나오고, 노드에서 systemctl status k3s로 본다. 뜨는 방식이 다를 뿐 6443으로 요청을 받는 건 같다.
클러스터 접근은 세 단계를 거친다. 암호화 → 인증 → 인가.
- ① 암호화 (TLS) — API 서버는 HTTPS로만 받는다(https://192.168.0.201:6443). 클러스터를 세우면 k3s가 API 서버용 인증서를 자동 발급하는데, 이때 "이 인증서는 어떤 주소로 불릴 때 유효한가"를 인증서 안에 주소 목록(SAN, Subject Alternative Name)으로 박아 넣는다.
- kubectl이 붙을 때 이걸 검사한다 — "내가 접속한 주소가 그 목록에 있나?" 없으면 서버가 살아 있어도 인증서 오류로 거부된다. 엉뚱한 서버가 인증서를 흉내 내는 걸 막는 장치다.
- 이 목록(SAN)은 인증서를 발급할 때 박힌다. 발급된 인증서 자체는 못 고치지만, tls-san에 주소를 더하고 k3s를 재시작하면 서버 인증서를 다시 발급한다 — CA는 그대로라 이미 나눠준 kubeconfig가 깨지지도 않는다.
- 다만 그러려면 세 노드에서 k3s를 차례로 재시작해야 하는데, k3s 프로세스 안에 API 서버와 etcd가 같이 들어 있어 그동안 그 노드의 제어면이 잠깐 끊긴다. 나중에 쓸 주소는 어차피 지금 알고 있으니 처음부터 넣어두는 편이 낫다. config.yaml의 tls-san 항목이 그 목록이다.
- ② 인증 (Authentication) — "너 누구냐" — 요청이 들어오면 API 서버가 먼저 신원을 확인한다. 수단은 클라이언트 인증서, 토큰 등이고, 그 결과 "이 요청은 누구다"가 정해진다. 통과 못 하면 거기서 끝이다.
- ③ 인가 (Authorization) — "그래서 뭘 할 수 있냐" — 신원이 확인돼도 아무거나 할 수 있는 건 아니다. "이 사람이 이 네임스페이스에서 파드를 지워도 되나"를 따로 판정하는데, 그게 RBAC다.
- 여기서 헷갈렸던 걸 짚어둔다. 인증과 인가는 완전히 별개다. 신원이 확인됐다고 권한이 생기지 않고, 권한이 있어도 신원이 확인 안 되면 아무것도 못 한다. 이 편에서는 전권 자격 하나로 시작하고, 권한을 나누는 일(RBAC)은 7부에서 다룬다.
5.2 열쇠는 내 손에, 권한은 클러스터에 — kubeconfig
앞의 세 단계는 전부 API 서버가 하는 검사다. 그럼 요청을 보내는 쪽은 뭘 챙겨야 통과하나. 여기서 구조를 한 번 그려야 했다. 필요한 것이 두 곳에 나뉘어 있기 때문이다.
[클러스터 쪽] [내 로컬 쪽 — ~/.kube/config]
API 서버 (:6443)
├ 암호화 — 서버 인증서 제시 ────▶ cluster : 붙을 주소
│ + 그 인증서를 검증할 CA
├ 인증 — 너 누구냐 ◀──── user : 내 열쇠(인증서 + 개인키)
│ └ CA로 그 열쇠를 검증 (권한은 여기 없다)
└ 인가 — 뭘 할 수 있냐 context : 위 둘을 묶은 스위치
└ 권한(RBAC) 객체
= 여기에만 산다
화살표 방향이 곧 누가 무엇을 대는지다. 암호화는 서버가 인증서를 내밀고 내 쪽이 그걸 검증하고, 인증은 내가 열쇠를 내밀고 서버가 검증한다. 인가는 내가 댈 것이 없다 — 판정에 쓰이는 권한이 통째로 클러스터 안에 있어서다.
여기서 착각하기 쉬운 게 있다. 열쇠는 클러스터가 보관해주지 않는다. 쿠버네티스에는 "사용자" 객체라는 게 아예 없어서, 사람 몫의 신원은 CA가 서명해준 인증서와 개인키 형태로 내 파일에만 있다.
클러스터에 있는 건 그걸 검증할 CA와, "이 신원은 뭘 해도 된다"는 권한(RBAC)뿐이다. 그래서 파일을 그대로 둬도 클러스터에서 권한을 바꾸면 할 수 있는 일이 달라지고, 반대로 파일이 새어나가면 그 자체로 남이 나를 사칭할 수 있다.
내 손에 쥐는 파일 즉, 클러스터에 접근하기 위해 필요한 파일이 kubeconfig이고, 여기엔 세 조각이 들어 있다.
- cluster — 어디로 붙나. API 서버 주소와, 그 서버 인증서를 검증할 CA.
- user — 누구로 붙나. 인증에 쓸 열쇠(인증서와 개인키, 또는 토큰).
- context — 위 둘을 묶어 이름 붙인 스위치. kubectl은 "현재 컨텍스트"를 보고 어느 클러스터에 누구로 붙을지 정한다. 클러스터가 여럿이 되면(dev·stg) 여기에 여러 개를 두고 kubectl config use-context로 갈아탄다.
이 구조에서 컨텍스트를 두 번 헷갈렸다. 둘 다 위 그림으로 풀린다.
- 컨텍스트는 권한을 주지 않는다 — 로컬 쪽 항목이라 "이 user로 저 cluster에 붙는다"만 가리킨다. 그 user가 무엇을 할 수 있는지는 클러스터 쪽 RBAC가 정한다.
- 컨텍스트는 클러스터 객체가 아니다 — 내 파일 안의 항목이라, 바꿔도 클러스터엔 아무 일도 안 일어난다. 내 kubectl이 다른 곳을 보게 될 뿐이다.
클러스터에서 누군가에게 접근을 열어주려면 다음의 요소들이 필요하다.
그 사람 몫의 열쇠를 만들어주고(사람이면 CA로 서명한 클라이언트 인증서, 클러스터 안에서 도는 프로그램이면 ServiceAccount 토큰),
클러스터 쪽에는 그 신원이 무엇까지 해도 되는지를 RBAC로 묶어둬야 한다. 열쇠만 주면 문은 열려도 아무것도 못 하고, 권한만 걸어두면 들어올 사람이 없다.
이 편에서는 그 둘 — 열쇠를 만드는 일과 권한을 묶는 일 — 을 내가 하지 않는다. k3s가 설치하면서 이미 해두기 때문이다.
- 열쇠 — 인증서와 개인키를 발급해 kubeconfig에 담아둔다(/etc/rancher/k3s/k3s.yaml).
- 권한 — 그 인증서에는 관리자 그룹이 박혀 있고, 쿠버네티스에는 그 그룹을 모든 것이 허용되는 역할(cluster-admin)에 묶어둔 규칙이 기본으로 들어 있다.
그래서 이 편에서 할 일은 그 kubeconfig를 내 데스크탑으로 가져오는 것 하나다. 다만 그 파일은 클러스터가 서야 생기니, 순서는 셋을 세우고(5.3–5.5) 그다음 가져오는 것(5.6)이다. 열쇠를 직접 만들고 권한을 깎아 나눠주는 일은 7부에서 한다.
5.3 설정 — config.yaml
먼저 클러스터를 세운다. k3s는 /etc/rancher/k3s/config.yaml을 뜰 때마다 읽어 그대로 선다. 세 노드에 같은 파일이 들어가야 한다 — 한 노드라도 이 파일을 못 받으면 그 노드만 기본값으로 떠서 나머지와 네트워크가 어긋난다.
내용의 절반이 끄는 것이다. k3s는 혼자서도 바로 쓸 수 있게 기본 기능을 안고 오는데, 나는 그 자리에 각자 고른 걸 넣을 거라 미리 비운다.
- flannel-backend: none — 기본 파드 네트워크(CNI)를 끈다. 그 자리에 Calico가 들어간다(7부).
- disable-network-policy: true — 내장 NetworkPolicy 컨트롤러도 끈다. 네트워크 정책까지 Calico가 맡으니 둘이 겹치면 안 된다. 위 항목만 꺼서는 이게 같이 꺼지지 않아 따로 적는다.
- disable: local-storage — 기본 스토리지(local-path)를 끈다.
- disable: servicelb, traefik — 기본 로드밸런서·인그레스를 끈다. MetalLB·Traefik을 GitOps로 올린다(7부).
- etcd-expose-metrics: true — etcd가 자기 상태 지표를 내놓게 연다. 7부 인프라 대시보드에서 클러스터 건강을 볼 때 쓴다.
- tls-san — 앞서 본 그 인증서 주소 목록(SAN)이다.
- 지금은 공유기에 직결이라 192.168.0.201-203을 쓰지만, 나중에 방화벽(OPNsense) 뒤 10.0.0.x로 재구성할 계획이라 그 대역도 미리 넣었다,
- 아직 확정 전이라 잠정값인데, 넣어서 잃는 건 없고 빠지면 그때 인증서를 다시 발급하며 세 노드를 재시작해야 한다.

5.4 스크립트를 노드로 옮긴다
설치·조인 스크립트는 인프라 레포(cgv-infra)의 bootstrap/cluster/에 있다. 데스크탑에서 각 노드로 복사한다.
scp bootstrap/cluster/config.yaml bootstrap/cluster/01-server-init.sh bootstrap/cluster/02-server-join.sh subin@192.168.0.201:~/
scp는 SSH 위에서 도는 파일 복사라, 5부에서 넣어둔 키로 그대로 붙는다. 주소만 바꿔 세 노드에 같은 방식으로 보낸다.
셋을 같이 보내는 이유가 있다. 스크립트가 실행되면서 자기 옆의 config.yaml을 /etc/rancher/k3s/로 복사하기 때문이다 —바로 앞에서 말한 그 경로다. 그래서 홈 디렉터리에 던져두고 실행해도 설정이 제자리를 찾아간다.

5.5 첫 노드를 세우고, 나머지를 합류시킨다
여기서부터는 되돌리려면 k3s-uninstall.sh로 지우고 다시 설치해야 한다. 노드 라벨은 그마저도 안 되는데, Node 객체가 처음 만들어질 때만 적용돼서 오타로 박으면 스크립트를 다시 돌려도 바뀌지 않는다. 그래서 스크립트에 확인 절차를 넣었다 — 실행 노드가 맞는지(hostname), 이미 etcd 데이터가 있는지, 라벨 값이 obs/obj 중 하나인지.
k3s-1 — 클러스터의 시작점이다.
sudo ./01-server-init.sh
스크립트는 k3s를 server로 띄운다. k3s에서 server는 클러스터를 관리하는 쪽이고 agent는 일만 받는 쪽인데, 셋 다 server라 API 서버와 etcd가 세 대에 다 놓인다. 설정 파일이 아니라 설치 명령의 인자로 정해지는 부분이다.
그 인자 중 둘이 이 편의 결과를 정한다.
- --cluster-init — 데이터스토어를 내장 etcd로 잡고 그 첫 멤버로 이 노드를 띄운다. 이게 없으면 k3s는 단일 SQLite로 서고, 그러면 뒤의 두 대가 붙어도 복제가 안 된다. 뒤이어 둘이 조인하면 etcd 멤버가 셋이 되고 과반(2/3) 합의로 굴러간다 — §0에서 말한 HA가 여기서 정해진다.
- --node-label cgv.io/data=db — 이 노드에 db 라벨을 박는다. §4에서 mysqldata 디스크를 k3s-1에만 붙였으니 MySQL은 이 노드에 떠야 하고, 나중에 차트가 이 라벨을 가리켜 파드를 고정한다.
설치 로그 첫 줄에 Using v1.36.2+k3s1 as release가 뜬다. 버전을 스크립트에 박아둔 결과다. 나머지 컴포넌트(Calico·ArgoCD 등)는 버전을 고정해뒀는데 그 밑에 깔리는 k3s만 최신을 따라가면, 노드마다 다른 버전으로 설 수 있고 재구축했을 때 같은 클러스터가 안 나온다.

끝나면 조인 토큰을 꺼낸다. 나머지 두 노드가 합류할 때 "들어와도 되는 노드"임을 증명하는 값이다.
sudo cat /var/lib/rancher/k3s/server/node-token

이 값만 있으면 누구나 이 클러스터에 관리 노드로 조인할 수 있다. 조인 스크립트도 토큰을 인자로 받지 않게 고쳤는데, 인자로 주면 셸 히스토리와 ps 출력, 그리고 /etc/systemd/system/k3s.service(0644, 누구나 읽는다)에 평문으로 남기 때문이다. 실행 중에 입력받아 K3S_TOKEN 환경변수로 넘기면 k3s가 그 값을 k3s.service.env(0600)에만 기록한다.
k3s-2 → k3s-3, 한 대씩 순서대로 붙인다.
sudo ./02-server-join.sh 192.168.0.201 obs # k3s-2 — Loki·Tempo 노드
sudo ./02-server-join.sh 192.168.0.201 obj # k3s-3 — MinIO 노드

인자는 첫 노드 주소와 라벨 둘뿐이고, 토큰은 실행하면 입력 프롬프트가 뜬다(화면에 안 보이게 받는다). 라벨은 디스크 배치한 것과 짝이다 — obs는 lokiwal·tempowal이 있는 k3s-2, obj는 miniodata 100G가 있는 k3s-3.
한 대씩 붙이는 이유는 etcd 쪽에 있다. 멤버가 하나뿐인 상태에서 두 대가 동시에 들어오면 멤버가 추가되고 승격되는 구간이 겹쳐 쿼럼 계산이 흔들릴 수 있다. k3s-2가 붙어 노드가 둘로 보이는 걸 확인하고 k3s-3을 돌렸다.

5.6 접근을 가져온다
지금까지는 노드에 ssh로 들어가서 작업했다. 이제 데스크탑에서 클러스터를 다루기 위해 5.2에서 본 그 admin kubeconfig를 가져온다 — 5.1에서 갈라놓은 두 층(노드 접근·클러스터 접근)이 실제로 만나는 지점이다. ssh로 노드에 들어가 클러스터 열쇠를 꺼내오는 셈이다.
mkdir -p ~/.kube
ssh subin@192.168.0.201 "cat /etc/rancher/k3s/k3s.yaml" > ~/.kube/config
sed -i 's/127.0.0.1/192.168.0.201/' ~/.kube/config
chmod 600 ~/.kube/config
주소를 바꾸는 이유(sed -i ~)는 그 파일이 노드 자기 자신 기준(127.0.0.1)으로 적혀 있어서다. 데스크탑에서 쓰려면 k3s-1의 실제 주소여야 한다. 노드 자기 IP는 k3s가 알아서 인증서에 넣어주니 여기선 그냥 통과하고, 5.3에서 tls-san에 따로 적어둔 값이 필요해지는 건 노드 IP가 아닌 주소로 붙을 때다.
이 파일(k3s.yaml)에 5.2의 세 조각(cluster, user, context)이 다 들어 있다. 주소와 서버 인증서를 검증할 CA, 내 신원을 증명할 열쇠(인증서와 개인키), 그리고 둘을 묶은 컨텍스트까지 k3s가 넣어둔 상태로 온다. 열쇠가 파일 안에 통째로 있으니 이 파일을 읽는 쪽이 곧 클러스터 전권을 갖는다 — 마지막 chmod 600이 그래서 붙는다.


cat에 sudo를 안 붙인 이유는 config.yaml에 write-kubeconfig-mode: "0644"를 넣어둬서 일반 사용자로도 읽히게 해놨기 때문이다. 기본값은 root 전용이라, 그대로였으면 여기서 막히고 앞으로 작업할 sudo 없이 kubectl을 쓰는 설치 스크립트도 첫 줄에서 멈춘다.
이 작업부터는 데스크탑의 WSL에서 한다. 이후 다루는 도구가 전부 리눅스 쪽이고, kubectl도 서버와 같은 버전(v1.36.2)으로 맞춰 설치한다.
컨텍스트가 어떻게 잡혔는지 본다.
kubectl config get-contexts
CURRENT NAME CLUSTER AUTHINFO NAMESPACE
* default default default

앞서 개념으로만 짚었던 그 세 조각이 여기 그대로 있다 — CLUSTER(어디로) · AUTHINFO(누구로) · 그 둘을 묶은 이름이 NAME이고, *가 지금 서 있는 자리다. 클러스터가 하나뿐이라 컨텍스트도 하나다. 나중에 환경이 늘면 여기에 줄이 늘고 kubectl config use-context로 갈아탄다.
5.7 확인 — 그리고 여기서 멈추는 이유
kubectl get nodes -L cgv.io/data
NAME STATUS ROLES AGE VERSION DATA
k3s-1 NotReady control-plane,etcd 14m v1.36.2+k3s1 db
k3s-2 NotReady control-plane,etcd 9m8s v1.36.2+k3s1 obs
k3s-3 NotReady control-plane,etcd 7m33s v1.36.2+k3s1 obj
한 줄에 이 편의 결과가 다 들어 있다.
- 이 명령이 답을 받아왔다는 것 자체가 5.1(암호화-인증-인가)의 세 단계를 통과했다는 뜻이다.
- 인증서 검사를 통과했고(암호화),
- k3s.yaml의 열쇠로 신원이 확인됐고(인증),
- 그 신원이 전권에 묶여 있어 조회가 허용됐다(인가).
- ROLES가 셋 다 control-plane,etcd — 세 대가 다 관리 노드이고 etcd 멤버라는 뜻이다. 이제 한 대가 죽어도 나머지 둘이 과반이라 클러스터는 산다.
- DATA가 db·obs·obj — 라벨이 디스크 배치와 맞게 박혔다. 라벨은 Node가 생길 때 한 번만 박히니 여기서 반드시 확인해야 하고, 틀렸다면 kubectl label node <노드> cgv.io/data=<값> --overwrite로 고친다.
- VERSION이 셋 다 동일 — 버전을 박아둔 결과다.
- STATUS가 NotReady — 5.3에서 기본 CNI를 껐기 때문이다. 파드 네트워크가 아직 없어서 일반 파드는 못 뜬다. 그 자리에 들어갈 Calico는 7부에서 깐다(파드 네트워크 없이도 뜰 수 있는 방식으로 들어와 이 상태를 푼다).
노드 IP도 한 번 본다. 세 대가 192.168.0.201-203을 제대로 잡았는지 확인하는 것인데, NIC가 하나뿐이라 자동으로 맞게 잡히지만 나중에 방화벽 뒤로 재구성해 NIC가 늘면 이 값이 흔들릴 수 있다.
kubectl get nodes -o wide

이걸로 6부가 끝났다. 클러스터 하나 + 그걸 관리할 전권 접근 하나. 다만 이 절에서 안 한 것 하나는 짚어둔다 — 5.2에서 본 그 양쪽(열쇠를 만들어주는 일, 허용 범위를 RBAC로 묶는 일)을 직접 하는 건 다음 섹션이다. 지금은 k3s가 만들어둔 전권 열쇠 하나로 내가 다 한다.
6. 다음
여기까지가 6부다. 디스크 열 장을 얹고, 세 대를 클러스터로 묶고, 그걸 관리할 접근을 확보했다. 남겨둔 것부터 정리하면 —
- PV 등록 — §1의 여섯 마디 중 네 번째다. 디스크는 마운트까지 왔지만 쿠버네티스는 아직 그걸 모른다. PV를 YAML로 등록하는 건 7부 첫머리에서 한다. 어차피 파드 네트워크가 서기 전엔 그 PV를 쓸 파드가 못 뜨니 순서가 그렇다.
- 권한 나누기 — 5.2에서 개념만 짚고 미뤄둔 일. 열쇠를 만들어주고 RBAC로 허용 범위를 묶는 것, 그리고 네임스페이스로 클러스터 안을 가르는 것까지 7부다.
- kubectl 접근의 한계 — 제어면은 세 대로 HA인데 내 kubeconfig는 192.168.0.201 한 대만 본다. 그 노드가 내려가면 클러스터는 살아 있어도 내 접근은 끊긴다(주소를 .202로 바꾸면 바로 복구된다). 앞단에 대표 주소를 두는 건 나중 편이다.
- 네트워크·SSH 조이기 — 지금 이 클러스터는 공유기 뒤 사설망 안에 있어 밖에서 닿지 않는다. 외부로 열 때(12부) 노드 주소가 10.0.0.x로 바뀌는데, 5.3에서 SAN에 미리 넣어둬 인증서는 안 깨진다.
7부에서 이어갈 것 — 순서대로:
- NotReady → Ready — Calico로 파드 네트워크를 깔면 노드가 Ready가 되고, 그때부터 파드가 뜬다.
- PV와 경계 — 정적 PV를 등록하고, 네임스페이스·권한을 세운다.
- 관측 먼저 — 관측 스택(LGTM)을 GitOps로 올리고, 인프라 대시보드로 클러스터 건강을 본다. 앱 배포는 그 관측 아래에서 한다.
'HomeLab' 카테고리의 다른 글
| 노트북 홈서버 k3s 구축기 - 번외. e1000e NIC hang (0) | 2026.07.29 |
|---|---|
| 노트북 홈서버 k3s 구축기 - 7. 플랫폼 부트스트랩 (0) | 2026.07.29 |
| 노트북 홈서버 k3s 구축기 - 5. 노드(VM) 구축 (0) | 2026.07.19 |
| 노트북 홈서버 k3s 구축기 - 4. Proxmox 설치 직후 설정 (1) | 2026.07.19 |
| 노트북 홈서버 k3s 구축기 - 3. 내 골목 개통, 그리고 Proxmox 설치 (0) | 2026.07.19 |