0. 이어받기
지난 편 끝에서 세 대가 한 클러스터로 묶였다. etcd가 세 대에 복제돼 과반 합의로 서고, 데스크탑에서 kubectl이 붙는다. 그런데 kubectl get nodes는 세 대 모두 NotReady로 답한다.
NAME STATUS ROLES AGE VERSION
k3s-1 NotReady control-plane,etcd 40h v1.36.2+k3s1
k3s-2 NotReady control-plane,etcd 40h v1.36.2+k3s1
k3s-3 NotReady control-plane,etcd 40h v1.36.2+k3s1
원인은 설정이다. k3s를 세울 때 기본 파드 네트워크를 껐다(flannel-backend: none). 파드 네트워크가 없으면 각 노드의 kubelet은 "파드를 받을 준비가 안 됐다"고 보고하고, 그게 NotReady로 나온다. 고장이 아니라, 그 자리에 넣기로 한 것이 아직 안 들어온 상태다.
k3s는 혼자서도 바로 쓸 수 있게 기본 기능을 안고 온다. 클러스터를 세울 때 다섯을 껐다 — 넷은 각자 고른 것으로 바꿔 채우고, 하나(내장 NetworkPolicy 컨트롤러)는 Calico가 그 역할을 겸해서 대체 없이 껐다.
k3s 기본 이 클러스터
┌──────────────────┐ ┌──────────────────────────┐
│ Flannel (CNI)│ → │ Calico │ 파드끼리 통신 + 정책 집행
│ ServiceLB (LB) │ → │ MetalLB │ 사설망 IP 할당
│ Traefik (Ingress)│ → │ Traefik (설정을 직접 소유) │ IP 하나로 여러 서비스
│ local-path (SC) │ → │ 정적 PV (붙여둔 디스크) │ 노드에 붙인 열 장
└──────────────────┘ └──────────────────────────┘
지난 편이 남긴 파일 둘
각 노드의 /etc/rancher/k3s/에 파일이 둘 있다. 이름이 비슷한데 방향이 반대다.
| 파일 | 누가 만들었나 | 무엇을 정하나 |
|---|---|---|
config.yaml |
내가 쓰고 설치 스크립트가 옮겼다 | 클러스터가 어떻게 설지 |
k3s.yaml |
k3s가 뜨면서 만들었다 | 클러스터에 어떻게 붙을지 |
k3s.yaml(kubeconfig와 config는 다름)을 데스크탑으로 복사하고 주소만 127.0.0.1에서 노드 IP로 고쳐 ~/.kube/config에 뒀다. 지금 kubectl이 붙는 경로다.
그 자격은 전권이다. kubectl auth whoami로 보면 system:masters 그룹으로 인증서 인증을 하고 있고, 클러스터가 그 그룹을 전권 역할에 묶어두고 있다. 권한이 파일에 들어 있는 게 아니다. 파일은 그룹을 증명하고, 뭘 할 수 있는지는 클러스터가 판정한다.
7부에서 둘 다 다시 나온다. config.yaml 은 값을 더해 다시 보내고, k3s.yaml 은 설치 스크립트가 찾아서 쓴다.
이 편이 하는 일
지금까지는 노드에 들어가 명령을 쳤지만, 이 편이 끝나면 git에 커밋하는 것이 배포가 된다. 그래서 이 편이 손으로 하는 마지막 구간이다.
순서는 이렇게 간다.
1. 워크로드를 받기 전에 노드가 파드에게 얼마를 내줄지 정한다
2. 손은 어디까지인가 부트스트랩과 GitOps의 경계를 긋는다
3. 무엇이 서는가 목표 구조 — 무엇이 어디에, 왜 그렇게 앉는지
4. install.sh를 돌린다 토대 아홉 단계
5. 비밀번호를 git에 올리는 법 암호를 코드로 관리하는 방법
6. 손을 뗀다 ArgoCD에 운영을 넘긴다
7. 목표 구조와 대조한다 3의 그림, 1의 추정값을 실측으로 검증한다
1과 3이 각각 7과 짝이다. 앞에서 추정하고 그린 것을, 뒤에서 실측으로 검증한다.
순서에 관해 하나만 미리 짚는다. 가장 급해 보이는 것은 NotReady 해소(Calico)인데, 그보다 앞에 노드 정비를 뒀다. 그 작업은 k3s 재시작을 요구하고, 파드가 없는 지금만 재시작에 비용이 없다. Calico가 들어가 파드가 뜨기 시작하면 그 창은 닫힌다.
1. 워크로드를 받기 전에 — 노드 몫을 정한다
워크로드는 노드 위에서 도는 것 전부다. 그런데 이 노드에는 성격이 다른 두 종류가 섞여 있다.
쿠버네티스 워크로드 파드. 지금 0개, 이 편이 끝나면 토대까지 합쳐 70개 가까이 된다
리눅스 워크로드 파드가 아닌 프로세스.
├ k3s 자신 — API 서버·etcd·스케줄러·kubelet·containerd 한 덩어리
└ OS — systemd·journald·sshd
둘이 같은 물리 메모리 7941Mi를 나눠 쓴다(노드 RAM 8GB). 그리고 파드를 올리기 전에 바로잡을 것이 하나 있다 — 이 나눔의 경계가 지금 그어져 있지 않다. 이 절이 하는 일 자체는 작다. 설정 세 줄을 노드에 넣고 재시작한다.
나머지는 전부 그 세 줄이 왜 필요한지다 —
경계가 없으면 어떤 문제가 생기고(1.1–1.3),
값을 어떻게 정했고(1.4),
어떻게 반영하는지(1.5).
1.1 두 워크로드, 관리자가 다르다
리눅스 워크로드는 리눅스가 관리한다. 프로세스를 그룹으로 묶어 그룹별 상한과 사용량을 관리하고(cgroup), 물리 메모리가 바닥나면 점수를 매겨 아무 프로세스나 죽인다(OOM killer). OOM killer는 무엇이 중요한지 모른다 — etcd도 예외가 아니다.
/sys/fs/cgroup/ ← 루트
├─ system.slice/ ← OS·k3s 프로세스들이 사는 곳
│ ├─ k3s.service/ ← k3s 덩어리(API·etcd·kubelet·containerd) ← kube-reserved가 겨냥하는 실체
│ ├─ sshd.service/ ┐
│ └─ journald … ┘← system-reserved가 겨냥하는 실체
└─ kubepods.slice/ ← 파드들이 사는 곳
├─ <파드 A>/ ← memory.max = allocatable 걸림
└─ <파드 B>/ kubelet이 세는 유일한 폴더
쿠버네티스 워크로드는 kubelet이 관리한다. 관리의 재료는 파드 명세에 적는 두 값이다.
resources:
requests: { cpu: 250m, memory: 64Mi }
limits: { cpu: 500m, memory: 64Mi }
- requests — "뜨려면 이만큼 자리가 있어야 한다"는 예약값이다. 노드를 고르는 계산에 쓰인다. 메모리 requests는 리눅스에 안 걸리고 실제로 이만큼 쓰는지 아무도 확인하지 않는다(CPU requests만 경합 시 배분 가중치로 리눅스에 걸린다)
- limits — 실행 중 상한이다. kubelet이 리눅스(cgroup)에 그대로 걸어서, 메모리는 넘는 순간 그 컨테이너가 죽고(
OOMKilled) CPU는 안 죽고 그 선에서 눌린다 - 그래서 파드의 실제 사용량은 requests와 무관하게 limits까지 간다. 둘 사이의 폭이 "평소보다 튈 수 있는 폭"이다
requests를 받아 배치를 정하는 것이 스케줄러다. 스케줄러가 노드를 고르는 기준이 allocatable — 각 노드의 kubelet이 "내가 파드에게 내줄 수 있는 양"으로 계산해 신고하는 값이다.
allocatable = Capacity − 예약 − eviction 선
Capacity 노드가 물리적으로 가진 양. 리눅스가 보는 전체(free -m의 total)와 같다
예약 리눅스 워크로드(k3s·OS) 몫으로 미리 떼어두는 값 — 1.4에서 정한다
eviction 선 메모리 여유가 이 밑으로 내려가면 kubelet이 파드를 내보내는 비상선 — 역시 1.4에서
스케줄러는 노드의 실제 사용량을 보지 않는다. 신고된 allocatable과, 그 노드에 앉은 파드들의 requests 합만 비교한다. 합이 allocatable을 넘지 않는 노드에만 새 파드를 앉힌다. 그래서 이 신고가 틀리면 배치 전체가 틀린 전제 위에서 돈다.
여기서 경계가 갈린다. kubelet이 세는 것은 파드뿐이다. 리눅스 워크로드는 kubelet의 관리 밖이라 사용량을 세지 않는다 — k3s 자신도 파드가 아니라서 그 바깥에 있다. 그러니 kubelet은 리눅스 워크로드 몫을 스스로 알아낼 방법이 없다.
kubeadm 같은 다른 배포판은 API 서버·etcd를 파드로 띄워서 kubelet의 집계 안에 들어온다. k3s는 전부 프로세스 한 덩어리라 집계 밖이다. k3s에서 노드 자원을 읽을 때 이 차이를 모르면 숫자를 잘못 본다.
1.2 그래서 지금 숫자가 틀려 있다
파드가 0개인 노드에서:
free -m
total used free buff/cache available
Mem: 7941 897 6053 1239 7043
이미 897Mi를 쓰고 있다. 전부 리눅스 워크로드 몫이고, 주범이 누군지는 cgroup을 직접 읽으면 나온다.
cat /sys/fs/cgroup/system.slice/k3s.service/memory.current # 867426304 → 827Mi
grep "^anon " /sys/fs/cgroup/system.slice/k3s.service/memory.stat # 471859200 → 450Mi
k3s 덩어리가 827Mi를 차지하고 있다. 그중 실제로 쥔 메모리(anon)가 450Mi이고, 나머지는 부족해지면 커널이 회수하는 캐시다. 이 두 숫자는 뒤에서 값을 정할 때와 검증할 때 그대로 쓰인다.
그런데 노드가 신고하는 값을 보면:
kubectl describe node k3s-1
Capacity:
memory: 8131768Ki # 약 7941Mi — 물리적으로 가진 양
Allocatable:
memory: 8131768Ki # 파드에게 내줄 수 있다는 양 — Capacity와 같다
둘이 같다. "7941Mi 전부 파드에게 줄 수 있다"고 신고하는 중이다. kubelet은 리눅스 워크로드를 안 세니까, 빼야 하는 줄도 모른다. (즉, Allocatable은 실제 사용할 수 있는 값을 넣어야하므로, system.slice가 물고있는 값 - reserved 값을 빼야 함)
신고 파드 몫 7941Mi │ 리눅스 워크로드 몫 0
실제 파드 몫 7114Mi │ 리눅스 워크로드 827Mi
1.3 방치하면 etcd가 죽는다
스케줄러는 이 신고를 믿고 requests 합이 7941Mi에 찰 때까지 파드를 앉힌다.
그런데 파드가 실제로 쓰는 양은 requests가 아니라 limits까지 간다. 이 클러스터의 Kafka 브로커가 그렇다:
requests 768Mi × 3 = 2304Mi ← 스케줄러가 계산에 넣는 값
limits 1536Mi × 3 = 4608Mi ← 셋이 동시에 몰리면 실제로 나가는 양
(둘을 다르게 적는 이유는 밀도다. 워크로드는 평소 적게 쓰고 가끔 튀므로, requests를 평소 수준으로 잡아야 한 노드에 더 앉힐 수 있다.)
파드들이 동시에 limits 쪽으로 튀면 — 신고에 안 잡힌 827Mi까지 겹쳐서 — 물리 메모리가 바닥난다. 그 순간 나서는 건 kubelet이 아니라 리눅스의 OOM killer고, 대상을 가리지 않는다. 그 시점 노드에서 가장 큰 프로세스 중 하나가 상한도 없이 커 있는 k3s 덩어리다 — etcd를 안은 채로.
k3s가 죽으면 그 노드의 etcd 멤버가 사라진다. 세 대 중 두 대에서 같은 일이 나면 과반이 깨져 클러스터 전체가 멈춘다. 파드 우선순위(PriorityClass)로는 못 지킨다 — 파드끼리의 순서일 뿐, k3s는 파드가 아니라 그 목록에 없다.
즉 쿠버네티스에서 kubelet이 k3s 프로세스가 노드에 얼마나 할당되어있는지 감지하지 못하기때문에, 전체 노드 용량에서 k3s 프로세스만큼을 제외하고 클러스터에서 사용가능함을 인지해야한다.
1.4 처방 — 설정 세 줄
노드 config.yaml의 kubelet 설정으로 경계를 긋는다.
kubelet-arg:
- "system-reserved=cpu=200m,memory=512Mi,ephemeral-storage=2Gi"
- "kube-reserved=cpu=500m,memory=1Gi,ephemeral-storage=4Gi"
- "eviction-hard=memory.available<300Mi,nodefs.available<10%,imagefs.available<15%,nodefs.inodesFree<5%"
하는 일은 두 가지다.
① 리눅스 워크로드 몫을 신고에서 미리 뺀다 — system-reserved·kube-reserved. 둘 다 capacity에서 빼두는 예약 값이다.
- system-reserved = OS 살림 몫. 쿠버네티스와 무관한 리눅스 자체 — systemd·journald·sshd·자동 업데이트 등.
- kube-reserved = 쿠버네티스 살림 몫. k3s에선 곧 k3s server 덩어리 — API 서버·etcd·스케줄러·kubelet·containerd. 이것들 쓸 메모리·CPU를 미리 뺀다.
둘 다 kubelet이 재서 정하는 값이 아니다. 리눅스 워크로드는 kubelet 관리 밖이라 잴 수 없고, 사람이 적어준 상수를 뺄 뿐이다. 그래서 맞는지도 사람이 확인해야 한다.
② 커널보다 먼저 개입할 선을 긋는다 — eviction-hard. 노드의 실제 여유가 이 선 밑으로 가면 kubelet이 파드만 골라 내보낸다. OOM killer까지 가기 전에 끊는 장치다.
k3s의 기본값을 확인해 보면 이 선이 필요한 이유가 나온다.
kubectl get --raw "/api/v1/nodes/k3s-1/proxy/configz"
evictionHard: {'imagefs.available': '5%', 'nodefs.available': '5%'}
systemReserved: None
kubeReserved: None
메모리 선이 아예 없다. 디스크 신호 둘뿐이고 예약도 없다(Capacity와 Allocatable이 같았던 출처가 이 두 None이다). 지금은 메모리가 마르면 kubelet 단계 없이 곧장 OOM killer다. 디스크 신호를 이 값에 다시 적는 이유도 여기 있다 — eviction-hard는 기본에 더해지지 않고 통째로 교체되므로, 메모리만 적으면 디스크 선이 사라진다.
값은 잰 것과 추정을 섞어 정한다. system-reserved 512Mi는 OS가 실제로 쓴 몫(약 70–198Mi)에 로그·업데이트 버스트 여유를 더한 값, eviction 300Mi는 kubelet이 여유를 10초마다 확인하는 사이 큰 할당이 그 틈을 뚫지 않게 둔 선이다. kube-reserved만 절반이 추정이다. 파드 0개인 지금 k3s가 회수 못 하게 쥔 건 450Mi인데, 파드 수십 개가 붙으면 API 서버·etcd가 얼마나 커질지는 지금 알 수 없다. 그래서 넉넉히 얹어 1Gi로 눈대중한다 — 이 추정이 맞았는지는 파드를 다 올린 뒤 §7에서 같은 값을 다시 재서 본다.
세 줄을 적용하면 노드가 이렇게 신고하게 된다.
memory 7941 − 512 − 1024 − 300 = 6105Mi (Capacity − 예약 − eviction)
cpu 4000 − 200 − 500 = 3300m
세 노드 합이 메모리 17.9Gi·CPU 9.9코어. 이 편에서 올릴 것 전체의 requests 합(12.9Gi·7.6코어)이 그 안에 72%·77%로 들어가니, 몫을 떼어도 자리는 남는다. 실제 배치가 이 예상과 맞는지도 §7에서 대조한다.
1.5 반영 — 한 대씩, 지금
config.yaml은 k3s가 기동할 때만 읽으니, 재시작해야 반영된다. 파드가 0개인 지금은 재시작해도 옮길 파드가 없다 — 워크로드가 올라간 뒤였다면 노드를 비우고 내려야 해서 비용이 몇 배가 된다. 이 절을 이 편 맨 앞에 둔 이유다.
한 대씩 한다. etcd 과반(3대 중 2대)이 살아 있어야 클러스터가 안 멈춘다. 각 노드의 config.yaml에 위 세 줄을 더하고 재시작한다.
sudo cp /etc/rancher/k3s/config.yaml /etc/rancher/k3s/config.yaml.bak # 되돌릴 길
sudo vi /etc/rancher/k3s/config.yaml # kubelet-arg 세 줄 추가
sudo systemctl restart k3s
재시작한 노드가 kubectl get nodes에 Ready로 돌아온 걸 보고 다음 대로 넘어간다. 세 대가 끝나면 신고가 바뀌었는지 확인한다.
kubectl describe node k3s-1 | grep -A6 Allocatable
Capacity는 그대로인데 Allocatable이 6105Mi로 작아졌으면 반영된 것이다. 설치 이후 줄곧 같던 두 숫자가 여기서 처음 갈라진다.

노드 몫이 정해졌다. 이제 그 위에 무엇을 손으로 올리고 무엇을 GitOps에 맡길지로 넘어간다.
2. 손은 어디까지인가 — 부트스트랩과 GitOps의 경계
2.1 두 가지 배포 경로
노드 정비가 끝났다. 이제 NotReady를 풀고 빈 자리들을 채울 차례인데, 채우기 전에 정할 것이 있다 — 무엇을 어떤 방식으로 올리는가.
여기서부터 올릴 것은 스무 개가 넘는다. 파드 네트워크, 로드밸런서, 인그레스, 인증서 발급기, 시크릿 컨트롤러, 관측 스택 여덟, 데이터베이스, 메시지 브로커, 캐시, 애플리케이션. 전부 손으로 설치하면 두 가지가 안 된다. 지금 클러스터에 뭐가 어떤 설정으로 떠 있는지 아무도 모르고, 다시 세울 때 같은 결과가 안 나온다.
그래서 GitOps를 쓴다. 원하는 상태를 git에 선언해두면 ArgoCD가 클러스터를 그 상태로 맞춘다. 사람은 클러스터가 아니라 git을 바꾼다. 클러스터가 선언과 어긋나면 ArgoCD가 되돌린다. 그런데 ArgoCD 자신은 GitOps로 설치할 수 없다. ArgoCD가 없는데 ArgoCD에게 ArgoCD를 설치해달라고 할 수 없다. 어딘가에서 손으로 시작해야 한다.
2.2 손에 남긴 것의 공통점
손으로 할 것을 고르는 기준은 "중요한가"가 아니다. "GitOps로는 원리적으로 안 되는가"다.
세 종류를 걸렀다.
첫째, 순환. ArgoCD가 파드로 뜨려면 파드 네트워크가 있어야 한다. 그러니 파드 네트워크는 ArgoCD보다 먼저다. ArgoCD 자신도 마찬가지다. 시크릿 컨트롤러도 여기 들어간다. 암호를 푸는 주체가 없으면 암호를 쓰는 것들이 아무것도 못 뜬다.
둘째, CRD. 쿠버네티스에 없는 종류의 객체를 쓰려면 그 종류를 먼저 등록해야 한다. 등록 전에 그 객체를 만들면 "그런 종류 모른다"로 거절당한다. ArgoCD가 CRD와 그걸 쓰는 객체를 한 번에 밀면 순서가 어긋날 수 있어 CRD는 미리 깐다.
셋째, 오퍼레이터. Kafka는 Kafka라는 사용자 정의 객체 하나로 선언하고, Strimzi 오퍼레이터가 그걸 읽어 실제 브로커를 만든다. 오퍼레이터가 없으면 그 선언은 아무 일도 안 일으킨다. 오퍼레이터가 먼저다.
이 셋에 안 걸리는 것은 전부 GitOps로 내렸다. MetalLB, Traefik, 데이터베이스도 여기 들어간다. 인그레스는 설정을 자주 만지는데, 손으로 설치하면 그때마다 직접 업그레이드해야 하고 그 사이 트래픽이 끊길 수 있다. 선언으로 두면 ArgoCD가 반영한다.
그래서 손으로 하는 것은 아홉 단계로 정리됐다.
1 Calico 파드 네트워크
2 네임스페이스 + PodSecurity 구역을 나누고 각 구역의 파드 제약을 정한다
3 StorageClass + 정적 PV 붙여둔 디스크를 쿠버네티스에 등록한다
4 cert-manager 인증서 발급. 지금은 쓰는 곳이 없다
5 sealed-secrets 컨트롤러 암호를 푸는 주체
6 관측용 CRD + etcd 수집 경로
7 Strimzi 오퍼레이터 Kafka 선언을 실제 브로커로 만드는 주체
8 ArgoCD 여기서부터 GitOps
9 ArgoCD 준비 대기
아홉이 세 기준에 걸리는 자리 — 순환: Calico(1)·sealed-secrets(5)·ArgoCD(8·9). CRD: 관측용 CRD(6). 오퍼레이터: Strimzi(7). 남는 셋은 기준 밖의 실용 판단이다. 네임스페이스(2)와 스토리지(3)는 첫 sync가 시작되기 전에 반드시 있어야 하는 전제라 손에 뒀고, cert-manager(4)는 GitOps로도 되지만 아직 소비자가 없는 선설치라 손 묶음에 같이 뒀다.
2.3 스크립트가 둘인 이유
아홉 단계를 마쳐도 바로 GitOps로 넘길 수 없다. 사이에 사람이 해야 하는 일이 하나 있다. 암호를 봉인해서 git에 올리는 것이다.
봉인하려면 시크릿 컨트롤러의 공개키가 필요하고, 그 컨트롤러는 5단계에서 뜬다. 봉인은 아홉 단계 뒤다. 반대로 봉인 전에 GitOps를 시작하면 데이터베이스·캐시·오브젝트 스토리지·대시보드가 암호를 못 찾아 전부 실패한다.
그래서 실행 스크립트를 둘로 나눴다.
install.sh 아홉 단계
↓
(사람) 암호 봉인 · 커밋 · push
↓
root-app.sh GitOps 인계
root-app.sh는 봉인본 개수를 세어 부족하면 시작하지 않는다. 순서를 사람 기억이 아니라 스크립트가 강제한다.
3. 무엇이 서는가 — 목표 구조
세우는 방법으로 가기 전에, 다 세워진 뒤의 그림부터 둔다. 이 편의 나머지는 전부 이 그림을 만드는 과정이다.
브라우저 (사설망)
│
192.168.0.240 ← MetalLB가 배정한 외부 주소
│
Traefik ×2 ── 경로를 보고 안쪽 서비스로 분배
──────────────────────────────────────────────────────────────────────
k3s-1 (db) k3s-2 (obs) k3s-3 (obj)
──────────── ──────────── ────────────
MySQL ←핀 Loki ←핀 MinIO ←핀
Tempo ←핀
Kafka broker① Kafka broker② Kafka broker③ ┐
Mimir ingester① Mimir ingester② Mimir ingester③ ├ 노드당 1
Redis① Redis② Redis③ ┘
──────────────────────────────────────────────────────────────────
어느 노드든(float): Grafana · Mimir 나머지 8 · ArgoCD 5 · queue·booking·frontend
모든 노드에 하나씩(DaemonSet): Alloy · node-exporter · calico-node · MetalLB speaker
배치가 세 부류로 갈리고, 부류마다 이유가 다르다.
| 부류 | 무엇 | 왜 |
|---|---|---|
| 핀 — 특정 노드 고정 | MySQL→k3s-1 · Loki/Tempo→k3s-2 · MinIO→k3s-3 |
각자의 데이터 디스크가 그 노드에만 붙어 있다. 다른 노드에 앉으면 디스크에 닿을 방법이 없다 |
| 노드당 1 — 셋이 흩어짐 | Kafka 브로커 · Mimir ingester · Redis |
복제본을 세 벌 두는 구성이다. 둘이 한 노드에 몰리면 그 노드가 죽을 때 세 벌 중 둘이 동시에 사라져 복제의 의미가 없어진다 |
| float — 스케줄러 자율 | 나머지 전부 | 로컬 디스크가 없고 어디서 죽어도 다른 노드에서 다시 뜨면 그만이다 |
데이터가 흐르는 길은 두 갈래다.
사용자 트래픽 브라우저 → 192.168.0.240 → Traefik → queue / booking / frontend
관측 데이터 Alloy(전 노드에서 수집) → Mimir·Loki에 적재 → 몸통은 MinIO(S3)에 저장
→ Grafana가 조회
관측 저장소 셋(Mimir·Loki·Tempo)은 로컬 디스크에 최근분(WAL)만 두고, 진짜 데이터는 전부 MinIO로 보낸다. k3s-3의 디스크가 100G로 제일 큰 이유고, 열 장 중 유일하게 계속 차오르는 디스크라 관측 1순위다.
이 그림을 세우는 채널이 둘로 나뉜다는 것까지가 설계다. 손(install.sh 9단계) 이 토대 — 네트워크·구역·디스크 등록·시크릿 컨트롤러·오퍼레이터·ArgoCD — 를 깔고, GitOps(App 18개) 가 그 위의 워크로드 전부를 세운다. 어느 쪽에 넣을지의 기준은 하나였다 — ArgoCD가 스스로 할 수 없는 것만 손에 남긴다.
마지막으로 경계 하나. 이 그림에는 클러스터 밖의 조각이 둘 있고, 이 편이 채우지 않는 빈칸이 셋 있다.
클러스터 밖 (이 편에서 쓰는 것)
git 저장소 ArgoCD가 읽는 원본. 여기 없는 것은 클러스터에 없는 것과 같다
데스크탑 관제석 — kubectl·helm·kubeseal이 도는 곳
이 편이 만드는 것 비워 두는 것 (뒤 편)
그림의 뼈대 전부 — 토대 + 워크로드 Grafana 안의 대시보드 → 8부
앱 이미지 저장소·CI (클러스터 밖) → 9부
앱 실동작·티켓팅 접속 → 10부
그래서 이 편이 끝난 그림에서 앱 자리 셋만 비어 있다. 파드는 만들어지는데 담을 이미지가 없어 멈춘 상태로 남고, 그 빈칸이 다음 편들의 입구가 된다. Grafana도 뜨긴 하지만 안은 백지다 — 대시보드는 관측이 실제로 흐르는 걸 확인한 다음의 일이다.
4. install.sh를 돌린다 — 토대 아홉 단계
4.1 실행 위치와 도구
이 스크립트는 노드에 들어가서 돌리지 않는다. 클러스터를 세울 때의 스크립트와 실행 위치가 다르다.
| 어디서 | 왜 | |
|---|---|---|
| 클러스터 설치 스크립트(01·02) | 노드 안 | k3s를 그 머신에 설치하는 일이라 그 머신에서만 된다 |
install.sh |
데스크탑 WSL | 미는 것이 전부 클러스터 API로 들어가는 객체다. 노드 디스크에 놓일 게 없다 |
kubectl과 helm은 API 서버에 HTTPS 요청을 보내는 클라이언트일 뿐이라, 도구와 접속 정보가 있는 곳(데스크탑 WSL)이면 어디서든 된다. 접속 정보는 이미 있다 — 클러스터를 세우고 kubeconfig를 데스크탑으로 가져온 것이 바로 이 순간을 위해서였다. 그때부터 데스크탑이 관제석이고, 노드 SSH는 노드 자체를 만질 때(위의 config.yaml 교체 같은)만 필요하다.
helm은 쿠버네티스 매니페스트 묶음을 패키지로 다루는 도구다. 하나를 설치하려면 Deployment·Service·ConfigMap 같은 YAML이 여러 장 필요한데, 그걸 차트라는 단위로 묶어두고 바뀌는 값만 밖에서 주입한다.
차트(템플릿 여러 장) + values.yaml(우리 값) → 실제 매니페스트 → 클러스터
같은 차트에 다른 values를 주면 다른 환경이 나온다. 이 클러스터의 values는 저장소에 있고, 무엇을 왜 바꿨는지가 그 파일에 남는다.
필요한 것은 셋이다.
kubectl과helm- 클러스터 접속 정보(kubeconfig)
bootstrap/폴더 전체. 스크립트가 옆의 매니페스트와 values를 상대경로로 읽는다
스크립트는 시작할 때 앞의 둘 — 도구와 접속(kubectl·helm·kubeconfig 가독·클러스터 응답) — 을 검사하고, 없으면 아무것도 하지 않고 멈춘다. 중간에 죽어 절반만 적용된 상태가 남는 것을 막기 위해서다. bootstrap/ 폴더는 검사 대상이 아니라 실행 전제다 — 상대경로로 읽으므로 트리째 있어야 한다.
kubeconfig는 $KUBECONFIG → k3s 기본 경로(/etc/rancher/k3s/k3s.yaml) → ~/.kube/config 순으로 찾는다 — 노드에서 돌리면 두 번째가, 데스크탑에서 돌리면 세 번째가 잡힌다.
cd cgv-infra/bootstrap
./install.sh
아홉 단계는 전부 멱등이다. 같은 명령을 다시 돌려도 이미 있는 것은 그대로 두고 없는 것만 만든다. 중간에 실패하면 원인을 고치고 처음부터 다시 돌린다. 예외가 하나 있다 — helm 단계 도중에 죽어 릴리스가 pending-* 상태로 남으면 재실행이 거부되는데, 그때는 helm rollback이나 helm uninstall로 그 릴리스를 정리한 뒤 다시 돌린다.
전체는 10분대에 끝났다. 대부분이 첫 단계(Calico)다 — 노드 세 대가 각자 이미지를 받는 시간이고, 나머지 여덟 단계는 합쳐서 2분 안팎이였다.
4.2 파드 네트워크란 무엇이고, 왜 Calico인가
파드는 각자 IP를 하나씩 받는다. 그리고 노드가 달라도 그 IP로 서로 직접 통신해야 한다.
k3s-1 k3s-2
파드 A (10.42.0.5) ───▶ 파드 B (10.42.1.7)
└─ 노드 IP는 192.168.0.201 / .202. 파드 IP는 그 위의 다른 대역
문제는 이 IP 대역이 물리 네트워크에 없다는 점이다. 공유기는 10.42.x.x를 모른다. 파드 A가 보낸 패킷이 노드 밖으로 나가 노드 2까지 가고, 거기서 다시 파드 B로 들어가려면 누군가 그 경로를 만들어야 한다. 그 일을 하는 게 CNI 플러그인이다.
k3s 노드 세 대는 같은 vmbr0에, 같은 물리 노트북 안에 물려 있다. vmbr0은 L2 스위치로 201<->202<->203 간의 프레임을 호스트 커널 안에서 스위치한다. nic0(바깥 포트)으로 안나가니 공유기까지 가지는 않는다.
k3s는 Flannel을 기본으로 준다. 이걸 끄고 Calico를 넣은 이유는 하나다. NetworkPolicy를 집행할 수 있으니까. 더 풍부한 정책을 일관되게 쓰기 위해선 Calico를 Flannel 대신 사용하는게 좋아보인다.
NetworkPolicy는 "이 파드는 저 파드에만 접속할 수 있다"는 규칙이다. 그런데 이건 선언일 뿐이라, 실제로 막는 주체가 따로 있어야 한다. Flannel은 트래픽을 나르기만 하고 정책은 집행하지 않는다. Calico는 둘 다 한다.
k3s에는 정책만 따로 맡는 내장 컨트롤러도 있는데, 그것도 껐다(disable-network-policy: true). 둘이 같은 규칙을 각자 해석하면 어느 쪽이 막았는지 알 수 없게 된다. 통신과 정책을 한 주체(Calico)가 맡게 정리한 것이다.
다만 직접 쓴 NetworkPolicy는 아직 0건이다. 몇몇 차트가 자기 보호용으로 딸려 보내는 정책은 생기지만, 네임스페이스 사이를 막는 정책은 없어 통신이 전면 허용인 상태다. 이걸 닫는 것은 나중이다.
4.3 [1/9] Calico — NotReady가 풀린다
Calico는 세 단계로 들어간다.
CRD 등록 → tigera-operator 설치 → Installation 객체 생성 → operator가 노드마다 파드 배치
CRD를 따로 미는 이유가 있다. Calico 3.30부터 CRD 묶음이 오퍼레이터 매니페스트에서 분리됐다. CRD 없이 Installation 객체를 만들면 "그런 종류 모른다"로 거절당한다. 그리고 CRD는 등록했다고 바로 쓸 수 있는 게 아니라 API 서버가 받아들이는 시점이 따로 있어서, 스크립트가 그때까지 기다린 뒤 다음으로 간다.
- CRD (Custom Resource Definition) = 새로운 "종류(kind)"를 API에 등록하는 정의
- 객체를 만드는 게 아니라, "이런 타입의 객체가 존재할 수 있다"를 쿠버네티스 API 서버에 가르치는 것이다. API의 어휘를 늘리는 선언.
- 비유: DB 테이블 스키마를 정의하는 것 / 프로그래밍의 클래스를 선언하는 것.
- CR (Custom Resource) = 그 정의로 실제로 만든 객체(인스턴스)
- 비유: 그 테이블의 행 하나 / 그 클래스의 인스턴스 하나.
- "사용자가 직접 객체를 만드는 것"은 이쪽(CR)이다.
kubectl wait --for=condition=Established --timeout=120s \
crd/installations.operator.tigera.io crd/apiservers.operator.tigera.io
CRD 묶음이 3MB 정도라 일반 apply로는 크기 제한에 걸린다. 서버 쪽에서 병합하는 방식(--server-side)으로 민다.
실행하면 이 사슬이 터미널에 그대로 찍힌다. CRD 서른한 개가 serverside-applied로 지나가고, 오퍼레이터가 뜨고, 노드마다 네트워크 파드가 하나씩 배치된다.
Waiting for daemon set "calico-node" rollout to finish: 0 out of 3 new pods have been updated...
Waiting for daemon set "calico-node" rollout to finish: 2 of 3 updated pods are available...
daemon set "calico-node" successfully rolled out
node/k3s-1 condition met
node/k3s-2 condition met
node/k3s-3 condition met
마지막 세 줄이 이 편의 첫 관문이다. 지난 편 끝부터 걸려 있던 NotReady가 여기서 풀린다.
NAME STATUS ROLES AGE VERSION
k3s-1 Ready control-plane,etcd 2d20h v1.36.2+k3s1
k3s-2 Ready control-plane,etcd 2d20h v1.36.2+k3s1
k3s-3 Ready control-plane,etcd 2d20h v1.36.2+k3s1
그동안 뜨지 못한 채 대기하던 DNS와 메트릭 서버도 같이 뜬다.


- 쿠버네티스에선 Service마다 이름이 붙고, CoreDNS가 그 이름을 ClusterIP로 변환을 해준다. 즉, 파드끼리 서비스 이름으로 찾을 수 있게 해준다.
- metrics-server는 실시간 자원 사용량 API이다. 각 노드 kubelet에서 파드, 노드의 현재 CPU/메모리를 긁어 API로 낸다. HPA를 판단할 때 이 값을 읽는다.
- 여기서 중요한게.. Alloy가 긁는 cadvisor로 가져오는 관측과, 이 metrics-server는 다르다.
- metrics-server가 컨트롤 경로로 메모리에 최근 값만(약 15초 창) 들고, 히스토리가 없다.
4.4 [2/9] 네임스페이스와 PodSecurity — 구역과 규칙
네임스페이스는 클러스터 안을 가르는 논리 구획이다. 세 가지가 그 단위로 묶인다.
- 이름 — 다른 네임스페이스에 같은 이름의 객체가 있어도 부딪히지 않는다
- 권한 — "이 계정은 이 네임스페이스에서만 파드를 지울 수 있다"처럼 RBAC의 범위가 된다
- 정책 — 파드에 어떤 제약을 걸지도 네임스페이스 단위로 정한다
install.sh가 만드는 구역은 여섯이다.
| 네임스페이스 | 담는 것 |
|---|---|
app |
queue · booking · frontend |
data |
MySQL · Redis · Kafka |
observability |
관측 스택 (Mimir·Loki·Tempo·Grafana·Alloy·MinIO 등) |
observability-host |
호스트 지표 수집기 (node-exporter) |
argocd |
GitOps 컨트롤러 |
cert-manager |
인증서 발급기 |
세 번째 항목인 정책이 이 절의 주제다. 무엇을 막으려는 건지부터 본다.
컨테이너는 힘을 키울 수 있다
컨테이너는 결국 노드에서 도는 리눅스 프로세스다. 기본적으로는 자기 울타리 안에 갇혀 자기 것만 본다. 그런데 파드 YAML에 몇 줄을 더하면 그 힘이 커진다. 커지는 방향이 둘이다.
요구 A — 울타리 밖으로: 노드를 침범한다.
hostNetwork: true # 노드의 네트워크를 그대로 쓴다 (다른 파드 트래픽까지 보인다)
volumes:
- hostPath: { path: / } # 노드의 디스크를 통째로 마운트한다
securityContext:
privileged: true # 노드에 대한 전권 (커널까지)
이게 뚫리면 컨테이너 하나로 끝나지 않는다. 노드가, 그 위의 etcd·시크릿까지 넘어간다.
요구 B — 울타리 안에서: 자기를 세게 만든다.
노드는 안 건드리는데, 컨테이너 자기 안에서 root로 돌거나 권한을 올리거나 리눅스 특수 능력을 든다. 이건 그 자체로 노드를 먹지는 않지만, A로 넘어가는 발판이 된다 — 약한 컨테이너보다 뚫고 탈출하기 쉽다.
PSA — 그 요구를 문 앞에서 검사한다
이 두 요구를 파드가 생성될 때 걸러내는 검사기가 PodSecurity Admission(줄여서 PSA)이다.
쿠버네티스 API 서버에 원래 들어 있고, 네임스페이스에 라벨을 붙이면 켜진다. 파드 YAML을 읽어 위험한 요구가 있으면 아예 안 띄운다.
얼마나 막느냐가 세 수준이고, A·B 두 요구를 각각 막느냐로 갈린다.
| 수준 | 요구 A (노드 침범) | 요구 B (안에서 세짐) |
|---|---|---|
privileged |
허용 | 허용 |
baseline |
막는다 | 허용 |
restricted |
막는다 | 막는다 |
privileged= 문 활짝. 검사 없음baseline= "노드는 못 건드려. 네 울타리 안에선 뭘 하든 상관 안 해"restricted= "노드도 못 건드리고, 네 안에서도 약하게 돌아"
restricted가 요구하는 "약하게 돌겠다"는 파드가 YAML에 직접 선언해야 한다. 이 네 줄이 그 선언이다.
securityContext:
runAsNonRoot: true # root로 안 돈다
allowPrivilegeEscalation: false # 도중에 권한을 올리지 않는다
capabilities: { drop: ["ALL"] } # 리눅스 특수 권한을 전부 반납한다
seccompProfile: { type: RuntimeDefault } # 위험한 시스템 콜을 차단한다
핵심은 이 네 줄이 자동으로 생기지 않는다는 것이다. 파드 YAML에 이 선언이 명시돼 있어야 하고 — 차트가 넣어주든 사람이 써넣든 — 없으면 restricted 방에서 거절당한다. 검사기는 이미지 안을 열어보지 않는다. YAML에 이 선언이 있는지만 본다.
(넷은 파드 레벨 podSecurityContext와 컨테이너 레벨 securityContext에 나눠 적기도 한다 — 검사기는 위치가 아니라 최종 값이 넷을 만족하는지만 본다. 위 블록은 네 통제를 한데 모아 보인 개념 예시다.)
되도록 restricted를 걸고 싶다 — 그런데 방마다 사정이 다르다
수준을 정하는 원칙은 하나다. 보안상 센 문지기가 좋으니 모든 방에 restricted를 걸고 싶다. 센 수준일수록 뚫린 컨테이너가 할 수 있는 짓이 줄기 때문이다. 걸림돌은 하나뿐이다 — restricted를 걸면, 그 방 파드 중 네 줄을 안 갖춘 게 있을 때 그놈이 못 떠서 서비스가 죽는다.
그래서 방마다 "restricted를 견디나?"를 확인하고, 못 견디면 한 단계씩 낮췄다. 여섯 방이 셋으로 갈렸다.
app · data · argocd · cert-manager → restricted (맞췄다)
- 여기 뜨는 것들 — queue·booking·frontend·MySQL·Redis·Kafka·ArgoCD·cert-manager — 은 자기 일(웹 요청·DB·큐·배포·인증서)만 하지 노드를 만질 이유가 없다. 요구 A·B 둘 다 안 하니, 네 줄만 붙으면 restricted를 통과한다.
- 그 네 줄이 어디서 오는지가 셋으로 갈린다. cgv-app(queue·booking·frontend)은 내가 만든 차트라 restricted를 걸기로 정하고 통과 조건인 네 줄을 values에 직접 써넣었다.
- MySQL·Redis·ArgoCD·cert-manager는 가져다 쓴 공식 차트가 네 필드를 기본으로 갖춘 채 나와 손댈 것 없이 그대로 통과한다. Kafka만 파드를 오퍼레이터가 만들어 기본값이 네 줄을 빠뜨리는데, 이건 오퍼레이터에게 따로 지시해 맞춘다.
- 셋으로 갈려도 결과는 같다 — 검사기는 '누가 만든 차트냐'가 아니라 '렌더된 파드 YAML에 네 줄이 있느냐'만 보기 때문이다. 그래서 네 방 다 가장 센 restricted를 건다.
observability → baseline (타협했다)
- 여기 뜨는 tempo·alloy·minio도 남이 만든 차트다(Grafana사·MinIO사의 공식 차트를 그대로 가져다 쓴다). 같은 공식 차트라도 앞의 MySQL·Redis·ArgoCD는 네 줄을 갖춘 채 나오지만, 이 관측 차트들은 갖추지 않은 채 나온다 — 만든 쪽이 restricted를 안 쓰는 환경까지 고려해 그 선언을 비워뒀다. 내가 원한다고 그 안에 넣을 수가 없다. 뜯어고칠 순 있지만 차트 버전을 올릴 때마다 다시 고쳐야 해서 dev 단계에선 그 비용을 안 진다.
- 여기서 중요한 구분 — 이것들이 위험해서 못 올린 게 아니다. 이것들도 노드는 안 건드린다(요구 A 안 함). 단지 "약하게 돌겠다"는 선언서(네 줄)가 없을 뿐이다. 그래서 A를 검사하는 baseline은 통과한다. 서류가 없어 restricted에서 탈락하고 baseline으로 내려앉은 것이다.
observability-host → privileged (포기했다)
- node-exporter는 앞의 둘과 차원이 다르다. 네 줄이 있고 없고의 문제가 아니라, 노드를 읽는 게 존재 이유다 — 노드의 CPU·메모리·디스크·네트워크를 재서 관측 스택에 넘긴다. 그러려면 hostNetwork·hostPath를 반드시 써야 하고, 그건 요구 A 그 자체다. baseline은 A를 막으니 여기서도 거절된다. 더 내릴 곳이 없어 문지기 없는 privileged밖에 없다.
- 정리하면 셋을 가른 건 두 질문이다 — 그 방 파드가 노드를 침범하나(요구 A), 그리고 restricted가 요구하는 네 줄을 갖췄나.
| 방 | 수준 | 왜 |
|---|---|---|
| app · data · argocd · cert-manager | restricted | 요구 A 안 함 + 네 줄 다 있다 (cgv-app은 직접 써넣고, 나머지는 공식 차트·오퍼레이터가 갖춤) |
| observability | baseline | 요구 A 안 함 + 네 줄 없다 — 남의 차트가 안 갖춰 나와 못 넣음 (A는 안 하니 baseline은 통과) |
| observability-host | privileged | 요구 A가 기능(노드를 읽어야 함) — 어떤 문지기도 못 통과 |
그리고 이것이 관측을 두 방으로 나눈 이유다. node-exporter 하나 때문에 관측 전체를 privileged로 열 수는 없으니, 그 파드만 observability-host로 빼내고 나머지 관측은 baseline을 지켰다.
여기서 뒤 단계 하나가 미리 걸린다. data가 restricted인데 Kafka 브로커 파드의 명세는 사람이 아니라 Strimzi 오퍼레이터가 쓴다. 오퍼레이터가 기본값대로 만들면 네 줄이 빠져 restricted에 거절당한다. 그래서 [7/9]에서 오퍼레이터에게 "네 줄을 넣어 파드를 만들어라"고 지시하는 값(STRIMZI_POD_SECURITY_PROVIDER_CLASS=restricted)을 넘긴다. 남의 오퍼레이터가 만드는 파드를 내 기준에 맞추는 유일한 방법이다.
kube-system과 Calico가 쓰는 네임스페이스에는 아무것도 걸지 않는다. Calico 자체가 노드 네트워크를 만지는(hostNetwork) 컴포넌트라 여기에 restricted를 걸면 클러스터의 신경망이 못 떠서 전체가 죽는다. 안 거는 것도 근거 있는 결정이다.
목표 구조의 MetalLB·Traefik 구역(metallb-system·traefik)은 이 여섯에 없다. 그 둘을 배포하는 주체가 ArgoCD라, 네임스페이스도 ArgoCD가 만들면서 PSA 라벨(각각 privileged·restricted)을 같이 붙인다. 그래서 privileged 구역은 결과적으로 둘이다 — observability-host와 metallb-system.
4.5 [3/9] StorageClass와 정적 PV — 디스크 등록
디스크 열 장은 이미 각 노드의 /mnt/disks/ 밑에 마운트돼 있다. 리눅스는 안다. 쿠버네티스는 아직 모른다. 여기서 등록한다.
등록 대상은 PV다. 디스크 한 조각을 나타내는 쿠버네티스 객체다.
PV가 있으면 파드의 요청(PVC)과 짝지어지고, 실제 경로를 파드 안에 붙이는 것은 kubelet이 자동으로 한다. 이 클러스터에는 PV를 자동으로 만들어주는 프로비저너가 없어서, PV를 사람이 YAML로 미리 만들어 둔다.
PV에는 두 가지를 적는다.
local경로 — 그 디스크가 마운트된 자리(/mnt/disks/mysqldata같은)nodeAffinity— 그 디스크가 물려 있는 노드
두 번째가 목표 구조의 "핀"을 만드는 장치다. MySQL이 k3s-1에 고정되는 것은 스케줄러가 mysqldata PV의 nodeAffinity를 읽기 때문이다 — 디스크가 있는 노드로만 파드가 간다.
StorageClass는 여섯 종류를 만든다. 용도가 다른 PVC가 엉뚱한 PV를 물지 않게 가르는 이름표다. 여섯 다 프로비저너 없음(no-provisioner)이고, 바인딩을 파드가 배치될 때까지 미룬다(WaitForFirstConsumer). 미루지 않으면 파드가 어디 갈지 모르는 상태에서 PV를 먼저 골라버려 배치와 어긋난다.
k3s 기본 local-path는 클러스터를 세울 때 껐다. 남겨두면 StorageClass를 안 적은 PVC가 기본값으로 흘러 부팅 디스크에 조용히 쓴다. 아예 없애서 모든 PVC가 StorageClass를 명시하게 만들었다.
kubectl get pv
열 개가 Available로 나온다. 쓸 파드가 아직 없어서 Bound가 아니다 — 워크로드가 올라오면 전부 Bound로 바뀐다.(Proxmox에서 각 노드에 붙인 디스크들이 stateful 객체의 PV로써 할당된다.)

4.6 [4/9]–[9/9] 나머지 — 컨트롤러와 오퍼레이터
cert-manager — 인증서를 자동으로 발급·갱신한다. 지금은 쓰는 곳이 없다. 클러스터를 밖으로 열 때 필요해서 미리 넣고, 그 대가로 파드 셋이 자원을 차지한다.
sealed-secrets 컨트롤러 — 암호를 푸는 주체다. 뜨면서 키 한 쌍을 만든다.(다음 파트)
관측용 CRD와 etcd 수집 경로 — 수집 대상을 선언하는 객체 종류(ServiceMonitor, PodMonitor)를 등록한다. 이 종류를 만든 프로젝트의 컨트롤러는 설치하지 않는다. 이 클러스터에서 그 객체를 읽는 쪽은 수집기(Alloy)이고, 컨트롤러는 필요 없다.
같이 etcd 지표를 긁을 경로도 만든다. 여기에 우회가 하나 들어간다. 보통 수집 대상은 Service로 가리킨다 — Service는 라벨로 파드를 고르고, 그 결과 목록이 Endpoints로 자동 생성되며, 수집기는 그걸 보고 어디를 긁을지 안다.
Service (라벨 조건) → 조건에 맞는 파드들 → Endpoints(자동) → 수집기가 읽음
그런데 etcd는 파드가 아니다. 노드에서 도는 프로세스다. 고를 파드가 없으니 Service가 성립하지 않는다. 그래서 라벨 조건이 없는 Service를 만들고, Endpoints를 자동 생성에 맡기지 않고 노드 세 대의 주소를 직접 적어 붙였다.
Service (조건 없음) → Endpoints(수동: .201:2381 · .202:2381 · .203:2381) → 수집기가 읽음
Strimzi 오퍼레이터 — Kafka 선언을 실제 브로커로 만드는 주체다. data 네임스페이스를 감시하도록 설정했다.
이 네임스페이스는 restricted 수준이라 손을 한 번 더 봐야 한다. 오퍼레이터가 만드는 브로커 파드의 명세를 오퍼레이터 자신이 정하는데, 기본값은 baseline에 맞춘 명세다. 그대로 두면 브로커가 생성되는 순간 검사기에 거절당한다. 오퍼레이터에게 restricted용 명세를 쓰라고 알려준다.
extraEnvs:
- name: STRIMZI_POD_SECURITY_PROVIDER_CLASS
value: restricted
ArgoCD — 여기서부터 GitOps다. 손으로 설치하는 마지막 구성요소이고, 설치된 뒤에는 자기 자신도 git에 선언된 상태로 관리한다.
ArgoCD는 파드 하나가 아니라 역할이 다른 파드 다섯이 나눠 맡는다. 그중 둘이 GitOps의 엔진이다 — repo-server는 git의 차트를 실제 매니페스트로 렌더하고(로컬에서 친 helm template을 클러스터 안에서 대신 하는 셈), application-controller는 그 매니페스트(원하는 상태)를 클러스터 실물(현재 상태)과 비교해 다르면 맞춘다. 나머지 셋(server·redis·appset)은 UI·캐시·생성기라 상대적으로 가볍다.
이 둘의 메모리를 다르게 잡았다. 평소엔 한가하다 — 커밋 하나 하면 바뀐 App 한둘만 렌더·비교하고 끝난다. 그런데 다음 절에서 운영을 넘기는 순간에는 App 열아홉 개가 한꺼번에 이 둘에게 몰려, 잠깐 메모리가 크게 튄다. 평소 쓰는 양(request)과 튈 때의 상한(limit)을 벌려 잡는 방식이 이 자리에 그대로 쓰인다.
request 256Mi·384Mi 평소 이만큼 쓴다 (스케줄러 배치 기준)
limit 1Gi 인계 순간 여기까지 튄다 (이때 죽지 말라고)
limit도 평소값으로 잡으면 인계 직후 렌더하다 상한을 넘겨 OOM으로 죽고, repo-server가 죽으면 렌더가 멎어 sync 전체가 정체된다. 이 폭주는 인계·재구축 때만 오는 일회성이라, 렌더 병렬도를 상시 제한해 속도를 깎는 대신 그 순간만 버틸 상한을 준 것이다. (열아홉 개가 왜 한꺼번에 몰리는지는 인계를 실제로 하는 §6에서 본다.)
아홉 단계가 끝난 상태는 이렇다 — 노드 셋 Ready, 파드 24개(calico 11 · argocd 5 · cert-manager 3 · kube-system 3 · strimzi 1 · tigera 1), PV 10개 Available. 아홉 번째 단계가 설치가 아니라 대기인 것도 이 확인을 위해서다 — argocd-server가 응답할 때까지 기다렸다가 끝나, 다음 절부터 사람이 상대할 수 있는 상태임을 보장한다. 목표 구조에서 토대에 해당하는 부분이 전부 섰고, 워크로드(서비스 app) 자리는 아직 비어 있다.

ArgoCD까지 떴다. 그런데 바로 인계하면 안 된다. 인계하는 순간 올라올 것들이 찾을 암호가 아직 git에 없다. 그걸 넣는 것이 다음이다.
5. 비밀번호를 git에 올리는 법
5.1 문제
곧 올릴 것들이 암호를 요구한다 — 데이터베이스·캐시 비밀번호, 오브젝트 스토리지 계정, 대시보드 관리자, 스토리지 접근 키.
GitOps는 원하는 상태를 전부 git에 적는 방식이다. 그런데 암호를 그냥 적으면 저장소를 보는 사람이 다 읽는다(공개 저장소면 인터넷 전체가). 쿠버네티스 Secret 객체도 답이 아니다 — 값을 base64로 담을 뿐이라 한 줄 명령으로 원문이 나온다.
그렇다고 암호만 손으로 따로 넣으면, 클러스터를 다시 세울 때 "뭘 어디에 넣었더라"가 남는다. GitOps 밖에 구멍이 생긴다. 암호도 git에 두되, 읽을 수는 없어야 한다.
5.2 비대칭 키
sealed-secrets는 이걸 잠그는 열쇠와 여는 열쇠를 다르게 해서 푼다. 컨트롤러가 뜰 때 한 쌍을 만든다 — 공개키는 누구나 가져가고, 개인키는 클러스터 안에만 있고 밖으로 안 나간다.
경계를 기준으로 그리면 어느 객체가 어디 사는지, 그리고 어디까지가 사람 손이고 어디부터 클러스터가 자동인지가 같이 보인다. 세로줄이 그 경계다 — 왼쪽(git·데스크탑)은 공개돼도 되는 영역, 오른쪽(클러스터 안)은 개인키가 지키는 영역이다.
데스크탑 · git (공개 가능) │ 클러스터 안 (개인키 보유)
──────────────────────────────────────┼──────────────────────────────────────
│
┌── seal-secrets.sh (내가 실행) ──┐ │ [sealed-secrets 컨트롤러]
│ │ │ · 개인키 ← 여기서만 산다. 밖으로 안 나감
│ ① 공개키를 가져온다 ◀──────────┼──┼───── · 공개키를 내준다(잠그는 데만 쓰임)
│ │ │
│ ② 암호 원문 ─공개키로 잠금→ │ │
│ SealedSecret(암호문) 10개 │ │
└────────────────┬────────────────┘ │
│ ③ git commit·push │
▼ │
(GitHub 저장소) ─────────┼──▶ ArgoCD가 읽어 클러스터에 적용
│ │
── 여기까지 사람 손 ────────────┼─── 여기부터 자동 ──
│ ▼
│ 컨트롤러가 개인키로 연다
│ ▼
│ Secret(평문) ── 이 경계를 못 넘는다
│ ▼
│ 파드가 읽는다
왼쪽 절반(①②③)이 seal-secrets.sh + 커밋·push다. 잠그는 데는 공개키만 있으면 되고 클러스터가 필요 없다 — 그래서 데스크탑에서 한다. push로 SealedSecret이 경계를 넘어가는 순간부터는 오른쪽이 자동으로 받는다: ArgoCD가 읽고, 컨트롤러가 개인키로 열고, 파드가 쓴다.
세로줄을 넘는 것은 SealedSecret(암호문) 하나뿐이다. 잠긴 채로 넘어가고, 열리는 것은 넘어간 뒤 오른쪽 안에서다. 풀린 Secret(평문)은 경계를 되넘어 왼쪽으로 나오지 않는다 — git에도, 데스크탑에도 평문은 없다.
공개키로 잠근 것은 개인키로만 열린다. 개인키가 클러스터 밖으로 안 나가니, 암호문을 통째로 가져가도 클러스터 없이는 못 푼다. 그래서 SealedSecret은 공개 저장소에 올려도 된다.
두 가지가 여기서 따라 나온다.
- 봉인에는 공개키가 필요하다. 그 공개키는 컨트롤러가 떠야 생기니, 봉인은 install.sh가 끝난 지금에만 가능하다.
- 개인키를 잃으면 git의 암호문을 전부 못 푼다. 클러스터를 다시 세우면 새 키 쌍이 생기고, 그 개인키로는 예전 암호문이 안 열린다. 그래서 개인키 백업이 이 절의 마지막 일이 된다.
5.3 열 종류 — 값이 여러 곳에 물린다
봉인할 것은 열 종류다.
| 네임스페이스 | 이름 | 키 |
|---|---|---|
| observability | minio-root-secret |
rootUser, rootPassword |
| observability | minio-lgtm-user |
password |
| observability | loki-s3-credentials |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
| observability | mimir-minio-credentials |
MIMIR_S3_ACCESS_KEY_ID, MIMIR_S3_SECRET_ACCESS_KEY |
| observability | tempo-s3-credentials |
S3_ACCESS_KEY, S3_SECRET_KEY |
| observability | grafana-admin |
admin-user, admin-password |
| data | mysql-secret |
mysql-root-password, mysql-password |
| data | redis-secret |
redis-password |
| app | booking-secrets |
MYSQL_PASSWORD, REDIS_PASSWORD |
| app | queue-secrets |
REDIS_PASSWORD |
키 이름이 제각각인 건 각 구성요소가 요구하는 이름이 달라서다. 그런데 같은 값이 여러 시크릿에 흩어져 들어간다 — 이게 이 절의 핵심이다. 세 그룹이다.
DB 비밀번호 mysql-secret(두 키) · booking-secrets.MYSQL_PASSWORD
캐시 비밀번호 redis-secret · queue-secrets · booking-secrets.REDIS_PASSWORD
lgtm 비밀번호 minio-lgtm-user · loki·mimir·tempo 각 S3 SECRET
비밀번호 하나가 서너 자리에 들어가는데 이름은 자리마다 다르다. 손으로 봉인하면 그 자리에서 틀리기 쉽다 — 다음 절이 이걸 스크립트로 맞추는 이유다.
두 개는 실제로 걸렸던 함정이다.
mysql-secret은 두 키에 같은 값을 넣어야 한다(한쪽만 넣으면 파드가 조용히 멈춘다). 그리고 데이터베이스 차트의auth.username에root를 적으면 컨테이너가 기동을 거부한다 — root 접속이면 이 칸을 비운다.
5.4 봉인과 백업
물린 값을 손으로 맞추지 않게, 표 전체를 스크립트로 만들었다(bootstrap/seal-secrets.sh). 입력은 일곱 개(비밀번호 다섯 + 이름 둘)뿐이고, 흩어진 자리는 스크립트가 같은 값으로 채운다.
cd cgv-infra/bootstrap
./seal-secrets.sh # 비밀번호는 화면에 안 보이고, 두 번 입력해 확인
스크립트가 지키는 것은 셋이다 — 원문을 파일로 남기지 않고(표준입력으로 kubeseal에 흘린다), 네임스페이스를 표대로(봉인은 이름+네임스페이스에 묶여 다른 곳에선 안 풀린다), 컨트롤러 이름을 인자로(안 넘기면 공개키를 못 받는다).
봉인본 열 개가 workloads/manifests/secrets/에 생기면 커밋·push한다. ArgoCD는 git에서 읽으므로 push 전엔 없는 것과 같다. 파일 안은 암호문뿐이라 공개 저장소에 올라가도 된다.
spec:
encryptedData:
redis-password: AgBv1n2K...
마지막이 개인키 백업이다. 이 파일 하나가 git에 올린 암호문 전체의 생사를 쥔다.
kubectl -n kube-system get secret -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml \
> ~/sealed-secrets-key-backup.yaml
chmod 600 ~/sealed-secrets-key-backup.yaml
절대 커밋하지 않는다 — 유출되면 공개 저장소의 암호문이 전부 열린다. 저장소 밖에, 클러스터(외장 SSD)와 데스크탑이 함께 죽는 경우까지 대비해 다른 매체에도 한 벌 둔다.
6. 손을 뗀다 — GitOps 인계
6.1 root-app이 펼치는 것
./root-app.sh
스크립트는 봉인본 개수를 세고, 열 개보다 적으면 아무것도 하지 않고 멈춘다. 통과하면 객체 하나를 만든다.
application.argoproj.io/root created
이 객체는 저장소의 argocd/ 폴더를 가리키고, 폴더 안에는 다시 다른 선언들이 있다. ArgoCD가 그걸 읽어 아래로 펼친다.
root
└─ argocd/ 폴더
├─ 프로젝트 3개 어떤 저장소에서 어떤 네임스페이스로 배포할 수 있는지 정한 울타리
├─ 생성기 3개 목록을 받아 배포 단위를 자동으로 찍어낸다
└─ 개별 선언 5개
↓
배포 단위 18개
├─ 플랫폼 2 MetalLB · Traefik
├─ 관측 8 Mimir · Loki · Tempo · Grafana · Alloy · MinIO · 지표 수집기 둘
├─ 데이터 3 MySQL · Redis · Kafka
├─ 앱 3 queue · booking · frontend
└─ 기타 2 시크릿 배달 · IP 대역 선언
목표 구조의 워크로드 전부가 이 열여덟 개다. 생성기를 쓰는 이유는 관측 스택 여덟처럼 형태가 같은 것들 때문이다 — 이름과 차트 좌표만 다른 선언을 여덟 번 쓰는 대신, 목록 하나와 틀 하나로 둔다. ArgoCD 화면에는 열아홉 개로 보인다 — 배포 단위 열여덟에, root 자신도 App 하나라서다.
여기서 손이 끝난다. 이후 변경은 git 커밋으로 한다.
6.2 네트워크를 완성하는 둘 — MetalLB와 Traefik
열여덟 개 중 둘이 목표 구조의 맨 윗줄 — 브라우저에서 들어오는 길 — 을 만든다.
MetalLB — 쿠버네티스에서 서비스를 밖으로 여는 방법 중 하나가 LoadBalancer 타입이다.
- 클라우드에서는 이렇게 만들면 클라우드가 알아서 외부 IP를 붙여준다.
- 온프렘에는 그 주체를 직접 세워야 한다.
- k3s는 servicelb라는 간이 구현을 번들하는데, 노드 IP를 그대로 빌려 쓰는 방식이라 전용 IP 대역을 줄 수 없어 클러스터를 세울 때 껐다.
- 그래서 지금은 붙여줄 주체가 없어 서비스가 EXTERNAL-IP: <pending>으로 남고, MetalLB가 그 자리를 대신한다.
- 사설망에서 쓸 IP 대역을 미리 주면, LoadBalancer 서비스가 생길 때 거기서 하나를 떼어 붙이고 그 IP로 오는 트래픽을 클러스터 안으로 넣는다.
공유기 192.168.0.0/24
├─ 노드 .201 .202 .203
└─ MetalLB 풀 .240 - .250 ← 서비스에 붙는 IP
이 대역을 정할 때 확인할 것이 하나 있다. 공유기가 DHCP로 나눠주는 범위와 겹치면 IP 충돌이 난다. 공유기가 어떤 기기에 .245를 임대했는데 MetalLB도 그걸 서비스에 붙이면 둘 다 통신이 끊긴다. 공유기 설정에서 임대 범위를 확인하고 겹치지 않는 구간을 골라야 한다.
Traefik — 서비스마다 IP를 하나씩 받으면 IP가 금방 마른다. 인그레스는 그 앞에 서는 계층이다. IP 하나로 트래픽을 다 받고, 경로나 도메인을 보고 뒤쪽 서비스로 넘긴다.
브라우저
↓
MetalLB IP 하나 → Traefik ┬─ /api/admission → queue
├─ /api → booking
└─ / → frontend
k3s가 Traefik을 기본으로 주는데 그걸 끄고 같은 Traefik을 직접 올렸다. 설정을 내가 소유하기 위해서다. 기본으로 딸려온 것은 값을 바꾸려면 k3s가 관리하는 자리를 건드려야 하고, 그 변경이 다음 업그레이드에 덮인다.
Traefik에 대한 Ip는 Traefik 파드 자체에 대한 Service가 생기며
지금 이 둘이 실제로 어떤 상태인가
같은 "네트워크를 여는 둘"이지만 지금 하는 일이 다르다.
- MetalLB는 이미 일하고 있다. IP 대역(
.240-.250)은 개별 App(metallb-pool)으로 이미 들어갔고, Traefik이LoadBalancer서비스로 뜨자 바로.240을 붙였다. 세워만 둔 게 아니라 지금 이 순간 IP를 배정하는 중이다.
kubectl -n traefik get svc traefik
NAME TYPE EXTERNAL-IP PORT(S)
traefik LoadBalancer 192.168.0.240 80, 443
- Traefik은 문은 열었지만 안이 비어 있다.
.240으로 들어오는 트래픽을 받을 준비는 됐는데, 넘길 뒷단(queue·booking·frontend)이 아직 안 떴다(이미지가 없어서 — 아래에서 본다). 그래서 지금/api/admission으로 들어와도 뒤가 없어 응답이 없다. 라우팅 규칙은 있지만 대상이 비어 공회전하는 상태다.
정리하면 클러스터에 처음으로 밖에서 부를 수 있는 주소(.240)가 생겼고, 그 문 뒤에 서비스가 채워지면 브라우저 → .240 → Traefik → 앱의 길이 완성된다.
앞으로 이 둘이 쓰이는 곳
지금은 앞의 상태로 멈춰 있지만, 이 둘은 뒤 편들의 도구가 된다.
- 8부 — 관측 대시보드를 만들면 Grafana·ArgoCD를 이 Traefik의 IngressRoute로 밖에 노출한다. 지금처럼 포트 포워딩으로 매번 여는 대신,
.240을 거쳐 브라우저로 바로 붙는다. - 10부 — 앱이 이미지를 얻어 뜨면 Traefik의 뒷단이 채워진다. 그때 위 그림의 경로 분배(
/api/admission→queue 등)가 실제로 작동하고, 티켓팅을 브라우저에서 돌린다.
즉 metallb-pool(순수 IP 대역 설정)과 달리, 이 둘(MetalLB·Traefik)은 "나중을 위해 세워만 둔" 것이 아니다 — MetalLB는 이미 IP를 배정하는 중이고, Traefik은 뒷단만 채워지면 곧바로 라우팅을 시작한다. (소비자 없이 정말 세워만 둔 것은 cert-manager 하나다 — 인증서를 쓸 곳이 아직 없어서다.)
6.3 수렴 — 그리고 수렴하지 않는 것
인계 직후 화면은 빨갛다. 배포 단위 사이의 순서가 강제되지 않아 열여덟 개가 거의 동시에 시작하고, 의존하는 것이 아직 없는 동안 재시작을 반복한다. ArgoCD가 주기적으로 다시 맞추면서 몇 분에 걸쳐 수렴한다 — 관측 스택, 데이터베이스, 캐시, 브로커가 하나씩 Healthy로 넘어간다.
수렴하지 않는 것도 있다. 애플리케이션 셋은 ImagePullBackOff로 남는다. 파드를 띄우려면 컨테이너 이미지를 받아와야 하는데, 받을 곳을 아직 안 만들었다. 쿠버네티스는 일정 간격으로 다시 시도하고 계속 실패한다. 이미지 저장소는 9부의 일이라, 이 편에서는 그대로 둔다.
이 수렴 과정을 ArgoCD 화면으로 볼 수 있다. Traefik 연결은 다음 편에 하므로 지금은 포트 포워딩으로 연다.
kubectl -n argocd port-forward svc/argocd-server 8080:443
브라우저에서 https://localhost:8080 (자체 서명 인증서라 경고를 수락한다). 계정은 admin, 초기 비밀번호는 설치 때 무작위로 생성된 값이다 — 봉인한 열 개에 ArgoCD가 없는 이유는 ArgoCD가 GitOps 폭포보다 먼저 떠야 하는 쪽이라, 자기 암호를 자기가 배달할 수 없어서다.

kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d | tr -d '\n' | clip.exe # 클립보드로 복사
터미널에서 드래그로 복사하면 줄바꿈이 딸려 붙기 쉽다. 로그인 실패가 몇 번 쌓이면 잠깐 계정이 잠기므로, 클립보드 명령으로 정확히 넣는다. 로그인 후에는 User Info에서 비밀번호를 바꾸고 초기 Secret을 지운다.
kubectl -n argocd delete secret argocd-initial-admin-secret
7. 목표 구조와 대조한다
7.1 선 것 — 파드 69개
kubectl -n argocd get applications
kubectl get pods -A -o wide
수렴이 끝난 실측: 파드 69개. App 19개 중 앱 셋(이미지 없음)을 뺀 나머지가 Healthy. PV는 10개 전부 Bound — 등록만 되어 있던 디스크 열 장이 전부 워크로드에 물렸다.
배치가 목표 구조대로 앉았는지 본다.
| 확인 대상 | 기대 |
| MySQL · Loki · Tempo · MinIO | 각자의 디스크가 있는 노드 (k3s-1 · k3s-2 · k3s-2 · k3s-3) |
| Kafka 브로커 3 · Mimir ingester 3 · Redis 3 | 노드마다 정확히 하나씩 |
| Alloy · node-exporter · calico-node · speaker | 모든 노드에 하나씩 |
7.2 자원 — 계산과 실측의 대조
파드를 올리기 전에 "requests 합 12.9Gi가 allocatable 17.9Gi에 들어간다(72%)"고 계산했었다. 실측:
kubectl describe node k3s-1 | grep -A8 "Allocated resources"
| 노드 | memory requests | cpu requests |
| k3s-1 | 4168Mi (68%) | 2570m (77%) |
| k3s-2 | 4434Mi (72%) | 2260m (68%) |
| k3s-3 | 3646Mi (59%) | 1960m (59%) |
세 노드 합 약 12.0Gi(67%) · 6.8코어(69%). 계산(12.9Gi·7.6코어)과의 차이 대부분은 끝난 일회성 작업이다 — 초기화 Job(MinIO 버킷·유저 생성)은 완료되면 장부에서 빠진다. 계산은 상한으로 유효했고, 실측이 그 안에 앉았다.
Limits 열은 각 노드 120–144%로 allocatable을 넘는다. 이것은 정상이다 — 파드들이 동시에 상한까지 쓰지 않는다는 전제로 배치하고, 그 전제가 깨질 때를 위해 kubelet 예약과 eviction 선을 걸어둔 것이다.
7.3 떼어둔 몫이 맞았는지
이 편 맨 앞에서 노드에 "k3s 몫"(kube-reserved)을 미리 떼어뒀다. k3s는 API 서버·etcd를 파드가 아니라 리눅스 프로세스로 돌리는데, 노드는 파드 아닌 것의 메모리를 신고에 넣지 않는다. 그대로 두면 노드가 파드에게 전부 내줄 수 있다고 신고하고, 메모리가 마르면 커널이 아무 프로세스나 — etcd까지 — 죽일 수 있어서였다.
그때 그 값을 파드가 0개일 때 정한 게 문제였다.
파드 0개일 때 재보니 k3s가 450Mi 사용
파드가 붙으면 더 쓸 테니 약 570Mi를 눈대중으로 얹음
────────────────
kube-reserved = 1Gi ← 뒤의 570은 잰 게 아니라 추측
뒤의 570은 잰 게 아니라 추측이었다. 파드가 예순 개 넘게 뜬 지금, 그 추측이 맞았는지 같은 값을 다시 잰다.
재는 값은 anon — k3s가 회수 못 하게 쥐고 있는 메모리다. 파일 캐시는 부족하면 커널이 반납하니, 예약해야 할 건 이 anon뿐이다.
grep -E "^anon " /sys/fs/cgroup/system.slice/k3s.service/memory.stat
노드마다 얹힌 파드가 다르니 셋 다 재고, 가장 큰 값을 기준으로 본다.
| 노드 | anon (실측) | 예약해둔 값 |
| k3s-1 (MySQL·Kafka·Mimir ingester가 몰린 노드) | 1783Mi | 1024Mi |
| k3s-2 | 1571Mi | 1024Mi |
| k3s-3 | 1549Mi | 1024Mi |
세 노드 다 예약(1Gi)을 넘겼다. 이게 무슨 뜻인지 그림으로 보면 —
예약해둔 것 [ k3s 몫 1024Mi ][ 파드 몫 (allocatable) ]
실제 [ k3s가 진짜 쓰는 1783Mi ][ 남은 파드 몫 ]
└─ 759Mi 초과 ─┘
이 759Mi를 k3s가 "파드 자리"에서 빼 쓰는 중
k3s가 예약보다 759Mi를 더 쓰고, 그 초과분은 원래 파드에게 줄 자리에서 나간다. 떼어둔 울타리가 실제 몸집보다 작아, 안전장치가 절반만 선 셈이다. 원인은 그 570Mi 추측이었다 — 실제 증가분은 1783 − 450 = 1333Mi였는데 절반도 안 얹었다. 파드가 붙으면서 API 서버의 감시 캐시와 etcd 인덱스가 예상보다 크게 늘어난 몫이다.
그래서 가장 큰 k3s-1(1783Mi)에 여유를 더해 kube-reserved를 2Gi로 올린다. 여기서 코드와 클러스터가 잠깐 갈린다.
- 코드(config.yaml)는 지금 2Gi로 고쳐 커밋한다.
- 클러스터 노드엔 아직 1Gi가 걸려 있다. 반영하려면 재시작해야 하는데, 처음 넣을 때와 사정이 다르다 — 그때는 파드가 없어 그냥 내렸지만, 지금은 파드가 올라가 있어 노드를 먼저 비워야(drain) 한다. 그 작업은 앱을 올리는 편에서 함께 한다.
그래서 지금은 config.yaml=2Gi, 노드=1Gi로 어긋난 채 둔다. 노드가 신고하는 allocatable이 아직 6105Mi(1Gi 기준)인 것도 그래서다.
미뤄둔 재시작 절차는 이렇게 된다. 노드를 drain으로 비우면 스테이트리스 파드는 다른 노드로 가고, 디스크에 묶인 것들(MySQL·Kafka 브로커·Mimir ingester)은 갈 곳이 없어 uncordon 전까지 Pending으로 기다린다. 그래서 한 대씩 짧게 끝낸다.
kubectl drain k3s-1 --ignore-daemonsets --delete-emptydir-data # daemonset은 옮길 곳 없어 그대로 둠
sudo systemctl restart k3s # config.yaml의 2Gi 반영
kubectl uncordon k3s-1
2Gi도 최종값은 아니다. 앱 셋이 이미지가 없어 아직 안 떠 있는데, 앱이 실제로 뜨면 k3s가 더 커질 수 있다. 앱을 올리는 편에서 한 번 더 재고, 부하까지 걸어 확정하는 건 그 뒤다.
7.4 손으로 한 번 잰 것을, 8부에선 계속 본다
방금 k3s 몫을 grep으로 한 번 쟀다. anon — 프로세스가 쥐고 있어 회수 못 하는 메모리. 딱 한 번의 측정이다.
그런데 "지금 실제로 얼마 쓰는가"는 k3s에만 물을 게 아니라 모든 파드에 대해 계속 봐야 하는 값이다. 그걸 컨테이너 단위로 부르는 이름이 워킹셋(working set)이다 — anon에, 당장 회수하기 어려운 파일 페이지를 더한 "지금 실제로 붙잡고 있는" 메모리. kubelet이 파드를 evict할지 판단할 때 보는 값도 이 워킹셋이다.
7부에서 그은 선들이 실제로 지켜지는지는 결국 이 워킹셋과 CPU 사용률로 드러난다.
- 노드 여유가 eviction 선(memory.available < 300Mi)에 가까워지나
- 어떤 컨테이너가 자기 limit에 임박했나 (그래서 OOMKilled 위험이 있나)
- k3s 몫이 예약(2Gi)을 또 넘기 시작하나
7부에선 이걸 grep으로 한 번 봤다. 8부에서 관측을 세우면 cAdvisor가 모든 컨테이너의 워킹셋과 CPU 사용률을 계속 뽑아 Grafana에 그린다. 손으로 한 일회성 측정이 상시 패널이 되는 것이다. "2Gi도 최종값이 아니다"의 그 재측정도, 8부부터는 grep이 아니라 대시보드로 보고, 부하를 걸어 확정하는 건 그 뒤다.
그래서 7부가 정한 예약·limit·eviction은 8부 대시보드의 기준선이 된다. 그 선에 얼마나 가까운지를 읽는 게 인프라 대시보드의 첫 화면이고, 이 편은 그 화면이 설 자리를 만든 셈이다.
8. 다음
여기까지가 7부다. 노드가 파드에게 내줄 몫을 정하고, 파드 네트워크와 스토리지를 넣고, 암호를 코드로 관리할 수 있게 만들고, 운영을 GitOps로 넘겼다. 목표 구조에서 이미지가 없어 못 뜬 앱 셋을 뺀 전부가 실물로 서 있다.
지금 상태를 정리하면 —
- 노드 세 대가 Ready이고, 파드 69개 — 관측 스택·데이터베이스·메시지 브로커·캐시·인그레스가 떠 있다
- 클러스터에 외부 주소가 하나 생겼다(192.168.0.240, Traefik)
- 디스크 열 장이 전부 워크로드에 바인딩됐다
- 애플리케이션 셋은 이미지를 받을 곳이 없어 ImagePullBackOff로 남아 있다
- 클러스터 변경은 git 커밋으로 한다 — 이 편 이후로 손 명령은 노드 정비와 ArgoCD 자신을 만질 때뿐이다
남겨둔 것 —
- 관측이 실제로 흐르는지 — 수집기가 무엇을 긁고 있고 그게 저장소에 도착하는지는 아직 확인하지 않았다. 대시보드도 한 장도 없다
- 대시보드 접근 경로 — 지금은 포트 포워딩으로만 본다
- NetworkPolicy — 집행할 주체(Calico)는 세웠는데 직접 쓴 정책은 0건이다(차트들이 딸려 보낸 자기 보호용 8건뿐)
- 권한 — 클러스터에 접근하는 자격이 여전히 전권 하나다
- 이미지 저장소 — 애플리케이션 이미지를 만들고 둘 곳이 없다
8부에서 이어갈 것 — 먼저 관측이 실제로 흐르는지 연다. 수집기가 무엇을 긁고, 그게 저장소까지 닿는지 하나씩 확인하고, 어긋난 데가 있으면 바로잡는다. 그 위에 클러스터 상태를 보는 대시보드를 세운다. 앱이 아직 없어도 인프라 자신(노드·etcd·API 서버·저장소)은 지표를 내니, 검증도 대시보드도 그것부터다.
'HomeLab' 카테고리의 다른 글
| 노트북 홈서버 k3s 구축기 - 8. 관측 파이프라인 (0) | 2026.08.02 |
|---|---|
| 노트북 홈서버 k3s 구축기 - 번외. e1000e NIC hang (0) | 2026.07.29 |
| 노트북 홈서버 k3s 구축기 - 6. 데이터 디스크와 k3s 클러스터 (0) | 2026.07.21 |
| 노트북 홈서버 k3s 구축기 - 5. 노드(VM) 구축 (0) | 2026.07.19 |
| 노트북 홈서버 k3s 구축기 - 4. Proxmox 설치 직후 설정 (1) | 2026.07.19 |