0. 이어받기
7부 끝에서 토대와 워크로드가 다 섰다. 노드 세 대가 Ready, 파드 69개, 데이터 디스크 열 장이 전부 Bound. 플랫폼(Calico·MetalLB·Traefik·cert-manager·sealed-secrets·ArgoCD)과 데이터 계층(MySQL·Redis·Kafka), 관측 스택(Mimir·Loki·Tempo·Grafana·Alloy·MinIO)이 올라왔고, 손을 떼고 GitOps에 넘겼다.
두 자리가 비어 있다.
- 앱은 아직. queue·booking·frontend의 Deployment·Service는 클러스터에 있는데, 이미지를 받아올 레지스트리가 없어
ImagePullBackOff다. 파드 69개 중 4개가 그 상태고, 레지스트리는 10부에서 세운다. - Grafana 안이 백지다. 대시보드가 한 장도 없다.
여기서 채울 건 이렇다 — 관측이 실제로 흐르는지 열어 확인하고, 막힌 데를 뚫고, 그 파이프라인이 다시 막히는 걸 알아채는 계기판을 세운다. 인프라 자체를 들여다보는 대시보드는 그 위에 서는 것이라 다음 편이다.
열어 볼 파이프라인은 이런 모양이다.
[긁는 쪽] [담는 쪽] [보는 쪽]
Alloy (노드마다) ─ 메트릭 ──▶ Mimir ┐
─ 로그 ──▶ Loki ├─ 몸통은 MinIO(S3)로 ──▶ Grafana
─ 트레이스 ─▶ Tempo ┘
긁는 쪽과 담는 쪽의 구조를 먼저 본다. 담는 쪽을 모르면 "값이 안 보인다"가 수집 문제인지 저장 문제인지 가를 수가 없다. 그리고 그 전에 배포판(k3s)의 생김새부터 본다 — 뒤에서 메트릭이 그 구조를 그대로 비춘다.
앱이 없다는 사실이 이 편에서 볼 신호를 정한다. 지금 흐르는 것은 앱 트래픽이 아니라 인프라 자신이 내는 신호다 — 노드 자원, 컨트롤플레인 상태, 컨테이너 실사용, 그리고 관측 스택이 자기 자신에 대해 내는 지표.
7부가 남긴 숙제도 하나 걸려 있다. 노드 자원 예약(kube-reserved)을 2Gi로 올려 잡기로 했는데 설정 파일에만 반영하고 노드에는 아직 안 넣었다. 반영하려면 노드를 한 대씩 재시작해야 하고, 앱이 올라와 실사용을 다시 잰 뒤 한 번에 하기로 미뤄 뒀다.
1. k3s는 어떻게 생겼나
1.1 컨트롤플레인 넷이 프로세스 하나 안에 있다
kubeadm으로 세운 클러스터는 컨트롤플레인 컴포넌트가 각각 스태틱 파드로 뜬다. apiserver 파드, scheduler 파드, controller-manager 파드, etcd 파드 — 넷이 서로 다른 컨테이너이자 서로 다른 프로세스다.
k3s는 이 넷을 하나로 합쳤다.
[프로세스] k3s server (systemd 유닛 하나)
├ apiserver (고루틴)
├ etcd (embedded) (고루틴)
├ scheduler (고루틴)
├ controller-manager (고루틴)
├ kubelet (고루틴)
└ kube-proxy (고루틴)
세 노드 전부 server 역할이라 이 프로세스가 노드마다 하나씩, 클러스터에 셋 있다.
왜 합쳤나. k3s는 저사양·엣지·단일 바이너리를 노리고 만든 배포판이다. 컴포넌트를 프로세스로 나누면 각각 컨테이너 이미지와 매니페스트가 필요하고, 기동 순서와 인증서 배선을 따로 맞춰야 한다. 그걸 하나로 합치면 바이너리 하나 내려받아 systemd 유닛 하나 띄우는 것으로 클러스터가 선다. 설치가 단순해지는 대신 뒤에 나오는 성질들이 따라온다.
이 합침은 자원 계산에 이미 영향을 줬다. kubelet은 자기가 띄운 파드만 셀 수 있다. kubeadm 쪽 컨트롤플레인은 스태틱 파드라 kubelet이 띄우고 관리하므로 그 사용량이 파드 몫으로 잡히고, 노드의 할당 가능량에서도 그만큼 빠진다.
k3s server는 파드가 아니라 systemd 프로세스다. kubelet의 셈에 아예 안 들어온다. 그래서 그 몫을 kube-reserved로 직접 떼어 두지 않으면, 실제로는 쓰이고 있는데 스케줄러 쪽에서는 비어 있는 공간으로 남아 파드에게 배정된다. 실측으로 노드당 1.5–1.8GiB를 쓰고 있어 예약을 2Gi로 잡았다.
같은 이유로 PriorityClass로 컨트롤플레인을 보호할 수도 없다. 파드가 아니니 eviction 대상 목록에 없다.
1.2 나머지는 전부 파드다
컨트롤플레인 밖의 것은 전부 컨테이너로 뜬다. 즉 각각 별개 프로세스다.
| 계층 | 무엇이 떠 있나 | 배치 |
|---|---|---|
| 플랫폼 | Calico · MetalLB · Traefik · cert-manager · sealed-secrets · ArgoCD | 노드에 분산 |
| 데이터 | MySQL · Redis 3 · Kafka(Strimzi) | 디스크 라벨로 고정 |
| 관측 — 수집 | Alloy | DaemonSet, 노드마다 1 |
| 관측 — 저장 | Mimir(인제스터 3 + 컴포넌트 8) · Loki 1 · Tempo 1 · MinIO 1 | 인제스터는 노드마다 1 |
| 관측 — 조회·부가 | Grafana · kube-state-metrics · node-exporter | node-exporter는 DaemonSet |
| 앱 | queue 2 · booking · frontend | 오브젝트만, 컨테이너 미기동 |
배치가 아무 데나 앉은 것은 아니다. 노드마다 용도 라벨(cgv.io/data = db · obs · obj)을 붙여 뒀고, 디스크를 쓰는 것은 그 디스크가 있는 노드에 고정된다. MySQL은 데이터 디스크가 있는 노드로, Loki·Tempo는 각자 WAL 디스크가 있는 노드로, MinIO는 100G 디스크가 있는 노드로 간다.
Mimir 인제스터만 다르다. 세 대가 노드에 하나씩 앉는다. 노드마다 인제스터용 WAL 디스크를 한 장씩 잘라 둔 것이 그래서다. 왜 이렇게 흩어 뒀는지는 담는 쪽 구조를 볼 때 이어서 나온다.
여기서 컨트롤플레인과 갈리는 성질이 생긴다. 파드는 각자 프로세스라 자기 메모리를 갖고, 컨테이너 단위로 limit을 걸 수 있고, 하나가 죽어도 옆이 안 죽는다. 합쳐 놓은 넷은 그 반대다.
1.3 합쳐 놓은 대가
Go에서 고루틴이 패닉하고 아무도 받지 않으면 프로세스 전체가 죽는다. 그래서 "apiserver만 죽고 kubelet은 산다"가 k3s에는 없다. 죽으면 그 노드의 컨트롤플레인이 통째로 나간다.
| kubeadm(스태틱 파드) | k3s(한 프로세스) | |
|---|---|---|
| 컴포넌트 하나가 패닉 | 그 컨테이너만 재시작 | 넷이 함께 종료 |
| 메모리 폭주 | 해당 컨테이너만 OOMKilled | 프로세스 전체 OOM |
| 컴포넌트별 메모리 limit | 걸 수 있다 | 못 건다 |
| 재시작 범위 | 죽은 것 하나 | 넷 전부 + etcd WAL 재생 |
여기에 관측 쪽으로 바로 걸리는 것이 하나 더 있다. 컴포넌트별 자원을 따로 잴 수가 없다. 프로세스가 하나라 "메모리를 apiserver가 먹었나 kubelet이 먹었나"가 안 갈린다. 프로세스 사용량 지표는 k3s server 하나의 값으로만 나온다. 대시보드를 세울 때 이 한계를 전제로 깔아야 한다.
그래서 격리를 컴포넌트가 아니라 노드 단위로 만든다. 세 대를 전부 server로 두고 etcd를 3-member로 묶은 것이 그 대응이다. 한 노드의 k3s server가 통째로 죽어도 남은 두 대로 쿼럼이 유지되고 apiserver도 계속 답한다. 같은 이유로 설정을 바꿔 재시작할 때 세 노드를 동시에 만지지 않는다 — 한 대씩 순차로 돌린다. 동시에 내리면 쿼럼이 끊긴다.
이 합침은 뒤에서 한 번 더 나타난다. 자원과 장애에서만이 아니라 메트릭에서도 넷이 하나로 묶여 있다.
2. Alloy는 무엇을 어떻게 긁나
2.1 "긁는다"가 무슨 뜻인가
메트릭을 내는 쪽은 HTTP 주소를 하나 열어 둔다. 주소:포트/metrics로 요청하면 그 순간의 값이 텍스트로 돌아온다. 한 줄이 값 하나다.
apiserver_request_total{verb="GET",resource="pods",code="200"} 41823
node_cpu_seconds_total{cpu="0",mode="idle"} 918273.44
수집기(alloy)가 하는 일은 하나다. 그 주소로 주기적으로 요청을 보내고, 돌아온 텍스트를 저장소로 넘긴다. 이 한 번의 요청·응답을 스크레이프(scrape)라고 부른다.
여기서 뒤까지 계속 쓰는 용어 둘이 갈린다.
- 시리즈 — 메트릭 이름 하나에 라벨 조합 하나가 붙은 것. 위 예에서
verb="GET"과verb="LIST"는 서로 다른 시리즈다. - 샘플 — 그 시리즈의 한 시점 값 하나. 시리즈 하나를 15초마다 긁으면 15초마다 샘플이 하나 생긴다.
up{~}
그리고 수집기인 Alloy는 스크레이프마다 값을 하나 직접 만들어 붙인다.
up{job="kubelet", instance="k3s-1"} 1 ← 붙는 데 성공
up{job="kubelet", instance="k3s-2"} 0 ← 실패
up은 긁히는 쪽이 내는 값이 아니다. 수집기가 성공·실패를 판정해 만드는 값이라, 대상이 죽어도 0으로 남는다. 그래서 "무엇을 긁고 있고 그중 뭐가 실패하나"를 이 하나로 훑는다.
2.2 Alloy는 노드마다 하나씩 있다
Alloy는 DaemonSet이라 노드마다 파드 하나씩, 셋이 돈다.
문제는 셋이 각자 전부를 긁으면 같은 값이 세 벌 들어온다는 것이다. 대상 목록은 클러스터 단위로 발견되므로, 세 파드가 같은 목록을 보고 각자 다 긁는다.
그래서 clustering을 켠다. 세 파드가 서로의 존재를 알아본 뒤 대상 하나하나를 누가 맡을지 나눠 갖는다. 결과적으로 대상은 셋 중 한 파드만 긁고, 어느 파드가 긁었든 값은 같은 저장소로 간다. 파드가 하나 죽으면 그 몫이 남은 둘에 다시 분배된다.
로그와 이벤트도 같은 장치를 쓴다. 쿠버네티스 이벤트는 노드가 아니라 클러스터 단위 스트림이라, 세 파드가 각자 읽으면 같은 이벤트가 세 벌 적재된다. 그래서 이벤트도 한 파드만 담당하게 나눠 둔다.
2.3 지금 무엇을 긁고 있나
수집 목록은 두 갈래로 선언돼 있다.
① Alloy 설정에 주소를 직접 적은 것
| 대상 | 주소 | job | 타깃 수 |
|---|---|---|---|
| kubelet | 각 노드IP :10250/metrics |
kubelet |
3 (노드마다) |
| cAdvisor | 각 노드IP :10250/metrics/cadvisor |
cadvisor |
3 |
| apiserver | 각 노드IP :6443/metrics |
k3s-apiserver |
3 |
| MinIO | minio.observability.svc:9000/minio/v2/metrics/cluster |
minio |
1 |
노드 주소를 손으로 적지는 않는다. 설정에 적힌 실제 모양이 이렇다.
discovery.kubernetes "nodes" { role = "node" } // ① 노드 목록을 받아온다
discovery.relabel "apiserver" { // ② 그 목록을 스크레이프 대상으로 고쳐 쓴다
targets = discovery.kubernetes.nodes.targets
rule {
source_labels = ["__meta_kubernetes_node_address_InternalIP"]
replacement = "$1:6443"
target_label = "__address__" // 내부 IP 뒤에 :6443을 붙여 주소로
}
}
prometheus.scrape "apiserver" { // ③ 그 주소를 실제로 긁는다
targets = discovery.relabel.apiserver.output
job_name = "k3s-apiserver" // 붙일 job 라벨
metrics_path = "/metrics"
scheme = "https"
bearer_token_file = "/var/run/secrets/kubernetes.io/serviceaccount/token"
tls_config { insecure_skip_verify = true } // 노드 인증서가 자체 서명이라 이름 검증 생략
forward_to = [prometheus.remote_write.mimir.receiver] // ④ 받은 값을 Mimir로 보낸다
clustering { enabled = true }
}
세 단계로 읽으면 — 목록을 받고(①), 주소로 고쳐 쓰고(②), 긁어서 보낸다(③④). 노드가 늘면 ①이 알아서 늘어나므로 대상도 따라 는다.
가운데의 relabel이 뒤에서 다시 나온다. 이름 그대로 라벨을 고쳐 쓰는 단계인데, 하는 일이 두 가지다. 하나는 여기처럼 대상 주소를 만드는 것이고, 다른 하나는 저장소로 보내기 직전에 라벨을 바꾸거나 시리즈를 걸러 내는 것이다. 수집 단계에서 무언가를 덜어 내려면 손댈 자리가 이 relabel뿐이다.
cAdvisor는 kubelet에 내장된 부분이다. 별도 프로세스가 아니라 kubelet 코드 안에 들어 있고, 컨테이너 런타임이 만든 컨테이너의 실사용을 커널의 cgroup에서 직접 읽어 낸다. 그래서 주소는 kubelet과 같은 :10250이되 경로가 다르다.
메트릭이 아닌 것도 이 갈래에 있다.
- 파드 stdout → Loki — 컨테이너가 찍는 로그
- 쿠버네티스 이벤트 → Loki(
k8s-events) — 파드가 왜 죽었는지는 컨테이너 로그가 아니라 이벤트에 남는다. 이벤트는 약 한 시간 뒤 사라지므로 담아 둬야 사후 추적이 된다 - OTLP 수신 → Tempo —
:4317·:4318로 트레이스를 받아 넘긴다. 보내는 앱이 있어야 채워진다
② ServiceMonitor·PodMonitor로 자동 발견되는 것
이쪽은 Alloy 설정에 이름이 없다. 각 컴포넌트가 자기 옆에 ServiceMonitor라는 오브젝트를 두면 Alloy가 그것을 주워 수집 대상으로 바꾼다. 대부분의 차트가 그 오브젝트를 자동으로 같이 배포한다.
| 대상 | 주소 | 타깃 수 |
|---|---|---|
| etcd | 각 노드IP :2381/metrics |
3 |
| node-exporter | 파드(노드마다) | 3 |
| kube-state-metrics | 파드 1 | 1 |
| Redis exporter | 파드 3 | 3 |
| Mimir 각 컴포넌트 · Loki · Tempo · Grafana · Alloy 자신 | 각 파드 | 각 1 |
| 앱 queue · booking | 파드 | 오브젝트만 있어 발견은 된다 |
etcd만 사정이 다르다. ServiceMonitor는 Service를 보고 대상을 찾는데, etcd는 파드가 아니라 노드 프로세스라 가리킬 Service가 없다. 그래서 셀렉터 없는 Service를 만들고, 거기 붙는 Endpoints에 노드 IP 셋을 직접 적어 실체 없는 대상을 세웠다.
2.4 왜 방법이 갈렸나
갈림은 대상의 성질 두 가지에서 나온다 — 가리킬 Service가 있는가, 인증이 필요한가.
| 대상 | Service | 인증 | 그래서 |
|---|---|---|---|
| kubelet · cAdvisor · apiserver | 없음(노드 프로세스) | 필요 | Alloy 설정에 직접 |
| etcd | 없음(노드 프로세스) | 불필요(:2381 평문) |
Service를 손으로 만들어 ServiceMonitor |
| node-exporter · KSM · Redis · LGTM | 있음(파드) | 불필요 | 차트가 붙인 ServiceMonitor 그대로 |
| MinIO | 있음(파드) | 불필요 | 차트가 ServiceMonitor 대신 Probe로 내서 직접 지목 |
apiserver가 직접 설정으로 간 이유를 한 겹 더 본다. :6443/metrics는 그냥 요청하면 막는다. apiserver는 그 특징으로 요청 헤더에 서비스어카운트 토큰을 실어야 값을 준다. 파드는 뜰 때 그 토큰을 파일로 받으므로, 수집 설정에 적을 것은 "그 파일을 읽어 헤더에 실어라" 하나다.
Alloy 설정에는 그 칸이 있어 한 줄로 끝난다. ServiceMonitor에는 인증을 적는 칸 둘이 다 막혀 있다.
- 파일 경로를 적는 칸 — 없어지는 방향이라 CRD 버전에 따라 무시되거나 거부된다. ServiceMonitor는 어느 네임스페이스에서나 만들 수 있는 오브젝트라, 거기에 "수집기 파드의 이 경로를 읽어라"를 적게 두면 토큰이 아닌 파일도 읽어 밖으로 내보낼 수 있다.
- Secret을 참조하는 칸 — 그러려면 Alloy가 그 Secret을 직접 읽어야 하는데, 그 권한은 RBAC에서 빼뒀다. 주면 데이터 계층의 DB 시크릿, 앱 네임스페이스의 앱 시크릿까지 전부 읽힌다.
3. Mimir는 어떻게 담나
3.1 무엇의 개수를 세는가 — 시리즈와 카디널리티
Mimir가 세고 제한하는 단위가 시리즈다.
node_cpu_seconds_total{node="k3s-1", cpu="0", mode="idle"} ← 시리즈 하나
node_cpu_seconds_total{node="k3s-1", cpu="0", mode="system"} ← 라벨 하나만 달라도 다른 시리즈
라벨 값의 가짓수가 곱해지며 시리즈가 는다. 노드 3 × 코어 4 × mode 8이면 이름 하나에 시리즈가 96개다. 이 곱이 부는 정도를 카디널리티라고 부른다.
여기서 뒤에 그대로 걸리는 성질이 하나 있다. 라벨이 하나라도 다르면 값이 같아도 다른 시리즈다. 같은 값을 다른 라벨로 두 번 넣으면 저장 비용이 두 배가 된다.
담는 단위도 하나 짚어 둔다. Mimir는 여러 테넌트를 한 클러스터에 나눠 담도록 만들어졌다. 모든 요청은 X-Scope-OrgID 헤더로 "누구 것인가"를 밝히고, 테넌트마다 저장 공간과 상한이 따로 매겨진다. 이 클러스터는 테넌트를 나누지 않아 전부 하나(anonymous)로 들어간다. 뒤에 나오는 상한이 "테넌트당" 값인 것이 이 때문이고, 테넌트가 하나뿐이라 그 값이 곧 클러스터 전체 값이 된다.
3.2 쓰기 경로 — 들어온 값이 어디에 앉나
Alloy (노드마다)
│ remote_write
▼
mimir-gateway ──▶ distributor
│ 시리즈마다 해시를 계산해 해시 링에서 담당 인제스터를 찾고
│ RF=3이므로 링에서 이어지는 세 대로 복제 전송
▼
ingester-0 ingester-1 ingester-2 ← 두 대가 받으면 쓰기 성공(quorum)
│
│ 받은 값을 메모리에 들고 있다가 2시간마다 블록으로 만든다
▼
MinIO (S3)
- mimir-gateway — 모든 요청이 처음 닿는 입구. 쓰기는 distributor로, 조회는 query-frontend로 넘긴다.
- distributor — 받은 시리즈를 어디로 보낼지 정하는 문지기. 시리즈 이름·라벨로 해시를 내고 해시 링에서 담당자를 찾는다. 링은 인제스터들이 자기 위치를 등록해 둔 원형 목록이다.
- RF(replication factor) = 3 — 시리즈 하나를 인제스터 세 대에 복사해 둔다. 쓰기는 그중 두 대가 받으면 성공으로 친다. 세 대 중 과반이 받았으면 값이 남는다고 보는 것이고, 그래서 한 대가 느리거나 죽어도 쓰기가 멈추지 않는다.
- 인제스터 — 최근 값을 메모리에 들고 있는 컴포넌트. 2시간마다 그 구간을 블록 파일로 만들어 MinIO에 올린다. 올린 뒤에도 그 블록을 한동안 자기 디스크에 남겨 둔다 — 조회하는 쪽이 새 블록을 발견하고 색인을 내려받을 시간이 필요해서다.
- WAL — 메모리에 있는 동안 프로세스가 죽으면 값이 사라지므로, 들어온 값을 영속 디스크에도 순서대로 적어 둔다. 재시작하면 이 기록을 되읽어 메모리 상태를 복원한다. 그래서 인제스터는 디스크를 붙일 수 있는 StatefulSet으로 뜬다.
3.3 읽기 경로 — 조회가 어디를 보나
Grafana ──▶ mimir-gateway ──▶ query-frontend ──▶ query-scheduler ──▶ querier
│
최근 구간 ────────────────┤──▶ ingester (메모리)
지난 구간 ────────────────┘──▶ store-gateway ──▶ MinIO
조회는 한 군데를 보지 않는다. 최근 두 시간 남짓은 아직 MinIO에 없고 인제스터 메모리에만 있으므로, querier가 인제스터와 저장소를 같이 뒤져 합쳐 돌려준다.
각 컴포넌트가 맡는 일은 이렇게 나뉜다.
- query-frontend · query-scheduler — 들어온 쿼리를 시간 구간으로 쪼개고 줄 세워 querier에게 나눠 준다. 무거운 쿼리 하나가 전체를 막지 않게 하는 층이다.
- querier — 실제로 값을 모아 계산하는 쪽. 인제스터와 store-gateway 양쪽에 물어본다.
- store-gateway — MinIO에 있는 블록들의 색인을 들고 있다가, 지난 구간을 물으면 어느 블록을 읽어야 하는지 찾아 준다.
- compactor — 인제스터 세 대가 2시간마다 각자 올린 블록을 뒤에서 병합한다. 복제본이 셋이라 같은 구간의 블록도 셋 올라오는데, 그걸 합쳐 조회할 때 뒤질 파일 수를 줄인다.
읽기에도 quorum이 걸린다. 인제스터가 재시작하면 멈춰 있던 동안의 값이 그 대에만 비므로, 한 대만 물어보면 구멍 난 답을 받을 수 있다. 그래서 RF가 3이면 읽을 때도 두 대에서 받아 맞춰 본다.
3.4 그래서 인제스터만 HA가 필요하다
이 구조에서 각 구간의 안전도가 다르다.
| 구간 | 어디에 있나 | 잃으면 |
|---|---|---|
| 오래된 값 | MinIO(오브젝트 스토리지) | 저장소가 지킨다. 컴포넌트가 죽어도 데이터는 남는다 |
| 최근 값 | 인제스터 메모리(+ WAL) | 그 인제스터가 죽으면 그 구간이 빈다 |
store-gateway·compactor·querier는 죽어도 MinIO에 있는 것을 다시 읽으면 된다. 상태를 자기가 들고 있지 않다. 반면 인제스터는 아직 아무 데도 안 올라간 최근 구간을 자기 메모리에 들고 있는 유일한 지점이다.
WAL이 이 구간을 지키긴 하지만 한계가 분명하다. WAL은 프로세스가 죽었다 살아나는 것을 복구할 뿐, 그 디스크가 있는 노드 자체가 나가면 같이 못 쓴다. 그리고 복구되는 동안은 조회가 안 된다.
그래서 최근 구간의 가용성은 WAL이 아니라 복제로 확보한다. 이 편에서 계속 나오는 RF=3이 그것이다.
3.5 노드마다 인제스터를 하나씩 — 그리고 왜 monolithic이 아닌가
Mimir는 두 가지 배포 모드를 제공한다.
| 모드 | 성격 | 문서 권장 |
|---|---|---|
| monolithic | 모든 컴포넌트를 프로세스 하나로 | 시작·개발 환경 |
| microservices | 컴포넌트를 각각 별도 프로세스로 | 프로덕션 |
자원만 보면 dev에는 monolithic이 맞다. 파드 하나면 되고 설정 항목도 적다. 그런데 이 클러스터는 microservices로, 인제스터를 세 대 띄워 노드마다 하나씩 뒀다. 이유가 둘이다.
- monolithic으로는 최근 구간을 복제할 수 없다. 프로세스 하나에 전부 들어 있으면 그 프로세스가 죽을 때 인제스터도 같이 죽고, 메모리에만 있던 최근 구간이 통째로 빈다. 복제를 하려면 인제스터가 독립된 복제본 여럿으로 존재해야 한다.
- dev를 stg·prd와 같은 구조로 두는 것이 이 홈랩의 목적이다. 규모만 낮추고 구조는 같게 가져가야, 여기서 잰 값이 위 환경의 스펙을 정하는 근거가 된다. 인제스터를 노드마다 두는 것은 Kafka 브로커나 Redis 복제본을 노드에 흩어 두는 것과 같은 결이다 — 한 노드를 잃어도 나머지로 버티게 만드는 배치.
대가도 분명하다. 파드 수가 늘고(Mimir만 11개), 그만큼 자원을 더 쓴다. 이 규모에 과한 구성이라는 점은 인정하고 가는 선택이다.
3.6 그 구조가 만드는 제약 — 시리즈 상한
인제스터가 최근 값을 메모리에 든다는 것은, 메모리가 유한 자원이라는 뜻이다. 카디널리티가 폭주하면 메모리를 끝없이 먹다가 커널에 종료된다(OOMKilled).
그 전에 끊는 장치가 시리즈 상한이다. 상한에 닿으면 새 시리즈만 거절하고 기존 시리즈는 계속 받는다. 크래시로 통째로 잃는 대신 새로 들어오려는 것만 막는다. 노드 메모리가 마를 때 커널 OOM 전에 kubelet이 파드를 내보내는 것과 같은 결의 가드레일이다.
값은 max_global_series_per_user이고, 차트에 따로 지정하지 않아 Mimir 기본값 150,000이 그대로 걸려 있다.
이 값이 세는 대상은 "복제 전, 테넌트 전체의 고유 시리즈" 수다. 인제스터 한 대가 지는 몫은 이렇게 나온다.
인제스터당 = 전역 상한 × RF ÷ 인제스터 개수
= 150,000 × 3 ÷ 3
= 150,000
RF가 3인데 인제스터도 정확히 3대라 모든 시리즈가 세 대 전부에 복제된다. 그래서 전역 상한과 인제스터당 보유량이 같은 값이 된다. 세 대가 15만씩 나눠 갖는 게 아니라, 같은 15만을 세 벌 복사해 각자 다 들고 있다.
4. 그런데 절반만 흐른다
앱은 하나도 안 떠 있다. 지금 들어오는 것은 인프라 자신이 내는 신호뿐인데, 그것만으로 Mimir의 시리즈 상한을 넘어선다.
4.1 증상 — 도착한 것과 안 온 것이 갈린다
Grafana에서 무엇이 들어와 있는지 훑으면 목록이 고르지 않다.
| 대상 | 상태 |
|---|---|
| kube-state-metrics | 통째로 없음 (kube_pod_info 0건) |
| node-exporter | 세 노드 중 한 대분만 |
| kubelet · cAdvisor · apiserver | 각각 한 노드분만 |
| MinIO | 원천이 250줄인데 55개만 |
| etcd · Redis | 세 노드분 온전 |
수집기 파드는 셋 다 Running이고 재시작도 없다. 배선이 끊긴 모양은 아니다. 조회를 맡는 mimir-querier만 93분 사이 25번 재시작했다.
그리고 이 상태에서 화면에 에러가 안 뜬다. 쿼리는 정상으로 응답하고 값만 없다. 무언가 버려지고 있다는 신호는 수집기 로그에만 남는다.
4.2 저장소가 상한에 붙어 있다
cortex_ingester_memory_series
인제스터가 나란히 150000이다. 이 값은 "최근에 샘플이 있던 시리즈"가 아니라 인제스터가 지금 메모리에 들고 있는 시리즈 수이고, 상한이 걸리는 대상이 정확히 이것이다.
수집기 로그에도 거절이 주기적으로 찍힌다.
... err-mimir-max-series-per-user (per-user series limit of 150000 exceeded)
밀리는 순서는 선착순이다. 상한에 닿기 전 등록된 시리즈는 계속 받고, 그 뒤에 오는 것만 거절된다.
이게 추정이 아니라는 증거가 나왔다. 노트북 배터리가 나가 클러스터가 통째로 재기동됐는데, 설정을 하나도 안 바꿨는데 도착한 세트가 뒤바뀌었다.
재기동 전 재기동 후
k3s-apiserver 약 5만 약 7만 5천
kubelet 약 7만 7천 약 3만
kube-state-metrics 0 약 6천 5백 ← 없다가 전부 들어옴
node-exporter 1노드분 3노드분
────────────────────────────────────────────
인제스터가 든 총량 150,000 150,000 ← 총량은 늘 같다
kube-state-metrics가 고장이었다면 재기동으로 저절로 고쳐질 리 없다. 등록 순서가 다시 섞였을 뿐이다. 차오르는 속도도 그와 맞는다 — 재기동 뒤 30초 만에 150,000에 닿고 그 뒤로 계속 붙어 있다.
죽은 게 아니라 거절이다. 겉보기가 비슷한 OOMKilled과 갈라 둔다. 처방이 반대라서다.
| 상한 포화(지금) | OOMKilled | |
|---|---|---|
| 파드 상태 | 1/1 Running, 재시작 거의 없음 |
RESTARTS 상승 · CrashLoopBackOff |
| 수집기가 받는 응답 | 살아 있는 쪽이 400으로 거절 | 연결 거부 · 타임아웃 |
memory_series |
상한 값에 붙어 있음 | 재시작마다 0에서 다시 오름 |
OOMKilled이면 컨테이너 메모리 limit을 올려 푼다. 지금은 설정 상한에 걸린 거절이라 limit으로 될 일이 아니다.
4.3 그 15만의 절반이 사본이다
15만을 job별로 쪼갠다 — 아래 숫자는 재기동 전 스냅샷이다.
topk(20, count by (job) ({__name__=~".+", job=~".+"}))
kubelet 약 7만 7천
k3s-apiserver 약 5만 두 job 합이 15만의 약 85%
cadvisor 약 5천 8백
k3s-etcd 약 5천 3백
나머지 전부 약 1만 2천
kubelet과 k3s-apiserver가 시리즈 상한 15만의 대부분을 차지한다. 그런데 job="kubelet" 안을 열면 kubelet 지표가 아니다. 두 job을 각각 열어 나란히 놓는다.
topk(12, count by (__name__) ({job="kubelet"}))
topk(12, count by (__name__) ({job="k3s-apiserver"}))
| 메트릭 이름 | job="k3s-apiserver" |
job="kubelet" |
|---|---|---|
etcd_request_duration_seconds_bucket |
14,400 | 5,760 |
apiserver_request_duration_seconds_bucket |
11,640 | 3,672 |
apiserver_request_sli_duration_seconds_bucket |
8,558 | 2,904 |
apiserver_watch_list_duration_seconds_bucket |
4,626 | 1,512 |
apiserver_watch_cache_read_wait_seconds_bucket |
3,682 | 1,428 |
apiserver_request_body_size_bytes_bucket |
3,072 | 1,760 |
apiserver_response_sizes_bucket |
2,336 | 600 |
apiserver_watch_events_sizes_bucket |
2,241 | 783 |
apiserver_admission_controller_admission_duration_seconds_bucket |
770 | 770 |
workqueue_queue_duration_seconds_bucket |
964 | 407 |
kubelet 주소를 긁었는데 apiserver·etcd·workqueue 지표가 나온다. k3s-apiserver 주소도 마찬가지다 — 두 목록이 같은 이름을 같은 순서로 늘어놓는다. 개수가 다른 것은 두 job에 착지한 노드분이 달라서고, 같은 노드분끼리 만난 항목은 값까지 똑같다(표에서 admission_controller 770, 이 둘만 그렇다).
두 주소가 같은 것을 내고 있다. "메트릭은 포트에 붙어 있는 게 아니라 프로세스에 붙어 있어"서다.
- 프로그램이 메트릭을 하나 만들면 프로세스 안의 목록에 등록한다.
- 이 목록을 레지스트리라고 부른다. /metrics 요청이 하는 일은 하나다 — 그 레지스트리를 통째로 텍스트로 찍어 보낸다. 그러니 포트가 달라도 읽는 레지스트리가 같으면 응답이 같다.
- 컨트롤플레인 넷이 한 프로세스(k3s)라 그 레지스트리가 하나다. apiserver·kubelet·scheduler·controller-manager가 전부 그 하나에 등록하고, :6443과 :10250이 각자 같은 그것을 찍어 보낸다.
원천을 직접 받아 대조하면 그대로 나온다.
k3s-1 :10250/metrics 72,093 줄
k3s-1 :6443/metrics 72,093 줄
메트릭 이름 종류 536 / 536
한쪽에만 있는 이름 0 ← 이름 집합이 완전히 같다
같은 프로세스라는 것이 값으로도 보인다.
process_resident_memory_bytes{job=~"kubelet|k3s-apiserver"}
k3s-3 job="k3s-apiserver" 1,125 MB
k3s-3 job="kubelet" 1,130 MB ← 같은 프로세스, 스크레이프 시점만 다르다
이 1.1GB는 k3s server 프로세스 하나의 값이다. 노드 예약(2Gi)의 근거로 쓴 1.5–1.8GiB는 컨테이너 런타임까지 포함한 서비스 단위 실측이라 더 크다.
그런데 job 라벨이 다르다(내가 alloy에서 다르게 설정했기에). 시리즈는 라벨이 하나라도 다르면 다른 시리즈다. Mimir는 이 둘을 사본으로 알아보지 못하고 각각 세고 각각 저장한다. 15만의 85%를 차지한 두 job이 실은 한 벌의 데이터다.
kubeadm으로 세운 클러스터에서는 이게 정상이고 필요한 설정이였을 것이다. 컨트롤플레인이 각각 별개 프로세스라 포트마다 다른 내용이 나오니 둘 다 긁어야 한다. 컨트롤플레인을 한 프로세스로 합친 배포판에서만 사본이 된다.
다만 한 프로세스 안이라고 레지스트리가 하나뿐인 것은 아니다. - 한 프로세스에 여러 메트릭
| 주소 | 어느 레지스트리 | 노드당 |
|---|---|---|
:6443/metrics |
공용 — apiserver·kubelet·scheduler·controller-manager | 약 3만–7만 |
:10250/metrics |
같은 공용 레지스트리 | 위와 동일 |
:10250/metrics/cadvisor |
cAdvisor 전용 | 약 5,300–5,800 |
:2381/metrics |
etcd 전용 | 약 1,800 |
- cAdvisor — kubelet 안에 있지만 컨테이너 cgroup을 읽는 별개 수집기라 경로도 레지스트리도 따로다.
/metrics만 긁으면 컨테이너 지표가 아예 안 온다. - etcd — 쿠버네티스 컴포넌트가 아니라 별개 오픈소스를 품어 돌린 것이라 자기 레지스트리를 쓴다.
이름이 헷갈리는 것 하나.
etcd_request_duration_seconds는 etcd 레지스트리에 없다. "apiserver가 etcd에 다녀온 시간"이라 apiserver가 재는 값이고 공용 레지스트리에 있다.
디스크 압력을 보는 쪽은:2381의 etcd 전용 레지스트리 매트릭인etcd_disk_wal_fsync_duration_seconds다.
그리고 scheduler·controller-manager는 자기 메트릭 포트(:10259·:10257)가 노드 밖으로 안 열려 있는데도 지표가 이미 들어와 있다. 공용 레지스트리에 함께 등록돼 있어서다. 표준 문서를 따라 그 포트를 여는 인자를 넣으면 같은 레지스트리를 세 번째·네 번째로 긁게 된다.
4.4 원천은 얼마를 내나
저장소 안의 숫자는 상한을 통과해 들어온 것이라 실제 생산량을 모른다. 상한과 무관한 값은 각 엔드포인트가 내놓는 줄 수를 직접 세면 나온다.
| 대상 | 합 |
|---|---|
kubelet /metrics 3노드 |
179,225 |
apiserver :6443 3노드 |
179,225 (같은 레지스트리 사본) |
| cAdvisor 3노드 | 16,937 |
| etcd · KSM · node-exporter · MinIO · Redis · Alloy · LGTM 자기 지표 | 약 32,000 |
| 합계 | 약 407,000 |
상한 15만에 원천이 약 40만이다. 2.7배고, 그중 약 17.9만이 사본이다.
이 값은 고정된 수가 아니다. verb × resource × scope 조합은 apiserver가 겪은 요청 종류만큼 생기므로 재기동 직후에는 작고 클러스터가 돌수록 는다. 재기동 40분 뒤에 다시 세니 노드 세 대 합이 118,642였다. 원천은 자라는데 상한은 고정이라는 것이 이 구도의 성질이다.
막힌 것은 Mimir 하나다. Loki는 인메모리 스트림이 73개로 기본 상한 5,000의 1.5%고 거절이 0이다. 세는 단위가 달라서다 — Loki가 세는 스트림은 라벨 조합이고 로그 본문은 압축된 청크에 들어가므로, 로그는 양이 늘어도 스트림이 안 는다. Tempo는 보낼 앱이 없어 유입이 0이다.
k3s가 컨트롤플레인 넷을 한 프로세스로 합침 → 메트릭 레지스트리도 하나
Alloy가 :6443 과 :10250 을 각각 긁음 → 같은 레지스트리를 두 번
Mimir는 라벨이 다르면 다른 시리즈로 저장 → 사본이 상한의 절반을 차지
──────────────────────────────────────────────────────────
원천 약 40만 vs 상한 15만
사본을 걷어도 약 22만 — 그래도 15만보다 크다
한쪽만 손봐서는 안 뚫린다. 무엇을 어떻게 손댈지는 다음에 정한다.
5. 뚫는다
손댈 수 있는 자리가 둘이고, 둘 다 필요하다.
- 사본을 걷는다 — 같은 레지스트리를 두 번 긁는 것을 한 번으로. 약 17.9만이 줄어 원천이 약 22만이 된다.
- 상한을 올린다 — 그래도 22만이 15만보다 크다. 천장을 올려야 나머지가 들어온다.
한쪽만으로는 안 된다. 사본만 걷으면 22만이 15만에 여전히 막히고, 상한만 올리면 아무도 안 읽는 사본에 메모리를 지불한다.
5.1 사본을 어떻게 걷을까
:6443/metrics와 :10250/metrics가 같은 레지스트리를 찍으므로, 둘 중 하나만 남기면 된다. 어떻게 남기느냐가 갈린다.
- 한쪽 scrape를 통째로 지운다 — 설정에서 블록 하나를 뺀다. 대신 그 job의
up도 같이 사라진다. - 둘 다 두되 한쪽은
up만 남긴다 — 보내기 직전 relabel에서 그 job의 메트릭을 걸러 낸다.up은 수집기가 스스로 만드는 값이라 본문을 다 버려도 남는다.
up 둘이 갈리는 경우가 있다. 같은 프로세스지만 통과하는 경로가 다르다.
up{job="k3s-apiserver"} :6443 → apiserver 인증·flowcontrol을 지난다
up{job="kubelet"} :10250 → kubelet의 단순한 경로를 지난다
apiserver 처리 경로가 막히면 앞엣것만 0이 되고 뒤엣것은 1로 남는다. 다만 그 지경이면 kubectl도 안 되는 상태라 다른 데서 먼저 드러난다. 그리고 노드 생존은 이미 다른 신호가 본다 — node-exporter의 up, KSM이 내는 노드 상태, etcd 리더 유무.
결정 — :6443 scrape를 지우고 :10250만 남긴다. 그리고 job 이름을 k3s-server로 바꾼다.
:10250을 고른 이유는 둘이다. apiserver의 인증·flowcontrol 경로를 지나지 않아 apiserver가 밀릴 때도 응답하고, cAdvisor가 같은 포트의 다른 경로라 설정이 나란히 붙는다.
이름을 바꾸는 이유는 어느 쪽 이름도 사실과 안 맞기 때문이다. 이 레지스트리에는 apiserver·kubelet·scheduler·controller-manager 지표가 전부 들어 있다. kubelet이라 부르면 안의 대부분인 apiserver 지표를 가리키지 못하고, k3s-apiserver라 불러도 반만 맞다. k3s-server는 그 프로세스 전체를 가리키므로 나중에 값을 볼 때 헷갈리지 않는다.
이름을 합쳐도 컴포넌트를 못 나누게 되는 것은 아니다. 컴포넌트 구분은 job이 아니라 메트릭 이름이 한다.
apiserver_request_* apiserver
scheduler_* 스케줄러
workqueue_* 컨트롤러 매니저
kubelet_* kubelet
etcd_server_* · etcd_disk_* etcd
job은 "어디서 긁었나"를 적는 라벨이고 "무슨 컴포넌트냐"는 이름이 말한다. 그래서 job을 하나로 두어도 대시보드에서 컴포넌트를 나눠 보는 데 지장이 없다.
5.2 상한을 얼마로 둘까 — 실측으로 계산한다
사본을 걷으면 원천이 약 22만이 된다. 그래도 15만보다 크니 천장을 올려야 하고, 얼마로 올릴지는 인제스터 메모리가 정한다. 얼마나 더 쓰는지는 추정할 필요가 없다 — 인제스터가 자기 값을 낸다.
cortex_ingester_memory_series
process_resident_memory_bytes{job="observability/ingester"}
시리즈 150,000 ↔ RSS 506–618 MiB (세 대, 재는 시점에 따라)
→ 시리즈 하나당 3.5–4.2 KiB
값이 한 자리에 머물지 않는다. 시리즈 수는 15만으로 고정인데 RSS는 계속 오르내린다. Go가 회수한 메모리를 운영체제에 바로 돌려주지 않고, 2시간 블록 주기 안에서 메모리에 든 구간이 자랐다 줄고, 조회가 오면 그만큼 더 쓰기 때문이다. 재기동 직후가 가장 낮고(3.5 KiB) 안정되면 4.2 KiB다. 상한을 정할 때는 저점이 아니라 안정 상태 값을 쓴다.
Mimir 공식 용량 계획은 시리즈당 약 8.3KB를 잡는다(인메모리 30만당 2.5GB). 실측의 두 배다. 공식 값은 피크와 여유를 포함한 프로비저닝 권장치라 그렇고, 여기 값은 앱이 없어 시리즈 교체가 적은 상태의 값이다. 둘 다 두고 계산한다.
RF가 3인데 인제스터도 3대라 전역 상한이 곧 인제스터 한 대가 지는 양이다. 그래서 상한이 그대로 노드당 메모리가 된다.
| 상한 | 실측 4.2KiB 기준 | 공식 8.3KB 기준 |
|---|---|---|
| 15만 (지금) | 약 0.6 GiB | 약 1.2 GiB |
| 25만 | 약 1.0 GiB | 약 1.9 GiB |
| 30만 | 약 1.2 GiB | 약 2.3 GiB |
| 50만 | 약 2.0 GiB | 약 3.9 GiB |
여기에 걸리는 제약이 둘이다.
- 인제스터 컨테이너 limit — 지금 1Gi로 걸어 뒀다. 이 값을 넘기면 컨테이너가 OOMKilled 된다. 25만에서 이미 닿는다.
- 노드 여유 — 노드 8GB에서 자원 예약을 뺀 몫 안에 인제스터 말고도 다 들어가야 한다.
그런데 "노드 여유"에 값이 두 개 있다. 스케줄러가 세는 여유와 실제로 남은 메모리가 서로 다르다.
스케줄러가 세는 여유 = 할당 가능량 − 그 노드 파드들의 request 합
실제 남은 메모리 = 커널이 보는 값 (free)
스케줄러는 파드가 얼마를 쓰는지 안 보고 선언한 request만 더한다. dev라 대부분의 파드가 자기 request보다 적게 쓰므로 두 값이 벌어진다.
스케줄러가 세는 여유 실제 남은 메모리
k3s-1 1,969 MiB 3,532 MiB
k3s-2 1,671 MiB 3,030 MiB
k3s-3 2,427 MiB 4,268 MiB
둘이 막는 것이 다르다. 스케줄러가 세는 여유가 모자라면 파드가 그 노드에 뜨지 못하고(Pending), 실제 메모리가 모자라면 뜬 파드가 죽는다(OOMKill·eviction). 그래서 request를 올릴 때는 왼쪽으로, limit을 올릴 때는 오른쪽으로 검산한다.
결정 — 상한 30만, 인제스터 limit 2Gi, request 1Gi.
- 상한 30만 — 사본을 걷은 뒤 실수요는 가동 시간에 따라 약 17만–22만 사이를 오르내리고(재기동 직후 낮고 돌수록 는다), 앱이 붙으면 더 는다. 25만은 여유가 13%뿐이라 앱이 붙으면 곧 다시 찬다. 30만이면 36% 여유고, 실측 기준 인제스터당 1.2 GiB다.
- limit 2Gi — 30만이 실측 4.2KiB로 약 1.2 GiB라 1Gi로는 컨테이너가 OOMKilled 된다. limit은 스케줄링에 쓰이지 않으므로 올려도 다른 파드를 밀어내지 않는다.
- request 1Gi — 지금 512Mi인데 실사용이 571–581 MiB이고 30만이면 1.2 GiB가 된다. request가 실사용보다 작으면 그 초과분 때문에 인제스터가 eviction 우선순위 앞쪽에 놓인다. 상태를 들고 있는 컴포넌트라 그건 피한다. 512Mi를 더 떼도 스케줄러가 세는 여유가 가장 빠듯한 k3s-2에서 1,159 MiB 남고, 실제 메모리는 30만을 다 채워도 2,390 MiB 남는다.
5.3 무엇을 고치나
수집 쪽과 저장 쪽에서 파일 두 개가 바뀐다.
수집 — Alloy 설정
삭제 prometheus.scrape "apiserver" :6443 — 같은 레지스트리의 사본
삭제 discovery.relabel "apiserver" 그 블록만 쓰던 주소 변환
변경 prometheus.scrape "kubelet" job_name 을 "k3s-server" 로
discovery.kubernetes "nodes"는 남긴다 — 남은 kubelet·cAdvisor scrape가 그 노드 목록을 쓴다.
저장 — Mimir 설정
추가 limits.max_global_series_per_user: 300000 기본값 150000 을 덮어씀
변경 ingester.resources requests.memory 512Mi → 1Gi
변경 ingester.resources limits.memory 1Gi → 2Gi
적용 — 롤링 교체와 WAL 재생
값이 반영되면 인제스터가 한 대씩 새 파드로 교체된다. StatefulSet의 교체 순서는 번호가 큰 쪽부터다. 30초 간격 기록.
17:44:00 2번 옛 파드 마지막 값 150,000
17:44:30 2번 새 파드 첫 값 196,456
17:45:30 2번 297,078
17:47:30 세 대 모두 300,000
새 파드가 0이 아닌 값에서 시작하는 것은 디스크에 적어 둔 WAL을 되읽어 메모리 상태를 복원하기 때문이다. 196,456은 재생이 끝나기 전에 긁힌 값이고, 1분 뒤 297,078로 올라간다. 상태를 들고 있는 컴포넌트에 디스크를 붙이면 교체가 데이터 손실 없이 끝난다.
교체 후 인제스터가 든 것은 30만이다. 사본을 걷은 뒤 들어오는 양은 약 18만이므로 12만이 더 있다.
잔존 — 라벨을 바꾸면 옛 시리즈가 옆에 남는다
job 이름은 라벨이고, 라벨 조합이 다르면 다른 시리즈다. 새 이름의 시리즈는 옛 이름의 자리를 대체하지 않고 별도로 생성된다. 옛 시리즈는 샘플이 끊겨도 즉시 사라지지 않는다.
새로 들어오는 것 k3s-server 계열 약 18만
아직 안 빠진 것 kubelet · k3s-apiserver 계열 약 12만
──────────────────────────────────────────────
인제스터가 든 것 30만
그래서 적용 직후에는 사용률이 다시 100%가 되고 거절도 이어진다. 이름을 바꾸는 변경에는 옛것과 새것을 동시에 들고 있는 구간이 따라온다.
제거 — head 절단이 시점을 정한다
인제스터가 메모리에 들고 있는 최근 구간을 head라 부른다. 샘플이 끊긴 시리즈를 head에서 덜어내는 동작이 head 절단(truncation) 이고, 블록 생성과는 다른 동작이다.
| 하는 일 | 시리즈가 빠지나 | |
|---|---|---|
| 블록 생성 | head의 데이터를 파일로 떠서 오브젝트 저장소로 보낸다 | 아니오 |
| head 절단 | head가 담는 시간 구간의 시작점을 앞으로 민다 | 마지막 샘플이 그 시작점보다 앞서면 빠진다 |
두 시간 간격으로 도는 절단을 5분 간격으로 기록했다.
시각 보유 시리즈 제거 누적 head 절단 거절/초
17:47 300,000 0 0 0.0
18:02 300,000 0 1 26.9 1회차 — 제거 0
19:58 300,000 0 1 36.7 115분째
20:03 300,000 120,973 2 28.3 2회차 — 120,973 제거
20:08 181,128 120,973 2 1.4
1회차에 제거가 0인 이유는 절단의 정의에서 나온다. 옛 라벨의 샘플이 끊긴 시각은 17:4x이고, 18:02의 절단이 민 시작점은 아직 그보다 앞이라 옛 시리즈의 마지막 샘플이 head 구간 안에 남아 있었다. 20:03의 절단에서 시작점이 17:4x를 넘어가며 120,973이 한 번에 빠졌다.
대기 시간 = 샘플이 끊긴 시각부터 최대 두 주기(약 4시간). 이번은 17:47에서 20:03까지 2시간 16분이었다. 주기가 두 시간이므로 한 주기 미만의 관찰로는 "안 도는 것"과 "아직 안 온 것"이 갈리지 않는다.
확인 — count로는 이 12만이 안 보인다
옛 시리즈가 남아 있는지를 count로 확인하면 0이 나온다. count는 최근 몇 분 안에 샘플이 있는 시리즈만 세기 때문이다. 옛 라벨은 샘플이 끊겼으므로 count에는 잡히지 않고, 인제스터 메모리에는 남아 있다.
sum(scrape_samples_scraped) 타깃이 지금 생산하는 양 약 18만
max(cortex_ingester_memory_series) 인제스터가 든 양 30만
max(cortex_ingester_active_series) 최근 샘플이 온 시리즈 약 18만
memory_series는 "지금 메모리에 들고 있는 시리즈", active_series는 "최근 샘플이 온 시리즈"다. 자리를 차지하는 것은 memory_series 쪽이다. 두 값의 간격이 잔존량이고, 12만 벌어져 있다가 20:08에 붙었다.
결과
인제스터 보유 182,397 상한 300,000 의 61%
활성 시리즈 182,397 보유와 같다 = 잔존 0
거절 0/s
실제 메모리 469 · 486 · 479 MiB limit 2Gi 의 약 24%
파드 재시작 0
사본을 걷어 이 시점 생산량이 약 18만이 됐다 — 걷기 직전에는 두 벌을 합쳐 약 25만이었다. 진단 때 계산한 "걷은 뒤 약 22만"보다 작은 것은, 재기동 뒤 apiserver 계열 시리즈가 아직 다 자라지 않은 시점이라서다. 상한을 30만으로 올려 12만의 여유를 뒀고, 시리즈당 실측 4.2 KiB로 30만을 다 채워도 1.2 GiB라 limit 2Gi 안에 든다.
6. 계기판 — Mimir가 정의하는 세 축으로 짓는다
이 대시보드의 질문은 하나다 — 관측이 도는가. 클러스터가 건강한가는 다음 편 인프라 대시보드의 질문이라 여기 없다.
행 구조는 Mimir 공식(mimir-mixin)이 자기 관측을 나누는 세 축을 그대로 쓴다.
쓰기 받아지는가 Alloy → distributor → ingester
읽기 답하는가 query-frontend → querier
저장 여정 내려가는가 ingester → MinIO / ring · 메모리
시리즈 상한 사건에서 나온 신호들(수집 생존·전송 실패·거절·보유 vs 상한)은 전부 쓰기 축 소속이라 첫 행에 모인다. 패널은 12개로 잡는다 — 빠진 신호가 드러나면 그날 추가한다. 패널 제목은 "컴포넌트 — 신호" 형식이고, 설명(ⓘ)은 역할 · 패널 · 읽는 법 세 줄이다. 화면만 봐도 어느 컴포넌트의 어느 단계인지 읽히게.
대시보드는 git에 푸시하면 Grafana까지 자동으로 배달된다.
① git push 대시보드 JSON을 담은 ConfigMap (라벨 grafana_dashboard: "1")
② ArgoCD 3분 주기 폴링으로 새 커밋 발견 → sync → ConfigMap 생성·갱신
③ 사이드카 Grafana 파드 안의 보조 컨테이너가 쿠버네티스 API를 watch → 라벨 감지
④ 파일 반영 JSON을 공유 볼륨에 파일로 (ConfigMap이 지워지면 파일도 제거)
⑤ 리로드 Grafana 리로드 API 호출 → 재시작 없이 반영
push부터 화면까지는 ②의 폴링 주기가 지배한다 — 최대 약 3분. Grafana에는 영속 저장소가 없어 UI 수정은 재시작에 사라진다 — 수정이 남는 경로는 git push 하나다.
6.1 쓰기 행 — 받아지는가
데이터가 저장소에 앉기까지: Alloy가 긁고, 자기 WAL(전송 전 값을 로컬 디스크에 적어 두는 버퍼)에 적어 배치로 밀고(remote_write), distributor가 해시 링에서 담당 인제스터를 찾아 RF3로 복제하고, ingester가 메모리에 든다. 이 여정의 어디가 끊겨도 결과는 같다 — 값이 조용히 사라진다. 여섯 패널이 그 여정을 단계 순서로 지킨다.

Alloy 수집 — job별 생존 이력
min by (job) (up)
up: 수집기가 스크레이프 성공(1)·실패(0)를 판정해 직접 만드는 값 — 대상이 죽어도 남는 유일한 신호min by (job): 타깃을 job으로 접기 — 하나만 죽어도 그 줄이 빨강. 이산 상태라 색 블록(state timeline)- 빨간 두 줄(booking·app/queue) = 이미지 반입 전 앱. 필터로 가리지 않는다 — 쿼리는 사실만, 맥락은 운영자 몫
distributor·ingester — 거절 (사유별)
sum by (reason) (rate(cortex_discarded_samples_total[5m]))
- 두 컴포넌트가 한도·유효성 검사에서 거부한 샘플 — reason 라벨에 사유가 문자열로 붙는다
- 0이 정상.
per_user_series_limit= 시리즈 상한 도달 — 늦게 온 시리즈부터 선착순으로 밀린다 - 상한 사건 때 로그를 뒤져서야 봤던 그 신호의 상시화
Alloy remote_write — 전송 실패·재시도
sum by (pod) (rate(prometheus_remote_storage_samples_failed_total[5m]))
sum by (pod) (rate(prometheus_remote_storage_samples_retried_total[5m]))
- 실패 = 재시도 없이 버려진 샘플(4xx) = 곧 유실. 옆 거절 패널과 같은 사건의 양면이다
- 재시도 = 일시 오류(5xx·네트워크)로 재전송 중 — 노드 재시작 때 튀었다가 유실 0으로 회복해야 정상
- Mimir가 통째로 죽으면 거절 카운터는 침묵하지만 여기는 뜬다 — 백업 신호

distributor — 유입 (샘플/초)
sum(rate(cortex_distributor_received_samples_total[5m]))
- 모든 쓰기가 처음 닿는 관문이 받은 초당 샘플 — Mimir Writes 대시보드의 첫 헤드라인
- Alloy 전송 처리량 합과 같아야 정상 — 유실 0의 교차 증거 (실측 둘 다 약 3,500–3,650)
- 꺼지면 상류(수집·전송)가 멈춘 것
ingester — 시리즈 보유·활성 vs 상한 300,000
cortex_ingester_memory_series (보유 — 파드별 세 선)
max(cortex_ingester_active_series) (활성 — 한 선)
- 보유 = 메모리에 든 것 — 상한이 세는 대상(각 노드마다). 활성 = 최근 샘플이 온 것
- 두 선의 간격 = 잔존. 라벨 변경 뒤 최대 두 절단 주기(약 4시간)까지 정상 — 사본 걷을 때 12만이 이 모양
- 보유가 파드별 세 선인 이유 = RF3 복제 — 셋이 같아야 하고, 갈라지면 복제 이상. 집계하면 그 신호를 잃는다
Alloy 수집 — job별 생산 시리즈
topk(12, sum by (job) (scrape_samples_scraped))
- 스크레이프 한 번에 시리즈당 샘플 하나 → 이 값 = 그 타깃의 시리즈 수 — 저장소를 훑지 않고 세는 방법
- 패널 쿼리는 자동 갱신으로 열어 두면 상시 부하가 된다 — 전 시리즈를 세는 쿼리 하나가 조회 컴포넌트를 42번 죽인 전례가 있어, 무거운 쿼리를 패널에 걸지 않는다
- bar gauge: 숫자 = 절대치, 막대 배경 전체 = 상한 300,000
- 누가 상한을 먹는지 — 상한 튜닝의 근거. k3s-server 하나가 절반 가까이
6.2 읽기 행 — 답하는가
쓰기와 읽기는 독립적으로 고장 난다 — 상한 사건 때 쓰기가 거절로 막히는 동안, 읽기는 별개로 querier가 93분 사이 25번 재시작하고 있었다. 조회는 query-frontend가 쿼리를 쪼개 querier에게 나눠 주고, querier가 인제스터(최근 2시간)와 store-gateway(그 전, MinIO 블록)를 합쳐 답한다. 컴포넌트를 나눈(microservices) 구성의 격리가 여기서 보인다 — 읽기가 죽어도 쓰기는 돈다.

query-frontend — 조회 요청 (상태코드)
sum by (status_code) (rate(cortex_request_duration_seconds_count{
job="observability/query-frontend", route=~"prometheus_api.*"}[5m]))
- 조회 입구의 응답 분포 — 조회 API(route 필터)만 남겨 내부 gRPC·헬스체크 소음 제거
- 499 = 기다리다 취소(느림의 증상), 5xx = 고장
- 이 대시보드의 패널 쿼리도 여기로 들어온다 — 계기판 자신이 이 경로의 고객
query-frontend — 조회 지연 p99
histogram_quantile(0.99, sum by (le) (rate(
cortex_request_duration_seconds_bucket{
job="observability/query-frontend", route=~"prometheus_api.*"}[5m])))
_bucket(지연이 le 이하인 요청 수의 구간별 누적)에서 "100건 중 99건이 든 시간"을 역산 — 백분위의 표준 조합- 오르면 화면 체감이 먼저 느려진다
- 최근 조회만 느리면 ingester 쪽, 과거 구간 조회가 느리면 store-gateway(MinIO) 쪽
6.3 저장 여정·상태 행 — 내려가는가
인제스터의 메모리는 종착지가 아니다. 2시간마다 메모리 구간(head)을 블록으로 떠 MinIO에 올리고(shipper), RF3라 세 벌 올라간 블록을 compactor가 병합한다. 이 길이 막히면 겉은 멀쩡한데 로컬 WAL 디스크만 차오른다 — 증상이 늦게 드러나는 고장이라 축이 "마지막 성공 후 경과"다.

ingester shipper — 마지막 블록 업로드 후 경과
time() - cortex_ingester_shipper_last_successful_upload_timestamp_seconds
- "마지막 성공 시각" 게이지를 time()에서 빼면 경과 초 — 이 류 메트릭의 표준 사용법
- 평시 2시간 미만(업로드 주기). 4시간(빨강) = Mimir 공식 알림 기준
- 그 너머면 블록이 안 나가 디스크만 차는 중 — 끝은 쓰기 정지
compactor — 마지막 병합 성공 후 경과
time() - cortex_compactor_last_successful_run_timestamp_seconds
- 같은 패턴, 대상만 compactor. 24시간(빨강) = 공식 알림 기준
- 멈추면 같은 데이터가 세 벌씩 쌓여 저장·조회 비용이 계속 는다
ingester ring — ACTIVE 수
max(cortex_ring_members{name="ingester", state="ACTIVE"})
- 해시 링 = 인제스터들이 자기 위치를 등록한 원형 명단 — distributor가 여기서 담당자를 찾는다
- 링을 지켜보는 컴포넌트마다 각자 보고하므로 sum은 관찰자 수만큼 부풀려진다(실측 21) → max = 실제 멤버 수
- 3 = 초록. 하나 빠지면 쓰기 quorum(2/3)의 여유가 사라진 상태
ingester — 메모리 vs limit 2Gi
process_resident_memory_bytes{job="observability/ingester"}
- 시리즈를 메모리에 드는 대가 — 시리즈당 실측 약 4.2KiB, 30만 만재 시 약 1.2GiB
- 빨간 선 = 컨테이너 limit 2Gi. 닿으면 OOMKilled — WAL 재생으로 복구되지만 그동안 조회가 빈다
- 시리즈 상한(거절)이 이 OOM을 막는 가드레일 — 두 패널이 짝이다
6.4 알림 후보 — 대시보드에서 역추적한 셋
대시보드는 열어야 보이고, 알림은 안 봐도 온다. 개별 고장마다 알림을 걸지 않고, 여러 고장이 결국 흘러드는 하류 세 지점에만 건다 — 울리면 어느 행을 열지도 정해져 있다.
| 알림 | 조건 | 여는 행 | 우산 아래 들어오는 고장 |
|---|---|---|---|
| 데이터 유실 | 거절 > 0 or 전송 실패 > 0 | 쓰기 | 상한 포화 · 카디널리티 폭발 · Mimir 사망 |
| 인제스터 이탈 | ring ACTIVE < 3 | 저장 여정·상태 | 인제스터 크래시·OOM — 최근 구간을 유일하게 든 컴포넌트가 빠짐 |
| 저장 정체 | 블록 업로드 경과 > 4시간 | 저장 여정·상태 | 플러시 정체 · MinIO 이상 · 디스크 문제 |
구현은 다음 편으로 넘긴다. Grafana Alerting으로 룰을 ConfigMap에 담으면 대시보드와 같은 사슬로 배달되고, 수신 채널(Discord webhook)의 URL은 시크릿이라 봉인이 필요하다. 그 봉인 작업이 다음 편의 저장소 이전 작업과 같은 결이라 거기 묶는다.
7. 대조, 그리고 되짚기
7.1 시작할 때와 대조
이 편을 열 때 비어 있던 두 자리가 이렇게 됐다.
| 열 때 | 지금 |
|---|---|
| 관측이 실제로 흐르는지 확인한 적이 없다 | 구조를 열어 저장된 15만의 절반이 사본임을 찾았다 — 사본을 걷고 상한을 올려 거절 0, 보유 182,397 / 상한 300,000 (61%) |
| Grafana 안이 백지다 | 파이프라인 자신을 보는 계기판 한 장이 코드로 들어갔다 — Mimir 3축 12패널 |
앱 세 종이 ImagePullBackOff |
그대로 — 이미지는 10부(CI/CD·레지스트리)에서 생긴다 |
그 과정에서 이 배포판의 성질 하나가 드러났다. 컨트롤플레인을 한 프로세스로 합쳤다는 사실이 자원 계산·장애 반경·메트릭 세 곳에서 같은 모양으로 나타난다. kube-reserved를 따로 떼야 했던 것, 하나가 죽으면 넷이 같이 죽는 것, 두 포트가 같은 응답을 내던 것이 전부 같은 원인이다.
7.2 남은 것과 걸렸던 것
- 알림. 후보 셋(데이터 유실 · 인제스터 이탈 · 저장 정체)과 조건까지 확정했고 구현은 9부다. 그때까지 이 계기판은 "열어봐야 아는" 상태로 남는다 — 이 공백은 9부까지 안고 간다.
- 노드 자원 예약 반영. 설정 파일에는 2Gi, 노드에는 아직 1Gi다. 세 대 순차 재시작이 필요하고, 앱이 올라와 실사용을 다시 잰 뒤 request 전반과 함께 조정한다. 그 재시작이 복제(RF3)가 실제로 일하는지 보는 첫 시험이 된다.
- 인제스터 request가 지금은 실사용보다 크다. request 1Gi에 실사용 약 478 MiB — 30만 만재(약 1.2 GiB)를 내다보고 절충한 값이라 지금은 절반이 놀고, 만재에 실제로 가까워지면 재조정이 다시 필요하다. 앱 이후 실측으로 다시 잡는다.
- MinIO 요청 실패가 초당 0.29로 잡혀서 파봤다 — 고장이 아니었다. 실패의 전부가 compactor의 get이고, MinIO 응답은 4xx(없음)다. compactor는 "이 블록의 인덱스 헤더가 이미 있나", "이 조각(partial) 블록의 메타가 있나"를 조회해 없으면 만들거나 치우는데, 그 "없음(404)" 응답이 실패 카운터에 계수된다. 작업 성공 로그까지 확인했다. 실패 카운터가 0이 아닌 것과 고장은 다르다 — 이 값으로 알림을 걸었다면 상시 오탐이었다.
실측 없이 잡았다가 걸린 자원 값이 이 편에서만 셋 나왔다. 전부 같은 모양이다 — 재보지 않고 정한 값.
| 무엇 | 잡았던 값 | 무슨 일이 났나 | 고친 값 |
|---|---|---|---|
| 인제스터 request | 512Mi | 실사용이 넘어서 eviction 우선순위가 앞당겨지는 상태 | 1Gi |
| 대시보드 사이드카 limit | 64Mi | 기동 직후 exit 137 반복 — watch 연결과 런타임을 든 컨테이너였다 |
192Mi |
| 조회 컴포넌트 limit | 256Mi | 전 시리즈를 훑는 패널 쿼리에 42회 exit 137, 다른 패널까지 전부 시간 초과 |
512Mi |
세 번째는 자원만의 문제가 아니었다 — 계기판에 넣은 쿼리 자체가 저장소를 무너뜨리고 있었고, 쿼리를 가벼운 것으로 바꾼 뒤에야 안정됐다. 계기판을 세우는 일이 그 자체로 저장소에 부하를 얹는다는 것을 여기서 겪었다. 진단 첫머리에 보였던 25회 재시작도 같은 자리다 — 무거운 조회가 올 때마다 256Mi에 걸려 죽던 것이고, 512Mi로 올린 뒤 재시작은 0이다.
7.3 실무에서 짚이는 것 — 보안 쪽
이 편에서 실제로 손댄 것에 걸리는 항목만.
- 상한을 올린 만큼 방어선이 물러났다. 시리즈 상한은 자원 보호 장치이면서 카디널리티 폭탄에 대한 방어다 — 라벨에 요청 ID처럼 값이 무한한 것을 넣는 코드 하나면 저장소가 마비된다. 30만으로 올린 만큼 그 방어선도 뒤로 갔고, 그 자리를 메울 알림이 9부까지 비어 있다.
- 수집기 권한을 좁혀 둔 것이 여기서 값을 했다. Alloy에서 Secret 읽기 권한을 뺐기 때문에 apiserver 인증을 ServiceMonitor로 못 걸었고 설정에 직접 쓰는 경로로 갔다. 권한을 넓혔다면 더 편했겠지만, 수집기 하나가 클러스터의 모든 시크릿을 읽는 상태가 됐을 것이다.
- 대시보드 사이드카의 감시 범위도 같은 결이다. 전역으로 두면 편하지만 RBAC이 전 네임스페이스로 넓어진다. 대시보드를 한 네임스페이스에만 두기로 하고 범위를 거기로 묶었다.
- 메트릭 자체가 유출 통로가 될 수 있다. 라벨에 경로·쿼리스트링이 통째로 들어가면 토큰이 메트릭에 실려 저장소로 간다. 지금은 인프라 지표뿐이라 해당이 없고, 앱이 붙는 편에서 앱이 내는 라벨을 볼 때의 확인 항목이다.
- Grafana는 아직 클러스터 밖으로 안 열려 있다. 노출하는 순간 인증·TLS·접근 제어가 따라온다 — 9부에서 Traefik으로 열 때의 일이다. 알림에 쓸 webhook URL도 시크릿이라 봉인(SealedSecret) 대상이고, GitLab 저장소 접근 자격과 같은 결로 그때 함께 처리한다.
7.4 다음 — GitOps 루프부터
이 편에서 대시보드를 고칠 때마다 push 후 최대 3분을 기다렸다. ArgoCD가 저장소를 3분 주기 폴링으로 보고 있어서인데 — 사실 이 지연을 체감하기 전까지 webhook이라는 선택지를 검토한 적 자체가 없었다. 폴링은 선택한 것이 아니라 기본값이라 그냥 돌던 것이다. 이번에 따져 보니 지금 구조에서는 걸고 싶어도 못 건다 — GitHub(인터넷)가 ArgoCD에 요청을 보내려면 ArgoCD가 밖으로 열려 있어야 하는데, 노출은 뒤로 미뤄 둔 상태다.
9부는 그 루프부터 고친다 — GitLab을 데스크탑에 세워 GitOps 소스를 옮기면 Git 서버와 ArgoCD가 같은 망에 있게 되고, 노출 없이 webhook이 성립해 push 즉시 sync가 된다. 알림 세 개가 그 위에서 서고, 그다음 인프라 대시보드 — 클러스터 자체를 보는 눈 — 를 짓는다.
'HomeLab' 카테고리의 다른 글
| 번외 — WSL2와 커밋 한도 (1) | 2026.08.05 |
|---|---|
| 노트북 홈서버 k3s 구축기 - 9. GitLab 세우기 (0) | 2026.08.04 |
| 노트북 홈서버 k3s 구축기 - 번외. e1000e NIC hang (0) | 2026.07.29 |
| 노트북 홈서버 k3s 구축기 - 7. 플랫폼 부트스트랩 (0) | 2026.07.29 |
| 노트북 홈서버 k3s 구축기 - 6. 데이터 디스크와 k3s 클러스터 (0) | 2026.07.21 |