0. 앞 편에서 이어지는 자리
앞 편에서 노드를 사설망에서 떼어 격리망(10.0.0.x)으로 옮겼다. 바깥으로 나가는 통로는 방화벽 VM 하나뿐이고, 관리 접근은 등록된 키를 가진 기기만 터널로 들어온다.
그것은 클러스터 바깥의 경계다. 다음 편에서 클러스터 내부의 대기열 웹 서비스를 https로 공개하면 443 은 열 수밖에 없고, 그 문으로 정상 방문자처럼 들어와 그 뒤의 앱을 치는 것은 이 경계가 막지 못한다. 파드 하나가 잡히는 순간부터는 클러스터 안에 통제가 하나도 없다.
바깥 경계 — 앞 편에서 끝났다
인터넷 → 공유기 → 방화벽 VM → 격리망
│
안쪽 경계 — 이 편 ▼
파드 사이 통신 NetworkPolicy 잡힌 파드가 옆으로 못 번지게
API 서버 접근 권한 RBAC 사람과 파드가 쓸 수 있는 것을 나눠
데이터 계정 MySQL · Kafka 최소권한 앱이 DB 전권을 못 갖게
자원 상한 Quota · LimitRange 한 워크로드가 다른 몫을 못 잠식하게
여기에 하나가 더 붙는다. 공개는 사람이 안 보는 시간대를 만든다. 지금까지 이 서비스는 화면을 직접 보는 동안에만 돌았고, 그 밖의 시간에 무엇이 멈췄는지 알 수단이 없다. 그래서 공개보다 감시가 먼저다.
- 1절 — 클러스터 내부. 격리된 망 위에서 파드 사이 통신과 API 서버 권한을 좁힌다
- 2절 — 하이퍼바이저 감시와 스케줄. 노트북 자체(온도·전원·가동)를 보는 대시보드와 알림, 밤에 끄고 아침에 깨우는 스케줄
1. 클러스터 내부 보안 조치
앞 편의 격리망은 클러스터 바깥의 경계다. 그 안쪽은 비어 있었다.
- 클러스터 안에서는 어느 파드가 어느 파드에 접속하든 막는 것이 없었다
- 조회만 하는 컴포넌트가 전 네임스페이스의 Secret 을 읽을 수 있었다
- 예매 앱은 데이터베이스에 root 로 붙어 있었다
이 절은 그 셋을 순서대로 좁힌다. 파드 사이 통신이 먼저인 이유 — 어디서든 앱에 닿을 수 있으면, 앱이 가진 권한이 곧 모두의 권한이 된다. 나머지 둘의 노출 범위가 통신 위에서 정해진다.
조치 전후는 전부 실측한다. 파드 안에서 TCP 접속을 시도하고, 밖에서 curl 로 확인하고, 권한은 판정 명령과 실제 자격으로 잰다.
파드 사이 통신 — NetworkPolicy
정책이 판정하는 방식
쿠버네티스의 기본값은 전부 통과다. 파드는 클러스터 안 어느 파드에든 접속할 수 있고, 목적지가 그것을 거절할 수단이 없다. NetworkPolicy 가 그 기본값을 파드 단위로 뒤집는다.
판정 방식은 여섯 가지 성질로 정해져 있고, 이것이 아래 모든 조치의 문법이 된다.
- 정책이 고른 파드만 막힌다.
spec.podSelector가 고른 파드는 들어오는 접속이 전부 차단되고ingress에 적은 것만 통과한다. 어떤 정책도 고르지 않은 파드는 기본값 그대로 열려 있다. - 거부 규칙이 문법에 없다. 허용만 적을 수 있다. 막는 방법은 고르고 아무것도 적지 않는 것 하나 —
default-deny라 불리는 것의 실체가 그 빈 정책이다. - 같은 파드를 고르는 정책이 여럿이면 허용의 합집합이 된다. 넓은 정책이 하나 남아 있으면 좁은 정책이 무의미해진다. 차트가 자동으로 만드는 정책이 여기 해당할 수 있어, 정책 파일은 직접 읽어야 한다.
from은 원소끼리 OR, 한 원소 안은 AND 다.-하나가 원소 하나다.podSelector앞에-를 붙이느냐로 뜻이 뒤집히고, 문법 오류가 아니라서 틀리면 조용히 넓게 열린다.- ingress 와 egress 는 따로 판정된다. 한쪽만 걸면 다른 방향은 그대로 열려 있다. 연결 하나가 성립하려면 출발지의 egress 와 목적지의 ingress 를 모두 통과해야 한다.
- 오브젝트는 네임스페이스에 살고 판정은 파드 단위다. "네임스페이스에 정책을 건다"는 말은
podSelector: {}로 그 안의 모든 파드를 고른 경우를 가리킨다.
podSelector: {} # 라벨 조건 없음 = 이 네임스페이스의 모든 파드
policyTypes: [Ingress]
ingress: [] # 규칙 0개 = 통과할 조건이 없다
from:
- namespaceSelector: {app} # app 네임스페이스의 booking 파드 (AND)
podSelector: {booking}
- namespaceSelector: {app} # app 네임스페이스 전부 또는 (OR)
- podSelector: {booking} # 어느 네임스페이스든 booking 라벨 파드
조치 전 — 어느 파드에서든 세 앱과 관측 스택에 닿는다
착수 시점의 네임스페이스별 정책 현황이다.
| 네임스페이스 | 정책 | 내용 |
|---|---|---|
data |
6 | default-deny + MySQL·Redis·Strimzi 출처 제한 |
argocd |
4 | 셋은 좁다. argocd-server 만 ingress: [{}] 로 전면 허용 |
observability |
1 | 이미지 렌더러의 8081 만 좁힌다 |
app |
0 | 없다 |
data 를 먼저 잠근 것은 MySQL·Redis·Kafka 가 거기 있어서다. 그 판단이 앱 자신을 놓쳤다.
data 의 규칙은 "MySQL 에는 booking 만 접속한다"까지만 정한다. booking 파드에 누가 접속할 수 있는지는 정하지 않는다. booking 은 이미 MySQL 접속 자격을 갖고 있으므로, booking 에 닿을 수 있는 쪽은 booking 을 거쳐 그 자격을 쓸 수 있다.
실제 노출 범위를 data 의 mysql 파드 안에서 TCP 접속으로 쟀다. 시도한 목적지 전부가 연결됐다.
| mysql-0 에서 시도한 목적지 | 결과 |
|---|---|
frontend:8080 · booking:8091 · queue:8090 · queue:9091 |
연결 |
grafana:80 · mimir-gateway:80 · mimir-distributor:8080 |
연결 |
loki-gateway:80 · tempo:3200 · minio:9000 |
연결 |
alloy:4317 · alloy:4318 · argocd-server:80 · traefik:80 |
연결 |
이 상태에서 파드 하나가 잡히면 거기서 끝나지 않는다.
grafana 파드 침해
→ mysql:3306 차단. data 정책이 booking 만 허용한다
→ booking:8091 통과. 침해자가 booking 이 가진 MySQL 접속을 쓴다
목적지를 각각 뚫을 필요가 없다. 하나를 잡으면 그 하나가 닿는 곳 전부가 따라오고, data 에 걸어 둔 규칙은 앱을 거쳐 우회된다.
관측 스택 쪽은 노출의 성격이 다르다. Mimir 에 지표, Loki 에 로그, Tempo 에 트레이스가 있고 MinIO 에 그 셋의 오브젝트 원본이 있다. 접속이 열려 있다는 것은 그 저장소들의 읽기와 쓰기가 열려 있다는 뜻이다.
선행 — 노드 주소 이전이 남긴 죽은 값부터 고친다
정책을 새로 쓰기 전에 고칠 것이 있다. 앞 편에서 노드 주소를 192.168.0.201-203 에서 10.0.0.11-13 으로 옮겼는데, 노드 IP 를 숫자로 적어 둔 파일 둘이 옛 주소인 채로 남아 있었다. 기존 정책을 참고해 새 정책을 쓰면 그 죽은 주소가 함께 복사된다.
| 파일 | 고친 것 | 그동안의 증상 |
|---|---|---|
bootstrap/control-plane/metrics.yaml |
etcd 지표 Endpoints 주소 3개 | 존재하지 않는 주소를 스크레이프해 대시보드의 etcd 합의 패널이 0, 세 노드가 "비정상". 고친 뒤 3 |
workloads/manifests/netpol/data-ingress.yaml |
ipBlock cidr 9줄 |
어떤 출발지와도 일치하지 않는 상태 |
반영 경로가 갈린다.
| 경로 | 반영 방법 |
|---|---|
workloads/manifests/** |
ArgoCD Application 이 보고 있다. 머지하면 자동 |
bootstrap/** |
보는 Application 이 없다. kubectl apply -f bootstrap/control-plane/ 를 직접 실행한다 |
조치 1 — app 으로 들어오는 접속을 여섯 줄로
정책은 실제로 오가는 통신을 알아야 쓸 수 있다. 빠뜨린 경로는 그대로 끊긴다. 그래서 통신을 전수로 세는 것이 첫 작업이고, 출발점은 Ingress 라우팅이다.
Ingress "frontend" (10.0.0.240:80)
/api/admission ─► queue:8090
/api/movies ─► frontend:8080 frontend 의 nginx 가 응답을 캐시하며 booking 으로 되보낸다
/api ─► booking:8091
/ ─► frontend:8080
여기에 지표 수집 경로를 더하면 여섯 줄이 전부다.
| 목적지 | 포트 | 출처 | 근거 |
|---|---|---|---|
| frontend | 8080 | traefik/traefik |
Ingress 의 / 와 /api/movies |
| booking | 8091 | traefik/traefik |
Ingress 의 /api |
| booking | 8091 | app/frontend |
nginx.conf 의 location /api/movies 가 booking 으로 되보낸다 |
| booking | 8091 | observability/alloy |
ServiceMonitor 가 /actuator/prometheus 를 15초 간격으로 긁는다 |
| queue | 8090 | traefik/traefik |
Ingress 의 /api/admission |
| queue | 9091 | observability/alloy |
PodMonitor 가 파드에 직접 붙는다 |
세다 보면 갈리는 자리가 둘 나온다.
- booking 의 8091 은 앱 트래픽과 지표 노출이 같은 포트다. 스프링 액추에이터가 별도 포트를 열지 않는다.
- queue 는 앱 트래픽 8090 과 지표 9091 로 갈린다.
frontend → queue는 표에 없다. nginx.conf 에는location /api/admission/프록시 규칙이 있지만, Traefik 이 그 경로를 queue 로 직접 보내 frontend 를 거치지 않는다. 설정 파일만 보고 세면 없는 경로를 열게 되는 자리다. Ingress 에서 그 규칙을 빼면 여기도 열어야 한다 — 이 조건은 정책 파일 주석에 남긴다.
거는 순서에도 함정이 있다. default-deny 를 먼저 걸면 podSelector: {} 가 세 앱을 동시에 고르는데, 허용 규칙이 아직 없어 API 가 통째로 끊긴다. 그래서 frontend 하나만 고르는 정책을 먼저 건다. 정책이 고르지 않은 booking 과 queue 는 그대로 돌고, 실패해도 영향이 정적 페이지에 그친다.
이 첫 수가 확인 하나를 겸한다. 기존 data 정책은 kubelet 의 httpGet 프로브를 통과시키려고 노드 IP ipBlock 을 적어 두고 있었다. 이번 정책에는 그것을 넣지 않고 70초를 관찰했다.
frontend 파드 재시작 0 · NotReady 0
같은 시점의 대조군 data 파드에서 frontend:8080 접속 → 차단
정책이 실제로 차단하는 상태에서 프로브가 통과한다. 이 클러스터(CNI 는 Calico)에서 프로브 통과에 노드 IP 가 필요하지 않다는 뜻이다. 이후의 모든 정책에서 ipBlock 을 뺐고, data 에 남아 있던 아홉 줄은 이 절 끝에서 걷어낸다.
정책 파일의 자리도 정해야 했다. data 정책이 있는 디렉터리에 같이 두면 동기화가 실패한다.
namespace app is not permitted in project 'infra'
ArgoCD 의 AppProject 는 Application 마다 배포할 수 있는 네임스페이스를 제한한다. 그 디렉터리를 배달하는 Application 이 속한 infra 프로젝트에 app 이 없다. infra 에 app 을 추가하면 인프라 Application 이 앱 네임스페이스에 무엇이든 배포할 수 있게 되므로, 그 경계는 그대로 두고 — 정책 파일을 netpol-app 디렉터리로 옮겨 apps 프로젝트 소속 Application 이 배달하게 했다.
조치 2 — app 에서 나가는 접속을 아홉 줄로
들어오는 방향을 막아도 파드가 잡혔을 때 나가는 방향이 남는다. 조치 전의 booking 파드에서 재 보면 grafana:80 · minio:9000 · argocd-server:80 은 물론 인터넷(1.1.1.1:443)까지 연결됐다. 침해자가 데이터를 밖으로 보내는 경로가 그대로 열려 있는 것이다.
나가는 쪽도 같은 방식으로 전수를 세면 아홉 줄이다.
| 출발 | 목적지 | 포트 |
|---|---|---|
| 세 앱 전부 | kube-system/kube-dns |
53 (UDP · TCP) |
| frontend | app/booking |
8091 |
| booking | data/mysql |
3306 |
| booking | data/redis |
6379 · 26379 |
| booking | data/kafka |
9092 |
| booking | observability/alloy |
4318 |
| queue | data/redis |
6379 · 26379 |
| queue | data/kafka |
9092 |
| queue | observability/alloy |
4317 |
줄마다 조건이 붙는 자리가 셋 있다.
- DNS 가 나머지 전부의 전제다. 이름 해석이 막히면 목적지 주소를 구하지 못해 접속 자체가 시작되지 않는다. frontend 의 nginx 는 업스트림을 요청 시점에 해석해서(
resolver 10.43.0.10) 특히 그렇다. 응답이 512바이트를 넘으면 DNS 가 TCP 로 재질의하므로 UDP 와 TCP 를 함께 연다. - Redis 는 두 포트가 한 쌍이다. 클라이언트가 26379(Sentinel)로 마스터 주소를 받고 그 주소의 6379 로 다시 접속한다. 26379 만 열면 마스터 조회는 되고 다음 명령에서 멈춘다.
- Kafka 는 규칙 한 줄이 두 단계를 덮는다. 클라이언트가 bootstrap 주소로 붙은 뒤 개별 브로커 주소를 받아 다시 붙는데, 브로커 파드가 모두
app.kubernetes.io/name=kafka라벨이라 한 줄로 둘 다 허용된다.
ports 에 적는 값은 목적지 파드가 여는 포트다. Service 포트가 아니다 — kube-proxy 가 Service 주소를 파드 주소로 바꾼 뒤에 정책이 판정한다. mysql·tcp-clients 처럼 이름으로 적힌 targetPort 는 파드의 containerPort 를 찾아 숫자로 옮겨 적었다.
조치 3 — observability 로 들어오는 접속
관측 스택은 범위를 네임스페이스 경계로 잡았다. 안은 열고, 밖에서 들어오는 것만 좁힌다.
Mimir 는 마이크로서비스 여덟 종이고 gossip(7946)·gRPC(9095)·HTTP(8080)·MinIO(9000)로 서로 얽혀 있다. 그 그래프를 규칙으로 옮기다 틀리면 관측이 멈추고, 관측이 멈춰서 원인 추적도 막힌다. 좁혀서 얻는 것은 같은 네임스페이스 안의 격리뿐이라 비용이 이득보다 크다.
| 정책 | 고르는 파드 | 허용 |
|---|---|---|
default-deny-ingress |
전부 | 없음 |
allow-same-namespace |
이미지 렌더러를 뺀 전부 | 같은 네임스페이스의 모든 파드 |
grafana-ingress |
grafana | traefik/traefik → 3000 |
alloy-otlp-ingress |
alloy | app/booking → 4318 · app/queue → 4317 |
여기서 걸리는 자리가 둘 있다.
- 이미지 렌더러를 빼는 이유는 합집합 성질이다. 그 파드는 grafana 에서만 닿게 하는 정책이 이미 있다.
allow-same-namespace가 렌더러까지 고르면 허용이 합쳐져 그 좁은 정책이 무의미해진다. 제외에 쓴NotIn셀렉터는 그 라벨이 아예 없는 파드도 고른다 — 라벨 없는 minio 가 이 정책에 포함되는 근거다. - grafana 를 여는 포트는 3000 이다. grafana 의 Service 는 80 인데 파드는 3000 을 연다. 정책의
ports는 파드 포트로 판정하므로 80 으로 적으면 아무것도 통과하지 못한다. 실제로 처음에 80 으로 적어 접속이 Traefik 까지만 가고 타임아웃이 났고, 3000 으로 고쳐 복구했다.
observability-host 의 node-exporter 는 이 정책의 대상이 아니다. hostNetwork: true 로 떠서 파드 네트워크를 쓰지 않는다.
완성 — 통로 열여섯 줄과 검증
세 조치를 합치면 클러스터 안 통신이 이 목록으로 줄어든다. 적히지 않은 조합은 전부 차단된다.
app — 들어오는 것
traefik/traefik ─► frontend:8080 · booking:8091 · queue:8090
app/frontend ─► booking:8091
observability/alloy ─► booking:8091 · queue:9091
app — 나가는 것
frontend ─► app/booking:8091
booking ─► data/mysql:3306 · data/redis:6379·26379 · data/kafka:9092
observability/alloy:4318
queue ─► data/redis:6379·26379 · data/kafka:9092
observability/alloy:4317
전부 ─► kube-system/kube-dns:53
observability — 들어오는 것
traefik/traefik ─► grafana:3000
app/booking ─► alloy:4318
app/queue ─► alloy:4317
같은 네임스페이스 안 ─► 서로 허용
검증은 차단과 허용을 모두 잰다. 한쪽만 재면 정책이 아예 작동하지 않는 상태와 구분되지 않는다.
| 검증 | 결과 |
|---|---|
data/mysql-0 → app 네 포트 · observability 여덟 목적지 |
전부 차단 |
| booking → mysql:3306 · redis:26379 · kafka:9092 · alloy:4318 | 연결 |
| booking → grafana:80 · minio:9000 · argocd-server:80 · frontend:8080 | 차단 |
| booking → 1.1.1.1:443 · 8.8.8.8:53 (인터넷) | 차단 |
외부 → / · /api/movies · grafana.cgv.lan/login |
HTTP 200 |
외부 → /api/admission/status |
HTTP 404 — queue 의 Go ServeMux 가 낸 응답. Traefik 이 목적지에 못 닿으면 502 가 온다 |
| app 파드 7개 · observability 파드 20개 | 재시작 없음, 로그 오류 0건 |
이것으로 지켜지는 것은 침해가 번지는 범위다. grafana 파드를 잡아도 booking 과 minio 에 못 가고, booking 파드를 잡아도 관측 저장소와 인터넷으로 못 나간다.
지켜지지 않는 것은 이미 허용된 통로로 오는 것이다. booking 파드를 잡으면 mysql:3306 은 여전히 간다 — booking 의 정당한 경로고, NetworkPolicy 는 주소와 포트만 판정할 뿐 그 연결로 어떤 SQL 이 흐르는지는 보지 않는다.
그래서 다음 조치가 둘로 갈린다. 허용된 통로 끝에서 무엇을 할 수 있는지 — 권한과 계정. 그리고 허용된 통로가 무엇을 실어 나르는지 — 계측 내용.
API 서버 접근 — 권한을 실제 사용 범위로
파드 사이 통신과 별개의 층이 하나 더 있다. 파드를 만들고, 목록을 읽고, Secret 을 가져오는 일 — 클러스터의 상태를 읽거나 바꾸는 요청이다. 이 요청은 전부 API 서버로 가고, 파드끼리의 통신이 아니라 NetworkPolicy 의 규칙이 닿지 않는다.
API 서버를 부르는 것은 사람(kubectl)만이 아니다. ArgoCD 는 배포하려고, Grafana 의 대시보드 보조 컨테이너는 ConfigMap 을 읽으려고 같은 API 서버를 부른다. 그래서 파드에도 신원과 권한이 있다.
API 서버는 요청 하나를 두 단계로 판정한다. 인증이 요청을 보낸 것이 누구인지 확정하고, 인가가 그 신원으로 이 요청을 해도 되는지 정한다. 신원의 증명 수단이 주체마다 다르다.
| 주체 | 신원 | 증명 수단 |
|---|---|---|
| 사람 | 인증서에 적힌 이름(CN)과 그룹(O) |
인증서 + 개인키. ~/.kube/config 에 담겨 있다 |
| 파드 | ServiceAccount | 파드 안에 마운트된 토큰 |
인증이 끝나도 권한은 0 이다. 그 이름이 무엇을 할 수 있는지는 클러스터 안의 RBAC 오브젝트가 정한다.
- 규칙 묶음 (
Role·ClusterRole) — 무엇을 어떻게 할 수 있는지 적은 것 - 연결 (
RoleBinding·ClusterRoleBinding) — 그 묶음을 신원에 붙이는 것
어느 조합이냐가 적용 범위를 정한다.
| 규칙 묶음 | 연결 | 적용 범위 |
|---|---|---|
Role |
RoleBinding |
그 네임스페이스에서만 |
ClusterRole |
RoleBinding |
그 네임스페이스에서만 — 같은 규칙을 여러 곳에 재사용할 때 |
ClusterRole |
ClusterRoleBinding |
모든 네임스페이스에서 |
연결이 없는 이름은 아무것도 못 한다. 관리자 인증서의 system:masters 가 전권인 것도 그 이름이 특별해서가 아니라, cluster-admin 이라는 연결이 그 그룹을 가리키고 있어서다. 그 연결을 지우면 같은 인증서로 아무것도 못 하게 된다.
인증서 검증의 동작과 kubeconfig 의 구조는 별도 글로 정리했다 — 번외 '쿠버네티스의 인증과 인가'.
확인 — 누가 무엇을 할 수 있나
사람 쪽 자격은 하나뿐이었다. 클러스터 전체에서 cluster-admin 이 묶인 연결은 system:masters 그룹 하나이고, 그 그룹은 관리자 kubeconfig 인증서의 O 값이다.
- 파드 목록을 보는 조회에도 쓰기·삭제 권한까지 든 전권 자격이 나간다
- 그 자격은 파일 하나(
~/.kube/config)로 존재한다 - 유출되면 만료까지 회수 수단이 없다
파드 쪽은 확인된 적이 없었다. 차트를 설치하면 차트가 자기 ServiceAccount 와 ClusterRole 을 함께 만드는데, 설치하면서 그것을 읽지 않았다. kubectl auth can-i 는 다른 신원인 척하고 판정만 물어보는 명령이라 상태를 바꾸지 않는다. 이것으로 Secret 을 읽을 수 있는 주체를 전수로 훑었다.
kubectl auth can-i list secrets --as=system:serviceaccount:observability:grafana -A
시스템 컨트롤러를 뺀 일곱이 나왔다. 다섯은 그 권한이 존재 이유이거나 곧 필요해지는 것이었고, 둘이 남았다.
| 주체 | 판단 |
|---|---|
sealed-secrets |
봉인 해제가 존재 이유다 |
cert-manager |
인증서 Secret 을 만들고 갱신한다 |
argocd-server · argocd-application-controller |
Secret 을 배포하는 주체다 |
tigera-operator |
Calico 설치 주체다 |
traefik |
지금 Ingress 셋에 tls 가 없어 필요 없다. 다음 편에서 인증서를 붙이면 필요해진다 |
grafana |
실제 사용 범위보다 넓다 |
kube-state-metrics |
쓰지도 않는 것을 읽고 있다 |
grafana 는 grafana-clusterrole(configmaps·secrets 읽기)이 ClusterRoleBinding 으로 묶여 있었다.
그 결과 grafana 의 ServiceAccount 가 전 네임스페이스의 Secret 52개를 읽을 수 있었다.
data/mysql-secret MySQL root 비밀번호
data/redis-secret
app/app-admin-token 초기화 API 토큰
app/gitlab-registry 레지스트리 자격
argocd/argocd-secret ArgoCD 서버 키
argocd/argocd-repo-cgv-infra GitLab 저장소 자격
kube-system/k3s-N.node-password.k3s 노드 조인 비밀번호 3개
grafana 파드는 그 토큰을 automountServiceAccountToken: true 로 안에 갖고 있고, Ingress 로 노출되는 컴포넌트다.
이 넓은 권한이 필요해서 생긴 것도 아니었다. 대시보드를 실어 오는 보조 컨테이너는 searchNamespace: observability 로 한 곳만 감시하도록 이미 설정돼 있었다. values 파일 주석은 그 값을 좁히면 RBAC 도 좁아진다고 적고 있었는데, 그 문장이 틀렸다 — searchNamespace 는 보조 컨테이너의 감시 범위를 정하고, RBAC 범위는 차트의 rbac.namespaced 값이 따로 정한다.
kube-state-metrics 는 차트 기본 collectors 목록에 secrets 가 들어 있어 전 네임스페이스 Secret 의 list 권한이 생겨 있었다. 이 클러스터의 대시보드는 kube_secret_* 지표를 하나도 쓰지 않는다.
조치 — 넓은 둘을 줄이고, 읽기 전용 자격을 만든다
grafana 를 차트 값으로 ClusterRole 을 네임스페이스 Role 로 내렸다. rbac.namespaced: true 가 그 전환인데, 그렇게 만들어지는 Role 은 rules 가 비어 있어 보조 컨테이너가 ConfigMap 을 못 읽는다. extraRoleRules 로 실제 쓰는 것만 채운다.
rbac:
namespaced: true
extraRoleRules:
- apiGroups: [""]
resources: ["configmaps", "secrets"] # 보조 컨테이너 설정이 RESOURCE=both — 둘 다 감시한다
verbs: ["get", "list", "watch"]
적용 전에 helm template 으로 렌더해 확인했다 — ClusterRole 이 0개가 되고 observability 의 Role 하나만 남는다.
kube-state-metrics 는 collectors 를 기본 목록에서 secrets 하나만 뺀 27개로 명시했다. 이 값을 바꾸면 수집 인자와 ClusterRole 규칙이 함께 빠진다.
읽기 전용 자격은 인가와 인증을 각각 채워야 성립한다. 연결만 만들면 그 이름을 주장할 사람이 없고, 인증서만 발급하면 그 이름이 아무것도 못 한다.
인가 — 그룹에 읽기 권한을 묶는다. 대상을 사람이 아니라 그룹(cgv:viewers)으로 잡으면, 나중에 사람이 늘어도 인증서만 그 O 로 발급하면 되고 연결은 그대로 둔다.
| 규칙 묶음 | 내용 |
|---|---|
내장 ClusterRole view |
네임스페이스 안 리소스 읽기. secrets 와 RBAC 오브젝트는 의도적으로 빠져 있다 — Secret 을 읽으면 그 안의 자격으로 다른 권한을 얻고, RBAC 오브젝트를 읽으면 어디를 노려야 권한이 올라가는지 알 수 있다 |
cgv-viewer-cluster-scope |
view 에 없는 것 — 노드 · PV · CRD · metrics.k8s.io(kubectl top) · NetworkPolicy · ArgoCD·Prometheus·Strimzi 커스텀 리소스 |
인증 — 노드에서 인증서를 발급한다. 인증서에 서명하는 client-ca 의 개인키가 노드에만 있다.
openssl genrsa -out viewer.key 2048
openssl req -new -key viewer.key -out viewer.csr -subj "/CN=viewer/O=cgv:viewers"
sudo openssl x509 -req -in viewer.csr -days 90 \
-CA /var/lib/rancher/k3s/server/tls/client-ca.crt \
-CAkey /var/lib/rancher/k3s/server/tls/client-ca.key \
-CAserial /tmp/ca.srl -CAcreateserial \
-out viewer.crt
- 유효기간 90일 — 인증서에는 폐기 목록이 없어 유출돼도 취소할 방법이 만료뿐이다. 유효기간이 곧 위험 노출 시간이 된다
-CAserial /tmp/ca.srl— 기본값이면 k3s 의 tls 디렉터리에 일련번호 파일이 생긴다
발급한 인증서와 키는 데스크탑으로 가져와 kubeconfig 에 등록한다.
kubectl config set-credentials viewer --client-certificate=... --client-key=... --embed-certs=true
kubectl config set-context viewer --cluster=default --user=viewer
--embed-certs=true 로 내용이 kubeconfig 안에 들어가므로 가져온 파일은 지우고, 노드의 임시 파일도 지운다. 평소 조회는 kubectl config use-context viewer 로 이 자격을 쓰고, 고칠 때만 관리자로 돌아온다.

결과 — 무엇이 보장되나
| 항목 | 조치 전 | 조치 후 |
|---|---|---|
| grafana 의 SA 가 읽는 Secret | 전 네임스페이스 52개 | observability 안에서만 |
grafana-clusterrole |
존재 | 없음 (NotFound) |
| kube-state-metrics 의 Secret 권한 | 전 네임스페이스 list |
없음. 수집 인자에서도 빠졌다 |
| 사람이 쓰는 자격 | 관리자 인증서 하나 | 관리자 + 읽기 전용 viewer contexts 한 벌 |
파드 쪽 판정은 auth can-i 로, 사람 쪽은 발급한 인증서로 실제 접속해서 쟀다.
kubectl --context=viewer auth whoami
Username viewer
Groups [cgv:viewers system:authenticated]
인증서에 적은 CN 과 O 가 그대로 신원으로 읽혔다.
| 판정 | 결과 |
|---|---|
grafana · kube-state-metrics 의 SA → list secrets -A |
no |
viewer → pods · nodes · kubectl top nodes · applications · networkpolicies |
응답 |
| viewer → secrets · delete pods · create ns · clusterrolebindings | Forbidden |
| grafana 대시보드 | 보조 컨테이너가 파일 5개를 떨어뜨림. forbidden 오류 0건, 화면 HTTP 200 |
거부는 요청자의 이름이 찍힌 채로 온다.
Error from server (Forbidden): secrets is forbidden:
User "viewer" cannot list resource "secrets" in API group "" in the namespace "data"
보장되는 것은 파드가 침해됐을 때 그 토큰으로 얻는 범위다. grafana 파드를 잡아도 이제 MySQL 비밀번호도 ArgoCD 키도 노드 조인 비밀번호도 읽히지 않는다 — observability 안까지다.
보장되지 않는 것은 정당한 권한을 가진 주체가 침해된 경우다. sealed-secrets 나 argocd-application-controller 가 잡히면 그 권한이 그대로 넘어간다. 그쪽은 권한이 존재 이유라 줄일 수 없고, 그 주체가 침해되지 않게 하는 것 — 이미지 출처 검증, 파드 보안 — 이 다른 층의 일이다.
허용된 접속이 나르는 것 — 계정과 계측
앞의 두 조치가 남긴 자리가 이 절이다. booking 이 잡히면 mysql:3306 은 여전히 간다 — 그 접속 끝에서 무엇을 할 수 있는지는 계정이 정한다. 그리고 허용해 둔 계측 경로(alloy 4317)가 무엇을 실어 나르는지도 확인한 적이 없었다.
booking 의 MySQL 계정을 root 에서 cgv 전용으로
booking 은 MYSQL_USER=root 로 접속하고 있었다. SHOW GRANTS 로 확인하면 그 계정은 SHUTDOWN·FILE·CREATE USER 를 포함한 전역 권한과 시스템 권한 40여 개를 WITH GRANT OPTION 으로 모든 데이터베이스에 갖는다. booking 이 실제로 쓰는 것은 cgv 데이터베이스의 테이블 5개다.
cgv 안에서만 권한을 갖는 계정을 만들었다.
CREATE USER cgvapp@'%';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, REFERENCES,
CREATE TEMPORARY TABLES ON cgv.* TO cgvapp@'%';
CREATE·DROP·ALTER는 booking 이DDL_AUTO=update로 떠서 Hibernate 가 스키마를 바꾸기 때문에 필요하다- 범위가
cgv라 그 밖에는 닿지 않는다 — 이 계정으로mysql.user조회는ERROR 1142,CREATE DATABASE는ERROR 1044로 거부된다
비밀번호도 새로 발급했다. 확인해 보니 root 비밀번호와 앱용 비밀번호(mysql-password)와 booking 의 MYSQL_PASSWORD 가 전부 같은 문자열이었다. 그대로 두면 계정을 나눠도 booking 이 잡혔을 때 그 문자열로 root 로 붙을 수 있다. SealedSecret 둘(booking-secrets·mysql-secret)을 새 값으로 다시 봉인했다.
차트 values 에도 auth.username: cgvapp 을 넣었다. 이 값은 데이터 디렉터리가 빈 최초 기동에만 적용된다 — 지금 인스턴스는 Using persisted data 로 떠서 초기화를 건너뛴다. 그래서 계정은 SQL 로 직접 만들었고, values 의 값은 이 스택을 새로 세울 때 같은 계정이 생기게 하는 재현용이다. 그 사실을 파일 주석에 적었다.
머지 후 booking 이 MYSQL_USER=cgvapp 으로 재기동했다. DB 오류 0건, /api/movies 는 HTTP 200 이다.
data 정책의 죽은 아홉 줄을 걷어낸다
조치 1 에서 확인한 대로 kubelet 프로브 통과에 노드 IP 가 필요 없다. data 정책에 남아 있던 ipBlock 아홉 줄을 걷어냈다.
mysql-ingress·redis-ingress— 그 줄만 지운다. Alloy 셀렉터가 남는다strimzi-operator-probe— 정책 자체를 지운다.from이 그 줄뿐이라 줄만 지우면from이 비어 8080 이 전면 개방되고, 정책을 지우면default-deny만 그 파드를 골라 8080 이 완전히 닫힌다. 조치 전보다 좁아진다
정책을 지워도 되는 근거는 셋이다 — 그 파드를 고르는 Service 가 없고, 그 파드를 대상으로 하는 모니터가 없고, 파드가 여는 포트는 프로브 전용 8080 하나다. 지운 뒤 80초 관찰에서 오퍼레이터의 재시작 카운터가 그대로였고, mysql 9104 와 redis 9121 의 지표 수집도 유지됐다.
정책 파일 머리의 틀린 전제("프로브 출발지가 노드 IP 라 ipBlock 이 필요하다")도 함께 고쳤다. 그 문장을 두면 다음에 그것을 근거로 다시 넣게 된다.
Redis 계측이 명령 인자를 싣지 않게 한다
queue 는 Redis 명령마다 트레이스 span 을 붙인다(redisotel). 옵션 없이 호출하고 있었는데, WithDBStatement 기본값이 켜짐이라 명령 인자가 span 의 db.statement 속성에 통째로 실린다. Tempo 에 저장된 실물에서 확인한 값이다.
evalsha <sha> 6 sessions:{1}:active … 500 <requestId> <ms> 1
zrem pending_events:{1} A|ENTER|<requestId>
requestId 는 대기열 이용자마다 클라이언트가 만들어 보관하는 식별자다. 트레이스를 조회할 수 있는 사람은 누구나 그 값을 읽을 수 있는 상태였다.
WithDBStatement(false) 로 끄면 db.statement 에 명령 이름만 남는다. 어느 명령에서 느렸는지는 span 이름과 소요 시간으로 그대로 보이므로, 계측을 넣은 목적 — 요청 안에서 Redis 구간을 가르는 것 — 은 유지된다. booking 은 같은 시점 트레이스에 db.* 속성 자체가 없어 해당 없다.
이 편에서 안 하는 셋
| 항목 | 안 하는 근거 |
|---|---|
| Kafka 인증 | 리스너가 plain 9092 · tls: false · 인증과 인가 없음. 다만 9092 에 닿을 수 있는 것이 이미 booking·queue 파드뿐이다. SASL 을 켜면 Kafka CR·클라이언트 설정·시크릿이 동시에 바뀌고 브로커가 재시작된다 |
argocd-server 정책 |
ingress: [{}] 로 전면 허용 상태다. 다만 닿아도 API 가 자격을 요구한다(앞 편 점검에서 앱 목록 401 확인). 이것보다 먼저 정할 것은 다음 편에서 argocd.cgv.lan 을 외부에 공개할지 여부다 |
| ResourceQuota | 새 파드가 requests 없이 뜨는 것을 막는 장치인데, 지금 도는 워크로드는 전부 requests 를 명시하고 있어 막을 대상이 없다. 그리고 노드 스케줄 여유가 k3s-1 41 MiB · k3s-2 7 MiB 라, 이 상태에서 쿼터를 걸면 보호가 아니라 다음 배포가 뜨지 못하는 원인이 된다 |
2. 하이퍼바이저 감시와 스케줄
이 절의 작업은 전부 한 곳 — pve 호스트 — 에서 이뤄진다.
하이퍼바이저(물리 머신 한 대에 VM 여러 대를 올리는 층)를 Proxmox VE로 정한 것은 1부 4절 '하이퍼바이저 선택'이고, 이 노트북에 설치하며 관리망을 유선 NIC에 붙이고 호스트를 pve.home.arpa · 192.168.0.200/24로 고정한 것은 3부 2절 'Proxmox 설치'다.
Proxmox 화면에서 이 호스트가 pve 노드 하나로 보이고, 그 아래 201·202·203 세 VM이 k3s 노드(k3s-1·2·3)로 올라가 있다.
공개 전까지 이 서비스는 화면을 직접 보는 동안에만 돌았다. 공개는 노트북을 하루 종일 돌리는데, 사람이 없는 시간대의 장애를 알 수단이 지금은 없다. 그래서 이 절은 pve 호스트에 두 가지를 갖춘다 — 안 볼 때 상태를 보는 감시(하이퍼바이저 대시보드와 알림), 그리고 밤에 끄고 아침에 깨우는 스케줄. 감시 대상은 이 노트북 자체 — 온도·전원·가동 — 이고, 클러스터 안(VM 안) 대시보드는 이미 있으니 더하는 것은 그 아래 하이퍼바이저 층 하나다.
대시보드 — 노트북 자체를 본다
호스트가 예고 없이 꺼진 사건이 있었다. 꺼지기 직전의 온도 기록이 없어 원인을 확정하지 못했다 — 온도를 수집하는 경로가 없었기 때문이다. lm-sensors를 설치해 재보니 유휴에 가까운 상태에서 CPU 패키지가 93°C, 하드웨어 임계는 110°C. 과열 셧다운이 이 측정으로 뒷받침되고, 24시간 가동 전에 이 사각을 메운다. (Proxmox API를 읽는 pve-exporter로는 온도가 안 나온다 — 그래서 위의 node-exporter 경로다.)
node-exporter는 어느 층을 재나
그 하이퍼바이저 층(Proxmox)도 리눅스(Debian)라, 노드들과 같은 node-exporter로 잰다. 지표를 내는 곳은 층마다 다른데(상세),
위에서 아래로:
- 쿠버네티스 층 — kube-state-metrics (
kube_*). API 객체의 선언·상태 — requests·limits, allocatable, 노드 Ready. - 컨테이너 층 — cAdvisor (
container_*). cgroup 안 실사용 — kubelet 축출(eviction) 기준 working set. - 노드 OS 층 — node-exporter (
node_*). 리눅스 커널이 보는 노드 전체. 이번에 쓸 도구이자, 지금까지 여기서 뽑아 쓴 건 셋뿐:- CPU —
node_cpu_seconds_total - 디스크 용량·inode —
node_filesystem_* - 노드 식별 —
node_uname_info
- CPU —
이 셋은 VM 안에서 돌고, 그 아래 물리 노트북 층(Proxmox 호스트)은 지금까지 재지 않았다.
node-exporter가 내는 건 그 셋이 전부가 아니다. 공식 정의로는 "리눅스 등 유닉스 계열 커널의 하드웨어·OS 지표를 위한 Prometheus exporter"이고, 컬렉터를 켜고 끄는(pluggable) 구조다.
- 기본 컬렉터 — CPU·메모리·디스크 I/O·네트워크·파일시스템
- 여기에 hwmon(온도)·power_supply(전원)도 기본 — VM엔 물리 센서가 없어 안 켰을 뿐
호스트는 물리 노트북이라 hwmon에 CPU·NVMe 온도, power_supply에 어댑터·배터리가 실제로 올라온다. 그래서 호스트 node-exporter에선 이 둘을 켠다. 설치는 apt(Proxmox가 Debian이라 — 유닉스 계열 전용이니 Windows 하이퍼바이저면 windows_exporter).
진행은 넷이다. 호스트에 exporter 를 얹고, Alloy 가 긁게 하고, 판을 세워 다듬고, 판이 답하지 못하는 것을 지표로 만든다.
진행 1 · 호스트에 node-exporter 설치 — 온도·전원을 :9100에 노출
호스트를 하나의 리눅스 서버로 보고 표준 exporter를 얹는다. 그러면 하이퍼바이저의 하드웨어 지표가 :9100/metrics로 나온다.
echo coretemp >> /etc/modules # 온도 센서 모듈을 부팅 시 로드
apt install -y prometheus-node-exporter # :9100, systemd 자동 기동
systemctl enable --now prometheus-node-exporter
노출 확인:
curl -s localhost:9100/metrics | grep -E 'node_hwmon_temp_celsius|node_power_supply'
- 온도
node_hwmon_temp_celsius— CPU(coretemp) 코어·패키지, NVMe Composite. 센서 번호와 이름은node_hwmon_sensor_label이 매핑한다. - 전원
node_power_supply_online{power_supply="ADP1"}=1(AC 연결),node_power_supply_capacity{power_supply="BAT0"}=95(배터리 %). - 데비안 패키지가 collectors도 함께 설치해 디스크 SMART(수명)까지 수집된다.
진행 2 · Alloy가 호스트를 긁게 — 192.168.0.200:9100 → Mimir
호스트는 k8s 노드가 아니라 ServiceMonitor 경로가 없다. 그래서 Alloy 설정에 정적 대상으로 직접 지목한다(minio와 같은 방식). job="hypervisor" 라벨로 VM 노드의 node-exporter 지표와 분리하고, DaemonSet 3파드가 같은 대상을 세 번 긁지 않게 clustering을 켠다.
prometheus.scrape "hypervisor" {
targets = [{
__address__ = "192.168.0.200:9100",
job = "hypervisor",
instance = "pve",
}]
forward_to = [prometheus.remote_write.mimir.receiver]
clustering { enabled = true }
}
반영 후 Grafana에서 node_hwmon_temp_celsius{job="hypervisor"}가 나오면 수집이 닿은 것이다.
진행 3 · 대시보드를 세우고 다듬는다 — 첫 판의 문제를 걷어내며
대시보드 작업 동안은 dashboards Application의 targetRevision을 전용 dashboard 브랜치로 옮긴다 — 수정마다 main 머지 없이 브랜치 푸시로 반영된다. 이 브랜치는 작업 중 MR로 머지하지 않는다 — 머지하면 소스 브랜치가 삭제되고, Application이 그 리비전을 못 찾는다(unable to resolve 'dashboard'). 작업이 끝나면 main에 합치고 targetRevision을 main으로 되돌린다.
첫 판은 지표를 나열해 판정이 안 됐다. 걷어낸 것:
- CPU 온도 코어 14개 선 → 패키지 한 선. 패키지 센서가 가장 뜨거운 코어를 따라간다(실측: 최고 코어와 Last·Max 일치)
- iowait가 CPU별 시리즈라 같은 이름이 CPU 수만큼 찍힘 →
avg로 접는다 - 디스크 dm-* 선 → 물리 장치만. dm-*는 물리 장치 위 LVM 매핑이라 같은 I/O의 사본. 이름 하드코딩 대신 제외 필터(장치명이 바뀌어도 안 빠짐)
- 임계가 설명에만 있던 것 → 차트에 선으로(thresholdsStyle line)
- 한눈 판정 부재 → 행0 stat 줄(응답·온도·전원·배터리·부팅 시각)
- fuse 가상 FS(lxcfs·pmxcfs) 막대 → 제외
- 부팅 후 경과 stat → 제거. 마지막 부팅 시각과 같은 값의 두 표현이라
걷어낸 뒤에도 행이 발견 순서대로 얹혀 있었다. 마지막에 물리 축 하나에 행 하나를 주는 구조로 다시 배치했다 — 행0에서 색이 바뀐 축의 행으로 내려가면 원인이 나오도록.
그 과정에서 나온 정정:
- 주 디스크 SMART 판정을 행0으로 올렸다 — 홈랩 전부가 사는 디스크의 축이 한눈 줄에 없었다
- 마운트 용량은 포화(속도)가 아니라 저장 질문이라 저장 행으로 옮겼다
- 전력이 자기 행을 갖게 했다 — 값만 있고 추이(언제 끊겼나·얼마나 빨리 빠지나)를 볼 곳이 없었다
- CPU 온도 빨강선을 100 → 95로 당겼다. 과거 셧다운이 92–102°C 관측 영역에서 났다
- 내장 NVMe의 수명·경고 패널은 뺐다 — 저장 축은 주 디스크고, 서버가 안 쓰는 윈도우 디스크가 죽어도 서버는 돈다
주 디스크 SMART는 확인 결과 smartctl이 SAT로 USB 브리지를 통과해 읽고 있었다(collectors 패키지의 타이머) — hwmon으로 막혔던 sda 온도가 이 경로로 채워졌다. 다만 이 컨트롤러가 재할당 섹터·수명 속성은 안 넘긴다. 그래서 판정이 사실상 읽기 오류율 하나에 걸리고, 조기 신호 패널은 만들 수 없다. 그물이 좁다는 것을 판에 적어 두고, 디스크 손실 대비는 알림이 아니라 백업이 받는 것으로 정리했다.
진행 4 · 못 보던 것을 지표로 만든다 — VM 디스크가 사는 저장고
판을 세우고 나서 마운트 용량이 답하지 못하는 질문이 드러났다. /는 Proxmox 시스템 영역(96 GiB)이고, VM 201·202·203의 디스크는 거기가 아니라 local-lvm이라는 별도 저장고에 있다. 그건 마운트된 파일시스템이 아니라 node_filesystem에 안 나온다.
시에 쓰기를 못 해 클러스터가 멈추는 자리인데, 판에서 아예 안 보였다.
lvs가 그 값을 주므로, 읽어서 지표 파일로 직접 쓰고 node-exporter가 실어 나르게 한다(이미 SMART·NVMe가 같은 방식으로 돌고 있다).
cat > /usr/local/bin/thinpool-textfile.sh <<'EOF'
#!/bin/sh
set -eu
export LC_ALL=C
OUT=/var/lib/prometheus/node-exporter/thinpool.prom
TMP="$OUT.tmp.$$"
{
echo "# HELP pve_thinpool_data_used_ratio Thin pool data usage ratio (0-1)"
echo "# TYPE pve_thinpool_data_used_ratio gauge"
echo "# HELP pve_thinpool_metadata_used_ratio Thin pool metadata usage ratio (0-1)"
echo "# TYPE pve_thinpool_metadata_used_ratio gauge"
lvs --noheadings -o vg_name,lv_name,lv_attr,data_percent,metadata_percent 2>/dev/null | awk '
$3 ~ /^t/ {
gsub(",", ".", $4); gsub(",", ".", $5);
if ($4 != "") printf "pve_thinpool_data_used_ratio{vg=\"%s\",lv=\"%s\"} %.4f\n", $1, $2, $4/100;
if ($5 != "") printf "pve_thinpool_metadata_used_ratio{vg=\"%s\",lv=\"%s\"} %.4f\n", $1, $2, $5/100;
}'
} > "$TMP"
mv "$TMP" "$OUT"
EOF
chmod +x /usr/local/bin/thinpool-textfile.sh
echo '*/5 * * * * root /usr/local/bin/thinpool-textfile.sh' > /etc/cron.d/thinpool-textfile
임시 파일에 쓰고 mv로 바꾸는 것은 node-exporter가 반쯤 쓰인 파일을 읽지 않게 하기 위한 것이다. data와 metadata를 함께 내는 이유는, 데이터에 여유가 있어도 메타데이터가 차면 저장고 전체가 쓰기를 거부하기 때문이다.
같은 확인에서 lvs가 이 저장고의 실제 구성을 보여줬다:
data 815.36g Data% 8.13 Meta% 0.50 ← 저장고
root 96.00g ← / (Proxmox 시스템)
swap 8.00g
vm-201-disk-0 40.00g 43.86% vm-202-disk-0 40.00g 41.20% vm-203-disk-0 40.00g 42.22%
vm-201-disk-1 20.00g 3.28% vm-202-disk-1 30.00g 2.42% vm-203-disk-1 30.00g 2.47%
vm-201-disk-2 30.00g 2.44% vm-202-disk-2 5.00g 33.86% vm-203-disk-2 5.00g 36.45%
vm-201-disk-3 5.00g 37.04% vm-202-disk-3 5.00g 3.62% vm-203-disk-3 100.00g 6.75%
vm-202-disk-4 5.00g 3.46%
VM들에게 약속한 디스크 합계는 355 GiB 인데, 저장고에서 실제로 차지하는 양은 66 GiB다(각 볼륨의 크기 × 사용률을 더한 값이고, 815 GiB 의 8.13%와 맞는다). 약속만큼 미리 떼어 두지 않고 쓴 만큼만 차지하는 방식이라 그렇다. 대신 약속 총합이 저장고보다 커질 수 있어, 실사용이 늘면 넘칠 수 있다.
실측 — 유휴 90°C대의 원인을 변수 하나씩 바꿔 좁혔다 (2026-08-24). 대시보드가 서자마자 첫 측정 대상이 자기 자신의 온도였다:
| 단계 | 조건 | CPU 패키지 (유휴) | 판정 |
|---|---|---|---|
| 1 | 쿨링팬 X · 뚜껑 닫힘 | 90–92°C (최대 102°C) | 기준선 |
| 2 | 쿨링팬 O · 뚜껑 닫힘 | 90°C 그대로 | 팬은 섀시만 식혔다 — NVMe 61→50°C, CPU 불변 |
| 3 | 쿨링팬 O · 뚜껑 열림 | 78°C | 뚜껑이 병목이었다 — 이 노트북은 키보드 면으로 배기한다 |
| 4 | + 거버너 powersave | 66°C | 클럭 판이 남은 열원을 잡았다 — 유휴인데 클럭이 평균 3.1GHz로 고정(performance 거버너). powersave로 바꾸니 유휴 평균 2.4GHz로 내려가고, 최고 코어는 4.3GHz 그대로라 부하 성능은 유지된다 |
전원 축은 충전선을 뽑아 실증했다 — 값이 AC에서 배터리로 바로 바뀌고, 서버는 배터리로 계속 돌았다(잔량 95→87% 소모가 추이에 남았다). 배터리 모드는 클럭을 낮춰 온도를 왜곡하므로 각 단계는 AC 연결 상태에서 쟀다.
네 단계로 유휴 92°C가 66°C가 됐다 — 합계 26°C. 팬·뚜껑은 물리 조치고, 거버너는 호스트 설정이라 재부팅하면 원래대로 돌아간다. 밤마다 껐다 켜는 스케줄이 생기면 매일 아침 원복되므로, 부팅 때 다시 적용되게 고정한다.
거버너를 부팅에도 유지 — systemd 유닛
cat > /etc/systemd/system/cpu-governor-powersave.service <<'EOF'
[Unit]
Description=CPU governor powersave - 24/7 유휴 발열 저감
After=multi-user.target
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo powersave | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor'
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable cpu-governor-powersave
powersave는 클럭을 낮게 고정하는 것이 아니라 부하에 따라 오르내리게 하는 설정이다. 유휴에서 내려가고 부하가 오면 터보까지 올라간다 — 실측에서 최고 코어가 4.3GHz를 유지한 것이 그 확인이다.
이 평시(66°C)가 온도 판정선과 알림 임계의 근거가 된다. 부하 중 온도 거동은 마지막 부하 실측(13-3)에서 나오므로, 판정선은 그때 한 번 재검한다.
완성된 판. 물리 축 하나에 행 하나를 주고, 행 안에서는 왼쪽에서 오른쪽이 인과 순서다.
행0 지금 응답 · CPU 온도 · 전원 · 배터리 · 주 디스크 판정 · 마지막 부팅
행1 열 온도(증상) → 클럭(원인) → NVMe(섀시까지 뜨거운가)
행2 전력 전원·배터리 추이 → 비정상 종료 이력
행3 포화 CPU → 메모리/스왑 → OOM(그 결과) → 디스크 I/O
행4 저장 thin pool(VM 디스크) → 마운트(호스트 영역) → 주 디스크 온도
평소에는 행0만 보고, 색이 바뀐 축의 행으로 내려간다. 다만 행0은 죽는 축만 담고 있어 "다 초록"이 "느리지 않다"는 뜻은 아니다 — 느려짐은 행3이 답한다.


판이 못 보는 것 둘은 판에 적어 뒀다. 팬 회전수는 이 노트북이 센서를 안 내놓는다 — 물리 냉각 실패는 클럭·부하가 정상인데 온도만 높은 것으로 소거해서 판단한다. 그리고 이 판의 데이터가 자기 위층(VM 안 관측)을 지나오므로 관측이 죽어도 판이 공백이 된다. Proxmox 웹UI(:8006)가 관측과 독립인 확인 경로다.
알림 — 화면을 안 볼 때를 위한 것
여기까지 세운 대시보드는 내가 볼 때 답한다. 공개는 안 보는 시간을 만들고, 그 시간에는 판이 아무리 잘 서 있어도 소용이 없다. 그래서 알림은 판단 기준이 다르다 — 패널은 "이게 궁금한가"로 고르지만, 알림은 "이게 오면 일어나서 뭘 하나"로 고른다. 행동이 없으면 알림이 아니라 대시보드에 있으면 되는 값이다.
여기에 하나를 더한다. 안 울리면 되돌릴 수 없나. 이 둘로 거르면 후보 열 몇 개가 다섯으로 줄고, 다섯이 전부 호스트 하드웨어 판에서 나온다. 이유는 구조적이다 — 클러스터·앱 층은 소프트웨어라 재기동으로 회복되지만, 물리 층만 되돌릴 수 없는 것(과열 셧다운·전원 상실·디스크 사망)을 갖는다.
| 알림 | 조건 | 오면 여는 곳 | 무엇을 하나 |
|---|---|---|---|
| CPU 과열 | 패키지 > 90°C, 5분 | 행1 CPU 온도 추이 | 클럭이 높으면 거버너 원복·부하, 클럭·CPU가 정상인데 뜨거우면 물리 냉각 |
| AC 전원 끊김 | 어댑터 0, 2분 | 행2 전원·배터리 추이 | 기울기로 남은 시간을 보고 플러그·정전·어댑터 확인 |
| 배터리 20% | 잔량 < 20% | 행2 같은 패널 | AC가 안 돌아왔으면 정상 종료. 갑자기 끊기면 etcd·MySQL이 깨진다 |
| 주 디스크 SMART 이상 | 판정 0, 5분 | 행4 읽기 오류율 점수 | 아직 읽히는 동안 백업을 다른 매체로 빼고 교체 준비 |
| 호스트 감시 경로 끊김 | up=0, 5분 |
행0 호스트 응답 | 관측 판의 job별 생존에서 이 호스트만인지 확인 |
다섯째가 나머지 넷을 보증한다. 넷은 전부 호스트 스크레이프 하나에 매달려 있어서, 그게 막히면 알림이 조용해지고 그 조용함이 정상으로 읽힌다. 그래서 감시 자체를 감시하는 룰을 둔다. 데이터가 없을 때의 처리도 이 룰만 발화로 두고 나머지 넷은 발화하지 않게 했다 — 안 나누면 스크레이프 하나 끊길 때 알림 다섯이 한꺼번에 쏟아져 원인이 묻힌다.
이 다섯의 자리 — 서비스 관리가 아니라 서버 관리
다섯은 전부 시스템 내부 상태(온도가 높다, 전원이 끊겼다)를 알린다. 사용자가 겪는 것(서비스가 안 열린다, 예매가 실패한다)은 하나도 없다. 공개 전이라 사용자가 없고, 그런 알림은 밖에서 서비스를 찔러 봐야 성립하기 때문이다. 순서가 이렇게 된다.
| 시점 | 알릴 것 | 성립 조건 |
|---|---|---|
| 지금 | 물리적으로 죽는 것 | 없음 — 지금 만든 다섯 |
| 공개 후 | 밖에서 서비스가 안 열린다 | 외부 경로가 있어야 한다(13-3) |
| 부하 실측 후 | 예매 실패율·지연 | 정상 범위를 알아야 임계에 근거가 선다(13-3) |
부하와 지연 임계를 지금 넣지 않은 것도 같은 이유다. 근거 없는 임계는 오탐이 되고, 오탐이 쌓이면 사람이 알림을 무시하게 되어 진짜가 와도 지나친다.
알림이 실제로 도는 방식
룰은 상태를 가지고, 그 상태가 바뀔 때만 알림이 나간다.
Normal 조건이 거짓
↓ 조건이 참
Pending 참이지만 지속 시간(for)을 아직 못 채웠다 ← 알림 안 나감
↓ for 만큼 계속 참
Firing 발화 → 알림
↓ 조건이 거짓
Normal 해소 → 해소 메시지
지속 시간이 순간 튀는 값을 걸러 준다. 이 값을 얼마로 두느냐가 곧 무엇이 정상 요동인지 안다는 근거다.
발화한 뒤에도 계속 보내면 소음이 되므로 발송 간격을 셋으로 나눈다 — 30초(첫 발화를 모아 보낸다) · 5분(같은 묶음에 새 알림이 붙을 때) · 4시간(아무것도 안 변한 채 계속 발화 중일 때).
그래서 발화해도 알림은 한 번만 오고, 다시 오는 것은 상태가 바뀌거나 4시간이 지날 때다. 재알림을 짧게 잡지 않은 이유는 첫 알림을 놓치는 상황이 대개 자는 중이라 간격을 줄여도 결과가 같고, 짧으면 조치하는 동안 계속 울려 오히려 방해가 되기 때문이다.
그림이 실려 오는 구조
폰 discord로 온 알림에 패널 화면이 붙는다. 그림을 그리는 것은 Grafana가 아니라 별도 파드(grafana-image-renderer)다.
알림 발화
↓ Grafana 가 "이 패널을 그려 달라"고 요청
image-renderer 파드 (헤드리스 크로미움 = 화면 없는 브라우저)
↓ 받은 주소를 브라우저로 열어 Grafana 를 되부른다
Grafana (렌더 키가 로그인 대신이다 — 인증 없이 그 화면을 보게)
↓ 다 그려지면 스크린샷
PNG → Grafana → Discord 첨부
호출이 양방향이라 두 주소를 서로 알려줘야 하고, 렌더러가 있어도 화면 캡처 스위치를 켜야 그림이 생긴다(기본은 꺼져 있다).
관측 파이프라인과의 관계도 정리해 둔다.
| 무엇 | 어디서 오나 |
|---|---|
| 알림이 평가하는 값 | Alloy가 수집해 Mimir에 저장한 그 지표다. 알림용 저장소를 따로 만들지 않았다 |
| 판정하는 주체 | Grafana. Mimir의 룰 기능은 쓰지 않는다 — 대시보드의 판정 기준과 알림 조건을 같은 자리에서 관리하려고 |
| 그림 | 관측과 무관하다. Grafana 화면을 브라우저로 찍는 별개 도구다 |
알림은 기존 관측 위에 얹은 층이지 새 파이프라인이 아니다. 클러스터에 늘어난 것은 넷 — 알림 설정 ConfigMap, 그것을 배달하는 Application, 렌더러 파드, 웹훅 주소를 담은 SealedSecret.
렌더러 비용은 실측으로 적어 둔다. 대기 88MiB, 그리는 순간 591MiB, 패널당 6초에서 8초. 진짜 브라우저를 띄우기 때문이고, 그만큼 알림도 늦게 나간다. 이 파드가 죽어도 잃는 것은 그림뿐이고 알림은 텍스트로 계속 간다 — 렌더러가 죽어 있던 동안 실제로 그렇게 동작했다.
못 하는 것
호스트가 완전히 꺼지면 이 다섯이 전부 침묵한다. 알림도 그 호스트 위 클러스터에서 돌기 때문이고, 룰을 더 만든다고 메워지지 않는다. 그 구간은 밖에서 보는 프로브(13-3)만 잡는다.
같은 이유로 밤 예약 종료 시간대를 따로 억제하지 않는다. 알림을 평가하는 Grafana가 그 호스트 위에 있어 꺼진 동안에는 평가 자체가 없고, 기동 후에도 감시 경로가 지속 시간 5분을 채우기 전에 돌아온다(전원 인가에서 전체 준비까지 2분 43초 실측). 호스트가 9시간 반 꺼져 있던 날에도 감시 경로 끊김은 오지 않았다.

진행 — 알림을 붙인 순서.
진행 5 · 발송처를 먼저 붙이고, 다섯을 강제로 울린다
안 울려 본 알림은 없는 것과 같다. 그래서 정상 임계로 올리기 전에 다섯이 전부 발화하는 판을 한 번 올려 폰까지 닿는 것을 확인했다.
- 발송처는 Discord webhook. URL 자체가 자격이라 SealedSecret으로 봉인해 환경변수로 주입하고, 설정 파일에는 평문으로 두지 않는다
- 다섯 조건을 지금 값에서 바로 참이 되게 뒤집고 지속 시간을 1분으로 줄인다. 확인이 끝나면 정상값으로 되돌리는데, 되돌리는 순간 다섯이 해소되므로 해소 알림까지 같이 확인된다
이 순서가 아니면 알림이 안 왔을 때 룰 문제인지 발송 배선 문제인지 못 가른다. 실제로 이 검증에서 셋이 드러났다.
- 링크가 안 열렸다 — Grafana가 자기 외부 주소를 몰라 내부 포트(
:3000)로 링크를 만들었다. 서비스는 Traefik이 80으로 받는다 - 메시지를 사람이 못 읽었다 — 기본 형식이 라벨을 통째로 늘어놓고 링크를 네 줄 붙인다. 제목과 본문 형식을 지정하고 값을 요약 줄에 넣었다
- 패널 링크 줄이 비어 있었다 — 대시보드 식별자를 애노테이션에서 읽어 주소를 조립했는데, Grafana가 그 애노테이션을 내부용으로 걷어내 템플릿에서는 보이지 않는다. 완성된 주소를 주는 값이 따로 있어 그것으로 바꿨다
진행 6 · 알림 설정을 어디에 둘 것인가 — 두 번 옮겼다
처음에는 Grafana 차트 값에 넣었다. 머지하자 ArgoCD가 Grafana를 그리지 못했다.
executing "gotpl" at <.CommonLabels.alertname>: nil pointer
차트가 그 값에 Helm 템플릿 처리를 돌린다. 그래서 알림 메시지의 {{ }}를 Grafana가 아니라 Helm이 먼저 해석하려다 죽었다. 데이터소스 설정에서 Grafana가 ${...}를 환경변수 자리로 보는 것과 같은 종류의 함정인데, 이쪽은 알림 템플릿 전체가 {{ }}라 이스케이프로 못 피한다.
그래서 알림 설정을 평범한 ConfigMap으로 옮기고 Grafana에 마운트했다. ConfigMap은 Helm을 거치지 않아 문자열이 그대로 전달된다. 배달은 별도 Application으로 둔다 — 대시보드를 배달하는 Application에 얹으면 알림이 대시보드 작업 브랜치에 묶여 버린다.
옮기고 나서 두 번째 문제가 나왔다. Grafana 파드가 기동에서 멈췄다.
MountVolume.SetUp failed for volume "alerting": configmap "grafana-alerting" not found
대시보드를 따라 배달 순서를 관측 스택 뒤로 뒀는데, 성격이 다르다. 대시보드는 나중에 읽어가도 되지만 마운트는 파드가 뜨기 전에 있어야 한다. 순서를 관측 스택 앞으로 당겼다.
여기서 제약이 하나 남았다. 마운트라서 알림 설정을 고쳐도 Grafana가 다시 읽지 않는다 — 시작할 때 한 번만 읽기 때문이다. 대시보드는 전용 감시 컨테이너가 붙어 있어 바뀌면 바로 반영되지만 알림에는 그것이 없다. 지금은 파드를 다시 띄워 반영한다.
진행 7 · 그림 붙이기 — 렌더러가 응답 없이 재시작되고 있었다
화면 캡처를 켜고 렌더러를 세웠는데도 그림이 안 왔다. Grafana 로그에는 이렇게 보였다.
Failed to send request to remote rendering service ... EOF
... connect: connection refused
네트워크 문제처럼 보이지만 원인은 렌더러 안에 있었다. OOMKilled — 메모리 상한을 넘어 죽었다. 처음 잡은 상한이 384MiB였는데 실측하니 대기 88MiB, 그리는 순간 591MiB였다. 상한이 피크의 65%였으니 요청이 올 때마다 죽은 것이다.
이 실패가 겉으로 잘 안 드러난 이유도 남긴다. 파드는 평소에 멀쩡했다 — 대기 중에는 88MiB만 쓰고 헬스체크도 계속 통과했다. 살아 있는데 일을 시키면 죽는 모양이라, 부르는 쪽에서는 연결이 끊긴 것으로만 보였다.
상한을 피크의 130%로 열고, 요청은 대기값 기준으로 내렸다. 안 쓰는 몫을 예약해 두면 스케줄 여유가 빠듯한 노드에서 다른 파드가 못 들어온다.
스케줄 — 밤에 끄고 아침에 깨운다
노트북 한 대가 서버이자 작업 기기라 24시간 상시 가동이 전제가 아니다. 운영 시간을 07:30에서 23:30으로 정하고 그 밖에는 꺼 둔다.
끄는 근거는 하나로 좁혀졌다. 처음 이유는 발열이었는데, 앞 절의 조치로 평시가 66°C가 되면서 그 근거는 약해졌다. 남는 것은 디스크 쓰기량이다 — Proxmox와 VM 3대가 전부 USB SSD 한 장 위에 있고, etcd·Mimir·Loki가 유휴 상태에서도 계속 쓴다. 하루 8시간 꺼 두면 그만큼 쓰기가 줄고, 이 디스크가 죽으면 전부 잃는다.
끄면 이력서에서 링크로 여는 포트폴리오도 같이 닫힌다.
그래서 둘을 나눈다 — 포트폴리오 정적 파일은 Cloudflare Pages가 호스팅하고(노트북과 무관, 24시간), 노트북은 데모만 담당한다. 꺼진 시간에는 앞에 선 Cloudflare가 운영 시간을 응답한다.
끄고 켜는 것은 둘 다 pve 호스트에서 한다. 파드로 도는 쿠버네티스 CronJob이 아니라 호스트 자체의 cron이다 — 끄는 대상이 그 파드들을 올리고 있는 물리 머신이다.
23:30 호스트 cron
→ 다음 기동 시각을 RTC 에 예약
→ shutdown VM 3대가 ACPI 종료 신호를 받고 스스로 내려간다
23:32 전원 차단 [실측 1분 47초]
07:30 RTC 알람이 전원을 넣는다
→ VM 3대 자동 기동 (onboot)
07:33 전체 준비 완료 [실측 2분 43초]
끄는 쪽의 관건은 강제 정지로 전환되지 않는 것이다. 게스트가 정해진 시간 안에 안 내려가면 Proxmox가 전원을 끊고, 그 상태로 잘리면 etcd가 쓰다 만 채로 남는다. 대기 시간을 300초로 명시했고 실측이 1분 47초라 2.8배 여유가 있다.
켜는 쪽은 RTC 알람이 받는다. 메인보드 시계는 전원이 꺼져도 자체 배터리로 돌고, 거기에 기동 시각을 예약할 수 있다. 깨우는 다른 기기가 필요한 WoL과 달리 노트북 한 대로 성립한다. 다만 알람은 한 번 발동하면 사라져서, 매일 켜려면 끌 때마다 다시 걸어야 한다. 그래서 종료 스크립트가 "알람 걸기 → 확인 → 끄기" 순서를 갖는다.
진행 8 · VM 종료 대기 시간을 명시한다
세 VM 모두 onboot: 1(호스트 기동 시 자동 시작)과 agent: 1(게스트 에이전트)은 이미 켜져 있었고, startup 항목만 없었다. 그 항목이 없으면 종료 대기 시간이 기본값으로 쓰이는데, 그 값이 얼마인지 확인 없이 기대는 대신 명시한다.
for id in 201 202 203; do qm set $id --startup down=300; done
for id in 201 202 203; do echo -n "$id: "; qm config $id | grep startup; done
agent: 1이라 Proxmox가 신호만 보내고 기다리는 것이 아니라 실제로 내려갔는지 게스트에게 확인한다.
실측을 두 번 나눠 잰다. 한 대만 끌 때가 1분 31초, 호스트를 통째로 끌 때가 1분 47초였다. 3대가 병렬로 내려가 대수에 비례해 늘지 않는다.
time qm shutdown 203 # 한 대: 1m31s
date; shutdown -h now # 통째로: 09:12:12 명령
journalctl -b -1 | tail -1 # 09:13:59 저널 정지 → 1분 47초
두 번째 것은 명령을 내린 시각과 저널이 멈춘 시각의 차이로 잰다. shutdown은 명령을 내리고 바로 돌아오므로 time으로는 못 잰다.
한 대만 끄는 방식은 실제 상황과 다르다. 그 노드의 파드가 다른 노드로 옮겨가려 하는데, 노드 RAM 이 8 GiB 씩이라 받을 자리가 없어 Pending이 쌓인다. 밤 종료에는 3대가 같이 내려가므로 이 일이 없다. 한 대 종료 시간은 하한선으로만 쓴다.
한 대만 내렸을 때 대시보드가 통째로 비는 것도 확인됐다. Grafana는 살아 있었지만 Mimir의 querier와 store-gateway가 복제본 1개씩이고 하필 그 노드에 있었다. ingester는 3개라 견뎠다. dev는 단일, prd는 다중으로 가는 항목으로 남긴다.
진행 9 · 알람을 걸고 끄는 스크립트
먼저 RTC가 어느 시간대로 도는지 확인한다.
timedatectl # RTC in local TZ: no → RTC 는 UTC 로 돈다
알람을 epoch 초로 주면 시간대 변환이 끼어들지 않는다. 걸고 나면 반드시 읽어서 확인한다.
rtcwake -m no -t $(date -d 'tomorrow 07:30' +%s) # -m no = 알람만, 아무것도 끄지 않는다
date -d @$(cat /sys/class/rtc/rtc0/wakealarm) # 걸린 시각을 사람이 읽는 형태로
-m off로 알람과 종료를 한 번에 하는 방법은 쓰지 않는다. 그 경로가 systemd 종료 절차를 온전히 타는지 확실하지 않고, 그러면 위의 down=300이 적용되지 않을 수 있다.
매일 도는 스크립트.
#!/bin/sh
# 매일 밤 호스트를 끄고, 다음 기동 시각을 RTC 에 예약한다.
# RTC 알람은 한 번만 발동하므로 끌 때마다 다시 건다.
LOG=/var/log/nightly-shutdown.log
WAKE=$(date -d 'tomorrow 07:30' +%s)
# 오늘 밤만 건너뛰기: /root/skip-shutdown 을 만들어 두면 끄지 않는다.
# 파일은 여기서 지워지므로 다음 날은 평소대로 꺼진다.
if [ -f /root/skip-shutdown ]; then
rm -f /root/skip-shutdown
echo "$(date '+%F %T') 건너뜀 (skip-shutdown)" >> $LOG
exit 0
fi
rtcwake -m no -t $WAKE >> $LOG 2>&1
# 알람이 실제로 걸렸는지 읽어서 확인한다. 안 걸렸으면 끄지 않는다 —
# 끄고 나면 스스로 켜지 못해 사람이 가서 눌러야 한다.
SET=$(cat /sys/class/rtc/rtc0/wakealarm)
if [ "$SET" != "$WAKE" ]; then
echo "$(date '+%F %T') 알람 실패 ($SET != $WAKE) — 끄지 않음" >> $LOG
exit 1
fi
echo "$(date '+%F %T') 종료. 기동 예정 $(date -d @$WAKE '+%F %T')" >> $LOG
shutdown -h now
chmod +x /usr/local/bin/nightly-shutdown.sh
echo '30 23 * * * root /usr/local/bin/nightly-shutdown.sh' > /etc/cron.d/nightly-shutdown
결정 둘을 남긴다.
- 알람을 읽어 확인한 뒤에만 끈다.
rtcwake가 실패했는데 그대로 끄면 아침에 스스로 켜지지 못하고 사람이 가서 전원 버튼을 눌러야 한다. 확인이 안 되면 켜진 채로 둔다 — 안 꺼진 것은 되돌릴 수 있지만 안 켜진 것은 사람이 가야 한다. - 건너뛰기는 하룻밤짜리다. 늦게까지 작업하는 날은
touch /root/skip-shutdown으로 그날만 넘긴다. 파일이 스스로 지워져 다음 날은 평소대로 꺼진다.
설치 직후 검증은 밤을 기다리지 않고 한다. 건너뛰기 경로로 한 번 돌려 스크립트가 실행되는 것을 보고, 임시 cron 항목을 2분 뒤로 넣어 cron이 새 파일을 읽는지 확인한다. /etc/cron.d 파일은 문법이 틀리면 조용히 무시되므로, 이걸 안 보면 그날 밤에야 안 꺼진 것을 알게 된다.
2026-08-25 09:30:15 건너뜀 (skip-shutdown)
2026-08-25 09:33:01 cron 픽업 확인
기동 구간에서 파드 일부가 여러 번 재시작한다. 의존 대상보다 먼저 떠서 실패하기 때문인데, 프런트엔드의 nginx는 기동 시점에 업스트림 이름을 해석하고 CoreDNS가 아직 없으면 기동을 거부한다. 재시작이 곧 재시도라 의존 대상이 뜨면 붙고, 사람이 개입하지 않아도 위의 2분 43초 안에 수렴한다.
밤에 예약 종료된 것과 과열로 꺼진 것은 다르게 다뤄야 한다. 하이퍼바이저 대시보드가 부팅 시각으로 이 둘을 가른다.
3. 이 편을 닫으며 — 실무에서 더 짚어봐야 할 것
이 절은 할 일 목록이 아니다. 이 편 작업의 빈 곳을 실무 기준으로 확인하고, 각각을 언제 채울지 정해 두는 절이다. 항목마다 같은 순서로 적는다 — 무슨 일이 생길 수 있나, 실무 팀은 어떻게 막나, 이 홈랩은 어떻게 하기로 했나. 배정의 요약이 절 끝의 공백 표다.
다음에 정책을 고치다 실수하면, 아무도 모른다
생길 수 있는 일. 나중에 앱을 하나 더 얹으며 정책을 고치다 셀렉터를 한 글자 틀린다. 문법 오류가 아니라 배포는 성공한다. 그런데 어느 통로 하나가 조용히 열리거나 조용히 막힌다 — 오늘 손으로 잰 검증은 오늘 맞았다는 것만 보증하고, 그날의 실수는 아무것도 잡아 주지 않는다.
실무 팀은. 정책을 고칠 때마다 자동으로 도는 연결성 테스트를 두고("이 파드에서 저 파드로 되어야/막혀야 한다" 목록을 기계가 확인), CNI 가 떨어뜨린 패킷을 기록으로 남겨 "끊겼다"는 신고가 오면 정책 때문인지 앱 때문인지 바로 가른다.
이 홈랩은. Calico 의 차단 기록 설정이 있는데 켜지 않았다 — 노드 RAM 이 8 GiB 라 그 기록이 쌓일 여유를 아직 재지 않았다. 다음 편 부하 실측에서 여유를 확인한 뒤 켤지 정한다. 그 전까지는 끊김 신고가 오면 정책 파일을 손으로 되짚는 수밖에 없다.
다음 차트를 깔면, grafana 사건이 또 생긴다
생길 수 있는 일. 이번에 찾은 둘(grafana·kube-state-metrics)은 차트가 설치될 때 함께 만들어진 권한이었다. 다음에 차트를 하나 더 얹으면 그 차트도 자기 ServiceAccount 와 ClusterRole 을 만들고 — 읽지 않으면 또 모른 채 몇 주를 간다.
실무 팀은. 배포 시점에 막는다. 클러스터에 들어오기 전에 매니페스트를 검사하는 정책 엔진을 두고, 넓은 권한(ClusterRoleBinding 신설 등)이 오면 통과시키지 않는다. 이 클러스터에 지금 있는 웹훅 셋(cert-manager·metallb)은 자기 CRD 검사용이라 이 일을 못 한다.
이 홈랩은. 정책 엔진은 규모 대비 과해서 안 둔다. 대신 지금 바로 할 수 있는 것 하나 — kubectl auth can-i --list 출력을 저장소에 저장해 두고, 다음 차트를 얹은 뒤 같은 명령을 돌려 차이로 본다. 늘어난 권한이 눈에 보이면 그때 이번과 같은 감사를 반복한다.
exporter 가 뚫리면, 아직 root 가 넘어간다
생길 수 있는 일. booking 은 전용 계정으로 옮겼지만 mysql 파드 안의 지표 사이드카(mysqld_exporter)는 여전히 root 비밀번호 파일을 읽는다. 그 컨테이너가 침해되면 이번 계정 분리와 무관하게 root 가 넘어간다.
실무 팀은. exporter 전용 계정을 둔다. 지표 수집에 필요한 것은 서버 상태 조회뿐이라 PROCESS·REPLICATION CLIENT 와 performance_schema 읽기면 된다.
이 홈랩은. 별도 작업으로 남긴다 — booking 때와 같은 절차(계정 생성 → 봉인 → 값 교체)를 한 번 더 돌리면 된다. 그리고 그 절차 자체가 지금은 손 순서다. 계정 생성, 봉인 두 번, 값 대조를 사람이 기억해서 했다. 비밀번호를 주기적으로 바꾸려면(실무의 순환·rotation) 이 순서가 문서가 아니라 스크립트여야 하고, 그것도 함께 남긴다.
viewer 의 키가 새면, 90일간 못 막는다
생길 수 있는 일. viewer 인증서의 개인키가 유출된다 — 파일 복사 한 번이면 된다. 쿠버네티스에는 인증서 폐기 목록이 없어서, 그 키는 만료일까지 유효하다.
이 클러스터에서 가능한 대응.
| 상황 | 할 수 있는 것 |
|---|---|
| 그 사람만 막고 싶다 | 없다. 만료를 기다린다 |
| 즉시 막아야 한다 | cgv:viewers 를 가리키는 바인딩을 지운다 — 그 그룹 전체가 함께 막힌다 |
| 근본 해결 | client-ca 를 새로 만들고 전 인증서 재발급 — 클러스터 재구성에 가깝다 |
이 홈랩은. 유효기간을 90일로 짧게 발급하는 것으로 감수한다 — 유효기간이 곧 유출 시 노출 시간이라서다. 사람이 여럿이 되면 이 방식을 버리고 외부 IdP(OIDC)로 짧은 수명 토큰을 쓰는 쪽으로 간다. 실무가 인증서 대신 OIDC 를 쓰는 이유가 정확히 이 회수 문제다.
호스트가 꺼지면, 알림도 같이 꺼진다
생길 수 있는 일. 2절의 알림 다섯은 전부 그 호스트 위 클러스터의 Grafana 가 평가한다. 호스트가 통째로 꺼지면 다섯이 함께 침묵하고 — 알림이 안 오는 것이 "정상"으로 읽힌다. 호스트 감시 경로 끊김(up=0) 룰을 뒀지만 그것도 같은 클러스터 안에서 돈다.
실무 팀은. 감시 대상 밖에서 찔러 보는 프로브를 둔다 — 서비스가 죽으면 죽은 시스템이 아니라 살아 있는 바깥이 알린다.
이 홈랩은. 다음 편에서 닫는다. 외부 경로가 생겨야 밖에서 찌를 수 있고, 그 프로브가 공개 후 알림("밖에서 서비스가 안 열린다")의 재료도 된다.
되짚어 남는 공백 — 배정 요약
| 공백 | 없으면 생기는 일 | 언제 채우나 |
|---|---|---|
| 정책 차단 기록 | 끊김의 원인(정책/앱)을 못 가른다 | 13-3 부하 실측에서 RAM 여유 확인 후 |
| 밖에서 보는 프로브 | 호스트가 꺼진 것을 모른다 | 13-3 (외부 경로가 생기면) |
| 감사 로그 | 누가 무엇을 요청했는지 기록이 없다 | 13-3 에서 재평가 (디스크 쓰기 비용) |
| exporter 전용 DB 계정 | 사이드카 침해 시 root 가 넘어간다 | 별도 — booking 절차 반복이면 된다 |
| 비밀번호 순환 스크립트 | 손 순서라 주기적 교체가 사실상 안 된다 | 별도 |
| 배포 시점 권한 검사 | 새 차트마다 grafana 사건이 반복된다 | 규모가 늘 때. 그때까지는 can-i --list 차이 보기 |
| 이미지 출처 검증 | 서명·스캔 없이 받아서 실행한다 | 별도 |
이 편이 닫은 것과 다음 편
앞 편이 세운 경계 안쪽에서, 이 편이 실제로 닫은 것이다.
닫힌 것 어느 파드에서든 앱·관측 스택에 닿던 것
→ 통로 열여섯 줄만 남기고 차단. 나가는 방향(인터넷 포함)도 차단
조회 컴포넌트가 전 네임스페이스 Secret 을 읽던 것
→ grafana 는 자기 네임스페이스 안으로, kube-state-metrics 는 권한 자체를 제거
조회에도 전권 자격을 쓰던 것
→ 읽기 전용 자격(viewer)을 만들어 평소 조회를 분리
앱이 데이터베이스 전권(root)으로 붙던 것
→ cgv 안에서만 권한을 갖는 계정으로. 비밀번호도 root 와 분리
계측이 이용자 식별자를 트레이스에 싣던 것
→ 명령 이름만 남게 차단
사람이 없는 시간대를 볼 수단이 없던 것
→ 하이퍼바이저 대시보드 5행 · 알림 다섯 · 밤 종료/아침 기동 스케줄
남은 것은 전부 한 방향을 가리킨다 — 서비스를 외부로 여는 일이다. 지금 공유기에 열려 있는 것은 관리용 터널 하나뿐이고, 서비스로 들어오는 길이 없다. 정식 인증서도, 밖에서 서비스를 확인할 프로브도, 실사용 경로 위의 부하 실측도 전부 외부 경로가 생겨야 성립한다.
13-3 에서 하는 것 — 도메인·TLS·포트포워딩으로 서비스를 공개하고, 밖에서 표면을 검증하고, 그 경로 위에서 마지막 부하 실측으로 실서비스 스펙을 확정한다. 이 편이 미룬 결정들 — 관리 UI 를 공개할지, 자원 여유를 어떻게 회복할지, 부하 뒤 알림 임계 — 도 그 자리에서 정한다.
'HomeLab' 카테고리의 다른 글
| 노트북 홈서버 k3s 구축기 - 13-3. 외부 공개와 실서비스 스펙 (0) | 2026.08.30 |
|---|---|
| 번외 — 쿠버네티스의 인증과 인가 (0) | 2026.08.27 |
| 노트북 홈서버 k3s 구축기 - 13-1. 네트워크 격리 (0) | 2026.08.26 |
| 번외 — 컨테이너 메모리와 런타임 메모리 (0) | 2026.08.17 |
| 번외 — 노드 메모리와 쿠버네티스 메모리 (0) | 2026.08.15 |