--- id: 7b113a04-180a-40ec-9270-033531c22221 kind: SETUP slug: keycloak-two-nodes-and-postgres-on-k3s title: Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다 topic: lab-environment-build topicName: 실험대 환경 구성 project: virtualization status: 게시 전 studio: "https://hyeonworks.com/studio/documents/7b113a04-180a-40ec-9270-033531c22221/edit" pinnedVersions: - name: k3s version: v1.36.4+k3s1 - name: curlimages/curl version: 8.11.1 sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6 source: - final/document.md#191-단계-05-keycloak-2노드와-postgresql - final/document.md#184-이-부의-출처와-범위 --- # Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다 매니페스트 한 장을 `apply` 해 네임스페이스 `keycloak-lab` 에 Keycloak 두 대와 PostgreSQL 하나를 세우는 절차다. 세우는 명령은 두 줄이고, 나머지는 그 둘이 하나의 클러스터로 묶였는지 확인하는 명령이다. ## 관계 - **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다** 여기 적힌 `kubectl` 이 도는 클러스터를 그 단계가 세운다. INTERNAL-IP 가 어긋난 채로 왔으면 여기서 Endpoints 가 한 줄만 나온다. - **Prometheus 와 Grafana 를 올려 클러스터 안을 밖에서 본다** 클러스터 크기를 지표로 묻는 확인 명령이 그 스택을 전제한다. 아직 없으면 임시 파드를 띄워 같은 값을 받는다. - **DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다** 밖에서 도메인으로 `200` 을 받는 마지막 확인이 그 단계가 올린 443 을 지난다. - **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다** `kubectl get all` 이 이름과 달리 전부를 세지 않고, 로그와 테이블과 지표가 각각 다른 시점을 말한다. 그 구분이 이 단계의 판정을 셋으로 가른다. - **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다** 밖에서 502 가 나올 때 Ingress 에서 Service 로, Service 에서 Endpoints 로 되짚는 순서가 그 기준에서 나온다. ## 본문 ## 읽기 전에 — 어디서 치는가 이 단계는 앞의 셋보다 단순하다. 전부 `[lab host]` 에서 치고, 저장소 루트에서 친다. 마지막 로그인 확인만 브라우저다. | 번호 | 무엇 | 어디서 | |---|---|---| | 1 | 매니페스트 적용 | `[lab host]` — `~/workspace/keycloak-pattern` 안에서 | | 2 | 파드 둘이 설 때까지 기다리기 | `[lab host]` | | 판정 | 리소스 · 클러스터 · 밖에서 | `[lab host]` | | 판정 | 관리 콘솔 로그인 | 브라우저 | `kubectl` 이 lab host 에서 도는 까닭은 앞 단계에 있다. kubeconfig 를 게스트에서 호스트로 가져다 두었으므로 게스트에 들어갈 일이 없다. `deploy/...` 로 시작하는 상대경로는 저장소 루트 기준이라, 다른 디렉터리에서 치면 `error: the path ... does not exist` 로 막힌다. 이 단계에는 손으로 쓰는 파일이 없다. 세우는 것이 전부 매니페스트 한 장에 들어 있고, 그 원문은 저장소의 `deploy/lab/k8s/keycloak-cluster.yaml` 이다. ## 이 단계가 세우는 것 가이드 05 의 「이 단계가 끝나면」은 두 줄이다. > `https://auth.hyeonworks.com` 에서 관리 콘솔에 로그인되고, 두 Keycloak 이 > 하나의 클러스터로 보인다. **두 마디가 따로다.** 「로그인된다」는 밖에서 잰 `200` 과 브라우저이고, 「하나의 클러스터로 보인다」는 아래 세 확인이다. 파드가 둘 다 `Running` 인 것과 하나의 클러스터로 묶인 것은 다르다. | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` — 매니페스트 첫 문서가 `kind: Namespace` 라 `apply` 가 같이 만든다 | | Keycloak | StatefulSet `keycloak` — 파드 `keycloak-0` · `keycloak-1` | | PostgreSQL | Deployment `postgres` — ReplicaSet `postgres-7b474b88c8` | | Service | `keycloak` → Endpoints `10.42.0.67:8080,10.42.1.155:8080` | | Secret | `keycloak-lab-secrets` — `KC_BOOTSTRAP_ADMIN_PASSWORD` 19 bytes · `POSTGRES_PASSWORD` 22 bytes | | PVC | StorageClass `local-path`, 이름 끝에 파드 번호가 붙는다 | | Ingress | HOSTS 가 `auth.hyeonworks.com` | | 클러스터링 | JGroups — 디스커버리 테이블 `jgroups_ping`, 메시지 포트 7800 | | 관리 포트 | `9000` — `/metrics` 가 거기 있다 | **판 번호는 여기 없다**(unknown). 가이드 05 는 Keycloak 과 PostgreSQL 의 이미지 태그를 적지 않았고 `keycloak-cluster.yaml` 원문은 반입되지 않았다. 가이드 안에 태그가 찍힌 이미지는 아래 임시 파드의 `curlimages/curl:8.11.1` 하나뿐이다. ## 전제와 되돌리기 전제는 한 줄이다 — 앞 단계까지 끝나 `https` 가 열린다. **되돌리는 절차는 원본 가이드 05 에 없다**(unknown). `kubectl delete -f` 도, 지우는 순서도 적혀 있지 않다. 이 단계가 클러스터에 남기는 것은 `keycloak-lab` 네임스페이스와 그 안의 전부이고, 그중 PVC 는 성격이 다르다 — StorageClass 가 `local-path` 라 데이터가 파드가 스케줄된 그 노드의 디스크에 놓인다. 네임스페이스를 지울 때 그 디렉터리가 어떻게 되는지는 가이드가 적지 않았고 이 실험대도 확인하지 않았다. ## 세우기 전에 먼저 본다 두 확인은 아무것도 바꾸지 않는다. 하나는 명령이 파일을 찾을 수 있는 곳인지 보고, 하나는 이 이름이 아직 Keycloak 이 아님을 확인한다. ### 확인 ① 지금 이 디렉터리에서 매니페스트가 보이는가 ```bash label="[lab host] 저장소 루트로 가서 매니페스트를 본다" cd ~/workspace/keycloak-pattern ls deploy/lab/k8s/ ``` **어디를 봐야 하는가** — `keycloak-cluster.yaml` 과 `observability.yaml` 이 보이는가. **이 결과가 의미하는 것** — 보이면 아래 `kubectl apply -f deploy/...` 가 파일을 찾는다. 안 보이면 첫 단계의 저장소 받기로 돌아간다 — 한 번 세웠다 철거한 뒤에는 VM 과 디스크만 지워지고 저장소는 각자 관리라 이 디렉터리가 없는 상태가 흔하다. ### 확인 ② 이 이름이 아직 Keycloak 이 아니다 ```bash label="[lab host] 앞 단계에서 잰 값을 그대로 다시 잰다" curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/ ``` **어디를 봐야 하는가** — 앞 단계에서 잰 `404 tls=0` 이 그대로인가. `tls=0` 이 아니면 이 단계를 시작할 때가 아니라 앞 단계로 돌아갈 때다. **이 결과가 의미하는 것** — 가이드가 그 값 옆에 한 줄을 적어 두었다 — 앞의 `404` 는 이 단계 이후에 `200` 으로 바뀐다. 지금 재 두면 아래의 `200` 이 이 단계가 만든 변화인지가 분명해진다. ## 실행 절차 ### 1. 매니페스트 한 장을 적용한다 **목적** — 네임스페이스부터 Ingress 까지 한 파일로 세운다. ```bash label="[lab host] ① 매니페스트가 있는 저장소 루트로 간다" cd ~/workspace/keycloak-pattern ``` ```bash label="[lab host] ② 매니페스트를 적용한다" kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml ``` **예상 결과** — 만든 객체가 줄마다 찍힌다. 파드가 서는 것은 그다음이라 이 출력만으로 끝났다고 판정하지 않는다. **왜 필요한가** — 네임스페이스를 따로 만들지 않는다. 매니페스트 첫 문서가 `kind: Namespace` 라 `apply` 가 같이 만들고, `kubectl create namespace` 를 먼저 치면 두 번째 실행부터 `AlreadyExists` 로 막힌다. **문제가 생기면** — 적용 자체가 거절되면 클러스터가 서 있는지부터 본다. 앞 단계의 `kubectl get nodes` 가 두 줄을 내는지가 전제다. `error: the path ... does not exist` 면 저장소 루트가 아닌 곳에서 쳤다. ### 2. 두 파드가 다 설 때까지 기다린다 **목적** — Keycloak StatefulSet 의 파드 둘이 Ready 가 될 때까지 다음 명령을 치지 않는다. ```bash label="[lab host] ① 끝날 때까지 멈춰 있는 것이 정상이다" kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s ``` **예상 결과** ```text partitioned roll out complete: 2 new pods have been updated... ``` 이 명령은 끝날 때까지 아무것도 안 찍고 멈춰 있다. 그 침묵이 정상이고, 마지막 한 줄에서 `complete` 라는 낱말과 파드 개수 `2` 를 본다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로 중간에 `0/2` 로 한참 멈춰 있는 것도 정상이다. **왜 필요한가** — 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이 된다. 「안 떴다」가 확정되고 진단으로 넘어간다. `rollout status` 를 쓰는 까닭은 `get pods` 를 반복해서 치는 것보다 나아서만이 아니라, 언제 끝났는지를 사람이 판정하지 않아도 되기 때문이다. 기다리지 않고 판정으로 가면 아직 안 뜬 것과 못 뜨는 것이 섞인다. **문제가 생기면** — 타임아웃으로 끝났으면 아래 「막히면」의 순서대로 본다. ## 구성 값 만들어지는 객체는 「이 단계가 세우는 것」의 표에 있고, 여기서는 그 값들이 어디서 왔는지를 적는다. | 값 | 어디서 정해지나 | |---|---| | 네임스페이스 이름 | 매니페스트 첫 문서 | | 파드 이름의 순번 | StatefulSet 이라 `keycloak-0` · `keycloak-1` | | 파드 이름의 해시 | Deployment 가 만든 ReplicaSet 이름에서 물려받는다 | | Secret 의 바이트 수 | 매니페스트가 넣은 값의 길이. 이 문서는 값을 적지 않는다 | | PVC 가 붙는 노드 | `local-path` 가 파드 스케줄을 기다렸다 그 노드에 만든다 | | Ingress 의 HOSTS | 앞 단계에서 발급한 인증서의 이름과 같아야 한다 | Ingress 의 호스트 이름이 인증서의 이름과 다르면, 밖에서는 TLS 는 되는데 `404` 가 나온다. ## 끝났는지 판정한다 `kubectl get pods` 만 보면 놓치는 것이 많다. 위에서 아래로 확인한다. ### 확인 ① 무엇이 만들어졌나 **무엇을 확인하는가** — 이 네임스페이스에 무엇이 서 있는지. ```bash label="[lab host] ① 워크로드와 Service 를 본다" kubectl -n keycloak-lab get all ``` ```bash label="[lab host] ② 앞 명령이 안 세는 넷을 따로 본다" kubectl -n keycloak-lab get secret,configmap,pvc,ingress ``` **어디를 봐야 하는가** — ① 은 종류별로 묶여 나오는 왼쪽 이름 열을 훑고, 파드 줄에서는 READY 칸의 `1/1` 과 RESTARTS 칸을 본다. RESTARTS 가 0 이 아니면 지금은 `Running` 이어도 한 번 죽었다 살아난 것이다. ② 는 네 종류가 하나씩이라도 있는가, PVC 줄의 STATUS 가 `Bound` 인가, Ingress 줄의 HOSTS 칸이 `auth.hyeonworks.com` 인가. **이 결과가 의미하는 것** — `get all` 은 이름과 달리 전부가 아니다. Secret 과 ConfigMap 과 PVC 와 Ingress 가 ① 의 출력에 나오지 않으므로, 첫 명령만 보고 「다 만들어졌다」로 판정하면 빠진 것을 모른 채 다음으로 간다. 매니페스트에 있는데 ② 에 없는 종류가 있다면 `apply` 가 부분적으로만 먹었다. ### 확인 ② Deployment 에서 ReplicaSet 을 지나 파드까지 이어졌나 **무엇을 확인하는가** — 사슬 어디까지 갔는지. Deployment 는 파드를 직접 만들지 않고 ReplicaSet 을 만들며 그것이 파드를 만든다. ```bash label="[lab host] 해시로 사슬을 맞춰 본다" kubectl -n keycloak-lab get rs,pod -l app=postgres ``` **실측**(observed) — 2026-09-11 ```text NAME DESIRED CURRENT READY AGE replicaset.apps/postgres-7b474b88c8 1 1 1 80m NAME READY STATUS RESTARTS AGE pod/postgres-7b474b88c8-ll87p 1/1 Running 0 80m ``` **어디를 봐야 하는가** — ReplicaSet 이름의 해시가 파드 이름 가운데 해시와 같은가, 그리고 `DESIRED` 와 `CURRENT` 와 `READY` 세 숫자가 다 `1` 인가. **이 결과가 의미하는 것** — 이 출력에 Deployment 줄이 없는 것도 정상이다. 이 매니페스트는 `app: postgres` 라벨을 파드 템플릿에만 달았고, ReplicaSet 과 파드는 템플릿에서 라벨을 물려받지만 Deployment 는 물려받지 않는다. Deployment 를 보려면 라벨 없이 친다. ```bash label="[lab host] Deployment 는 라벨 없이 본다" kubectl -n keycloak-lab get deploy ``` StatefulSet 쪽은 사슬이 한 마디 짧다. ReplicaSet 을 만들지 않고 파드를 직접 만들어서 `keycloak-0` 처럼 순번 이름이 붙는데, 해시를 끼워 넣을 중간 객체가 없기 때문이다. 이름이 고정이라 같은 이름의 파드가 둘일 수 없고, 그래서 `Terminating` 파드가 안 지워지면 대체 파드도 안 생긴다. 가이드는 그 성질이 어느 실험에서 어떻게 나타나는지까지 적어 두었는데 그 실험 문서는 여기 반입되지 않았고, 이 실험대가 그 상태를 재현해 본 적도 없다. ```bash label="[lab host] Keycloak 쪽에는 ReplicaSet 줄이 없다" kubectl -n keycloak-lab get sts,rs,pod -l app=keycloak ``` ```text NAME READY STATUS RESTARTS AGE pod/keycloak-0 1/1 Running 0 19m pod/keycloak-1 1/1 Running 0 19m ``` 배포를 여러 번 한 Deployment 는 ReplicaSet 이 여러 개 쌓인다. 새로 만들고 옛것은 `0` 으로 남기기 때문이고, 그래서 `kubectl rollout undo` 가 가능하다. 파드가 옛 해시를 달고 있으면 새 배포가 아직 안 넘어온 것이고, 그 상태로 실험하면 고친 적 없는 코드를 재게 된다. | 무엇이 보이나 | 어디를 보나 | |---|---| | Deployment 는 있는데 RS 가 없다 | 컨트롤러가 못 돌았다 — RBAC·admission 확인 | | RS 는 있는데 DESIRED 만 있고 CURRENT 가 0 | 파드를 못 만든다 — 이벤트를 본다 | | Pod 은 있는데 `0/1` | 컨테이너가 안 뜬다 — 로그와 describe | ### 확인 ③ Secret 이 파드까지 이어졌나 **무엇을 확인하는가** — 값이 있는 것과 파드가 그 값을 받은 것은 다르다. 세 확인 모두 값을 찍지 않고 길이만 본다. ```bash label="[lab host] ① Secret 에 무슨 키가 얼마만큼 들어 있나" kubectl -n keycloak-lab describe secret keycloak-lab-secrets ``` **실측**(observed) — 아래쪽 `Data` 절만 옮겼다 ```text Data ==== KC_BOOTSTRAP_ADMIN_PASSWORD: 19 bytes POSTGRES_PASSWORD: 22 bytes ``` ```bash label="[lab host] ② 키 하나가 의심스러울 때만 다시 잰다" kubectl -n keycloak-lab get secret keycloak-lab-secrets \ -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d | wc -c ``` ```bash label="[lab host] ③ 파드 안에 주입됐나 — 여기가 진짜다" kubectl -n keycloak-lab exec keycloak-0 -- \ sh -c 'echo "길이=${#KC_BOOTSTRAP_ADMIN_PASSWORD}"' ``` ```text 길이=19 ``` **어디를 봐야 하는가** — ① 은 `Data` 절의 키 이름과 그 옆의 바이트 수 두 칸, ② 와 ③ 은 숫자 하나다. 세 수가 서로 같은가. **이 결과가 의미하는 것** — `describe` 는 값을 절대 찍지 않고 길이만 보여 주므로 키 목록 확인과 「비어 있지 않은가」 확인이 한 명령으로 끝난다. 세 수가 같으면 Secret 에서 파드 환경변수까지 이어졌다. `길이=0` 이면 Secret 에는 있는데 이 파드가 그것을 안 받은 것이라, `envFrom` 이나 `valueFrom` 을 빠뜨렸거나 파드가 Secret 을 고치기 전에 떠서 옛 값을 들고 있다. 환경변수로 주입한 Secret 은 값을 바꿔도 파드를 다시 만들기 전까지 갱신되지 않는다. 바이트 수가 뜻밖에 크면, 이를테면 20 이어야 할 것이 21 이면 만들면서 개행이 같이 들어간 것이고 증상은 「비밀번호가 틀렸다」로 나온다. 매니페스트가 기대하는 키 이름이 하나라도 다르면 파드는 `CreateContainerConfigError` 로 멈추고, 까닭은 `describe pod` 의 Events 에 키 이름까지 적혀 나온다. `-o yaml` 로 보지 않는다. base64 는 암호화가 아니라 인코딩이라 화면과 스크롤백과 화면 공유와 터미널 로그에 값이 그대로 찍힌다. 어느 환경변수가 어느 Secret 에서 왔는지도 볼 수 있다. ```bash label="[lab host] Secret 에서 온 변수만 오른쪽에 이름이 붙는다" kubectl -n keycloak-lab get pod keycloak-0 \ -o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\t"}{.valueFrom.secretKeyRef.name}{"\n"}{end}' ``` ```text KC_DB KC_DB_URL KC_DB_USERNAME KC_DB_PASSWORD keycloak-lab-secrets ``` 오른쪽 칸이 채워진 줄만 Secret 을 참조한다. 비밀이어야 할 변수의 오른쪽이 비어 있으면 그 값은 매니페스트에 평문으로 적혀 있다는 뜻이고, 그 파일은 대개 git 에 들어간다. ### 확인 ④ Service 뒤에 파드가 있나 **무엇을 확인하는가** — Service 가 있어도 셀렉터가 안 맞으면 뒤가 비어 있다. ```bash label="[lab host] Endpoints 를 센다" kubectl -n keycloak-lab describe svc keycloak | grep -i endpoints ``` ```text Endpoints: 10.42.0.67:8080,10.42.1.155:8080 ``` **어디를 봐야 하는가** — 쉼표로 갈린 주소가 몇 개인가, 그 IP 들이 파드 IP 와 같은가, 포트 번호가 컨테이너가 실제로 듣는 포트인가. **이 결과가 의미하는 것** — 두 개면 Service 가 두 파드를 다 잡고 있다. 비어 있으면 Service 는 있는데 뒤가 없는 것이고, 이때 증상이 「연결은 되는데 응답이 없다」라 원인이 Service 에 있다는 것을 알아보기 어렵다. 하나뿐이면 나머지 한 파드가 readiness 를 통과하지 못한 것이라, 그 상태로 이중화 실험을 시작하면 이미 한쪽으로만 가고 있던 트래픽을 이중화 실패로 오독하게 된다. 목록으로 보려면 EndpointSlice 를 쓴다. ```bash label="[lab host] 같은 것을 EndpointSlice 로도 본다" kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak ``` ```text NAME ADDRESSTYPE PORTS ENDPOINTS AGE keycloak-xdph6 IPv4 8080 10.42.0.67,10.42.1.155 3d23h ``` `kubectl get endpoints` 는 쓰지 않는다. v1.33 부터 deprecated 이고 실행하면 경고가 나온다(external) — 이 실험대의 k3s 는 `v1.36.4+k3s1` 이라 해당한다. 옛 문서와 블로그에 그 형태가 많다. 준비 상태까지 함께 보려면 이렇게 뽑는다. ```bash label="[lab host] 주소마다 ready 가 true 인지 본다" kubectl -n keycloak-lab get endpointslice -l kubernetes.io/service-name=keycloak \ -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\t"}{.conditions.ready}{"\n"}{end}' ``` ```text 10.42.0.67 true 10.42.1.155 true ``` `ready` 가 `false` 면 파드는 있는데 readiness 프로브를 통과하지 못한 것이라 Service 가 그 파드로 트래픽을 보내지 않는다. 비어 있으면 셀렉터와 파드 라벨이 안 맞는다. ```bash label="[lab host] 셀렉터와 파드 라벨을 나란히 본다" kubectl -n keycloak-lab get svc keycloak -o jsonpath='{.spec.selector}'; echo kubectl -n keycloak-lab get pods --show-labels ``` ### 확인 ⑤ PVC 가 실제로 붙었나 **무엇을 확인하는가** — 볼륨이 실제로 잡혔는지. ```bash label="[lab host] PVC 의 상태와 StorageClass 를 본다" kubectl -n keycloak-lab get pvc ``` **어디를 봐야 하는가** — STATUS 칸이 `Bound` 인가 `Pending` 인가, VOLUME 칸이 비어 있는지, STORAGECLASS 칸이 `local-path` 인가. StatefulSet 이면 PVC 이름 끝에 파드 번호가 붙어 있어 어느 파드 것인지 바로 보인다. **이 결과가 의미하는 것** — `Bound` 면 볼륨이 붙었다. `Pending` 이면 StorageClass 가 없거나 노드에 자리가 없다. `local-path` 는 파드가 스케줄될 때까지 기다리므로, 파드가 안 뜨면 PVC 도 `Pending` 인 것이 정상이다. 둘이 서로를 기다리는 것처럼 보이지만 파드 쪽 원인을 먼저 본다. 사유는 PVC 의 이벤트에 적혀 있다. ```bash label="[lab host] 이름을 안 주면 전부 나온다" kubectl -n keycloak-lab describe pvc ``` 각 PVC 절의 맨 아래 `Events` 에 `waiting for first consumer` 인지 `no persistent volumes available` 인지가 적혀 있고, 둘은 대응이 다르다 — 앞엣것은 파드를 고치는 문제, 뒤엣것은 저장소를 고치는 문제다. ### 확인 ⑥ 클러스터가 묶였나 — 셋이 서로 다른 것을 말한다 **무엇을 확인하는가** — 두 Keycloak 이 하나의 클러스터로 묶였는지. 셋이 각각 다른 시점을 말한다. 로그는 「그때 그렇게 보였다」이고, 테이블은 「지금 등록되어 있다」이며, 지표는 「지금 그 노드가 그렇게 안다」이다. ```bash label="[lab host] 로그 — 그때 본 클러스터 뷰" kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1 ``` ```text ISPN000094: Received new cluster view for channel ISPN: [keycloak-0-10001|1] (2) [keycloak-0-10001, keycloak-1-52537] ``` **어디를 봐야 하는가** — 세 군데다. 괄호 안의 `(2)` 가 멤버 수, 대괄호 안의 이름 목록, 그리고 `|1` 이 뷰 번호다. 뷰 번호는 멤버가 들고 날 때마다 올라간다. 이름 목록의 `keycloak-0-10001` 은 파드 이름 그대로가 아니다. Infinispan 이 파드 이름 뒤에 임의의 접미사를 붙여 클러스터 노드 식별자로 쓰기 때문에, 아래 지표에 나오는 `keycloak-0-46674` 와는 `keycloak-0` 까지만 같다. 그래서 대조할 때 맞춰 보는 것은 접미사 앞의 파드 이름이다. Keycloak 만 StatefulSet 이라 그 앞부분이 고정이고, 덕분에 로그와 `jgroups_ping` 테이블과 지표를 같은 이름으로 견줄 수 있다. **이 결과가 의미하는 것** — `(2)` 면 이 노드는 상대를 봤다. `(1)` 이면 혼자 있다고 알고 있다. 뷰 번호가 계속 오르고 있으면 멤버가 붙었다 떨어지기를 반복하는 중이라, 「지금 2 다」라는 스냅숏보다 그 사실이 더 중요하다. 이 줄은 과거형이므로 지금 상태는 지표로 본다. ```bash label="[lab host] 테이블 — 지금 등록되어 있는 멤버" kubectl -n keycloak-lab exec deploy/postgres -- \ psql -U keycloak -d keycloak -c 'select name, ip from jgroups_ping' ``` 행이 몇 개인가, 그리고 `ip` 칸이 확인 ④ 에서 본 파드 IP 와 같은가를 본다. 이 표는 「등록되어 있다」이지 「서로 말이 통한다」가 아니다. 두 행이 다 있는데 로그가 `(1)` 이면 서로를 찾기는 했는데 7800 포트로 메시지가 안 가는 것이고, 이 실험대에서 실제로 그 일이 벌어졌다(observed). 옛 파드의 행이 남아 있을 수도 있으므로 IP 를 지금 파드와 대조한다. ```bash label="[lab host] 지표 — 지금 각 노드가 아는 멤버 수" kubectl -n observability exec deploy/prometheus -- \ wget -qO- 'localhost:9090/api/v1/query?query=vendor_cluster_size' ``` **실측**(observed) — 줄바꿈 없는 JSON 한 줄에서 읽어낸 값이다 ```text keycloak-1 → 2 keycloak-0 → 2 ``` 응답은 줄바꿈 없는 JSON 한 덩어리로 온다. `data.result` 배열에서 원소가 몇 개인가, 각 원소에서 `metric` 안의 `node` 라벨과 `value` 배열의 둘째 원소만 읽는다. 이 실험대에는 `jq` 가 없으므로 파서를 따로 짜지 말고 화면에 나온 JSON 을 그대로 읽는다. 원소가 하나뿐이면 나머지 노드의 스크레이프가 실패한 것이라 클러스터 문제가 아니라 관측 문제일 수 있다. 각 노드가 자기가 아는 멤버 수를 보고하므로 한 노드만 보면 분단을 놓친다 — 분단되면 한쪽은 2, 다른 쪽은 1 이 된다. :::warning Keycloak 컨테이너에는 `curl` 이 없다. 공식 이미지가 최소 구성이라 `wget` 도 `nc` 도 없고, 안에서 치면 `command not found` 와 exit code 127 로 끝난다. ::: ```text sh: line 1: curl: command not found command terminated with exit code 127 ``` Prometheus 가 아직 없으면 임시 파드를 띄운다. ```bash label="[lab host] 파드 IP 를 꺼내 임시 파드에서 긁는다" K0=$(kubectl -n keycloak-lab get pod keycloak-0 -o jsonpath='{.status.podIP}') kubectl -n keycloak-lab run m --rm -i --restart=Never \ --image=curlimages/curl:8.11.1 --quiet --command -- \ sh -c "curl -s http://$K0:9000/metrics | grep '^vendor_cluster_size'" ``` ```text vendor_cluster_size{cache_manager="keycloak",node="keycloak-0-46674"} 2.0 ``` 줄 끝의 숫자와 중괄호 안 `node` 라벨이 어느 파드인가를 본다. 이 형태는 그 파드 하나에게 직접 물은 것이라 라벨의 노드 이름과 고른 파드가 반드시 일치한다. `--rm` 을 붙였으므로 파드는 끝나면 사라진다. 값이 안 나오고 연결 거부가 나면 관리 포트 9000 이 안 열렸다. ### 확인 ⑦ 밖에서 닿나 **무엇을 확인하는가** — nginx 에서 Traefik, Ingress, Service 를 지나 파드까지 전부 이어졌는지. ```bash label="[lab host] 앞 두 단계와 같은 명령" curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master ``` ```text 200 ``` **어디를 봐야 하는가** — 코드 한 칸. 여기서 값만 뽑는 형태를 쓰는 것은 앞 두 단계에서 잰 것과 같은 명령으로 같은 값이 나오는지 비교하는 것이 목적이기 때문이다. **이 결과가 의미하는 것** — `200` 이면 2홉이 다 이어졌다. `502` 나 `503` 이면 뒤에서부터 되짚는다 — Ingress 가 있는지(확인 ①), Service 뒤에 파드가 있는지(확인 ④), 파드가 Ready 인지(확인 ②) 순서다. 처음 보는 오류라 헤더가 필요하면 읽는 형태로 바꾼다. ```bash label="[lab host] 처음 보는 오류는 헤더까지 읽는다" curl -I https://auth.hyeonworks.com/realms/master ``` ### 확인 ⑧ 관리 콘솔에 로그인된다 **무엇을 확인하는가** — 가이드가 적은 「이 단계가 끝나면」의 앞 절반. 브라우저로 `https://auth.hyeonworks.com/admin` 에 들어가 관리자로 로그인한다. 비밀번호는 확인 ③ 의 Secret 에 있고, 이 문서는 그 값을 적지 않는다 — 이 실험대의 확인은 전부 길이까지만 본다. 세션이 실제로 어디 저장되는지까지 보려면 DB 를 직접 본다. 평소에는 필요 없다. ```bash label="[lab host] 로그인 전과 후에 두 번 재서 차이를 본다" kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ -c "select offline_flag, count(*) from offline_user_session group by 1" ``` `offline_flag` 가 `0` 인 행의 `count` 를 로그인 전과 후에 두 번 재서 그 차이를 본다. 한 번만 재면 아무것도 알 수 없다. 로그인 뒤 수가 늘면 세션이 DB 에 남는 것이고, 안 늘면 메모리에만 있다. 메모리에만 있으면 파드를 재시작하는 순간 세션이 사라지고, DB 에 있으면 살아남는다. ## 통과 조건을 한 번에 다시 본다 | 무엇 | 명령 | 통과 | |---|---|---| | 롤아웃 | `kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s` | `complete` · 파드 `2` | | Service 뒤 | `kubectl -n keycloak-lab describe svc keycloak \| grep -i endpoints` | 주소 두 개 | | 클러스터 | `… query=vendor_cluster_size` | 원소 둘 · 값 둘 다 2 | | 밖에서 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` | ## 막히면 가이드 05 가 순서를 정해 두었다. 로그부터 보지 않는다. **① 이벤트부터.** 스케줄링과 이미지와 볼륨 실패가 여기 나온다. ```bash label="[lab host] 최근 이벤트를 시간순으로 본다" kubectl -n keycloak-lab get events --sort-by=.lastTimestamp | tail -20 ``` 맨 아랫줄부터 거꾸로 읽는다. `--sort-by` 를 준 까닭이 그것이다. TYPE 이 `Warning` 인 줄, REASON 칸, 그리고 OBJECT 칸이 어느 파드인가를 본다. 이벤트는 기본 한 시간만 남으므로 아무것도 없다고 문제가 없는 것은 아니다. REASON 하나가 다음 행동을 정한다 — `FailedScheduling` 은 노드에 자리가 없는 것이라 파드가 아니라 클러스터를 봐야 하고, `ErrImagePull` 은 이미지 이름 문제라 로그를 볼 것도 없으며, `BackOff` 는 컨테이너가 떴다가 죽는 중이라 ③ 으로 간다. **② describe.** 그 파드에 한정된 이벤트와 상태를 함께 본다. ```bash label="[lab host] 파드 하나를 자세히 본다" kubectl -n keycloak-lab describe pod keycloak-0 ``` 위에서부터 세 곳이다. `Conditions` 절에서 `Ready` 가 `False` 인가, 컨테이너 절의 `State` 와 `Last State` 와 그 안의 `Exit Code`, 그리고 맨 아래 `Events`. Exit Code 는 그것만으로 말이 된다 — `137` 은 OOM 이나 강제 종료, `1` 은 애플리케이션이 스스로 끝낸 것, `127` 은 명령을 못 찾았다는 뜻이다. `137` 이면 로그에 아무 단서도 없을 수 있어 메모리 한도를 본다. `Ready` 만 `False` 이고 컨테이너는 살아 있으면 readiness 프로브 문제이므로 확인 ④ 로 돌아간다. **③ 로그.** 컨테이너가 떴는데 죽는 경우를 본다. ```bash label="[lab host] 지금 로그와 재시작 직전 로그" kubectl -n keycloak-lab logs keycloak-0 kubectl -n keycloak-lab logs keycloak-0 --previous # 재시작 직전 로그 ``` 첫 명령에서는 마지막 줄들, 둘째 명령에서는 스택 트레이스의 맨 윗줄을 본다. Keycloak 은 기동에 성공하면 `Keycloak ... started in` 한 줄을 남기므로, 그 줄이 있는지 없는지가 기동 중과 기동 실패를 가른다. `--previous` 가 중요하다 — CrashLoopBackOff 면 지금 컨테이너는 방금 뜬 것이라 죽은 까닭은 이전 컨테이너 로그에 있다. `--previous` 가 `not found` 를 내면 아직 한 번도 재시작하지 않은 것이고, 그러면 지금 로그가 곧 전부다. **④ 그래도 모르면 안에서 본다.** ```bash label="[lab host] 컨테이너 안의 셸로 들어간다" kubectl -n keycloak-lab exec -it keycloak-0 -- sh ``` | 증상 | 어디를 보나 | |---|---| | 파드가 `Pending` | `describe pod` 의 Events — 스케줄 불가 사유 | | `ImagePullBackOff` | 이미지 이름과 태그. 자체 빌드면 두 노드 모두에 반입했는가 | | `CrashLoopBackOff` | `logs --previous` | | `Running` 인데 `0/1` | readiness 프로브 실패. `describe` 의 Conditions | | 밖에서 502 | Ingress 에서 Service 로, Service 에서 Endpoints 로 뒤를 본다 | | 클러스터가 1로 보임 | 7800 이 막혔거나 디스커버리 실패. 확인 ⑥ 의 셋 다 | | `AlreadyExists` | `kubectl create namespace` 를 먼저 쳤다. 매니페스트가 만든다 | | `error: the path ... does not exist` | 저장소 루트가 아닌 곳에서 쳤다 | ## 무엇이 관측이고 무엇이 아닌가 - (observed) 2026-09-11 의 ReplicaSet 과 파드 이름과 해시, Secret 의 19 와 22 bytes 와 주입된 길이 19 와 복호 길이 22, Endpoints 두 개와 EndpointSlice 의 `ready` 두 줄, `ISPN000094` 뷰 줄, `vendor_cluster_size` 가 둘 다 2, 임시 파드가 읽은 `2.0`, Keycloak 이미지에 `curl` 이 없다는 것과 exit code 127. - (observed) 디스커버리 테이블에는 둘 다 있는데 메시지가 안 가는 상태를 실제로 겪었다. - (external) `kubectl get endpoints` 의 v1.33 deprecation 은 쿠버네티스 쪽 규격이고 이 실험대가 잰 값이 아니다. - (external) Infinispan 이 파드 이름에 임의의 접미사를 붙여 클러스터 노드 식별자로 쓴다는 것은 Infinispan 의 동작이다. 이 기록에 실린 `keycloak-0-10001` 과 `keycloak-0-46674` 가 그렇게 생긴 이름이다(observed). - (unknown) `keycloak-cluster.yaml` 원문이 반입되지 않아 Keycloak 과 PostgreSQL 의 이미지 태그, 파드 자원 한도, 프로브 설정, `persistent-user-sessions` 설정값은 대조하지 못했다. 위에 적은 객체 이름과 해시와 바이트 수는 돌고 있던 실험대에서 읽은 것이고, 매니페스트가 그것들을 어떤 값으로 선언했는지는 여기서 확인할 수 없다. - (unknown) `get all` 과 `get deploy` 와 `get pvc` 와 `describe pvc` 와 디스커버리 테이블 조회와 `get events` 와 `describe pod` 와 `logs` 의 출력은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다. - (unknown) 원본 가이드 05 에 되돌리는 절차가 없다. `local-path` PVC 가 노드 디스크에 남긴 데이터가 네임스페이스를 지울 때 어떻게 되는지도 확인하지 않았다. - (unknown) 이 단계에는 「이 실험대는 이렇게 했다」와 「따라 하는 사람은」 두 갈래가 없다. 05 는 파일을 직접 쓰지 않고 저장소의 매니페스트를 적용한다. - (unknown) 만드는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.