feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다

기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만
있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문
종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다.

SSOT 결함 둘을 고쳤다.

- kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이
  「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽
  `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다.
- virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데
  원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다.

기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은
둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더
든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다).

계약을 셋 고쳤다.

- kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/**
  28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는
  것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온
  것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수
  대조했을 때 가장 가까운 커밋도 28개가 어긋났다).
- virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고
  재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다.
- kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고
  넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다.

style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져
(실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고,
engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고
있었다(Question 기록에서 11.94 → 3.86).

verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 11:01:55 +09:00
co-authored by Claude Opus 5
parent d473609e0a
commit 2109f726fe
574 changed files with 159654 additions and 1551 deletions
@@ -0,0 +1,588 @@
---
id: f972ca27-7e18-41c1-9494-59cc6f676ae2
kind: SETUP
slug: create-three-guests-with-cloud-init
title: cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/f972ca27-7e18-41c1-9494-59cc6f676ae2/edit"
pinnedVersions:
- name: libvirt
version: 12.7.0
- name: Debian GNU/Linux
version: 12 (bookworm)
- name: cloud-init
version: 22.4.2
source:
- final/document.md#187-단계-01-게스트-세-대
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#184-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다
Debian 12 클라우드 이미지 한 장 위에 오버레이로 게스트 셋을 만드는 절차다. 운영체제를 설치하지 않고 첫 부팅의 cloud-init 이 사용자와 SSH 키와 호스트명을 채운다. 끝나면 세 게스트가 192.168.122.10 부터 .12 까지를 받는다.
## 관계
- **lab host 에 가상화 패키지를 깔고 virsh 가 sudo 없이 돌게 만든다**
앞 단계다. 여기 나오는 `virt-install``virsh net-update` 가 거기서 고정한 `qemu:///system``default` 네트워크 위에서 돈다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
이 절차에서 SSH 가 안 붙을 때 원인 넷을 가르는 방법을 그 기록이 받는다. 여기에는 세우는 순서만 있다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
다음 단계이고, 여기서 고정한 192.168.122.11 과 192.168.122.12 를 그대로 쓴다.
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
게스트 디스크를 base 이미지 위의 오버레이로 만들 수 있는 근거를 그 글이 설명한다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
DHCP 예약이 게스트 생성보다 먼저여야 하는 까닭이 그 기준이 말하는 순서 문제다.
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
게스트마다 배정한 메모리를 여기서 정하므로 그 물음이 실제 점유량과 견줄 설정 값이 이 기록에서 나온다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
기본은 `[lab host]` 다. 예외가 둘이다.
| 무엇 | 어디서 |
|---|---|
| base 이미지 · 시드 · DHCP 예약 · `virt-install` · 붙어 보기 | `[lab host]` |
| `cloud-init schema -c` | `[kc-lab-1]` — 검사기가 게스트 안에만 있다 |
| 워크스테이션 공개키를 꺼내는 한 줄 | `[워크스테이션]` |
게스트에 들어가는 일은 스키마 검사 한 번과 콘솔로 로그를 읽을 때 둘이다. 나머지는 lab host 에서 `ssh <게스트> '...'` 형태로 원격 실행한다. 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없어서, 게스트 안에서 다른 게스트로 붙으려 하면 `Host key verification failed` 로 끝난다.
편집기를 여는 곳은 둘이다. 게스트마다의 `#cloud-config` 파일과 그 짝인 meta-data 파일이고, 나머지는 조회와 생성이라 CLI 를 그대로 쓴다.
## 이 단계가 세우는 것
가이드 01 의 「이 단계가 끝나면」은 한 줄이다.
> `kc-lab-edge`·`kc-lab-1`·`kc-lab-2` 세 게스트가 뜨고, lab host 에서 SSH 가 키로 붙는다.
| 게스트 | IP | MAC 끝 | vCPU | 메모리 | 디스크 | 무엇이 도나 |
|---|---|---|---|---|---|---|
| `kc-lab-edge` | 192.168.122.10 | `:10` | 1 | 1024MB | 10 | nginx · certbot |
| `kc-lab-1` | 192.168.122.11 | `:11` | 2 | 5120MB | 20 | k3s server · Traefik |
| `kc-lab-2` | 192.168.122.12 | `:12` | 2 | 4096MB | 20 | k3s agent · Traefik |
세 대를 세우는 까닭은 이 실험대의 질문 전부가 「인스턴스가 둘 이상이고 요청이 어느 쪽으로 갈지 모른다」를 전제하기 때문이다. 한 대면 세션 공유도 분단도 노드 상실도 실험이 되지 않는다. 엣지를 따로 둔 까닭은 그 결정을 담은 기록이 갖는다.
## 전제와 되돌리기
전제는 한 줄이다 — 앞 단계가 끝나 `virsh list``sudo` 없이 돈다.
**되돌리는 절차는 원본 가이드 01 에 없다**(unknown). 다만 01 이 만드는 것이 무엇인지는 단계 03 이 지나가며 한 줄로 적어 두었다.
```bash label="[lab host] 03 이 엣지를 왜 VM 으로 두는지 설명하며 적어 둔 한 줄"
virsh undefine kc-lab-edge --remove-all-storage
```
그 줄은 엣지를 VM 으로 두는 까닭을 설명하면서 나오고, 세 게스트를 걷어내는 절차로 적어 둔 것이 아니다. DHCP 예약 세 줄과 풀에 올린 시드 볼륨 세 개를 어떻게 지우는지는 가이드 어디에도 없고 이 실험대도 지워 본 적이 없다. 그래서 이 절차는 되돌리기를 갖지 않는다.
## 세우기 전에 먼저 본다
**가이드 01 에는 바꾸기 전 상태를 보는 단계가 없다.** 아래 세 줄은 앞 단계의 확인을 그대로 다시 치는 것이고, 01 이 요구하는 전제가 그 셋이다(inferred).
**무엇을 확인하는가** — 앞 단계가 남긴 세 값이 지금 셸에서도 그대로인지.
```bash label="[lab host] 연결 URI · 네트워크 · 도메인 목록을 차례로 본다"
virsh uri
virsh net-list --all
virsh list --all
```
**어디를 봐야 하는가** — `qemu:///system` 인가, `default` 가 `active` 인가, 그리고 `virsh list --all` 이 `sudo` 없이 통과하는가. 세 게스트를 아직 안 만들었으면 마지막 표는 비어 있다.
**이 결과가 의미하는 것** — 이 셋 가운데 하나라도 어긋난 채로 `virt-install` 을 치면 증상이 「VM 이 목록에 없다」나 「IP 를 못 받는다」로 나타나고, 원인은 이 단계가 아니라 앞 단계에 있다. 어긋난 값을 만든 곳으로 돌아간다 — 연결 URI 와 네트워크와 그룹이 각각 다른 번호다.
## 실행 절차
### 1. base 이미지를 받는다
**목적** — 게스트 셋의 디스크가 올라탈 Debian 12 genericcloud 이미지 한 장을 풀 디렉터리에 둔다. 운영체제를 설치하지 않는다.
```bash label="[lab host] ① 풀 디렉터리로 옮겨 이미지를 받는다"
cd /var/lib/libvirt/images
sudo curl -fL --output /var/lib/libvirt/images/base.qcow2 \
https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2
```
```bash label="[lab host] ② 받은 파일이 온전한 qcow2 인지 본다"
qemu-img info /var/lib/libvirt/images/base.qcow2
```
**예상 결과** — 세 줄을 본다. `file format:` 이 `qcow2` 인가, `virtual size:` 가 `disk size:` 보다 훨씬 큰가, `backing file:` 줄이 없는가. 가운데 것은 qcow2 가 희소 파일이라 그렇게 나오는 쪽이 정상이다.
**왜 필요한가** — 게스트 셋의 디스크가 전부 이 파일 위의 오버레이라, base 뒤에 또 무엇이 붙어 있으면 사슬이 한 겹 더 생긴다. base 는 아무것도 뒤에 두지 않는다.
**문제가 생기면** — 받다 끊기면 오류 페이지를 저장해서 `file format:` 이 `raw` 로 읽힌다. 형식이 틀린 채로 진행하면 증상이 `virt-install` 이 아니라 게스트가 부팅을 못 하는 것으로 나타나 원인을 찾기 어려워진다. 지우고 다시 받는다.
### 2. cloud-init 파일에 넣을 값 셋을 모은다
**목적** — 다음 단계의 파일에 넣을 공개키 둘과 콘솔 비밀번호 하나를 손에 든다.
```bash label="[lab host] ① 자기 공개키를 만들거나 꺼낸다"
[ -f ~/.ssh/id_ed25519.pub ] || ssh-keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519
cat ~/.ssh/id_ed25519.pub
```
```bash label="[워크스테이션] ② 워크스테이션 공개키를 꺼낸다"
cat ~/.ssh/id_ed25519.pub
```
```bash label="[lab host] ③ 콘솔용 비밀번호를 만든다"
openssl rand -base64 18
```
**예상 결과** — `ssh-ed25519 ...` 로 시작하는 줄 둘과 base64 한 줄이 화면에 나온다. 셋 다 다음 단계에서 붙여 넣으므로 창을 닫지 않는다.
**왜 필요한가** — 자리표시자를 문서에 남기지 않고 값을 찾는 명령을 함께 둔다. ③ 의 비밀번호는 cloud-init 의 `plain_text_passwd` 로 들어가고, 키가 안 들어갔을 때 게스트로 들어가는 유일한 통로가 된다. 이 문서에는 그 값을 싣지 않는다.
**문제가 생기면** — ② 만 워크스테이션에서 친다. lab host 에서 두 번 쳐서 같은 키를 두 줄 넣으면 워크스테이션에서는 게스트에 못 붙는다.
### 3. 게스트마다 cloud-init 파일을 쓴다
**목적** — 게스트가 첫 부팅에서 읽을 `#cloud-config` 를 게스트 이름별로 하나씩 만든다.
원본 가이드에서 이 단계가 비어 있다(unknown). 템플릿으로 `deploy/lab/cloud-init/kc-lab.yaml.example` 을 걸어 두고 곧바로 자리표시자를 바꾸는 치환 명령으로 넘어가는데, 그 파일을 게스트 이름으로 복사하거나 새로 만드는 명령이 원본에도 없다. 템플릿 원문도 반입되지 않아 대조하지 못했다. 그래서 아래는 원본 본문이 실은 내용을 파일로 옮겨 적었다.
```bash label="[lab host] ① 게스트 이름으로 파일을 연다"
nano kc-lab-1.yaml
```
② `__` 로 둘러싼 세 곳에 2번의 ①②③ 출력을 넣는다.
```yaml label="kc-lab-1.yaml 에 쓸 내용"
#cloud-config
hostname: kc-lab-1
fqdn: kc-lab-1
manage_etc_hosts: true
users:
- name: donghyeon
groups: [sudo]
shell: /bin/bash
sudo: ['ALL=(ALL) NOPASSWD:ALL']
lock_passwd: false
plain_text_passwd: __CONSOLE_PW__
ssh_authorized_keys:
- __LAB_HOST_KEY__
- __WORKSTATION_KEY__
ssh_pwauth: false
package_update: true
packages: [curl, nftables]
```
③ 다른 게스트는 `hostname` 과 `fqdn` 두 줄만 `kc-lab-2` 와 `kc-lab-edge` 로 바꿔 같은 방법으로 만든다.
**예상 결과** — lab host 의 현재 디렉터리에 `kc-lab-1.yaml` 과 `kc-lab-2.yaml` 과 `kc-lab-edge.yaml` 셋이 생긴다.
**왜 필요한가** — 세 가지가 의도적이다.
| 무엇 | 왜 |
|---|---|
| `NOPASSWD:ALL` | k3s 설치와 장애 주입이 비대화식으로 돌아야 한다. 비밀번호를 물으면 멈춘다 |
| `plain_text_passwd` | cloud-init 이 실패했을 때의 유일한 탈출구. 키가 안 들어가면 SSH 가 막히므로 콘솔로 들어가 로그를 봐야 한다 |
| 키 두 개 | lab host 에서 자동화가 돌고, 워크스테이션에서는 ProxyJump 로 직접 붙는다 |
들여쓰기에는 공백만 쓴다. YAML 은 탭을 금지하고, cloud-init 은 파싱에 실패해도 아무 오류를 남기지 않아 증상이 「SSH 가 안 붙는다」 하나로만 나타난다. 편집기로 여는 까닭도 여기에 있다 — 배울 것이 `hostname:` 과 `users:` 와 `packages:` 인데 치환 명령의 구분자와 명령 치환을 먼저 읽어야 하면 시선이 그쪽으로 간다.
이 실험대는 값 셋을 한 번에 치환했다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
sed -i \
-e "s|__LAB_HOST_KEY__|$(cat ~/.ssh/id_ed25519.pub)|" \
-e "s|__WORKSTATION_KEY__|<워크스테이션에서 복사해 온 값>|" \
-e "s|__CONSOLE_PW__|$(openssl rand -base64 18)|" \
kc-lab-1.yaml
```
**문제가 생기면** — `sudo:` 줄은 4번이 적은 대로 문자열 형태로 고쳐 쓴다. 그대로 두면 부팅은 되고 스키마 검사만 거절한다.
### 4. cloud-init 파일을 검사한다
**목적** — 자리표시자가 남았는지, 공개키가 둘 들어갔는지, cloud-config 로 유효한지를 시드를 굽기 전에 본다.
```bash label="[lab host] ① 자리표시자가 남았는지 센다"
grep -c '__' kc-lab-1.yaml
```
```bash label="[lab host] ② 공개키가 둘 들어갔는지 센다"
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml
```
**예상 결과** — `0` 과 `2`. 첫 줄이 `0` 이 아니면 `__LAB_HOST_KEY__` 같은 문자열이 남아 있고, 둘째 줄이 `2` 가 아니면 키 하나가 안 들어갔다. 두 숫자가 맞아야 시드를 만든다.
첫 파일에서는 여기까지다. cloud-config 스키마까지 보는 검증기는 `cloud-init schema` 인데, 그것은 게스트 안의 cloud-init `22.4.2` 이고 첫 파일을 쓰는 시점에는 게스트가 아직 없다. lab host 에 cloud-init 이 깔려 있는지는 원본 가이드에 적혀 있지 않고 재지도 않았으며(unknown), `yamllint` 은 이 실험대에 없다. 게스트가 한 대라도 뜬 뒤에는 둘째 파일부터 그 검증기로 본다. 파일을 보내고, 들어가고, 검사하고, 지운다.
```bash label="[lab host] ③ 검사할 파일을 게스트로 보낸다"
scp kc-lab-2.yaml donghyeon@192.168.122.11:~/
```
```bash label="[lab host] ④ 그 게스트에 들어간다"
ssh donghyeon@192.168.122.11
```
```bash label="[kc-lab-1] ⑤ 권한을 좁힌다"
chmod 600 ~/kc-lab-2.yaml
```
```bash label="[kc-lab-1] ⑥ cloud-config 스키마로 검사한다"
cloud-init schema -c ~/kc-lab-2.yaml
```
```bash label="[kc-lab-1] ⑦ 검사가 끝나면 지운다"
rm ~/kc-lab-2.yaml
```
```bash label="[kc-lab-1] ⑧ lab host 로 나온다"
exit
```
**예상 결과** — 통과하면 한 줄이다(observed).
```text
Valid cloud-config: /home/donghyeon/kc-lab-edge.yaml
```
통과하면 유효하다는 한 줄만 나오고, 아니면 `Invalid cloud-config` 아래에 어느 키가 왜 틀렸는지가 나열된다. `deprecated` 경고와 `error` 는 다르다 — 경고는 지금 동작한다.
**왜 필요한가** — YAML 로 파싱되는 것과 cloud-config 로 유효한 것은 다르다. 키 이름 오타(`user` 와 `users`)는 앞의 두 `grep` 도, YAML 파서도 그냥 통과한다. 오류가 나면 그 키는 조용히 무시되고, `users` 를 `user` 로 잘못 쓰면 계정이 안 생기며, 증상은 역시 「SSH 가 안 붙는다」 하나로만 나타난다.
이 파일에는 콘솔 비밀번호가 평문으로 들어 있다. `/tmp` 가 아니라 자기 홈에 `600` 으로 두고 검사가 끝나면 바로 지운다. `scp` 가 만드는 동안에는 파일이 잠깐 기본 권한으로 놓이므로 ⑤ 를 바로 뒤에 둔다.
이 실험대는 접속과 리다이렉션과 권한 설정을 두 줄에 몰아넣었다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
ssh donghyeon@192.168.122.11 'umask 077; cat > ~/kc-lab-2.yaml' < kc-lab-2.yaml
ssh donghyeon@192.168.122.11 'cloud-init schema -c ~/kc-lab-2.yaml; rm -f ~/kc-lab-2.yaml'
```
명령 수가 둘에서 다섯으로 늘고 행동 하나가 명령 하나가 된다. `umask 077` 이 `chmod 600` 으로 바뀌는 것 하나가 다르다. 이 다섯 줄 형태는 이 실험대에서 치지 않았다(unknown).
:::warning
`sudo` 는 리스트가 아니라 문자열로 쓴다. 게스트의 cloud-init 22.4.2 스키마 검사기가 리스트 형태를 거부한다.
:::
```yaml label="kc-lab-1.yaml 의 sudo 줄 — 두 형태"
sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
```
```text
Error: Cloud config schema errors: users.0: {'name': 'donghyeon', 'groups':
['sudo'], 'shell': '/bin/bash', ...} is not valid under any of the given schemas
```
**문제가 생기면** — 검사기는 어느 키가 문제인지 안 알려 준다. `users.0` 전체를 통째로 찍고 어느 스키마에도 안 맞는다고만 하므로 키를 하나씩 바꿔 가며 좁혀야 한다. 게다가 리스트 형태도 부팅은 된다 — `kc-lab-1` 과 `kc-lab-2` 가 그 상태로 NOPASSWD sudo 가 멀쩡히 돌고 있다. 검사기만 거부하므로 「검사는 실패했는데 왜 되지」로 읽히기 쉽다.
### 5. 시드 ISO 를 만들어 풀에 올린다
**목적** — user-data 와 meta-data 를 `cidata` 라벨이 붙은 볼륨 하나로 묶어 libvirt 풀에 올린다.
```bash label="[lab host] ① meta-data 파일을 연다"
nano meta-kc-lab-1
```
```yaml label="② meta-kc-lab-1 에 쓸 내용"
instance-id: kc-lab-1-20260912
local-hostname: kc-lab-1
```
`kc-lab-1-` 뒤의 `20260912` 는 예시 값이다. 원래 명령은 거기에 에폭 초를 넣었다. 값 자체에 뜻은 없고, cloud-init 이 이전 실행과 다른 인스턴스로 알아보게 이전 값과 겹치지 않게 둔다. 날짜든 에폭 초든 저번과 다르기만 하면 된다.
```bash label="[lab host] ③ 시드 이미지를 굽는다"
xorrisofs -quiet -output seed-kc-lab-1.iso -volid CIDATA -joliet -rock \
-graft-points /user-data=kc-lab-1.yaml /meta-data=meta-kc-lab-1
```
```bash label="[lab host] ④ 빈 볼륨을 만들고 내용을 채운다"
virsh vol-create-as default seed-kc-lab-1.iso "$(stat -c%s seed-kc-lab-1.iso)" --format raw
virsh vol-upload --pool default seed-kc-lab-1.iso seed-kc-lab-1.iso
```
```bash label="[lab host] ⑤ 볼륨이 풀에 올라갔고 비어 있지 않은지 본다"
virsh vol-list default
virsh vol-info --pool default seed-kc-lab-1.iso
stat -c%s seed-kc-lab-1.iso
```
**예상 결과** — `default` 풀에 `seed-kc-lab-1.iso` 가 잡히고 `Capacity` 가 방금 만든 로컬 파일 크기와 같다.
**왜 필요한가** — 시드는 `cidata` 라벨이 붙고 안에 `user-data` 와 `meta-data` 라는 정확한 이름의 파일이 있는 볼륨이어야 한다. `vol-create-as` 는 빈 볼륨을 만들 뿐이고 내용은 `vol-upload` 가 채운다. 뒤엣것을 빠뜨리면 목록에는 이름이 보이는데 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾아 조용히 끝난다 — 증상은 또 「SSH 가 안 붙는다」다. `instance-id` 를 매번 다른 값으로 두는 것은 cloud-init 이 인스턴스마다 한 번만 초기화 모듈을 돌리기 때문이고, id 가 같으면 user-data 를 고쳐도 반영되지 않는다.
이 실험대는 meta-data 를 명령으로 만들었다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
```
배울 것이 `instance-id:` 와 `local-hostname:` 두 키인데 그 형태는 서식 문자와 명령 치환을 먼저 읽게 만든다. 나머지 네 줄은 시드를 굽고 볼륨을 올리는 일이 명령 자체의 목적이라 그대로 둔다.
**문제가 생기면** — 게스트가 뜨고도 호스트명이 `localhost` 면 ④ 의 뒷줄을 빠뜨렸는지 본다.
### 6. DHCP 예약을 먼저 넣는다
**목적** — 게스트 셋의 MAC 에 주소를 못 박아 IP 가 매번 바뀌지 않게 한다.
:::warning
이 단계가 게스트 생성보다 먼저다. 순서가 반대면 게스트가 동적 대역에서 아무 주소나 받아 버리고, 그 뒤에 예약을 넣어도 이미 잡은 리스가 유지된다. 되돌리려면 리스 만료를 기다리거나 게스트를 재시작해야 한다.
:::
```bash label="[lab host] ① 엣지 게스트의 주소를 예약한다"
virsh net-update default add ip-dhcp-host \
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
--live --config
```
```bash label="[lab host] ② k3s server 게스트의 주소를 예약한다"
virsh net-update default add ip-dhcp-host \
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
--live --config
```
```bash label="[lab host] ③ k3s agent 게스트의 주소를 예약한다"
virsh net-update default add ip-dhcp-host \
"<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>" \
--live --config
```
```bash label="[lab host] ④ 예약이 들어갔는지 본다"
virsh net-dumpxml default | grep -E "host mac|range start"
```
**예상 결과** — 넣을 때 한 줄이 나오고(observed), 확인하면 네 줄이 나온다(observed).
```text
Updated network default persistent config and live state
```
```text
<range start='192.168.122.2' end='192.168.122.254'/>
<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>
<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>
<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>
```
`persistent config` 와 `live state` 두 마디가 다 나와야 `--live --config` 가 제대로 먹었다. `<host>` 세 줄은 MAC 끝 두 자리와 IP 끝 숫자가 짝이 맞는지 본다. `<range>` 줄은 예약이 아니라 동적 대역이라, 예약한 주소가 그 안에 들어 있어도 상관없다.
**왜 필요한가** — 아직 게스트가 없어도 예약은 들어간다. 예약은 「이 MAC 이 나타나면 이 IP 를 줘라」이지 「지금 있는 게스트에게 줘라」가 아니다. MAC 은 7번의 `virt-install --network mac=` 에 쓸 값을 여기서 미리 정한다. `--live` 만 주면 재부팅에 사라지고 `--config` 만 주면 지금 반영되지 않는다.
이미 있는 예약을 또 넣으면 이렇게 거부된다(observed). 오류처럼 보이지만 이미 들어가 있다는 뜻이라 그냥 넘어가면 된다.
```text
error: Requested operation is not valid: there is an existing dhcp host entry
in network 'default' that matches "<host mac='52:54:00:aa:bb:12' ... />"
```
**문제가 생기면** — 확인은 `<host mac=...>` 로 한다. `ip-dhcp-host` 는 `net-update` 의 섹션 이름이라 XML 안에 그 문자열이 없고, 그것으로 `grep` 하면 예약이 멀쩡히 들어가 있어도 아무것도 안 나온다. 세 줄을 셸 반복문으로 돌리지도 않는다 — zsh 는 따옴표 없는 변수를 단어로 나누지 않아서 `XML error: Cannot use host name ''` 로 끝난다. 값을 그대로 세 번 치는 쪽이 안전하다. 재부팅 뒤에도 남는지는 비활성 정의를 따로 본다.
```bash label="[lab host] 재부팅 뒤에도 남는 정의를 본다"
virsh net-dumpxml --inactive default
```
### 7. 게스트 셋을 만든다
**목적** — base 이미지 위의 오버레이 디스크와 시드 볼륨을 붙여 세 게스트를 띄운다.
```bash label="[lab host] ① k3s server 게스트"
virt-install --name kc-lab-1 --memory 5120 --vcpus 2 \
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
--network network=default,mac=52:54:00:aa:bb:11 \
--import --os-variant debian12 --noautoconsole
```
```bash label="[lab host] ② k3s agent 게스트"
virt-install --name kc-lab-2 --memory 4096 --vcpus 2 \
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
--disk vol=default/seed-kc-lab-2.iso,device=disk,bus=virtio,readonly=on \
--network network=default,mac=52:54:00:aa:bb:12 \
--import --os-variant debian12 --noautoconsole
```
```bash label="[lab host] ③ 엣지 게스트 — nginx 와 certbot 만 돌므로 훨씬 작아도 된다"
virt-install --name kc-lab-edge --memory 1024 --vcpus 1 \
--disk size=10,backing_store=/var/lib/libvirt/images/base.qcow2 \
--disk vol=default/seed-kc-lab-edge.iso,device=disk,bus=virtio,readonly=on \
--network network=default,mac=52:54:00:aa:bb:10 \
--import --os-variant debian12 --noautoconsole
```
MAC 은 6번에서 예약한 값을 한 글자도 다르지 않게 쓴다.
**예상 결과** — 엣지 생성 출력이다(observed).
```text
Starting install...
Allocating 'kc-lab-edge.qcow2' | 10 GB 00:00
Creating domain... | 00:00
Domain creation completed.
```
`Domain creation completed.` 한 줄을 본다. 그 위 `Allocating` 이 `00:00` 으로 즉시 끝나는 쪽이 정상이다 — 오버레이라 10GB 를 실제로 쓰지 않는다.
**왜 필요한가** — `virt-install` 은 VM 을 만드는 것 자체가 목적인 명령이라 옵션이 길어도 이 형태로 둔다. 여기서 준 값이 곧 게스트의 정체다 — 이름, 메모리, vCPU, 오버레이 디스크, 시드 볼륨, 예약해 둔 MAC. `--disk size=20,backing_store=...` 는 복사가 아니라 오버레이라, base 를 읽기 전용으로 두고 변경분만 새 파일에 쌓으므로 20GB 짜리를 둘 만들어도 실제 디스크는 몇백 MB 만 쓴다.
:::warning
시드를 `--cloud-init` 으로 붙이지 않는다. 그 옵션은 시드를 SATA CD-ROM 으로 붙이는데 Debian genericcloud 이미지는 크기를 줄이려고 물리 하드웨어 드라이버를 뺐고, AHCI 장치가 보이지 않아 cloud-init 이 데이터소스를 못 찾고 조용히 끝난다. `bus=virtio` 로 디스크로 붙여야 한다.
:::
**문제가 생기면** — `XML error: Cannot use host name ''` 이 나오면 6번의 예약이 빈 이름으로 들어간 것이니 그 세 줄을 값 그대로 다시 친다.
## 구성 값
게스트 셋의 배치다. 디스크는 전부 base 이미지 위의 오버레이이고 복사가 아니다.
| 게스트 | IP · MAC 끝 | vCPU · 메모리 · 디스크 | 무엇이 도나 |
|---|---|---|---|
| `kc-lab-edge` | 192.168.122.10 · `:10` | 1 · 1024MB · 10GB | nginx · certbot |
| `kc-lab-1` | 192.168.122.11 · `:11` | 2 · 5120MB · 20GB | k3s server · Traefik |
| `kc-lab-2` | 192.168.122.12 · `:12` | 2 · 4096MB · 20GB | k3s agent · Traefik |
이미지와 시드 쪽 값이다.
| 무엇 | 값 |
|---|---|
| base 이미지 | `/var/lib/libvirt/images/base.qcow2` — Debian 12 genericcloud amd64 |
| 게스트 OS | `Debian GNU/Linux 12 (bookworm)` |
| 디스크 | base 위의 오버레이(`backing_store=`). 복사가 아니다 |
| 시드 | `seed-<이름>.iso` — `CIDATA` 라벨 · `user-data`·`meta-data` · `bus=virtio` |
| cloud-init 패키지 | `[curl, nftables]` |
| 스키마 검사기 | 게스트의 cloud-init `22.4.2` |
메모리는 처음 만들 때 세 대 다 3584MB 였고, 실험을 늘리며 5120 과 4096 으로 재배분했다(observed). 세 게스트의 배정 합은 5120+4096+1024 = 10,240MB 이고, 호스트 RAM 은 11,648MiB 다. 배정 합이 더 작아서 배정률은 10240/11648 = 87.9% 이고, 배정하지 않고 남은 것이 1,408MiB 다. 그래서 이 배치는 「Guest configured memory 총량이 Host physical RAM보다 크다」는 Memory Overcommit 에 해당하지 않는다.
호스트 RAM 11,648MiB 는 2026-09-10 에 이 호스트에서 `free -m | head -2` 로 받은 `Mem:` 행의 `total` 이다. `free -m` 은 MiB 로 찍으므로, 자기 호스트에서 이 배치를 다시 잡을 때도 같은 명령으로 재고 MiB 로 읽는다.
표의 메모리 칸은 `virt-install --memory` 에 준 값, 곧 선언한 상한이다. 뒤에 `virsh dommemstat` 으로 보면 `kc-lab-2` 가 4096 이 아니라 3120 으로 나오는데 virtio-balloon 이 회수해 간 것으로 보인다. `dommemstat` 의 `actual` 은 현재 할당이지 선언한 상한이 아니고, 상한은 `virsh dominfo` 의 `Max memory` 에 있다 — 이 실험대는 그 둘을 나란히 찍어 보지 않았다. 둘을 헷갈리면 「메모리가 왜 줄었지」가 된다.
vCPU 합은 2 + 2 + 1 = 5 이고 이 호스트의 논리 코어는 8 이다. libvirt 는 vCPU 합이 논리 코어 수를 넘어도 막지 않으므로, 더 크게 잡아도 `virt-install` 은 통과한다. 여유를 둘지는 이 표에서 정한다.
## 끝났는지 판정한다
### 확인 ① 세 게스트가 떴고 cloud-init 이 제 일을 했는가
**무엇을 확인하는가** — 도메인 셋이 돌고 있는지, 그리고 게스트 안이 시드대로 채워졌는지.
```bash label="[lab host] 도메인 목록과 게스트 안을 함께 본다"
virsh list --all
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
```
**실측**(observed)
```text
Id Name State
-----------------------------
2 kc-lab-1 running
4 kc-lab-2 running
5 kc-lab-edge running
kc-lab-1
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
```
**어디를 봐야 하는가** — 세 가지다. State 가 세 대 다 `running` 인가(`shut off` 면 아직 안 뜬 것이고 Id 칸이 `-` 로 비어 있다), SSH 가 비밀번호를 묻지 않고 통과했는가, `hostname` 이 `kc-lab-1` 인가 `localhost` 인가.
**이 결과가 의미하는 것** — 이 세 줄이 cloud-init 성공의 판정 기준 전부다. 호스트명이 바뀌었다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다는 뜻이다. `localhost` 가 나오면 SSH 설정을 고치지 말고 시드부터 의심한다. Id 번호가 2 보다 큰 것에는 아무 뜻도 없다 — 만들고 지운 이력이 그렇게 남는다.
### 확인 ② 엣지가 예약한 IP 를 받았고 cloud-init 이 끝났는가
**무엇을 확인하는가** — DHCP 예약이 실제로 먹었는지, 그리고 초기화가 아직 도는 중인지 끝났는지.
```bash label="[lab host] 호스트명 · 주소 · cloud-init 상태를 한 번에 본다"
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
```
**실측**(observed)
```text
kc-lab-edge
enp1s0 UP 192.168.122.10/24 metric 100
status: done
```
**어디를 봐야 하는가** — 세 줄이다. 호스트명이 `kc-lab-edge` 인가, 주소가 예약한 `.10` 인가, `cloud-init status` 가 `done` 인가.
**이 결과가 의미하는 것** — `running` 이면 아직 패키지를 받는 중이니 기다린다. 이 실험대에서는 약 50초 걸렸다(observed). `error` 면 어느 모듈이 실패했는지 길게 본다.
```bash label="[lab host] 어느 모듈이 실패했는지 길게 본다"
ssh kc-lab-edge 'cloud-init status --long'
```
### 확인 ③ 게스트에 못 들어갈 때는 화면을 직접 뜬다
**무엇을 확인하는가** — SSH 가 막힌 원인이 시드 쪽인지 그 뒤인지.
```bash label="[lab host] 게스트 화면을 파일로 받는다"
virsh screenshot kc-lab-1 /tmp/kc1.ppm
```
확장자와 무관하게 PNG 로 저장된다.
**어디를 봐야 하는가** — 이미지를 열어 로그인 프롬프트 앞의 호스트명 한 낱말만 본다. `localhost login:` 인가 `kc-lab-1 login:` 인가.
**이 결과가 의미하는 것** — `localhost` 면 cloud-init 이 아예 안 돌았다. 시드를 못 찾았거나(7번의 `bus=virtio`) YAML 파싱에 실패한 것(3번)이므로 SSH 쪽은 볼 필요가 없다. `kc-lab-1` 이면 cloud-init 은 돌았고 그 안의 사용자·키 단계에서 틀린 것이라, 콘솔로 들어가 게스트 안의 로그를 본다.
```bash label="[lab host] 콘솔로 들어간다. 빠져나오려면 Ctrl+]"
virsh console kc-lab-1
```
```bash label="[kc-lab-1] 게스트 안에서 cloud-init 로그를 읽는다"
sudo cloud-init status --long
sudo journalctl -u cloud-init -n 50
```
콘솔 로그인에 쓰는 비밀번호가 2번 ③ 으로 만든 값이다. 이 한 장과 이 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| base 이미지 | `qemu-img info /var/lib/libvirt/images/base.qcow2` | `file format: qcow2` · `backing file:` 줄이 없다 |
| 시드 볼륨 | `virsh vol-info --pool default seed-kc-lab-1.iso` | `Capacity` 가 로컬 파일 크기와 같다 |
| 예약 | `virsh net-dumpxml default \| grep -E "host mac\|range start"` | `<host>` 세 줄 · MAC 끝과 IP 끝이 짝 |
| 도메인 | `virsh list --all` | 세 대 다 `running` |
| 게스트 안 | `ssh kc-lab-edge 'hostname; cloud-init status'` | `kc-lab-edge` · `status: done` |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| SSH `Permission denied (publickey)` · hostname 이 `localhost` | cloud-init 이 안 돌았다. 시드를 SATA 로 붙였거나 YAML 파싱 실패 | `virsh screenshot` |
| user-data 를 고쳤는데 반영 안 됨 | `instance-id` 가 같아 건너뜀 | meta-data 의 id 확인 |
| IP 가 매번 바뀐다 | DHCP 예약이 `--config` 없이 들어감 | `net-dumpxml` |
| `XML error: Cannot use host name ''` | zsh 가 따옴표 없는 변수를 단어 분리하지 않는다 | 세 줄을 값 그대로 친다 |
| 예약 넣을 때 `existing dhcp host entry` | 이미 들어가 있다. 오류가 아니다 | `net-dumpxml` 로 세 줄 확인 |
| 시드는 올라갔는데 cloud-init 이 안 돈다 | `vol-upload` 를 빠뜨려 볼륨이 비었다 | `virsh vol-info --pool default seed-kc-lab-1.iso` |
| `qemu-img info` 가 `raw` 라고 한다 | 내려받기가 끊겨 오류 페이지를 저장했다 | 다시 받는다 |
| VM 이 느리다 | KVM 미사용 | 앞 단계로 |
SSH 가 안 붙는 증상 넷을 갈라 보는 방법은 이 표보다 자세한 기록이 따로 있다.
## 무엇이 관측이고 무엇이 아닌가
- (observed) 게스트 세 대의 IP·MAC·vCPU·메모리, `Debian GNU/Linux 12 (bookworm)`, `cloud-init status: done`, cloud-init 대기 약 50초, 스키마 검사기의 거부 문구, `Valid cloud-config` 한 줄, 예약을 넣을 때와 다시 넣을 때의 문구, `virt-install` 의 네 줄 출력.
- (observed) 메모리는 처음 만들 때 3584MB 였고 실험을 늘리며 5120 과 4096 으로 재배분했다. 세 게스트의 배정 합 10,240MB 는 호스트 RAM 11,648MiB 보다 작고, 배정하지 않고 남은 것이 1,408MiB 다. 「Guest configured memory 총량이 Host physical RAM보다 크다」는 Memory Overcommit 에 해당하지 않는 배치다.
- (unknown) `kc-lab-1.yaml` 을 템플릿에서 어떻게 만드는지가 원본에도 없다. `kc-lab.yaml.example` 이 반입되지 않아 대조하지 못했다.
- (unknown) cloud-init 의 `packages` 에 certbot 이 들어 있었는지가 가이드 안에서 갈린다. 01 의 예시와 03 의 본문은 `curl` 과 `nftables` 뿐이라 적고, 04 는 템플릿의 `packages` 에 certbot 이 있다고 적는다.
- (unknown) 파일을 옮겨 검사하는 다섯 줄 형태, `virsh vol-list` 와 `vol-info`, `net-dumpxml --inactive`, `cloud-init status --long`, `virsh console` 은 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다.
- (unknown) 편집기로 `kc-lab-1.yaml` 과 `meta-kc-lab-1` 을 여는 형태도 이 실험대가 치지 않았다. 이 실험대는 치환 명령과 서식 출력으로 만들었고, 같은 상태에 닿는지는 다시 재지 않았다.
- (unknown) 원본 가이드에 되돌리는 절차가 없다. 단계 03 이 지나가며 적은 한 줄 말고는 게스트와 시드 볼륨과 DHCP 예약을 걷어내는 순서가 어디에도 없다.
- (inferred) `virt-install` 세 줄은 구축할 때 친 것을 옮겼고 재실행으로 검증되지 않았다.
<!-- body:end -->
@@ -0,0 +1,563 @@
---
id: 74a7bacf-e5d8-4129-926a-c8cf5cacb8c9
kind: SETUP
slug: edge-nginx-and-host-dnat
title: 엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/74a7bacf-e5d8-4129-926a-c8cf5cacb8c9/edit"
pinnedVersions:
- name: nginx (엣지 게스트)
version: 1.22.1
- name: Debian GNU/Linux
version: 12 (bookworm)
- name: libvirt
version: 12.7.0
source:
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
- final/document.md#179-엣지를-물리-호스트에서-vm-으로-옮기면-무엇이-새로-필요해지나
- final/document.md#180-nftables-는-앞-체인의-accept-로-뒤-체인의-reject-를-막지-못한다
- final/document.md#184-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# 엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다
엣지 게스트에 nginx 를 깔아 80 을 받게 하고 물리 호스트에 nftables DNAT 파일과 그것을 걸어 주는 systemd 유닛을 두는 절차다. 끝나면 밖에서 온 요청이 호스트를 지나 엣지 nginx 로, 거기서 Traefik 으로 닿는다. TLS 는 아직 없다.
## 관계
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
이 절차가 실행하는 결정이고, 왜 옮겼는지와 무엇을 감수했는지는 그 기록이 갖는다.
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
유닛의 `ExecStartPost` 가 뚫는 구멍이 왜 필요한지를 그 사건이 끝까지 따라간다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
앞 단계이고, 거기서 확인한 INTERNAL-IP 두 개가 여기서 upstream 주소가 된다.
- **DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다**
다음 단계이고, 여기서 `http` 로 둔 `X-Forwarded-Proto` 가 거기서 `https` 로 바뀐다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
① 의 `404` 를 성공으로 읽는 근거가 그 기준이다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
세우는 명령이 왜 재실행으로 검증되지 않은 채 남았는지를 그 기준이 가른다.
- **libvirt 의 firewall_backend 가 iptables 일 때도 guest_input 구멍이 필요한가**
이 절차의 유닛이 뚫는 구멍이 다른 백엔드에서도 필요한지는 아직 재지 않았다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
이 단계는 기계 둘을 오간다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 1 | nginx 설치와 기본 사이트 끄기 | `[kc-lab-edge]` |
| 2 · 3 | 라우팅 설정과 링크 · 문법 검사와 reload | `[kc-lab-edge]` |
| 4 | DNAT 파일 | `[lab host]` — 여기만 엣지가 아니다 |
| 5 | systemd 유닛과 libvirt 구멍 | `[lab host]` |
| 판정 | 층별 확인 ①~④ | `[lab host]``[워크스테이션]` |
**4번과 5번만 호스트에서 치는 까닭이 둘이다.** DNAT 규칙의 첫 줄이 `iifname "tailscale0"` 인데 VM 에는 Tailscale 을 넣지 않기로 했으므로 엣지에는 그 인터페이스 자체가 없고, 넘기는 대상이 `192.168.122.10` 으로 가는 트래픽이라 넘기는 주체는 그 앞에 있는 호스트다. 엣지에 들어가서 치면 `tailscale0` 이 없어 규칙이 의미가 없다.
여는 파일은 셋이다 — nginx 라우팅 설정, nftables DNAT 파일, systemd 유닛. 셋 다 사람이 내용을 읽고 고쳐야 하는 파일이라 편집기로 연다. `nano` 는 저장이 `Ctrl+O` 다음 `Enter`, 나가기가 `Ctrl+X` 다. 설치와 링크와 문법 검사와 reload 는 운영자가 그대로 치는 명령을 쓴다.
## 이 단계가 세우는 것
가이드 03 의 「이 단계가 끝나면」은 두 줄이다.
> 밖에서 보낸 요청이 **호스트 DNAT → 엣지 nginx → Traefik → 파드**로 닿는다.
> 아직 TLS 는 없다.
같은 nginx 인데 사는 곳만 바꾼 배치다.
```text
전: tailnet:443 ─▶ [호스트 nginx] ─────────────▶ Traefik(게스트 .11/.12)
후: tailnet:443 ─▶ [호스트 커널 DNAT] ─▶ [엣지 nginx(.10)] ─▶ Traefik(.11/.12)
```
파일 넷이 놓이는 곳과 저장소 원본이다.
| 어디에 | 무엇 | 저장소 원본 |
|---|---|---|
| `[kc-lab-edge]` | `/etc/nginx/sites-available/keycloak-lab` + `sites-enabled/` 심볼릭 링크 | `deploy/lab/edge/nginx-keycloak-lab.conf` |
| `[kc-lab-edge]` | `sites-enabled/default` 를 지운다 | — |
| `[lab host]` | `/etc/nftables.d/lab-edge-dnat.nft` | `deploy/lab/edge/lab-edge-dnat.nft` |
| `[lab host]` | `/etc/systemd/system/lab-edge-dnat.service` | `deploy/lab/edge/lab-edge-dnat.service` |
**왜 프록시가 두 겹인가.** nginx 와 Traefik 이 하는 일이 다르다.
| 어느 쪽 | 맡는 것 |
|---|---|
| 엣지 nginx (`kc-lab-edge`) | 바깥세상과의 접점 — TLS 종단 · 인증서 · `X-Forwarded-*` |
| Traefik | 클러스터 안의 동적 라우팅 — Ingress 를 보고 서비스를 고른다 |
이 2홉이 운영 구조와 같다는 것이 이 배치의 핵심이고, 동시에 헤더 실험이 성립하는 근거가 된다. 1홉을 가정하고 쓴 계약이 2홉에서도 유효한지를 재려면 두 겹이 있어야 한다.
## 전제와 되돌리기
전제는 두 줄이다 — 앞 단계가 끝나 두 노드가 `Ready` 이고, 게스트 세 대가 떠 있다. 그리고 엣지에 nginx 는 아직 없다. cloud-init 이 까는 것은 `curl``nftables` 뿐이라 1번에서 직접 깐다.
**되돌리기가 적힌 것은 가이드 7편 가운데 이 편뿐이다.** 세우는 절차가 아니라 걷어낼 때만 본다.
| 무엇을 | 어디서 | 명령 |
|---|---|---|
| DNAT 만 끈다 | `[lab host]` | `sudo systemctl disable --now lab-edge-dnat.service` |
| 규칙만 즉시 걷어낸다 | `[lab host]` | `sudo nft delete table ip lab_edge` |
| libvirt 에 뚫은 구멍을 막는다 | `[lab host]` | `sudo nft -a list chain ip libvirt_network guest_input` 로 handle 을 보고 `sudo nft delete rule ip libvirt_network guest_input handle <번호>` |
| 엣지 라우팅을 끈다 | `[kc-lab-edge]` | `sudo rm /etc/nginx/sites-enabled/keycloak-lab && sudo nginx -t && sudo systemctl reload nginx` |
`sites-available` 의 원본은 남으므로 `ln -sf` 로 다시 걸면 복구된다. 네 줄이 원문 그대로이고, 그 네 줄을 실제로 쳐서 걷어내 본 기록은 없다(unknown).
**`.nft` 파일 맨 위의 `delete` 는 이것과 다르다.** 그 두 줄은 파일을 적용할 때마다 자동으로 도는 재적용 안전장치이고, 위 표의 삭제 명령은 사람이 끄는 버튼이다.
## 세우기 전에 먼저 본다
엣지 게스트에는 lab host 에서 들어간다. 여기부터 4번 앞까지가 게스트 셸이다.
```bash label="[lab host] 엣지 게스트에 들어간다"
ssh kc-lab-edge
```
**무엇을 확인하는가** — 엣지에 nginx 가 이미 깔려 있는지.
```bash label="[kc-lab-edge] 실행 파일이 있는지 본다"
which nginx
```
**어디를 봐야 하는가** — 아무것도 안 찍히면 미설치다. 2026-09-11 에 새로 만든 `kc-lab-edge` 에서는 이렇게 나왔다(observed).
```text
donghyeon@kc-lab-edge:~$ cd /etc/nginx/
-bash: cd: /etc/nginx/: No such file or directory
```
**이 결과가 의미하는 것** — `/etc/nginx` 디렉터리는 패키지가 만든다. 그것이 없다는 것은 경로를 잘못 짚었다는 뜻이 아니라 설치가 안 됐다는 뜻이다. 앞 단계의 cloud-init 목록에 nginx 가 없으므로 1번에서 깐다.
## 실행 절차
### 1. 엣지에 nginx 를 깔고 기본 사이트를 끈다
**목적** — 80 을 듣는 것이 우리 설정 하나가 되게 만든다.
```bash label="[kc-lab-edge] ① 패키지 목록을 갱신하고 nginx 를 깐다"
sudo apt update && sudo apt install -y nginx
```
```bash label="[kc-lab-edge] ② 떴는지와 데비안 관례의 두 디렉터리가 있는지 본다"
systemctl status nginx --no-pager | head -5
ls /etc/nginx/
```
```bash label="[kc-lab-edge] ③ 지금 무엇이 걸려 있는지 본다"
ls -l /etc/nginx/sites-enabled/
```
```bash label="[kc-lab-edge] ④ 기본 링크를 지운다"
sudo rm /etc/nginx/sites-enabled/default
```
**예상 결과** — `Active:` 줄이 `active (running)` 이고 `ls` 결과에 `sites-available` 과 `sites-enabled` 가 둘 다 있다. ③ 에 `default` 한 줄이 보이고 ④ 뒤에는 목록이 빈다.
**왜 필요한가** — Debian 계열은 설치와 동시에 기동까지 하므로 이 시점에 nginx 는 이미 80 을 잡고 있고, 그것을 잡은 것이 `sites-enabled/default` 다. 2번에서 쓸 설정도 `listen 80 default_server` 라 그대로 두면 겹치고, 안 지우면 3번의 `nginx -t` 가 `a duplicate default server for 0.0.0.0:80` 으로 막는다. ③ 의 화살표를 보면 `default -> ../sites-available/default` 처럼 심볼릭 링크라, 지우는 것은 링크뿐이고 원본은 그대로 있다.
**문제가 생기면** — `nginx -t` 가 `duplicate default server` 를 내면 ④ 를 건너뛴 것이다. Arch 호스트에는 이 구조가 아예 없다 — `sites-available` 과 `sites-enabled` 는 Debian 패키징 관례이고 Arch 의 nginx 는 `nginx.conf` 에 직접 쓰거나 `conf.d/` 를 쓴다. 이 실험대는 운영과 맞추려고 Debian 게스트를 엣지로 두었으므로 여기서는 Debian 관례가 그대로 통한다.
### 2. 라우팅 설정을 쓴다
**목적** — 엣지가 받은 80 요청을 k3s 두 노드의 Traefik 으로 넘기게 한다.
```bash label="[kc-lab-edge] ① 설정 파일을 연다"
sudo nano /etc/nginx/sites-available/keycloak-lab
```
```nginx label="② keycloak-lab 에 쓸 내용"
# file: /etc/nginx/sites-available/keycloak-lab
upstream k3s_traefik {
server 192.168.122.11:80;
server 192.168.122.12:80;
}
server {
listen 80 default_server;
server_name _;
location / {
proxy_pass http://k3s_traefik;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto http;
proxy_set_header X-Forwarded-Port 80;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
```bash label="[kc-lab-edge] ③ 방금 쓴 파일이 제자리에 있는지 본다"
ls /etc/nginx/sites-available/
```
**예상 결과** — ③ 의 목록에 `keycloak-lab` 이 보인다. nginx 는 아직 이 파일을 읽지 않고, 읽게 만드는 것이 3번이다.
**왜 필요한가** — 이 단계에서는 80 만 세운다. 인증서가 없는데 `ssl_certificate` 줄을 미리 써 두면 설정 전체가 실패해서 80 블록까지 안 뜬다 — nginx 는 그 파일을 나중이 아니라 기동과 reload 시점에 읽는다. `X-Forwarded-Proto` 가 `http` 인 것도 지금이 그렇기 때문이고, 다음 단계에서 `https` 로 바뀐다. 실제로 80 으로 들어오는데 `https` 라고 적으면 Keycloak 이 리다이렉트 주소를 `https://` 로 만들고 로그인 도중에 끊긴다. upstream 이 둘인 것은 두 노드 모두 Traefik 이 뜨기 때문이고, nginx 는 기본 라운드로빈으로 번갈아 보내다가 한쪽이 죽으면 자동으로 뺀다.
:::warning
`sites-available` 은 복수형이다. `site-available` 로 치면 `nano` 가 군말 없이 빈 새 파일을 열고, 저장해도 nginx 는 그 파일을 영원히 안 읽고, `nginx -t` 는 멀쩡히 통과한다. 아무 에러 없이 아무 일도 안 일어나는 가장 찾기 어려운 형태라, 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다.
:::
**문제가 생기면** — 어디다 썼는지는 이렇게 찾는다.
```bash label="[kc-lab-edge] 파일을 어디에 썼는지 찾는다"
sudo find /etc/nginx -name 'keycloak*'
```
### 3. 링크를 걸고 문법을 검사한 뒤 reload 한다
**목적** — 2번에서 쓴 파일을 nginx 가 읽는 목록에 올리고, 문법이 맞을 때만 적용한다.
```bash label="[kc-lab-edge] ① sites-enabled 에 링크를 건다"
sudo ln -sf /etc/nginx/sites-available/keycloak-lab /etc/nginx/sites-enabled/
```
```bash label="[kc-lab-edge] ② 링크가 생겼고 default 가 없는지 본다"
ls -l /etc/nginx/sites-enabled/
```
```bash label="[kc-lab-edge] ③ 문법을 보고 통과하면 reload 한다"
sudo nginx -t && sudo systemctl reload nginx
```
```bash label="[kc-lab-edge] ④ reload 가 반영됐는지 프로세스 트리로 본다"
systemctl status nginx --no-pager | head -20
```
**예상 결과** — Debian 12 는 `[warn]` 한 줄을 늘 같이 내놓는다(observed).
```text
nginx: [warn] could not build optimal types_hash, you should increase either
types_hash_max_size: 1024 or types_hash_bucket_size: 64
nginx: configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
```
`syntax is ok` 와 `test is successful` 두 마디가 다 나와야 통과다. 앞의 `[warn]` 줄은 통과를 막지 않는다. 통과했으면 `&&` 뒤의 reload 가 이어서 돌고 `systemctl reload` 는 아무 말 없이 끝난다.
**왜 필요한가** — `&&` 로 이은 것은 문법이 깨진 설정으로 reload 하지 않으려는 것이다. 실패면 `[emerg]` 줄에 파일과 줄 번호가 찍히고 reload 는 아예 안 돌아, 지금 돌고 있는 nginx 는 옛 설정 그대로 멀쩡하다. 경고와 오류를 여기서 갈라 두면 다음 단계에서 같은 `types_hash` 경고를 실패로 오독하지 않는다. ④ 의 프로세스 트리에서 워커 줄의 PID 를 눈에 담아 둔다 — reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용된 것이다. 다음 단계에서 인증서 갱신이 서빙까지 닿았는지를 똑같은 방법으로 판정한다.
**문제가 생기면** — `unknown directive "http2"` 가 나오면 nginx 판 번호를 본다. Debian 12 는 1.22 이고 그 지시어는 1.25.1 부터다. `systemctl reload` 가 실패하면 reload 말고 `sudo nginx -t` 를 먼저 친다.
### 4. 호스트에 DNAT 파일을 쓴다
**목적** — 밖에서 tailnet 으로 들어온 80 과 443 을 커널이 엣지 게스트로 넘기게 한다. 여기까지 하면 엣지 nginx 는 살아 있는데 아무도 거기로 안 보낸다.
여기부터는 물리 호스트다. 게스트 셸에서 먼저 나온다. 나오지 않고 치면 `tailscale0` 이 없는 기계에 DNAT 을 쓰게 된다.
```bash label="[kc-lab-edge] ⓪ lab host 로 나온다"
exit
```
```bash label="[lab host] ① 저장할 디렉터리를 먼저 만든다"
sudo mkdir -p /etc/nftables.d
```
```bash label="[lab host] ② DNAT 파일을 연다"
sudo nano /etc/nftables.d/lab-edge-dnat.nft
```
```text label="③ lab-edge-dnat.nft 에 쓸 내용"
# file: /etc/nftables.d/lab-edge-dnat.nft
#!/usr/sbin/nft -f
table ip lab_edge
delete table ip lab_edge
table ip lab_edge {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10
}
}
```
**예상 결과** — 파일만 생긴다. 이 파일은 5번의 유닛이 읽을 때 비로소 커널에 들어간다.
**왜 필요한가** — 디렉터리가 없으면 `nano` 가 저장할 곳을 못 찾으므로 ① 을 먼저 친다. `table ip lab_edge` 와 `delete table ip lab_edge` 두 줄이 파일 맨 앞에 있는 것은 없는 테이블을 지우면 에러가 나기 때문이다. 한 번 만들고 지우면 같은 파일을 몇 번 적용해도 안전해진다. tailnet 주소 `100.83.212.4` 의 80 과 443 을 받는 것은 여전히 물리 호스트이고, 그 트래픽을 엣지로 넘기는 일이 물리 호스트가 실험대를 위해 하는 일의 전부다 — 이 규칙 하나와 게스트를 만들 때 넣은 DHCP 예약 세 줄이고, 둘 다 한 번 쓰고 다시 안 건드린다.
`prerouting` 체인 뒤에 빈 줄이 하나 남아 있다. 처음 쓴 파일에는 거기에 `priority filter - 10` 으로 먼저 도는 `forward` 체인이 있었고 `ct state new accept` 를 넣어 두었는데, 밖에서 오는 요청은 그래도 통과하지 못했다. 앞 체인의 `accept` 는 「이 체인은 통과」라는 뜻이지 「평가 끝」이 아니라서, 같은 훅에 붙은 뒤 체인이 그대로 `reject` 한다. 그래서 그 체인을 지웠고, 구멍은 5번에서 libvirt 체인 안에 뚫는다.
:::warning
`443` 을 `433` 으로 치지 않는다. `433` 도 유효한 포트라 nft 가 군말 없이 받는다. 80 은 멀쩡히 넘어가므로 이 단계와 층별 확인은 다 통과하고, 다음 단계에서 HTTPS 만 안 되는 형태로 뒤늦게 터진다. 이 실험대에서 실제로 나왔던 오타다.
:::
**문제가 생기면** — 80 은 되는데 HTTPS 만 안 되면 포트 숫자부터 본다.
```bash label="[lab host] 포트 숫자를 눈으로 확인한다"
grep dport /etc/nftables.d/lab-edge-dnat.nft
```
### 5. 호스트에 systemd 유닛을 쓰고 켠다
**목적** — 부팅할 때마다 DNAT 파일을 적용하고, libvirt 의 `reject` 앞에 구멍을 뚫는다.
```bash label="[lab host] ① 유닛 파일을 연다. 경로를 system 까지 끝까지 친다"
sudo nano /etc/systemd/system/lab-edge-dnat.service
```
```ini label="② lab-edge-dnat.service 에 쓸 내용"
# file: /etc/systemd/system/lab-edge-dnat.service
[Unit]
Description=Lab edge DNAT (tailnet :80/:443 -> kc-lab-edge)
After=network-online.target libvirtd.service
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/sbin/nft -f /etc/nftables.d/lab-edge-dnat.nft
ExecStartPost=-/usr/sbin/nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport {80,443} ct state new counter accept
ExecStop=/usr/sbin/nft delete table ip lab_edge
[Install]
WantedBy=multi-user.target
```
```bash label="[lab host] ③ 두 파일이 제자리에 있는지 본다"
ls -l /etc/nftables.d/lab-edge-dnat.nft /etc/systemd/system/lab-edge-dnat.service
```
```bash label="[lab host] ④ 유닛 목록을 다시 읽는다"
sudo systemctl daemon-reload
```
```bash label="[lab host] ⑤ 지금 켜고 부팅에도 걸어 둔다"
sudo systemctl enable --now lab-edge-dnat.service
```
**예상 결과** — ③ 에서 두 줄이 다 나와야 한다. 한 줄이라도 `No such file` 이면 다음 명령은 무조건 실패한다. `enable --now` 가 심볼릭 링크를 만들고 유닛을 한 번 돌리는데, `Type=oneshot` 과 `RemainAfterExit=yes` 라 프로세스는 안 남고 상태만 `active` 로 남는다.
**왜 필요한가** — 규칙의 알맹이는 두 줄이고 둘이 사는 곳이 다르다.
| 하는 일 | 어디에 |
|---|---|
| tailnet 으로 들어온 80 과 443 을 `192.168.122.10` 으로 넘긴다 | 우리 테이블 `lab_edge` (`.nft` 파일) |
| 그 주소로 가는 새 연결을 통과시킨다 | libvirt 테이블 `libvirt_network` 의 `guest_input` 체인 (유닛의 `ExecStartPost`) |
libvirt 는 게스트 대역으로 새로 들어오는 연결을 거절한다. 자기 테이블의 `guest_input` 체인이 `established,related` 만 받고 나머지를 `reject` 로 끝내기 때문인데, 우리 테이블에 아무리 먼저 `accept` 를 놔도 소용이 없다. 그래서 구멍은 libvirt 체인 맨 앞에 뚫고, `insert` 가 맨 앞에 넣는다는 점이 핵심이다 — `add` 는 맨 뒤라 `reject` 뒤가 되어 의미가 없다. 이 규칙은 libvirt 가 네트워크를 다시 세우면(호스트 재부팅, `virsh net-start`, libvirtd 재시작) 날아가므로 유닛에 붙여 둔다.
`ExecStartPost` 앞의 `-` 는 그 명령이 실패해도 유닛을 실패로 보지 않는다는 뜻이다. `libvirt_network` 테이블은 가상 네트워크가 떠 있어야 존재하는데 부팅 순서에 따라 이 유닛이 먼저 돌 수 있고, `-` 가 없으면 구멍이 안 들어가면서 DNAT 까지 같이 안 실린다. `-` 를 두고 감수한 것이 그 반대쪽이다 — DNAT 만 실리고 구멍이 빠진 상태에서도 유닛은 `active` 이고 아무 오류도 안 남는다. 그래서 `systemctl is-active` 가 `active` 라는 것은 DNAT 이 실렸다는 뜻이지 구멍이 뚫렸다는 뜻이 아니고, 그 상태는 호스트 안에서는 되는데 밖에서만 안 되는 형태로 아래 확인 ③ 에서야 드러난다. 이 실험대가 그 상태를 일부러 만들어 확인해 보지는 않았다.
손으로 한 번 넣어 볼 때와 날아갔을 때 다시 넣을 때는 이렇다.
```bash label="[lab host] 손으로 구멍을 한 번 넣어 본다"
sudo nft insert rule ip libvirt_network guest_input oif virbr0 ip daddr 192.168.122.10 tcp dport '{80,443}' ct state new counter accept
```
```bash label="[lab host] 새 규칙이 reject 위에 있는지 본다"
sudo nft -a list chain ip libvirt_network guest_input
```
```bash label="[lab host] 날아갔으면 유닛을 다시 돌려 넣는다"
sudo systemctl restart lab-edge-dnat.service
```
```bash label="[lab host] 우리 테이블이 들어갔는지 본다"
sudo nft list table ip lab_edge
```
마지막 명령에서는 세 가지를 본다. `dnat to 192.168.122.10` 한 줄이 있는가, 포트가 `80, 443` 인가, 그리고 `masquerade` 나 `snat` 이 없는가.
:::warning
SNAT 을 걸지 않는다. 게스트의 기본 게이트웨이가 호스트라 응답은 어차피 여기로 돌아오고 conntrack 이 되돌린다. masquerade 를 붙이면 출발지가 덮여서 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 되고, 이 실험대가 재고 있는 `X-Forwarded-For` 계약이 통째로 무의미해진다.
:::
**문제가 생기면** — `Unit lab-edge-dnat.service does not exist` 가 되풀이되면 두 곳을 본다. 경로를 `/etc/systemd/` 까지만 쳤는지, 그리고 `daemon-reload` 를 했는지. `nano` 는 없는 파일이면 말없이 새로 만들고, systemd 는 한 단계 위를 유닛 디렉터리로 읽지 않는다. 중괄호에 따옴표를 빼면 셸이 `80 443` 두 낱말로 펼쳐 `Error: syntax error, unexpected ct` 가 나는데, 규칙은 안 들어갔고 에러만 보고 넘기기 쉽다.
## 구성 값
| 무엇 | 값 |
|---|---|
| nginx (엣지) | Debian 12 의 `nginx/1.22.1` |
| upstream | `192.168.122.11:80` · `192.168.122.12:80` — 기본 라운드로빈 |
| DNAT | `iifname "tailscale0" tcp dport { 80, 443 } dnat to 192.168.122.10` |
| libvirt 구멍 | 유닛의 `ExecStartPost` 가 `guest_input` 맨 앞에 `insert` |
PREROUTING nat 은 라우팅 결정보다 먼저 도므로 이 규칙이 호스트 자신의 443 소켓보다 우선한다. 그래서 물리 호스트에 nginx 가 아직 떠 있어도 트래픽은 엣지로 가고, 전환이 원자적이며 되돌리기도 한 줄이 된다.
## 끝났는지 판정한다
한 번에 밖에서 치지 말고 가까운 층부터 본다. 네 명령이 각각 다른 층을 건너뛰므로 어디서 끊겼는지가 바로 나온다.
| # | 무엇을 건너뛰나 | 실측 |
|---|---|---|
| ① `192.168.122.11` | nginx 를 건너뛴다 | `404` |
| ② `192.168.122.10` | DNAT 을 건너뛴다 | `301` |
| ③ 도메인 | 밖에서 | `301 https://auth.hyeonworks.com/` |
| ④ 도메인 · TLS 이후 | — | `200` |
처음 볼 때는 응답을 눈으로 읽는 형태로 치고, 같은 것을 여러 번 재거나 두 노드를 나란히 비교할 때만 값만 뽑는 형태로 바꾼다.
### 확인 ① Traefik 이 듣고 있나
**무엇을 확인하는가** — 게스트의 80 을 누가 듣고 있는지. nginx 를 건너뛴다.
```bash label="[lab host] ① nginx 를 건너뛰고 Traefik 에 직접"
curl -I http://192.168.122.11
```
**어디를 봐야 하는가** — 첫 줄의 상태 줄 하나, 그리고 그 앞에 아무 오류도 없이 헤더가 나왔다는 사실. `curl: (7) Failed to connect` 이면 응답 자체가 없는 것이라 상태 코드를 볼 일도 없다.
**이 결과가 의미하는 것** — `404` 가 성공 신호다. 게스트의 80 을 Traefik 이 듣고 있고, 요청을 받아 「매칭되는 Ingress 규칙이 없다」고 답했다. `502` 면 Traefik 은 떴는데 그 뒤 백엔드가 없는 것이고, 연결 거부나 타임아웃이면 앞 단계로 돌아간다.
두 노드가 같은지 볼 때는 값만 뽑는 형태가 낫다.
```bash label="[lab host] 두 노드를 나란히 비교한다"
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.11
curl -s -o /dev/null -w '%{http_code}\n' http://192.168.122.12
```
`.11` 에서 잰 값이 `404` 였다(observed). 둘이 같으면 nginx 가 어느 쪽으로 보내도 같은 결과가 나온다. 한쪽만 다르면 upstream 둘 중 하나가 죽은 것이고, 그 상태에서는 요청의 절반만 실패해서 「가끔 안 된다」로 보인다.
### 확인 ② 엣지 nginx 가 직접 응답하나
**무엇을 확인하는가** — 엣지 안의 설정과 기동이 맞는지. DNAT 을 건너뛴다.
```bash label="[lab host] ② DNAT 을 건너뛰고 엣지 nginx 에 직접"
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10
```
**어디를 봐야 하는가** — `301` 과 `Location` 이 나오는가.
**이 결과가 의미하는 것** — 여기서 막히면 문제는 엣지 안이다. 통과하는데 아래 ③ 이 안 되면 문제는 DNAT 이다. 이 한 층을 끼워 두면 그 둘을 헷갈리지 않는다.
### 확인 ③ 밖에서, 즉 DNAT 을 거쳐 닿나
**무엇을 확인하는가** — DNS 와 DNAT 과 80 리스너가 전부 살아 있는지.
```bash label="[워크스테이션] ③ 밖에서 도메인으로"
curl -I http://auth.hyeonworks.com
```
**어디를 봐야 하는가** — 상태 줄과 `Location:` 헤더 한 줄. `Location` 이 `https://` 로 시작하고 원래 호스트명을 그대로 들고 있는가. 설정에 `$host` 대신 이름을 박아 두면 여기서 엉뚱한 호스트가 나온다.
**이 결과가 의미하는 것** — `301` 이 나왔다는 것은 바깥 요청이 DNAT 을 거쳐 엣지 nginx 까지 닿았다는 뜻이다. ① 은 Traefik 에 직접, ② 는 엣지에 직접 친 것이라 DNAT 을 안 거쳤다. 응답이 아예 없으면 nginx 가 안 떴거나 80 이 막혔다. 값을 반복해서 잴 때의 형태와 이 실험대의 실측은 이렇다(observed).
```bash label="[워크스테이션] 반복해서 잴 때의 형태"
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://auth.hyeonworks.com
```
```text
301 https://auth.hyeonworks.com/
```
### 확인 ④ 끝까지 닿나
**무엇을 확인하는가** — nginx 에서 Traefik 을 지나 파드까지 2홉이 다 이어졌는지.
```bash label="[워크스테이션] ④ TLS 를 얹은 다음 단계에서 통과한다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
```text
200
```
**어디를 봐야 하는가** — 코드 한 칸. 여기서만 값을 뽑는 형태를 바로 쓰는 까닭은, 이 `200` 이 다음 두 단계에서 매번 같은 명령으로 다시 잴 기준값이기 때문이다.
**이 결과가 의미하는 것** — `502` 는 nginx 는 살아 있는데 upstream 을 못 잡은 것이고, `curl: (60)` 같은 인증서 오류는 아직 다음 단계를 안 했다는 뜻이다. 여기까지는 TLS 가 없으므로 ④ 는 다음 단계에서 통과한다. 처음 보는 오류라 헤더가 필요하면 읽는 형태로 바꾼다.
```bash label="[워크스테이션] 처음 보는 오류는 헤더까지 읽는다"
curl -I https://auth.hyeonworks.com/realms/master
```
### 확인 ⑤ 로그에 무엇이 남았나
**무엇을 확인하는가** — nginx 가 어느 upstream 에서 무엇으로 실패했는지.
```bash label="[kc-lab-edge] 최근 에러만 · 지금 벌어지는 것"
journalctl -u nginx -p err -n 5 # 최근 에러만
journalctl -u nginx -f # 지금 벌어지는 것
```
**어디를 봐야 하는가** — 각 줄의 괄호 안 errno 와 그 뒤의 upstream 주소, 그리고 타임스탬프. 방금 친 요청 시각과 안 맞으면 지금 보고 있는 것은 옛 사고다.
**이 결과가 의미하는 것** — `-p err` 로 걸러도 아무것도 안 나오면 nginx 는 정상이고 문제는 더 위에 있다. 둘째 명령은 띄워 놓은 채로 다른 창에서 요청을 치는 용도이고, 끝내려면 Ctrl+C 를 누른다. upstream 이 둘이라 한쪽이 죽으면 nginx 가 자동으로 빼는데, 그 동작이 세 줄로 남는다(observed).
```text
connect() failed (113: No route to host) ← 호스트에 못 닿는다
connect() failed (111: Connection refused) ← 포트에 아무도 없다
no live upstreams ← 둘 다 죽었다고 판단
```
113 과 111 은 대응이 다르다 — 113 은 네트워크고 111 은 프로세스다. 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다(observed).
:::note
nginx 에러 로그는 2048바이트에서 잘린다. 긴 URL 이 단어 중간에서 끊겨 보이면 이 제한에 걸린 것이고, 되찾으려면 access 로그를 본다. 이 실험대에서 502 원인이 error 로그에 있었는데 잘려 있었고 access 로그에는 3492자로 온전히 남아 있었다.
:::
```bash label="[kc-lab-edge] 잘리지 않은 한 줄을 access 로그에서 본다"
grep oauth2/callback /var/log/nginx/access.log | tail -1
```
```bash label="[kc-lab-edge] 그 줄의 길이를 센다"
grep oauth2/callback /var/log/nginx/access.log | tail -1 | wc -c
```
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| nginx 문법 | `sudo nginx -t` | `syntax is ok` · `test is successful` |
| 링크 | `ls -l /etc/nginx/sites-enabled/` | `keycloak-lab` 이 있고 `default` 가 없다 |
| 유닛 | `systemctl is-active lab-edge-dnat.service` | `active` |
| 우리 테이블 | `sudo nft list table ip lab_edge` | `dnat to 192.168.122.10` · 포트 `80, 443` · SNAT 없음 |
| 층 ① | `curl -I http://192.168.122.11` | `404` |
| 층 ② | `curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' http://192.168.122.10` | `301` |
| 층 ③ | `curl -I http://auth.hyeonworks.com` | `301` · `Location` 이 원래 호스트명 |
층 ④ 는 다음 단계가 인증서를 얹은 뒤에 통과한다.
## 막히면
| 증상 | 어디서 끊겼나 | 확인 |
|---|---|---|
| ① 이 연결 거부 | Traefik 이 안 떴거나 게스트가 죽음 | `kubectl get pods -n kube-system` |
| ① 이 `502` | Traefik 은 떴는데 백엔드가 없음 | Ingress 확인 |
| ② 가 응답 없음 | nginx 가 안 떴거나 방화벽 | `systemctl status nginx` |
| ③ 이 `502` | 인증서 문제 또는 upstream 다운 | 다음 단계 · 위 확인 ⑤ |
| `/etc/nginx: No such file or directory` | nginx 미설치. cloud-init 은 안 깐다 | `which nginx` |
| `nginx -t` 가 `duplicate default server` | `sites-enabled/default` 가 살아 있다 | `ls -l /etc/nginx/sites-enabled/` |
| 설정을 썼는데 아무 변화가 없다 | 경로 오타 — `site-available`(단수)에 썼다 | `ls /etc/nginx/sites-available/` |
| `unknown directive "http2"` | nginx < 1.25.1. Debian 12 는 1.22 다 | `nginx -v` |
| `systemctl reload` 가 실패한다 | 설정이 `nginx -t` 를 못 통과했다 | reload 말고 `sudo nginx -t` 를 먼저 |
| `cp: cannot stat 'deploy/...'` | 엣지에서 쳤거나 lab host 에 저장소가 없다 | `ls ~/workspace` |
| `Unit lab-edge-dnat.service does not exist` | 유닛이 없거나 `/etc/systemd/` 에 썼거나 `daemon-reload` 를 안 했다 | `find /etc/systemd -name 'lab-edge-dnat*'` |
| 80 은 되는데 HTTPS 만 안 된다 | `.nft` 의 포트가 `433` 오타 | `grep dport /etc/nftables.d/lab-edge-dnat.nft` |
| 호스트 안에서는 404 인데 밖에서만 connection refused | libvirt `guest_input` 의 `reject`. 구멍이 빠졌거나 날아갔다 | `sudo nft -a list chain ip libvirt_network guest_input` — `reject` 줄의 카운터가 올라가면 여기다 |
| `Error: syntax error, unexpected ct` | 셸이 `{80,443}` 을 펼쳤다 | `'{80,443}'` 로 따옴표 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) 2026-09-11 새로 만든 엣지에서 `/etc/nginx` 가 없던 것, `nginx -t` 출력 세 줄, 층별 확인 ①~④ 의 코드, `301 https://auth.hyeonworks.com/`, upstream 실패 errno 세 줄, access 로그 3492자.
- (observed) `.nft` 와 유닛 파일의 내용은 저장소 원본과 같다.
- (inferred) 이 이동으로 L7 홉 수가 2홉 그대로라는 판정이 이 배치의 전제다. 늘어난 것은 커널이 하는 L4 전달 한 번뿐이라 `X-Forwarded-*` 계약이 그대로 성립한다.
- (unknown) `curl -I http://192.168.122.11` 의 전체 출력은 캡처해 두지 않았다. 가이드도 봐야 할 줄만 적었다.
- (unknown) `systemctl status nginx` 두 번과 `ls -l /etc/nginx/sites-enabled/`, 파일 찾기, 체인 조회, `journalctl -u nginx`, access 로그 두 줄은 가이드가 적어 둔 명령이고 출력이 남아 있지 않다.
- (unknown) libvirt 의 `firewall_backend` 가 iptables 일 때도 `guest_input` 구멍이 필요한지는 재지 않았다. 이 호스트는 nftables 백엔드다.
- (unknown) 되돌리기 네 줄은 가이드 원문 그대로이고, 그 네 줄을 실제로 쳐서 걷어내 본 기록이 없다.
- (unknown) 세우는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,499 @@
---
id: 5e629c2f-e653-4dd9-b1c9-1fe6b2bb181d
kind: SETUP
slug: install-k3s-server-and-agent
title: k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/5e629c2f-e653-4dd9-b1c9-1fe6b2bb181d/edit"
pinnedVersions:
- name: k3s
version: v1.36.4+k3s1
- name: Debian GNU/Linux
version: 12 (bookworm)
source:
- final/document.md#188-단계-02-k3s-server-와-agent
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#184-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다
`kc-lab-1` 에 k3s server 를 `kc-lab-2` 에 agent 를 깔고 lab host 에서 `kubectl get nodes` 로 두 노드를 보는 절차다. 설치 명령은 각각 한 줄이고, kubeconfig 를 가져오는 것과 토큰을 옮기는 것이 그 앞뒤를 채운다.
## 관계
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
이 절차가 전제하는 앞 단계이고, 거기서 고정한 192.168.122.11 과 192.168.122.12 를 그대로 쓴다.
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
토큰 길이를 재는 확인이 막으려는 실패를 그 기록이 처음부터 끝까지 따라간다.
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
다음 단계이고, 여기서 확인한 INTERNAL-IP 두 개가 거기서 nginx upstream 이 된다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
agent 에서 kubectl 이 거절되는 것을 어느 층의 신호로 읽어야 하는지 그 기준이 정한다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
설치 명령 두 줄이 왜 재실행으로 검증되지 않은 채 남았는지를 그 기준이 가른다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
이 절차를 다시 쳐서 같은 클러스터가 서는지는 아직 재지 않았다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
기본은 `[lab host]` 다. 게스트에 로그인해서 치지 않는다.
까닭이 셋이다. 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없어서 게스트 안에서 `ssh kc-lab-1` 을 치면 `Host key verification failed.` 로 끝난다. 그 실패를 셸 변수 대입으로 감싸면 오류는 화면으로 새고 변수에는 빈 문자열이 담기는데, 셸은 아무 불평도 하지 않는다. 그래서 토큰이 비고, agent 설치가 `--token is required` 로 죽는데도 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝난다.
셸을 하나만 쓰면 이 문제가 통째로 없어진다. 예외가 둘이다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 1 | server 설치와 그 확인 | `[lab host]` — 확인 한 번만 게스트 쪽 `kubectl` 로 돈다 |
| 2 · 3 | kubeconfig 와 토큰 | `[lab host]` |
| 4 | agent 설치 | 토큰 파일을 옮긴 뒤 `[kc-lab-2]` 안에서 |
| 5 | 워크스테이션에서 쓰기 | `[워크스테이션]` |
편집기를 여는 곳은 한 군데다. 2번에서 kubeconfig 의 `server:` 줄 하나를 고친다.
## 이 단계가 세우는 것
가이드 02 의 「이 단계가 끝나면」은 두 줄이다.
> lab host 에서 `kubectl get nodes` 를 치면 두 노드가 `Ready` 로 나온다.
> `sudo` 도 `ssh` 도 붙이지 않는다.
| 무엇 | `kc-lab-1` | `kc-lab-2` |
|---|---|---|
| 역할 | server (control-plane) | agent |
| 유닛 | `k3s.service` | `k3s-agent.service` |
| `--node-ip` | `192.168.122.11` | `192.168.122.12` |
| 판 번호 | `v1.36.4+k3s1` | `v1.36.4+k3s1` |
| kubeconfig | `/etc/rancher/k3s/k3s.yaml` | 없다 |
| ROLES 열 | `control-plane` | `<none>` — 라벨이 없다는 뜻이다 |
k3s 가 따로 설치하지 않아도 딸려 오는 것이 다섯이다. 뒤 단계에서 「왜 80 포트가 이미 잡혀 있지」와 「왜 PVC 가 이 노드에만 묶이지」의 답이 전부 이 목록에 있다.
| 이름 | 무엇 |
|---|---|
| Traefik | 인그레스 컨트롤러. `:80` 을 듣는다 |
| servicelb (klipper-lb) | LoadBalancer 타입을 호스트 포트로 매핑 |
| local-path | 기본 StorageClass. 노드 로컬 디스크 |
| flannel | 파드 네트워크 (VXLAN) |
| kube-router | NetworkPolicy 집행 |
## 전제와 되돌리기
전제는 한 줄이다 — 앞 단계가 끝나 lab host 에서 두 게스트에 SSH 가 붙는다.
**되돌리는 절차는 원본 가이드 02 에 없다**(unknown). k3s 설치 스크립트는 `k3s-uninstall.sh``k3s-agent-uninstall.sh` 를 함께 깔지만 가이드가 그 이름을 한 번도 적지 않았고 이 실험대도 부른 적이 없다. 가이드가 재설치를 말하는 곳은 두 군데인데 둘 다 되돌리는 명령이 아니라 뒤처리를 적어 두었다.
| 어디서 | 무엇을 적었나 |
|---|---|
| 노드 IP 가 다른 대역으로 잡혔을 때 | 그때 고치는 것보다 지금 재설치가 싸다 |
| CA 가 바뀌었을 때 | 2번의 kubeconfig 복사를 다시 한다 |
CA(Certificate Authority)는 인증서에 서명해 주는 쪽을 말한다. k3s 를 다시 깔면 그것이 바뀌므로 lab host 의 kubeconfig 도 같이 못 쓰게 된다. 걷어내는 명령은 여기에 적지 않는다.
## 세우기 전에 먼저 본다
**가이드 02 에도 바꾸기 전 상태를 보는 단계가 없다.** 앞 단계가 남긴 확인을 그대로 다시 치는 것이 이 단계의 전제다(inferred).
**무엇을 확인하는가** — 두 게스트가 돌고 있는지, 그리고 SSH 가 대화 없이 통과하는지.
```bash label="[lab host] 도메인 상태와 게스트 접속을 함께 본다"
virsh list --all
ssh donghyeon@192.168.122.11 'hostname; cat /etc/os-release | head -1'
```
**어디를 봐야 하는가** — 두 게스트가 `running` 인가, 그리고 SSH 가 비밀번호를 묻지 않고 호스트명을 찍는가.
**이 결과가 의미하는 것** — `ssh kc-lab-2` 가 비밀번호를 물으면 3번의 토큰 전달과 4번의 설치가 전부 대화식으로 멈춘다. 여기서 걸러야 설치 도중에 멈추지 않는다.
## 실행 절차
### 1. server 를 깐다
**목적** — `kc-lab-1` 을 control-plane 으로 세우고, 그 노드가 자기 주소를 `192.168.122.11` 로 알게 한다.
```bash label="[lab host] ① server 설치 스크립트를 원격으로 돌린다"
ssh kc-lab-1 'curl -sfL https://get.k3s.io | sudo sh -s - server --node-ip 192.168.122.11'
```
```bash label="[lab host] ② 유닛이 떴고 자기 자신을 노드로 등록했는지 본다"
ssh kc-lab-1 'sudo systemctl is-active k3s; sudo kubectl get nodes'
```
**예상 결과** — 유닛이 `active` 이고 `get nodes` 에 `kc-lab-1` 한 줄이 `Ready` 로 있다. 설치 직후 30초 남짓은 `NotReady` 이거나 아예 목록이 비어 있는 쪽이 정상이다 — 파드 네트워크가 아직 안 올라온 시간이라 한 번 더 친다.
**왜 필요한가** — `--node-ip` 를 준다. 게스트에 인터페이스가 여럿이면 k3s 가 엉뚱한 것을 고를 수 있고, 그러면 두 노드가 서로를 다른 주소로 알게 된다. 이때 Traefik 과 servicelb, local-path, flannel, kube-router 도 함께 선다. 이 가이드에서 `kubectl` 을 게스트 쪽에서 돌리는 일은 ② 한 번으로 끝난다. lab host 에는 아직 kubeconfig 가 없기 때문이고, 그것을 두는 일이 바로 2번이다.
**문제가 생기면** — 설치 출력만으로 판정하지 않는다. 유닛이 `active` 인데 `get nodes` 가 접속 오류를 내면 API 서버가 아직 기동 중이다. 유닛이 `activating` 에서 안 넘어가거나 `failed` 면 설치 자체가 실패한 것이니 로그를 본다.
```bash label="[lab host] server 설치가 실패했을 때 로그를 본다"
ssh kc-lab-1 'sudo journalctl -u k3s -n 50 --no-pager'
```
### 2. kubeconfig 를 lab host 로 가져온다
**목적** — lab host 에서 `sudo` 도 `ssh` 도 붙이지 않고 `kubectl` 을 치게 만든다. agent 노드에는 kubeconfig 가 없으므로 클러스터를 어디서 볼지 먼저 정해 둔다.
```bash label="[lab host] ① 받을 디렉터리를 만든다"
mkdir -p ~/.kube
```
```bash label="[lab host] ② 게스트의 kubeconfig 를 그대로 받는다"
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > ~/.kube/config
```
```bash label="[lab host] ③ 권한을 좁힌다"
chmod 600 ~/.kube/config
```
이 파일은 클러스터 admin 자격증명이라 `600` 으로 둔다. `sudo` 는 `cat` 에만 걸리고 `>` 에는 걸리지 않는다 — 리다이렉션은 셸이 명령보다 먼저 현재 사용자 권한으로 처리한다. 그래서 출력 파일은 홈 아래에 둔다.
```bash label="[lab host] ④ server 주소 한 줄을 고친다"
nano ~/.kube/config
```
`server:` 줄 하나만 고치고 나머지는 그대로 둔다.
```yaml label="고칠 줄"
server: https://192.168.122.11:6443
```
```bash label="[lab host] ⑤ 주소가 바뀌었는지 읽고 밖에서 붙어 본다"
grep server: ~/.kube/config
kubectl get nodes
```
**예상 결과**(observed)
```text
server: https://192.168.122.11:6443
NAME STATUS ROLES AGE VERSION
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1
```
아직 노드가 한 줄뿐인 쪽이 정상이다. agent 는 4번에서 붙인다.
**왜 필요한가** — 세 줄이 다 필요하고 빠뜨렸을 때 깨지는 곳이 다르다.
| 줄 | 빠뜨리면 |
|---|---|
| `mkdir -p ~/.kube` | `>` 는 파일을 열 뿐 경로를 만들지 않는다. `cat` 이 시작되기도 전에 `No such file or directory` 로 끝난다 |
| 주소 고치기 | k3s 가 쓴 `https://127.0.0.1:6443` 은 게스트 안에서만 맞는 주소라 lab host 에서는 자기 자신의 6443 을 두드린다 |
| `chmod 600` | 이 파일은 클러스터 admin 자격증명이다. 비밀번호 파일과 같은 급으로 다룬다 |
주소를 고쳐도 인증서 검증이 통과하는 것은 API 서버 인증서 SAN 에 두 주소가 다 들어 있기 때문이다. 5번의 SSH 터널이 `127.0.0.1` 로 붙을 수 있는 것도 같은 까닭이다.
```bash label="[lab host] SAN 에 두 주소가 들어 있는지 확인한다"
ssh kc-lab-1 'sudo openssl x509 -in /var/lib/rancher/k3s/server/tls/serving-kube-apiserver.crt -noout -ext subjectAltName'
```
이 실험대는 ②와 ④를 치환 한 줄로 이어 붙였다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' \
| sed 's|127.0.0.1|192.168.122.11|' > ~/.kube/config
```
치환은 바꾼 줄도 나머지 줄도 보여 주지 않는다. kubeconfig 는 클러스터를 볼 때마다 다시 열게 되는 파일이라 한 번은 전체를 보는 편이 낫고, 나눈 형태는 이 실험대에서 치지 않았다(unknown).
**문제가 생기면** — lab host 에서 `connection refused` 가 나면 ⑤ 의 `grep` 부터 친다. `127.0.0.1` 이 그대로 보이면 ④ 에서 저장이 안 됐다. `x509` 오류는 파일 안의 CA 와 서버가 지금 쓰는 CA 가 어긋난 것이라, k3s 를 다시 깔았다면 이 복사도 다시 한다.
### 3. 토큰을 파일로 꺼내 길이만 본다
**목적** — agent 가 클러스터에 들어갈 때 쓸 node-token 을 lab host 에 내려놓고, 값이 아니라 길이로 비어 있지 않은지 확인한다.
```bash label="[lab host] ① 토큰을 파일로 내려놓는다"
ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' > node-token
```
```bash label="[lab host] ② 값이 아니라 길이만 본다"
wc -c node-token
```
**예상 결과** — 세 자리 수와 파일 이름 한 줄. 이 실험대가 변수에 담아 잰 토큰은 108자였다(observed). `K10<해시>::server:<비밀번호>` 형식이라 k3s 판올림에 따라 자릿수가 달라진다. 확인할 것은 값이 아니라 `0` 이 아니라는 사실이다.
**왜 필요한가** — 값을 화면에 찍지 않는 것은 터미널 스크롤백과 셸 히스토리에 그대로 남기 때문이다. 그리고 이 한 줄이 4번의 조용한 실패를 여기서 끊는다. 게스트 안에서 토큰을 꺼내려 하면 `Host key verification failed.` 로 끝나는데, 셸은 그것을 오류로 알려 주지 않고 빈 값을 넘긴다.
**문제가 생기면** — `wc -c` 가 `0` 을 내면 원인이 셋 가운데 하나다.
| `0` 인 까닭 | 확인 |
|---|---|
| 게스트 안에서 쳤다 — 가장 흔하다 | 프롬프트가 `kc-lab-1` 이면 `exit` 로 lab host 로 나온다 |
| server 가 아직 안 떠서 파일이 없다 | 아래 한 줄로 파일 유무부터 본다 |
| 다른 창에서 쳤다 | 같은 셸에서 ① 부터 다시 친다 |
```bash label="[lab host] 토큰 파일이 있기는 한지 본다"
ssh kc-lab-1 'sudo ls -l /var/lib/rancher/k3s/server/node-token'
```
### 4. 토큰을 agent 노드로 옮기고 거기서 설치한다
**목적** — `kc-lab-2` 를 agent 로 붙이고, 토큰이 명령줄과 히스토리에 남지 않게 파일로 넘긴다.
```bash label="[lab host] ① 토큰 파일을 agent 노드로 옮긴다"
scp node-token kc-lab-2:~/node-token
```
```bash label="[lab host] ② lab host 쪽 사본을 지운다"
rm node-token
```
```bash label="[lab host] ③ agent 노드에 들어간다"
ssh kc-lab-2
```
```bash label="[kc-lab-2] ④ 권한을 좁힌다"
chmod 600 ~/node-token
```
```bash label="[kc-lab-2] ⑤ agent 를 깐다"
curl -sfL https://get.k3s.io | sudo sh -s - agent \
--server https://192.168.122.11:6443 \
--token-file ~/node-token \
--node-ip 192.168.122.12
```
```bash label="[kc-lab-2] ⑥ 설치가 끝나면 토큰 파일을 지운다"
rm ~/node-token
```
```bash label="[kc-lab-2] ⑦ lab host 로 나온다"
exit
```
**예상 결과** — 설치가 끝나면 `k3s-agent.service` 가 그 노드에 서고, lab host 의 `kubectl get nodes` 에 `kc-lab-2` 가 한 줄 더 붙는다.
**왜 필요한가** — 설치 스크립트는 `sudo` 아래 root 로 도니 홈의 `600` 파일도 읽는다. `--token` 대신 `--token-file` 을 쓰면 토큰이 명령줄에 안 들어가므로 프로세스 목록과 셸 히스토리에 남지 않고, 토큰을 꺼낸 셸과 같은 셸에서 쳐야 한다는 제약도 없어진다.
이 실험대는 두 줄로 했다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token' \
| ssh kc-lab-2 'sudo tee /tmp/token >/dev/null'
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
--server https://192.168.122.11:6443 --token-file /tmp/token \
--node-ip 192.168.122.12; rm -f /tmp/token"
```
두 줄 안에 원격 셸 둘과 `sudo` 둘, 파이프 하나, 설치 스크립트 하나, 마지막 삭제 하나가 겹쳐 있다. 실패했을 때 어느 쪽이 실패했는지 갈리지 않아 위에서는 ① 부터 ⑦ 까지로 나눴다. 토큰이 `/tmp/token` 대신 자기 홈에 놓이고 `sudo tee` 대신 `scp` 와 `chmod 600` 이 그 파일을 만드는 것도 그래서 달라진다. 이 나눈 형태는 이 실험대에서 치지 않았다(unknown).
**문제가 생기면** — agent 설치는 성공했는데 노드가 안 보이면 로그에 `--token is required` 가 있는지 본다. 토큰이 빈 값이었으면 설치 스크립트는 내려받기와 유닛 생성과 활성화까지 다 성공으로 찍고 끝나고, 유닛은 `Restart=always` 라 5초마다 조용히 재시도한다.
```bash label="[lab host] agent 가 왜 못 붙었는지 본다"
ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'
```
### 5. 워크스테이션에서도 쓰려면 터널을 뚫는다
**목적** — 개발 머신에서 같은 클러스터를 보게 한다. 이 단계를 건너뛰어도 클러스터는 선다.
```bash label="[워크스테이션] ① 터널을 연다. 이 창은 열어 둔다"
ssh -N -L 6443:192.168.122.11:6443 test-server
```
```bash label="[lab host] ② 게스트 원본을 lab host 로 받는다"
ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml' > kc-lab.yaml
chmod 600 kc-lab.yaml
```
```bash label="[워크스테이션] ③ 다른 창에서 받아 오고 lab host 의 사본은 지운다"
mkdir -p ~/.kube
scp test-server:kc-lab.yaml ~/.kube/kc-lab.yaml
ssh test-server 'rm kc-lab.yaml'
```
```bash label="[워크스테이션] ④ 권한을 좁히고 이 파일을 쓰게 한다"
chmod 600 ~/.kube/kc-lab.yaml
export KUBECONFIG=~/.kube/kc-lab.yaml
```
```bash label="[워크스테이션] ⑤ 주소를 읽고 두 노드가 보이는지 본다"
grep server: ~/.kube/kc-lab.yaml
kubectl get nodes
```
**예상 결과** — `server:` 가 `https://127.0.0.1:6443` 이고, `get nodes` 가 4번과 같은 두 줄을 낸다. 터널 창을 닫으면 바로 멎는다.
**왜 필요한가** — 여기서는 주소를 고치지 않는다. k3s 원본이 이미 `https://127.0.0.1:6443` 이고 터널 덕에 워크스테이션에서는 그 주소가 맞다. 2번에서 고쳤던 것은 lab host 에서 볼 때 `127.0.0.1` 이 lab host 자신을 가리키기 때문이었다. 같은 파일이라도 어느 기계에서 읽느냐에 따라 맞는 주소가 다르다.
이 실험대는 ②와 ③을 `ssh` 두 겹으로 겹쳐 한 줄에 넣었다(observed).
```bash label="[워크스테이션] 이 실험대가 실제로 친 형태"
ssh test-server "ssh kc-lab-1 'sudo cat /etc/rancher/k3s/k3s.yaml'" > ~/.kube/kc-lab.yaml
```
따옴표가 두 겹이라 어느 기계에서 어느 명령이 도는지가 한 줄에 묻힌다. 기계마다 한 명령이 되게 나눈 형태는 이 실험대에서 치지 않았다(unknown).
**문제가 생기면** — 타임아웃이면 ① 의 터널 창이 닫힌 것이고, `connection refused` 면 터널은 살아 있는데 반대편 API 서버가 죽은 것이다. 터널이 닫히면 `kubectl` 이 통째로 멎어 터널 상태와 클러스터 상태가 섞인다. 노드를 죽이고 살리는 실험은 lab host 에서 치는 편이 낫다.
## 구성 값
kubeconfig 사본이 사는 곳은 둘이고, 같은 파일인데 주소가 다르다.
| 어디에 | 무엇 |
|---|---|
| lab host | `~/.kube/config` — `600`. `server:` 를 `https://192.168.122.11:6443` 으로 고친 사본 |
| 워크스테이션 | `~/.kube/kc-lab.yaml` — 원본 그대로(`https://127.0.0.1:6443`) + SSH 터널 |
유닛 이름도 노드마다 다르다.
| 노드 | 유닛 |
|---|---|
| server | `k3s.service` |
| agent | `k3s-agent.service` |
뒤의 실험에서 노드를 멈출 때 칠 이름이 이것이다. `systemctl stop k3s` 를 agent 노드에서 치면 아무 일도 일어나지 않고, 「주입했는데 증상이 없다」로 읽히게 된다. server 노드를 잃으면 `kubectl` 자체가 불통이 되고, agent 를 잃으면 `kubectl` 은 되지만 그 위의 워크로드가 사라진다.
## 끝났는지 판정한다
### 확인 ① 두 노드가 우리가 준 주소로 서 있는가
**무엇을 확인하는가** — agent 가 클러스터에 들어왔는지, 그리고 제 주소로 들어왔는지.
```bash label="[lab host] 마지막 열까지 본다"
kubectl get nodes -o wide
```
**실측**(observed)
```text
NAME STATUS ROLES AGE VERSION INTERNAL-IP
kc-lab-1 Ready control-plane 47m v1.36.4+k3s1 192.168.122.11
kc-lab-2 Ready <none> 21m v1.36.4+k3s1 192.168.122.12
```
**어디를 봐야 하는가** — `-o wide` 를 주는 까닭이 마지막 열이다. INTERNAL-IP 두 개가 1번과 4번에서 `--node-ip` 로 준 값과 같은가. 그다음이 STATUS 두 줄 `Ready`, 그다음이 ROLES 열이다. `<none>` 은 오류가 아니라 역할 라벨이 없다는 뜻이고, agent 는 원래 그렇다.
**이 결과가 의미하는 것** — 두 줄이 `Ready` 이고 IP 가 맞으면 이 단계는 끝났다. IP 가 다른 대역으로 잡혀 있으면 지금은 아무 증상이 없다가 다음 단계의 nginx upstream 과 노드 상실 실험에서 어긋난다. 그때 고치는 것보다 지금 재설치가 싸다. `kc-lab-2` 가 아예 안 보이면 join 이 실패한 것이니 agent 로그를 본다.
### 확인 ② 우리가 준 주소가 유닛에 그렇게 적혔는가
**무엇을 확인하는가** — 설치 스크립트에 준 옵션이 유닛 파일에 굳었는지.
```bash label="[lab host] 유닛 파일의 ExecStart 를 본다"
ssh kc-lab-1 'systemctl cat k3s | grep -A3 ExecStart='
ssh kc-lab-2 'systemctl cat k3s-agent | grep -A4 ExecStart='
```
**실측**(observed)
```text
ExecStart=/usr/local/bin/k3s server '--node-ip' '192.168.122.11'
ExecStart=/usr/local/bin/k3s agent '--node-ip' '192.168.122.12'
```
**어디를 봐야 하는가** — `ExecStart=` 줄의 부분명령(`server` 인가 `agent` 인가)과 그 뒤의 인자.
**이 결과가 의미하는 것** — 확인 ① 은 k3s 가 보고한 주소이고 이 두 줄은 우리가 준 주소다. 둘이 다르면 옵션이 안 먹었다. 유닛 이름을 틀리면 `No files found` 가 나오는데, 그것 자체가 「이 노드는 agent 다」라는 답이 된다.
### 확인 ③ k3s 가 딸려 오게 한 것이 다 떴는가
**무엇을 확인하는가** — 우리가 안 깔았는데 이미 돌고 있는 것과 기본 저장소가 무엇인지.
```bash label="[lab host] 전 네임스페이스의 파드와 StorageClass 를 본다"
kubectl get pods -A
kubectl get storageclass
```
**실측**(observed)
```text
NAMESPACE NAME READY STATUS RESTARTS AGE
kube-system coredns-54996dc9b4-x5bsz 1/1 Running 0 49m
kube-system helm-install-traefik-cnv8z 0/1 Completed 2 (48m ago) 48m
kube-system helm-install-traefik-crd-c6vft 0/1 Completed 0 48m
kube-system local-path-provisioner-77b9867795-5k8d2 1/1 Running 0 49m
kube-system metrics-server-6dc596dfb8-nzwt2 1/1 Running 0 49m
kube-system svclb-traefik-a18ee1fc-2s9rl 2/2 Running 0 48m
kube-system svclb-traefik-a18ee1fc-xmrrt 2/2 Running 0 22m
kube-system traefik-59b7647586-rsrbc 1/1 Running 0 48m
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
local-path (default) rancher.io/local-path Delete WaitForFirstConsumer false 49m
```
**어디를 봐야 하는가** — `kube-system` 줄들의 STATUS 다. `Running` 과 `Completed` 가 섞여 있는 쪽이 정상이고, 일회성 잡은 `Completed` 로 남는다. `svclb-traefik-` 로 시작하는 줄이 둘인 것도 봐 둔다. StorageClass 에서는 이름 뒤의 `(default)` 표시가 어디 붙어 있는가.
**이 결과가 의미하는 것** — `svclb-traefik-` 두 줄은 DaemonSet 이 노드마다 하나씩 뜬 것이라, 4번의 join 이 실제로 먹었다는 또 하나의 증거가 된다. `local-path` 에 `(default)` 가 붙어 있으면 다음 단계의 PVC 는 StorageClass 를 안 적어도 그것으로 만들어진다. `Pending` 이나 `CrashLoopBackOff` 가 섞여 있으면 그 파드부터 `describe` 로 본다.
### 확인 ④ agent 노드에서 kubectl 이 거절되는 것은 정상이다
**무엇을 확인하는가** — agent 노드에서 `kubectl` 을 쳤을 때 나오는 거절이 어느 층의 신호인지.
```text
Get "http://localhost:8080/api?timeout=32s": dial tcp [::1]:8080: connect: connection refused
```
**어디를 봐야 하는가** — 주소 한 칸이다. `localhost:8080` 인가 다른 주소인가.
**이 결과가 의미하는 것** — 명령 자체는 있다. 설치 스크립트가 `/usr/local/bin/kubectl -> k3s` 심볼릭 링크를 만든다. 없는 것은 붙을 곳을 알려 주는 kubeconfig 이고, 넷 다 못 찾으면 kubectl 은 오류를 내지 않고 하드코딩된 기본값으로 넘어간다.
| 어디를 찾나 | kc-lab-1 | kc-lab-2 |
|---|---|---|
| `$KUBECONFIG` | 비어 있음 | 비어 있음 |
| `~/.kube/config` | 없음 | 없음 |
| `/etc/rancher/k3s/k3s.yaml` | 있음 | 없음 |
`http://localhost:8080` 은 쿠버네티스 1.20 이전 API 서버가 평문으로 열던 레거시 포트인데 지금은 아무도 열지 않는다(external). 이 주소는 어디에도 적혀 있지 않으므로, 그것이 보이면 네트워크 문제가 아니라 설정을 하나도 못 찾았다는 뜻이다. 방화벽이나 k3s 를 의심하기 전에 kubeconfig 부터 본다.
:::warning
`k3s.yaml` 을 복사해 넣으면 agent 노드에서도 다 보이지만 복사하지 않는다. 워커 한 대가 털리면 클러스터 전체가 털리는 구성이 된다.
:::
agent 가 원래 가진 신원은 급이 다르다.
```text
subject=O = system:nodes, CN = system:node:kc-lab-2
```
이 신원은 Node authorizer 와 NodeRestriction admission 이 자기 노드에 배정된 객체만 다루도록 제한하고(external), 그 자격증명은 kubelet 전용 경로(`/var/lib/rancher/k3s/agent/`)에 있어 kubectl 이 읽지도 않는다. 그래서 실패가 권한 없음이 아니라 설정 없음으로 나타난다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| 두 노드 | `kubectl get nodes -o wide` | 둘 다 `Ready` · INTERNAL-IP 가 `.11` 과 `.12` |
| 어디서 치나 | 위 명령에 `sudo` 도 `ssh` 도 안 붙는다 | lab host 에서 그대로 돈다 |
| server 유닛 | `ssh kc-lab-1 'systemctl cat k3s \| grep -A3 ExecStart='` | `server '--node-ip' '192.168.122.11'` |
| agent 유닛 | `ssh kc-lab-2 'systemctl cat k3s-agent \| grep -A4 ExecStart='` | `agent '--node-ip' '192.168.122.12'` |
| 딸려 온 것 | `kubectl get pods -A` | `svclb-traefik-` 로 시작하는 줄이 둘 |
| 기본 저장소 | `kubectl get storageclass` | `local-path (default)` |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `wc -c node-token` 이 `0` | 게스트 안에서 쳤거나, 셸이 바뀌었다 | 프롬프트가 `kc-lab-1` 이 아닌지. 3번 표 |
| agent 설치는 성공했는데 노드가 안 보임 | 토큰이 빈 값으로 넘어갔다 | `journalctl -u k3s-agent` 에 `--token is required` |
| agent 가 `NotReady` | 토큰이나 주소 오타 | `ssh kc-lab-2 'sudo journalctl -u k3s-agent -n 30 --no-pager'` |
| 노드 IP 가 예상과 다름 | `--node-ip` 없이 설치 | `kubectl get nodes -o wide` |
| lab host 에서 `kubectl` 이 `No such file` | `mkdir -p ~/.kube` 를 빠뜨렸다 | 2번 ① |
| lab host 에서 `connection refused` | kubeconfig 의 `127.0.0.1` 을 안 바꿨다 | `grep server: ~/.kube/config` |
| `kc-lab-2` 에서 `localhost:8080` | agent 에는 kubeconfig 가 없다. 정상이다 | 확인 ④ |
| 워크스테이션에서 타임아웃 | 터널이 없다 | 5번 ① |
| `x509` 오류 | k3s 재설치로 CA 가 바뀌었다 | 2번의 복사를 다시 한다 |
| 파드가 한 노드에만 몰림 | 스케줄러 판단 | `topologySpreadConstraints` 로 강제 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) `v1.36.4+k3s1`, 두 노드 `Ready`, INTERNAL-IP 두 개, 토큰 108자, `ExecStart` 두 줄, agent 의 `localhost:8080` 오류와 인증서 subject.
- (observed) `kube-system` 에 뜬 파드 여덟 줄과 `local-path (default)` StorageClass. `svclb-traefik-` 두 줄이 DaemonSet 이 두 노드에 다 떴다는 증거가 된다.
- (external) 쿠버네티스 1.20 이전의 평문 8080, Node authorizer 와 NodeRestriction 의 동작은 이 실험대에서 잰 값이 아니다.
- (unknown) 3번과 4번의 나눈 형태는 이 실험대에서 치지 않았다. 이 실험대가 친 것은 원격 셸 둘을 파이프로 이은 두 줄이고, 나눈 형태로 같은 클러스터가 서는지는 다시 재지 않았다.
- (unknown) 2번의 ②④ 도 이 실험대에서 치지 않았다. 이 실험대가 친 것은 치환 한 줄이고, 받아 놓고 편집기로 고친 kubeconfig 로 같은 클러스터가 보이는지는 다시 재지 않았다.
- (unknown) 5번의 ②③도 이 실험대에서 치지 않았다. 이 실험대가 친 것은 원격 셸을 두 겹으로 겹친 한 줄이다.
- (unknown) 인증서 SAN 조회와 두 `journalctl` 은 가이드가 적어 둔 명령이고 이 실험대가 캡처한 출력이 없다. SAN 에 두 주소가 들어 있다는 것은 주소 치환과 터널이 둘 다 통한다는 사실로 뒷받침된다(inferred).
- (unknown) 원본 가이드에 되돌리는 절차가 없다. `k3s-uninstall.sh` 라는 이름이 가이드에 한 번도 안 나오고 이 실험대도 부른 적이 없다.
- (unknown) 설치 명령 두 줄은 재실행으로 검증되지 않았다. 토큰 108자도 k3s 판올림에 따라 달라진다.
<!-- body:end -->
@@ -0,0 +1,520 @@
---
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 로 되짚는 순서가 그 기준에서 나온다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
이 단계는 앞의 셋보다 단순하다. 전부 `[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) 만드는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,199 @@
---
kind: SETUP
slug: power-cycle-the-lab-and-reallocate-guest-memory
title: 실험대를 껐다 켜고 게스트 메모리를 다시 나눈다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
pinnedVersions:
- name: libvirt
version: 12.7.0
- name: QEMU
version: 11.1.1
- name: k3s
version: v1.36.4+k3s1
- name: 게스트
version: Debian 12 genericcloud
- name: 기준 배치
version: 2026-09-03 · 호스트 RAM 증설 뒤 재배분, 그 뒤 값은 2026-09-10 실측
source:
- final/document.md#332-vm-메모리-재배분-게스트를-다시-만들지-않는다
- final/document.md#333-안전한-종료-순서
- final/document.md#334-복구-순서-종료의-역순
- final/document.md#313-k3s-server와-agent-죽였을-때가-다르다
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# 실험대를 껐다 켜고 게스트 메모리를 다시 나눈다
게스트를 다시 만들지 않고 메모리를 재배분하고, 노트북을 끄기 전에 워크로드를 위에서부터 내리고, 켤 때 그 역순으로 되살리는 절차다. 순서를 틀리면 PostgreSQL 이 강제 종료되어 다음 기동에 crash recovery 가 돈다.
## 관계
- **실험대를 철거하고 무엇이 남는지 확인한다**
지우는 쪽이다. 거기서는 `virsh destroy` 로 전원을 뽑고, 여기서는 `virsh shutdown` 으로 정상 종료한다.
- **Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다**
여기서 내리고 올리는 워크로드를 그 절차가 세운다. 네임스페이스와 리소스 이름도 거기서 정해졌다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
`kubectl` 이 lab host 에서 도는 까닭이 그 절차에 있다. 이 편의 명령도 전부 거기서 친다.
- **호스트에 직접 깔지 않고 게스트 VM 두 대로 간다 — 관측자가 실험 대상과 함께 죽으면 안 된다**
server 와 agent 를 죽였을 때가 왜 다른지, 그래서 관측 스택을 어디에 두는지를 그 결정이 받는다.
- **5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양**
재배분한 값은 상한이지 점유가 아니다. 상한과 실제 점유를 가르는 방법이 그 기록에 있다.
- **이 호스트의 가상 머신들은 설정한 메모리와 지금 잡고 있는 메모리가 얼마나 다른가**
여기서 준 값이 그 물음이 견줄 설정 값이 된다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
전부 `[lab host]` 다. `virsh``kubectl` 도 거기서 돌고, 게스트 안을 봐야 할 때만 `ssh kc-lab-1 ...` 형태로 원격 실행한다. 게스트에 로그인해서 치지 않는다.
이 절차는 메모리 값을 바꾸고 워크로드를 올렸다 내리는 일뿐이라 설정 파일을 손대지 않고, 편집기를 여는 곳도 없다.
## 이 절차가 다루는 일 셋
세우는 편 일곱과 지우는 편 하나 사이에 있는 일이다. 실험대를 계속 쓰면 실제로 자주 하는 쪽이 이쪽이다.
| 언제 | 무엇을 |
|---|---|
| 호스트 RAM 을 늘렸거나 게스트가 좁을 때 | 게스트를 다시 만들지 않고 메모리를 재배분한다 |
| 노트북을 끄기 전에 | 워크로드와 게스트를 위에서부터 내린다 |
| 다시 켤 때 | 종료의 역순으로 되살린다 |
셋 다 게스트를 지우지 않아서, 디스크도 시드 ISO 도 그대로 두고 도메인의 값과 파드 수만 바꾼다.
## 실행 절차
### 1. 게스트 메모리를 다시 나눈다
**목적** — 게스트를 다시 만들지 않고 도메인이 쓸 메모리 상한과 현재 할당을 바꾼다.
상한을 먼저 올리고 그다음에 현재 할당을 맞춘 뒤, 도메인과 게스트 양쪽에서 값을 확인한다.
```bash label="[lab host] ① 상한을 올리고 현재 할당을 그 값에 맞춘다"
virsh setmaxmem kc-lab-1 5120M --config
virsh setmem kc-lab-1 5120M --config
```
```bash label="[lab host] ② 도메인이 보는 값과 게스트가 인식한 값을 함께 본다"
virsh dominfo kc-lab-1 | grep -i memory
ssh kc-lab-1 free -m
```
**예상 결과** — `dominfo` 의 `Max memory` 가 바뀐 상한으로 나온다. 게스트의 `free -m` 은 재부팅 전까지 옛 값을 보여 준다.
**왜 필요한가** — 두 명령이 다른 것을 바꾼다.
`setmaxmem` : 상한. 부팅할 때 게스트가 보는 총량
`setmem` : 현재 할당. 상한 이하여야 한다
현재값을 상한보다 크게 줄 수 없으므로 `setmaxmem` 이 먼저다. 거꾸로 치면 두 번째 명령이 상한을 넘는 값을 받아 거부된다. 플래그도 갈린다 — `--config` 는 영구 정의라 다음 부팅부터 먹고, `--live` 는 실행 중인 도메인에 즉시 먹는다. 다만 `setmaxmem --live` 는 게스트가 부팅할 때 메모리 맵을 정하기 때문에 대개 거부된다. 그래서 상한을 바꾸려면 게스트를 껐다 켠다.
**문제가 생기면** — ② 의 `free -m` 이 옛 값이면 아직 재부팅하지 않은 것이다. `dominfo` 쪽도 안 바뀌었으면 `--config` 를 빠뜨렸는지 본다.
### 2. 위에서부터 내린다
**목적** — 애플리케이션과 데이터베이스와 게스트와 호스트를 순서대로 멈춰서 다음 기동에 복구 절차가 돌지 않게 한다.
Keycloak 을 먼저 0 으로 내려 클러스터에서 정상 탈퇴시키고, PostgreSQL 을 그다음에 내리고, 게스트를 정상 종료한 뒤 호스트를 끈다.
```bash label="[lab host] ① Keycloak 을 0 으로 내리고 파드가 사라질 때까지 기다린다"
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=keycloak --timeout=120s
```
```bash label="[lab host] ② PostgreSQL 을 마지막에, 충분한 시간을 주고 내린다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=0
kubectl -n keycloak-lab wait --for=delete pod -l app=postgres --timeout=120s
```
```bash label="[lab host] ③ 게스트를 ACPI 정상 종료한다"
virsh shutdown kc-lab-1 && virsh shutdown kc-lab-2
```
```bash label="[lab host] ④ 호스트를 끈다"
sudo systemctl poweroff
```
**예상 결과** — ① 과 ② 의 `wait` 가 각각 파드 삭제를 확인하고 돌아온다. ③ 뒤에는 `virsh list --all` 에서 두 게스트가 `shut off` 로 바뀐다.
**왜 필요한가** — ③ 의 `virsh shutdown` 은 게스트 systemd 가 k3s 를 멈추고 k3s 가 컨테이너에 SIGTERM 을 보내는 연쇄다. 유예 시간이 짧으면 PostgreSQL 이 강제 종료되어 다음 기동에 crash recovery 가 돈다. ① 과 ② 로 미리 내려 두면 그 연쇄가 데이터베이스까지 닿지 않는다. 순서를 뒤집어 PostgreSQL 을 먼저 내리면 Keycloak 이 데이터베이스 없이 남아 기동 실패와 재시작을 반복한다.
**문제가 생기면** — `wait` 가 `120s` 안에 안 끝나면 파드가 종료 중에 걸린 것이다. `kubectl -n keycloak-lab get pods -o wide` 로 어느 파드가 어느 노드에서 `Terminating` 인지 보고, 그 상태로 ③ 을 치지 않는다.
### 3. 종료의 역순으로 되살린다
**목적** — 게스트와 클러스터와 워크로드를 반대 순서로 올려 Keycloak 이 데이터베이스를 찾을 수 있게 한다.
게스트를 띄워 노드가 `Ready` 가 되기를 기다린 뒤, PostgreSQL 을 먼저 올리고 Keycloak 을 나중에 올린다.
```bash label="[lab host] ① 게스트를 띄우고 노드가 Ready 가 되기를 기다린다"
virsh start kc-lab-1 && virsh start kc-lab-2
kubectl get nodes
```
```bash label="[lab host] ② PostgreSQL 을 먼저 올리고 기동을 확인한다"
kubectl -n keycloak-lab scale deployment/postgres --replicas=1
kubectl -n keycloak-lab rollout status deployment/postgres
```
```bash label="[lab host] ③ Keycloak 을 두 벌로 올린다"
kubectl -n keycloak-lab scale statefulset/keycloak --replicas=2
```
**예상 결과** — ① 의 `kubectl get nodes` 에 두 노드가 `Ready` 로 나온다. ② 의 `rollout status` 가 배포 완료로 돌아온 뒤에 ③ 을 친다.
**왜 필요한가** — PostgreSQL 이 먼저다. Keycloak 이 데이터베이스 없이 뜨면 기동에 실패하기 때문이다. 그리고 스케일을 0 으로 내려 둔 것은 자동으로 복구되지 않는다. 게스트를 켜고 노드가 `Ready` 가 돼도 파드 수는 0 그대로이므로 ② 와 ③ 을 명시적으로 쳐야 한다.
**문제가 생기면** — ① 에서 노드가 `Ready` 로 안 올라오면 게스트 안의 k3s 유닛부터 본다. ② 의 `rollout status` 가 멈춰 있으면 PostgreSQL 이 crash recovery 중일 수 있으니 아래 확인 방법의 `postmaster.pid` 를 함께 본다.
## 구성 값
기준 배치는 2026-09-03 에 구축이 끝난 상태다.
| 게스트 | 역할 | vCPU · 메모리 |
|---|---|---|
| `kc-lab-1` | k3s server | 2 · 3584M |
| `kc-lab-2` | k3s agent | 2 · 2560M |
이 실험대는 그 뒤에 호스트를 8GB 에서 12GB 로 물리 증설했고, 위 1번의 방법으로 게스트 메모리를 다시 나눴다. 게스트를 다시 만들거나 디스크를 손댈 일은 전혀 없었다. 1번의 예에 쓴 `5120M` 이 그렇게 올린 값이다.
내리고 올리는 워크로드는 둘이다.
| 리소스 | 내릴 때 | 올릴 때 |
|---|---|---|
| `statefulset/keycloak` | 0 (먼저) | 2 (나중) |
| `deployment/postgres` | 0 (나중) | 1 (먼저) |
네임스페이스는 `keycloak-lab` 이고 `wait` 의 제한 시간은 양쪽 다 `120s` 다.
## 확인 방법
**무엇을 확인하는가** — 어느 게스트가 server 이고 어느 게스트가 agent 인지, 그리고 PostgreSQL 이 깨끗이 내려갔는지.
```bash label="[lab host] 노드의 control-plane 라벨을 열로 뽑는다"
kubectl get nodes -o custom-columns=\
'NODE:.metadata.name,CP:.metadata.labels.node-role\.kubernetes\.io/control-plane'
```
```bash label="[lab host] 게스트가 다시 뜬 뒤 postmaster.pid 가 있는지 본다"
ssh kc-lab-2 'sudo ls /var/lib/rancher/k3s/storage/*postgres-data*/pgdata/postmaster.pid'
```
**어디를 봐야 하는가** — 첫 명령에서 `kc-lab-1` 의 `CP` 열에 값이 있고 `kc-lab-2` 는 비어 있다. 둘째 명령은 파일이 없다고 나와야 한다. `postmaster.pid` 가 보이면 비정상 종료였고 다음 기동에 복구 절차가 실행된다.
**이 결과가 의미하는 것** — 둘째 명령은 게스트가 떠 있어야 읽을 수 있으므로 3번의 ① 뒤에 친다. 첫 명령이 필요한 까닭은 두 노드를 같은 것으로 다루면 안 되기 때문이다. `kc-lab-2` 를 죽이면 그 노드의 워크로드만 사라지고 클러스터 제어는 살아 있지만, `kc-lab-1` 을 죽이면 `kubectl` 이 안 되고 DNS 와 인그레스도 함께 사라진다. 노드 상실 실험은 agent 를 죽이는 것이고, server 를 죽이는 것은 컨트롤 플레인 상실이라 성격이 다르다.
## 이 절차가 감당하지 않는 것
관측 스택을 어느 노드에 둘지는 여기서 정하지 않는다. server 와 agent 가 왜 다른지만 위에서 한 번 적었고, 그래서 관측을 server 쪽에 두는 판단은 게스트 두 대를 고른 결정과 Prometheus 를 올리는 편이 받는다.
이 절차의 근거는 제9부의 개념 문서이고, 그 문서의 실측 스냅샷은 2026-09-03, 재배분 기록은 2026-09-11 이다. 제7부가 2026-09-10 에 잰 게스트 메모리 값과 날짜가 엇갈리므로, 게스트 메모리 수치는 각각 그 시점의 것으로 읽는다.
이 순서대로 다시 돌려 검증하지는 않았다. 위 명령들은 원본이 적어 둔 순서를 그대로 옮긴 것이고, 한 번 더 껐다 켜서 같은 출력이 나오는지는 확인하지 못했다.
<!-- body:end -->
@@ -0,0 +1,394 @@
---
id: 7c66a553-0008-4294-a27a-687bd1bda0c1
kind: SETUP
slug: prepare-the-lab-host-for-virtualization
title: lab host 에 가상화 패키지를 깔고 virsh 가 sudo 없이 돌게 만든다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7c66a553-0008-4294-a27a-687bd1bda0c1/edit"
pinnedVersions:
- name: libvirt
version: 12.7.0
- name: QEMU
version: 11.1.1
source:
- final/document.md#186-단계-00-lab-host-가상화-준비
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#184-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# lab host 에 가상화 패키지를 깔고 virsh 가 sudo 없이 돌게 만든다
물리 기계 한 대를 게스트를 올릴 수 있는 상태로 바꾸는 절차다. 가상화 패키지 넷을 깔고, libvirt 소켓을 켜고, 사용자를 `libvirt` 그룹에 넣고, 연결 URI 를 시스템 단위로 고정한다. 끝나면 `virsh list --all``sudo` 없이 통과한다.
## 관계
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
다음 단계이고, 여기서 고정한 `qemu:///system``default` 네트워크 위에서 `virt-install` 이 돈다.
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
여기서 깐 qemu 가 다루는 디스크 형식을 그 글이 설명한다. 다음 단계의 오버레이가 성립하는 근거도 거기 있다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`virsh list` 의 빈 표와 `net-list` 에서 `--all` 을 뺐을 때 안 보이는 네트워크를 어떻게 읽어야 하는지 그 기준이 정한다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
이 절차의 만드는 명령이 왜 재실행으로 검증되지 않은 채 남았는지를 그 기준이 가른다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
같은 명령을 다시 쳐서 이 호스트가 같은 상태가 되는지는 아직 재지 않았다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[lab host]` 에서 친다. 게스트가 아직 없어서 다른 셸 표시가 나오지 않는다.
`sudo` 가 붙는 곳은 둘이다. 패키지를 깔 때와 systemd 유닛을 만질 때이고, `virsh` 는 3번이 끝나면 `sudo` 없이 돈다. 그 뒤로 `sudo virsh` 를 치면 root 환경으로 돌아 사용자 홈의 설정을 못 보므로, 붙이지 않는 쪽이 맞는 형태다.
| 표시 | 어느 기계 | 어떻게 들어가나 |
|---|---|---|
| `[lab host]` | `test-server`. `virsh` 가 도는 곳 | `ssh test-server` |
편집기를 여는 곳은 한 군데다. 4번에서 `~/.bashrc` 를 연다. 나머지는 전부 조회·설치·유닛 조작이라 운영자가 평소에 치는 CLI 를 그대로 쓴다.
## 이 단계가 세우는 것
가이드 00 의 「이 단계가 끝나면」은 한 줄이다.
> `virsh list` 가 sudo 없이 돌고, VM 을 만들 수 있는 상태가 된다.
그 한 줄이 요구하는 것을 풀면 여덟이다.
| 무엇 | 값 |
|---|---|
| 저장소 | `~/workspace/keycloak-pattern` — 뒤 단계의 `deploy/...` 상대경로가 이 디렉터리 기준이다 |
| 패키지 (Arch) | `qemu-full` · `libvirt` · `virt-install` · `dnsmasq` |
| 패키지 (Debian/Ubuntu) | `qemu-system-x86` · `libvirt-daemon-system` · `virtinst` · `cloud-image-utils` |
| 판 번호 | libvirt `12.7.0` · `QEMU emulator version 11.1.1` |
| 유닛 | `libvirtd.socket``.service` 가 아니다 |
| 그룹 | `donghyeon libvirt wheel` |
| 연결 URI | `LIBVIRT_DEFAULT_URI=qemu:///system` |
| 가상 네트워크 | `default` / `active` / autostart `yes``virbr0` · `192.168.122.0/24` |
네 패키지가 하는 일이 서로 다르다.
| 무엇 | 하는 일 |
|---|---|
| qemu | 실제로 가상 기계를 돌리는 것 |
| libvirt | qemu 를 관리하는 층 (`virsh` 가 여기 붙는다) |
| virt-install | VM 을 만드는 명령 |
| dnsmasq | 가상 네트워크의 DHCP·DNS |
## 전제와 되돌리기
전제는 가이드가 두 줄로 적었다 — 물리 기계 한 대가 있고, 배포판은 상관없다. 이 실험대는 Arch Linux 로 세웠고 배포판이 다르면 패키지 이름만 달라진다. 앞 단계가 없으므로 이 절차는 다른 무엇도 전제하지 않는다.
**되돌리는 절차는 원본 가이드 00 에 없다**(unknown). 가이드 7편 가운데 되돌리기를 적은 편은 단계 03 하나다. 이 단계가 호스트에 남기는 것은 다섯이다.
| 남는 것 | 어디에 |
|---|---|
| 패키지 넷 | 배포판 패키지 데이터베이스 |
| `libvirt` 보조 그룹 | 사용자 계정 |
| `libvirtd.socket` 활성화 | systemd |
| `export` 한 줄 | `~/.bashrc` |
| `default` 네트워크의 autostart | libvirt 설정 |
무엇을 어떤 순서로 걷어내는지는 가이드에 적혀 있지 않고 이 실험대도 걷어내 본 적이 없다. 여기에 되돌리는 명령을 적으려면 지어내야 하므로 적지 않는다.
## 세우기 전에 먼저 본다
두 확인은 아무것도 바꾸지 않는다. 기계를 켤 때 먼저 도는 펌웨어와 그 설정 화면을 BIOS(Basic Input/Output System)라고 부르는데, 거기서 가상화가 꺼져 있으면 뒤가 전부 헛일이므로 먼저 본다.
### 확인 ① CPU 가 하드웨어 가상화 확장을 내놓고 있는가
```bash label="[lab host] CPU 플래그에서 vmx 나 svm 을 찾는다"
grep -Eo 'vmx|svm' /proc/cpuinfo | head -1
```
**어디를 봐야 하는가** — 출력 한 줄이 전부다. `vmx`(Intel) 또는 `svm`(AMD) 중 하나가 찍히는가, 아니면 아무것도 안 찍히는가.
**이 결과가 의미하는 것** — 찍혔으면 이 호스트에서 KVM 을 쓸 수 있다. 빈 출력은 CPU 가 못 한다는 뜻이 아니라 대개 BIOS 에서 꺼져 있다는 뜻이다. 재부팅해 Intel VT-x 나 AMD-V 를 켜고 다시 잰다. 여기서 막히면 뒤의 어떤 단계도 의미가 없으므로 진행하지 않는다.
### 확인 ② 커널이 그 확장을 실제로 잡고 있는가
```bash label="[lab host] 올라온 KVM 모듈을 본다"
lsmod | grep kvm
```
출력은 이 실험대에서 캡처해 두지 않았다(unknown). 가이드도 줄 모양만 적었다.
```text
kvm_intel ...
kvm ...
```
**어디를 봐야 하는가** — 왼쪽 첫 열의 모듈 이름 두 개. 벤더 모듈(`kvm_intel` 또는 `kvm_amd`)과 공용 `kvm` 이 둘 다 있어야 한다. 셋째 열은 이 모듈을 쓰고 있는 쪽의 수라서, 아직 VM 이 없으면 0 으로 나온다.
**이 결과가 의미하는 것** — 두 줄이면 커널이 하드웨어 가상화를 쓸 준비를 마쳤고 `virt-install` 이 KVM 가속으로 뜬다. `kvm` 만 있고 벤더 모듈이 없으면 확인 ① 의 BIOS 설정이 커널까지 안 넘어왔다. 아무것도 없으면 확인 ① 로 돌아간다. 모듈을 직접 올려 보면 거부 사유가 그대로 나온다.
```bash label="[lab host] 벤더 모듈을 손으로 올려 거부 사유를 받는다"
sudo modprobe kvm_intel
```
**왜 이걸 먼저 보나** — KVM 없이도 QEMU 는 돌지만 소프트웨어 에뮬레이션이 되어 수십 배 느려진다. 게스트가 뜨긴 뜨는데 느리다면 대개 여기서 갈린다. 그 상태로 게스트 셋을 올리면 원인을 게스트 안에서 찾게 된다.
## 실행 절차
### 1. 저장소를 lab host 에 받는다
**목적** — 뒤 단계가 `deploy/` 아래 파일을 쓴다. 단계 05·06 의 `kubectl apply -f deploy/lab/k8s/...` 가 그것이고, 경로는 저장소 루트 기준이다.
① 이미 세웠다 철거한 적이 있으면 무엇이 남아 있는지 먼저 본다.
```bash label="[lab host] ① 작업 디렉터리에 무엇이 있는지 본다"
ls ~/workspace
```
② 받는다.
```bash label="[lab host] ② 저장소를 받고 그 안으로 들어간다"
git clone https://git.learn.hyeonworks.com/donghyeon.kang/keycloak-pattern.git ~/workspace/keycloak-pattern
cd ~/workspace/keycloak-pattern
```
**예상 결과** — 매니페스트 두 장이 보인다.
```bash label="[lab host] ③ 뒤 단계가 쓸 매니페스트를 확인한다"
ls deploy/lab/k8s/
```
`keycloak-cluster.yaml` 과 `observability.yaml` 이 보이는가만 본다.
**왜 필요한가** — 이후 단계에서 `deploy/...` 로 시작하는 상대경로가 나오면 전부 이 디렉터리 안에서 친다. lab host 에 저장소가 없으면 `cp: cannot stat` 이나 `error: the path ... does not exist` 로 막히고, 이 실험대가 실제로 그 형태로 겪었다. 한 번 세웠다 철거했으면 이 디렉터리가 없을 수 있다. 철거는 VM 과 디스크와 네트워크만 지우고 저장소는 각자 관리라, `~/workspace` 에 cloud-init 시드만 남아 있는 상태가 흔하다. ① 을 먼저 치는 까닭이 여기 있다.
**문제가 생기면** — `ls` 가 아무것도 못 찾으면 클론이 실패한 것이니 ② 를 다시 친다. 클론은 됐는데 `deploy/lab/k8s/` 가 없으면 다른 브랜치를 받았다.
### 2. 가상화 패키지를 깐다
**목적** — `virsh` 와 `virt-install` 을 PATH 에 올리고 가상 네트워크의 DHCP 를 준비한다. PATH 는 셸이 실행 파일을 찾아 다니는 디렉터리 목록을 말한다.
```bash label="[lab host] ① Arch 에서 네 패키지를 깐다"
sudo pacman -S --needed qemu-full libvirt virt-install dnsmasq
```
Debian 이나 Ubuntu 면 이름이 다르다. 이 실험대는 Arch 로 세웠고 아래 줄은 가이드가 참고로 적어 둔 것이다(external).
```bash label="[lab host] Debian/Ubuntu 라면 이름이 이렇게 바뀐다"
sudo apt install qemu-system-x86 libvirt-daemon-system virtinst cloud-image-utils
```
```bash label="[lab host] ② 두 실행 파일이 PATH 에 들어왔는지 본다"
virsh --version
qemu-system-x86_64 --version
```
**예상 결과** — 판 번호 두 줄. 이 실험대는 libvirt `12.7.0` 과 `QEMU emulator version 11.1.1` 이었다(observed).
**왜 필요한가** — 여기서 나오는 libvirt 판 번호가 뒤의 옵션 이름을 가른다. 훨씬 낮은 판이면 `virt-install --cloud-init` 같은 옵션의 동작이 달라질 수 있으니, 다음 단계에서 막힐 때 이 번호를 같이 본다.
**문제가 생기면** — `command not found` 면 패키지가 안 깔렸다. 번호가 나오면 깔렸다.
### 3. libvirt 를 띄우고 권한을 받는다
**목적** — 지금 이 셸이 `sudo` 없이 libvirt 에 붙게 한다.
```bash label="[lab host] ① 소켓 유닛을 켜고 부팅에도 켜지게 한다"
sudo systemctl enable --now libvirtd.socket
```
```bash label="[lab host] ② 지금 사용자를 libvirt 보조 그룹에 넣는다"
sudo usermod -aG libvirt "$USER"
```
③ 로그아웃했다 다시 들어온다. 보조 그룹은 로그인할 때 정해지므로 `usermod` 만으로는 지금 셸에 반영되지 않는다.
```bash label="[lab host] ④ 그룹과 권한을 함께 확인한다"
groups # libvirt 가 보여야 한다
virsh list --all # sudo 없이 돌아야 한다
```
**예상 결과** — `groups` 가 이렇게 나왔다(observed).
```text
donghyeon libvirt wheel
```
`virsh list --all` 은 머리글만 있는 빈 표를 내놓는다. 아직 VM 을 안 만들었으므로 표가 비어 있는 쪽이 정상이고, 봐야 할 것은 표의 내용이 아니라 명령이 오류 없이 통과했는가다.
**왜 필요한가** — `libvirtd.service` 가 아니라 `.socket` 을 켠다. 소켓 활성화라 데몬이 미리 떠 있지 않아도 `virsh` 가 접속하는 순간 systemd 가 띄우고, 자원을 아끼며, 데몬을 재시작해도 클라이언트가 끊기지 않는다.
**문제가 생기면** — `groups` 에 `libvirt` 가 없으면 `usermod` 는 됐지만 지금 로그인 세션이 옛 그룹 목록을 들고 있다. `usermod -aG` 가 고치는 것은 `/etc/group` 파일이다. 프로세스의 그룹 목록은 로그인할 때 한 번 읽혀 고정되므로 이미 떠 있는 셸에는 소급 적용되지 않는다. 두 곳을 나란히 보면 그 상태가 그대로 드러난다.
```bash label="[lab host] 셸이 들고 있는 목록과 파일의 내용을 나란히 본다"
id # 현재 셸이 들고 있는 그룹 (여기 libvirt 가 보여야 함)
getent group libvirt # /etc/group 의 실제 내용 (이쪽엔 바로 반영됨)
```
두 결과가 다르면 재로그인이 필요하다는 뜻이다. 급하면 `newgrp libvirt` 로 그 셸만 갱신한다.
`groups` 에는 있는데 `virsh` 가 `Permission denied` 를 내면 그룹이 아니라 소켓 문제이므로 유닛 상태를 본다.
```bash label="[lab host] 그룹이 맞는데 거절당할 때 유닛부터 본다"
systemctl status libvirtd.socket
```
### 4. 연결 URI 를 시스템 단위로 고정한다
**목적** — `virsh` 가 VM 을 만들 곳과 같은 하이퍼바이저를 보게 한다. `virsh` 는 기본으로 사용자 단위인 `qemu:///session` 에 붙는데 VM 은 시스템 단위인 `qemu:///system` 에 만들어야 한다. 이걸 안 맞추면 만든 VM 이 목록에 안 보인다.
① 설정 파일을 연다.
```bash label="[lab host] ① 로그인 셸 설정을 편집기로 연다"
nano ~/.bashrc
```
② 파일 끝에 이 줄을 더한다. 저장은 `Ctrl+O` 다음 `Enter`, 나가기는 `Ctrl+X`.
```text
export LIBVIRT_DEFAULT_URI=qemu:///system
```
③ 저장한 설정을 현재 셸에 반영하고 확인한다.
```bash label="[lab host] ③ 지금 셸에 반영하고 어느 하이퍼바이저를 보는지 본다"
source ~/.bashrc
virsh uri
```
**예상 결과**(observed)
```text
qemu:///system
```
끝의 한 낱말만 본다. `system` 인가 `session` 인가.
**왜 필요한가** — `qemu:///system` 이면 뒤에서 만들 VM 과 지금 `virsh` 가 같은 곳을 본다. `qemu:///session` 이면 사용자 단위 하이퍼바이저를 보고 있어, VM 은 만들어졌는데 `virsh list` 에 안 나오는 상태가 된다.
이 실험대는 같은 줄을 편집기 없이 넣었다(observed).
```bash label="[lab host] 이 실험대가 실제로 친 형태"
echo 'export LIBVIRT_DEFAULT_URI=qemu:///system' >> ~/.bashrc
virsh uri
```
따라 하는 사람에게는 편집기 쪽이 맞다. `echo >>` 는 같은 가이드를 두 번 따라 하면 같은 줄을 한 번 더 붙이고, 파일에 이미 무엇이 들어 있는지도 보여 주지 않는다. 파일을 열면 둘 다 해결된다.
**문제가 생기면** — `.bashrc` 에 넣은 것은 새로 여는 셸에만 적용된다. 지금 셸에서 값이 안 바뀌었으면 `source ~/.bashrc` 를 치거나 새 셸을 연다.
`sudo virsh` 와 그냥 `virsh` 를 섞어 치지 않는다. 이 문제는 한 번 고쳐도 반복해서 재발한다. `sudo` 는 환경 변수를 물려주지 않아 여기서 넣은 줄이 전달되지 않는데, root 로 도니 결과적으로 `qemu:///system` 이 되어 그쪽도 동작한다. 둘 다 되기 때문에 섞어 쓰면 어떤 명령은 되고 어떤 명령은 `Network not found: no network with matching name 'default'` 가 나온다. 네트워크가 없어서가 아니라 두 명령이 서로 다른 인스턴스에 물어본 것이다.
### 5. 기본 네트워크를 켠다
**목적** — VM 이 붙을 `virbr0` 와 `192.168.122.0/24` 를 지금도 재부팅 뒤에도 있게 한다.
```bash label="[lab host] ① 가상 네트워크가 살아 있는지 본다"
virsh net-list --all
```
**예상 결과**(observed)
```text
Name State Autostart Persistent
--------------------------------------------
default active yes yes
```
② `default` 행의 State 와 Autostart 두 칸 중 하나라도 어긋나면 아래 두 줄로 맞춘다.
```bash label="[lab host] ② 꺼져 있거나 autostart 가 no 일 때만 친다"
virsh net-start default
virsh net-autostart default
```
두 줄을 `&&` 로 잇지 않는다. `virsh net-start default` 는 이미 `active` 면 `error: network is already active` 로 실패한다. `A && B` 는 A 가 성공했을 때만 B 를 실행하므로, 두 번째로 칠 때는 `net-autostart` 가 아예 돌지 않는다. 화면에는 둘 다 실패한 것처럼 보이지만 앞선 실행에서 이미 목적을 이룬 상태다. 이 가이드를 두 번 이상 따라 한다면 `;` 로 잇고 앞엣것의 실패를 삼킨다.
```bash label="[lab host] 두 번 이상 따라 할 때 쓰는 형태"
virsh net-start default 2>/dev/null; virsh net-autostart default
```
**왜 필요한가** — `active` 이면서 autostart 가 `yes` 면 지금도, 호스트를 재부팅한 뒤에도 `virbr0` 와 `192.168.122.0/24` 가 있다. `inactive` 면 VM 을 만들어도 DHCP 가 없어 IP 를 못 받는다. autostart 가 `no` 면 지금은 되고 호스트를 재부팅한 다음 게스트의 SSH 가 전부 실패하는데, 그때 원인을 게스트에서 찾게 된다.
**문제가 생기면** — `--all` 을 주는 까닭이 여기 있다. 빼면 `inactive` 인 네트워크는 아예 목록에 안 나와서 없는 것과 꺼진 것을 구분할 수 없다. `net-start default` 가 `already active` 말고 다른 사유로 실패하면 dnsmasq 가 안 깔린 것이므로 2번으로 돌아간다.
## 구성 값
| 무엇이 서나 | 어떤 이름과 값으로 |
|---|---|
| 저장소 | `~/workspace/keycloak-pattern` |
| 패키지 (Arch) | `qemu-full` · `libvirt` · `virt-install` · `dnsmasq` |
| 유닛 | `libvirtd.socket` |
| 보조 그룹 | `libvirt` |
| 연결 URI | `qemu:///system` — `~/.bashrc` 의 `LIBVIRT_DEFAULT_URI` |
| 가상 네트워크 | `default` · `active` · autostart `yes` |
| 브리지와 대역 | `virbr0` · `192.168.122.0/24` |
대역이 `192.168.122.0/24` 로 굳으면 다음 단계의 DHCP 예약 세 줄과 그 뒤 모든 단계의 upstream 주소가 그 안에서 정해진다. 게스트 주소를 바꾸고 싶으면 여기서 바꾸는 것이지 게스트 안에서 바꾸는 것이 아니다.
## 끝났는지 판정한다
### 확인 ① virsh 가 sudo 없이 통과하는가
**무엇을 확인하는가** — 이 셸에서 VM 을 만들 수 있는 상태인지.
```bash label="[lab host] 권한과 연결과 네트워크를 차례로 본다"
virsh uri
virsh net-list --all
virsh list --all
```
**어디를 봐야 하는가** — 첫 줄이 `qemu:///system` 인가, 둘째에서 `default` 가 `active` 이고 autostart 가 `yes` 인가, 셋째가 `sudo` 없이 오류 없이 끝나는가.
**이 결과가 의미하는 것** — 셋이 다 통과하면 이 단계는 끝났다. `virsh list --all` 이 `sudo` 없이 오류 없이 끝나는 것이 이 단계의 통과 조건 전부이고, 표의 내용은 아직 볼 것이 없다. 빈 표를 「호스트에 아무것도 없다」로 읽지 않는다 — 지금은 만들지 않았으니 비어 있는 것이고, 다음 단계가 끝난 뒤에 같은 명령이 세 줄을 내놓는다.
### 확인 ② 그룹이 지금 셸에 반영됐는가
**무엇을 확인하는가** — `usermod` 가 파일에만 들어간 것인지, 지금 세션이 들고 있는 목록에도 들어간 것인지.
```bash label="[lab host] 지금 세션이 들고 있는 그룹 목록"
groups
```
**어디를 봐야 하는가** — 출력에 `libvirt` 가 끼어 있는가.
**이 결과가 의미하는 것** — 끼어 있으면 소켓에 붙을 권한이 지금 셸에 있다. 없는데 `virsh` 가 도는 일은 없으므로, 확인 ① 이 `Permission denied` 로 끝났다면 먼저 여기를 본다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| 가상화 확장 | `grep -Eo 'vmx\|svm' /proc/cpuinfo \| head -1` | `vmx` 또는 `svm` 한 줄 |
| 그룹 | `groups` | `libvirt` 가 끼어 있다 |
| 연결 URI | `virsh uri` | `qemu:///system` |
| 네트워크 | `virsh net-list --all` | `default` 가 `active` · autostart `yes` |
| 빈 목록 | `virsh list --all` | sudo 없이 통과. 표가 비어 있어도 된다 |
다섯 칸이 다 맞으면 다음 단계로 넘어간다.
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| `virsh list` 에 permission denied | 그룹 반영 안 됨 | 로그아웃과 로그인을 했는가. `groups` 에 libvirt 가 있는가 |
| 만든 VM 이 목록에 없다 | URI 가 `session` | `virsh uri` |
| `sudo` 로는 되는데 그냥은 안 된다 | 세션 인스턴스를 보고 있다 | `virsh uri` |
| `net-start` 가 `already active` | 앞서 켜 두었다 | `virsh net-list --all` 의 State |
| VM 이 극단적으로 느리다 | KVM 미사용 | `lsmod \| grep kvm` · BIOS |
| `net-start default` 실패 | dnsmasq 없음 | 패키지 설치 확인 |
| `cp: cannot stat 'deploy/...'` | lab host 에 저장소가 없다 | `ls ~/workspace` |
## 무엇이 관측이고 무엇이 아닌가
- (observed) libvirt `12.7.0` 과 `QEMU emulator version 11.1.1`, `groups` 의 세 이름, `default` 네트워크가 `active` 이고 autostart 가 `yes` 인 것, `virsh uri` 가 내놓은 `qemu:///system`.
- (unknown) `lsmod | grep kvm` 의 출력은 캡처해 두지 않았다. 가이드도 줄 모양만 적고 값을 싣지 않았다.
- (unknown) 가이드의 실측 줄은 이 호스트가 16 코어 전부에서 지원한다고 적었는데 대상 환경 쪽은 논리 코어 8(i5-1135G7)로 적혀 있다. 두 값이 어긋나고 어느 쪽이 이 호스트의 값인지는 재지 않았다. 세 게스트의 vCPU 합이 5 라 8 에서도 16 에서도 CPU overcommit 이 아니므로 이 단계의 판정은 어느 쪽이어도 바뀌지 않는다.
- (unknown) 원본 가이드에 되돌리는 절차가 없다. 패키지와 그룹과 유닛과 `~/.bashrc` 와 네트워크 autostart 를 걷어내 본 적이 없다.
- (unknown) `sudo modprobe kvm_intel` 과 `systemctl status libvirtd.socket` 은 막혔을 때 치라고 가이드가 적어 둔 명령이고, 이 실험대에서는 막히지 않아 치지 않았다.
- (unknown) `nano ~/.bashrc` 로 여는 형태는 이 실험대가 치지 않았다. 이 실험대는 `echo >>` 로 넣었고, 편집기 쪽은 같은 상태에 닿는 형태로 적었다.
- (external) Debian 과 Ubuntu 의 패키지 이름은 가이드가 참고로 적어 둔 것이고 이 실험대는 Arch 로 세웠다.
- (external) 보조 그룹이 로그인 시점에 고정된다는 것, `virsh net-start` 가 이미 `active` 면 실패한다는 것, `sudo` 가 환경 변수를 물려주지 않는다는 것은 리눅스와 libvirt 의 동작이다. 이 실험대가 그 세 가지를 따로 재 보지는 않았다.
- (unknown) 만드는 명령은 구축할 때 친 것을 옮긴 것이라, 지금 다시 쳐도 같은 상태가 되는지는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,290 @@
---
id: 0cb0f195-b06b-4f8e-b52e-675eb0918805
kind: SETUP
slug: prometheus-and-grafana-for-the-lab
title: Prometheus 와 Grafana 를 올려 클러스터 안을 밖에서 본다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/0cb0f195-b06b-4f8e-b52e-675eb0918805/edit"
pinnedVersions:
- name: k3s
version: v1.36.4+k3s1
- name: Debian GNU/Linux
version: 12 (bookworm)
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#192-단계-06-prometheus-와-grafana
- final/document.md#184-이-부의-출처와-범위
---
# Prometheus 와 Grafana 를 올려 클러스터 안을 밖에서 본다
매니페스트 한 장을 `apply` 해 네임스페이스 `observability` 에 Prometheus 와 Grafana 와 node-exporter 를 세우는 절차다. 밖에서만 판정하면 분단된 노드가 스스로 빠지는 것을 놓치기 때문에 이 스택을 둔다.
## 관계
- **Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다**
그 단계가 세운 두 노드를 긁는다. 거기서 임시 파드로 물었던 클러스터 크기를 여기서는 Prometheus 에 묻는다.
- **k3s server 와 agent 를 게스트 두 대에 깔고 lab host 에서 kubectl 로 본다**
node-exporter 가 노드마다 하나씩 뜨므로 줄이 하나뿐이면 그 단계의 노드 상태부터 다시 본다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`up` 이 1 이라는 것은 프로세스가 살아 있고 응답한다는 뜻이지 그 노드가 쓸모 있다는 뜻이 아니다. 빈 결과가 0 을 뜻하지 않는 것도 같다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
스크레이프 대상 넷과 파드 네 줄이 다시 세운 실험대에서도 같은지가 그 질문이 셀 항목에 들어간다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
전부 `[lab host]` 에서 치고, 매니페스트를 적용하는 명령은 저장소 루트에서 친다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 1 | 매니페스트 적용 | `[lab host]``~/workspace/keycloak-pattern` 안에서 |
| 2 | 파드 네 줄 세기 | `[lab host]` |
| 판정 | 대상 · 상태 · 클러스터 크기 | `[lab host]` |
| 판정 | Grafana 포트포워드 | **브라우저로 볼 그 기계** |
**마지막 포트포워드만 예외다.** 그 터널은 명령을 친 기계에서만 열리므로, 브라우저로 볼 기계에서 쳐야 한다.
이 단계에도 손으로 쓰는 파일이 없다. 세우는 것이 전부 매니페스트 한 장에 들어 있고, 그 원문은 저장소의 `deploy/lab/k8s/observability.yaml` 이다.
## 이 단계가 세우는 것
가이드 06 의 「이 단계가 끝나면」은 두 줄이다.
> Prometheus 가 Keycloak 을 긁고, `vendor_cluster_size` 로 클러스터 상태를
> 밖에서 볼 수 있다.
**왜 두는가**(observed) — 판정을 밖에서만 하면 놓친다. 7800 을 끊었는데 외부 응답이 전부 200 이었고, 분단된 노드가 스스로 로드밸런서에서 빠졌기 때문이다. 클러스터 안을 보는 눈이 따로 있어야 한다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `observability` |
| 파드 | `grafana` 1 · `prometheus` 1 · `node-exporter` 2 (DaemonSet, 노드마다 하나) |
| 스크레이프 대상 | `keycloak` · `kubelet` · `node-exporter` · `prometheus` |
| Prometheus API | 파드 안 `localhost:9090``/api/v1/targets` · `/api/v1/query` |
| Grafana | 밖에 열지 않고 `port-forward svc/grafana 3000:3000` |
| 도구 | `jq` 가 없다. `grep -o``tr ',' '\n'` 로 필드만 뽑는다 |
**판 번호는 여기 없다**(unknown). 가이드 06 은 Prometheus 와 Grafana 의 이미지 태그를 적지 않았고 `observability.yaml` 원문은 반입되지 않았다. 파드 이름의 해시는 판 번호가 아니다.
## 전제와 되돌리기
전제는 한 줄이다 — 앞 단계가 끝나 Keycloak 두 노드가 떴다.
**되돌리는 절차는 원본 가이드 06 에 없다**(unknown). 이 단계가 남기는 것은 `observability` 네임스페이스와 그 안의 전부이고, `node-exporter` 는 DaemonSet 이라 노드마다 하나씩 붙어 있다. 지우는 순서도 지운 뒤의 확인도 가이드에 없다.
## 세우기 전에 먼저 본다
**무엇을 확인하는가** — 이 스택이 없으면 같은 질문에 어떻게 답하게 되는지. 앞 단계가 이미 한 번 보여 주었다.
```bash label="[lab host] 관측 스택 없이 클러스터 크기를 묻는 형태"
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'"
```
**어디를 봐야 하는가** — 이 명령은 고른 파드 하나에게만 물을 수 있다. `keycloak-1` 을 보려면 IP 를 다시 잡아 한 번 더 친다.
**이 결과가 의미하는 것** — 한 노드만 보면 분단을 놓친다. 두 노드를 매번 따로 물어야 하고, 값을 나란히 놓아 비교하기도 어렵다. 이 단계는 그 질문을 한 번에 받아 준다.
## 실행 절차
### 1. 매니페스트 한 장을 적용한다
**목적** — 네임스페이스 `observability` 에 Prometheus 와 Grafana 와 node-exporter 를 세운다.
```bash label="[lab host] ① 매니페스트가 있는 저장소 루트로 간다"
cd ~/workspace/keycloak-pattern
```
```bash label="[lab host] ② 매니페스트를 적용하고 Prometheus 가 설 때까지 기다린다"
kubectl apply -f deploy/lab/k8s/observability.yaml
kubectl -n observability rollout status deploy/prometheus --timeout=180s
```
**예상 결과** — ② 가 끝나면 Prometheus 가 Ready 다. node-exporter 는 DaemonSet 이라 이 명령이 기다리는 대상에 들어가지 않으므로 2번에서 따로 센다.
**왜 필요한가** — `deploy/...` 로 시작하는 상대경로는 저장소 루트 기준이라 다른 디렉터리에서 치면 파일을 못 찾는다. 관측 스택을 두는 까닭은 밖에서만 판정하면 놓치기 때문이고, 그 근거는 위에 적었다.
**문제가 생기면** — ② 가 타임아웃으로 끝나면 2번의 파드 목록부터 보고, 거기서도 안 보이면 k3s 단계의 노드 상태로 돌아간다.
### 2. 파드 네 줄을 센다
**목적** — 노드 둘에 node-exporter 가 하나씩 떴는지 확인한다.
```bash label="[lab host] ① 네임스페이스의 파드를 전부 본다"
kubectl -n observability get pods
```
**예상 결과**(observed)
```text
grafana-845b5678cf-b6gvc 1/1 Running
node-exporter-9qk9w 1/1 Running
node-exporter-c2mz4 1/1 Running
prometheus-6774f94f7c-pzr2t 1/1 Running
```
줄이 네 개인가, READY 칸이 전부 `1/1` 인가, 특히 `node-exporter` 로 시작하는 줄이 둘인가를 센다.
**왜 필요한가** — node-exporter 가 둘인 것은 DaemonSet 이라 노드마다 하나씩 뜨기 때문이다. 하나뿐이면 노드 하나가 빠진 것이고, 그러면 그 노드의 CPU 와 메모리와 디스크 지표가 통째로 없는 채로 실험을 하게 된다.
**문제가 생기면** — 줄이 하나면 관측을 더 볼 것이 아니라 k3s 단계의 노드 상태부터 본다. 어느 노드에 붙었는지는 이렇게 확인한다.
```bash label="[lab host] 어느 노드에 붙었는지 본다"
kubectl -n observability get pods -o wide
```
NODE 열에서 Prometheus 와 Grafana 가 어느 노드에 있는지 본다. 죽는 순간을 기록해야 하는 쪽이 대상과 함께 내려가면 기록이 남지 않으므로, 관측 스택은 관측 대상과 같이 죽으면 안 된다. 노드가 둘뿐인 실험대에서는 완전히 갈라 둘 수 없어서 규칙으로 정했다 — 관측 스택은 server 노드인 `kc-lab-1` 에 두고 장애 주입은 agent 노드인 `kc-lab-2` 에 한다. `nodeSelector` 로 못박아 두면 실험을 다시 돌려도 같은 노드에 뜬다. node-exporter 는 DaemonSet 이라 이 규칙 밖이고 두 노드에 다 떠 있어야 한다.
## 구성 값
자주 보는 지표들이다. 실험 중에는 Grafana 보다 Prometheus 쿼리 API 가 편하다 — 값을 그대로 뽑아 비교할 수 있고 스크린샷보다 근거로 남기기 좋다.
| 지표 | 무엇 |
|---|---|
| `vendor_cluster_size` | 이 노드가 아는 멤버 수 |
| `vendor_jgroups_*` | JGroups 프로토콜별 카운터 |
| `vendor_statistics_approximate_entries_unique{cache="sessions"}` | 이 노드의 세션 캐시 엔트리 수 |
| `agroal_*` | JDBC 커넥션 풀 |
| `up` | 스크레이프 성공 여부 |
이 실험대에는 `jq` 가 깔려 있지 않다. 아래 확인 명령이 `grep -o` 와 `tr` 로 필드를 뽑는 모양인 것은 그 때문이고, 그 이상 가공해야 하면 파서를 짜지 않고 화면에 나온 JSON 을 그대로 읽는다.
## 끝났는지 판정한다
### 확인 ① 무엇을 긁고 있나
**무엇을 확인하는가** — Prometheus 가 어느 대상을 스크레이프하고 있는지.
```bash label="[lab host] 스크레이프 대상의 job 이름만 뽑는다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- localhost:9090/api/v1/targets | grep -o '"job":"[^"]*"' | sort -u
```
**실측**(observed)
```text
"job":"keycloak"
"job":"kubelet"
"job":"node-exporter"
"job":"prometheus"
```
**어디를 봐야 하는가** — 거기 있는 이름이 아니라 없는 이름이다. 응답은 JSON 한 덩어리이고 그대로는 못 읽으므로 `grep -o` 로 필요한 필드만 뽑고 `sort -u` 로 중복을 없앴다. 사람이 손으로 치는 선이 여기까지다.
**이 결과가 의미하는 것** — Redis 와 BFF 와 PostgreSQL 이 없다. 이 실험대는 그것들을 긁지 않는다. 그래서 어떤 실험에 Grafana 화면이 없는데, 안 찍은 것이 아니라 지표가 없는 것이다. 어떤 실험에서 지표를 못 찾으면 「측정이 실패했다」로 적기 전에 이 목록에 그 job 이 있었는지부터 본다. 가이드는 그것을 스크린샷 누락이 아니라 측정된 공백으로 기록했다.
### 확인 ② 목록에는 있는데 값이 안 나올 때
**무엇을 확인하는가** — 목록에 있는 대상이 실제로 긁히고 있는지.
```bash label="[lab host] job 과 health 와 lastError 를 세로로 늘어놓는다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- localhost:9090/api/v1/targets | tr ',' '\n' | grep -E '"(job|health|lastError)"'
```
**어디를 봐야 하는가** — `"health":"up"` 이 아닌 줄과 그 바로 뒤의 `lastError`. `tr ',' '\n'` 로 쉼표마다 줄을 나눴으므로 필드가 원래 순서대로 세로로 늘어서고, job 줄 아래에 그 대상의 health 가 온다.
**이 결과가 의미하는 것** — `down` 인 대상이 있으면 `lastError` 가 까닭을 그대로 말해 준다. 연결 거부인지 타임아웃인지 404 인지가 거기 적혀 있다. 확인 ③ 에서 값이 한 노드만 나오는 증상의 원인이 대개 여기 있고, 그때는 클러스터가 아니라 스크레이프가 문제다.
권한이 모자라 대상 하나만 빠지는 일이 이 실험대에서 실제로 있었다. Prometheus 는 타깃을 적어 두지 않고 쿠버네티스 API 에 물어서 찾으므로 읽기 권한이 필요하다. 그 권한을 담는 `ClusterRole` 에서 `nodes/proxy` 를 빠뜨리자 kubelet 타깃만 `403 Forbidden` 으로 실패하고 나머지 잡은 전부 정상이었다. `nodes` 와 `nodes/metrics` 와 `nodes/proxy` 는 서로 다른 권한이라, 노드 지표를 긁는 경로 `/api/v1/nodes/<name>/proxy/metrics` 에는 셋째 것이 따로 있어야 한다. 부분 실패라 1번의 `rollout status` 는 성공이라고 말하고, 타깃 목록을 직접 봐야 드러난다. 권한만 따로 물을 수도 있다.
```bash label="[lab host] 그 ServiceAccount 가 그 동사를 쓸 수 있는지 묻는다"
kubectl auth can-i get nodes/proxy --as=system:serviceaccount:observability:prometheus
kubectl describe clusterrole prometheus
```
### 확인 ③ 클러스터 상태 — 두 노드가 각각 몇 명을 보고 있는가
**무엇을 확인하는가** — 두 Keycloak 이 서로를 보고 있는지.
```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
```
**어디를 봐야 하는가** — `data.result` 배열의 원소가 몇 개인가, 각 원소에서 `metric` 안의 `node` 라벨과 `value` 배열의 둘째 원소만 읽는다. `jq` 가 없으므로 눈으로 읽는다.
**이 결과가 의미하는 것** — 두 노드가 각각 자기가 아는 멤버 수를 보고한다. 둘 다 2 면 클러스터가 온전하다. 분단되면 한쪽은 2, 다른 쪽은 1 이 된다 — 한 노드만 보면 분단을 놓친다. 원소가 하나뿐이면 분단이 아니라 스크레이프 실패일 수 있으므로 확인 ② 의 `health` 를 먼저 본다. 값이 아예 안 나오면 `"result":[]` 로 빈 배열이 오는데, 이것은 「0 이다」가 아니라 「그런 지표가 없다」는 뜻이다. 지표 이름을 잘못 쳤거나 그 대상을 긁고 있지 않은 것이므로 확인 ① 로 돌아간다.
### 확인 ④ up 을 믿지 않는다
**무엇을 확인하는가** — 스크레이프 성공 지표가 무엇까지 말해 주는지.
```bash label="[lab host] 대상마다 스크레이프가 성공했는지 본다"
kubectl -n observability exec deploy/prometheus -- \
wget -qO- 'localhost:9090/api/v1/query?query=up'
```
**어디를 봐야 하는가** — 원소마다 `job` 라벨과 값이다. 값이 1 이라는 것은 마지막 스크레이프가 성공했다는 사실 하나만 말한다.
**이 결과가 의미하는 것** — 503 이 나는 동안에도 `up` 은 1 이었다(observed). 프로세스가 살아 있고 `/metrics` 가 응답하기만 하면 1 이 되므로, 살아 있지만 쓸모없는 상태를 이 지표로는 보지 못한다. 경보를 `up == 0` 하나로 걸면 그 상태를 통째로 놓친다. 그래서 기능 지표를 함께 본다. 밖에서 실제 응답을 받아 보는 것이 가장 짧다.
```bash label="[lab host] up 과 나란히 놓고 비교한다"
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
코드 한 칸을 `up` 의 1 과 0 옆에 놓는다. `up=1` 인데 이쪽이 200 이 아니면 그 조합이 곧 「살아 있지만 쓸모없는」 상태의 증거가 된다. 처음 보는 오류를 파고들 때는 값만 뽑는 형태를 버리고 헤더까지 읽는 형태로 바꾼다.
### 확인 ⑤ Grafana 를 볼 때
**무엇을 확인하는가** — 대시보드가 뜨는지. 밖에 열지 않고 포트포워드로 본다.
```bash label="[브라우저로 볼 기계] 보는 동안만 터널을 연다"
kubectl -n observability port-forward svc/grafana 3000:3000
```
**어디를 봐야 하는가** — `Forwarding from 127.0.0.1:3000 -> 3000` 한 줄이 찍히고 명령이 그대로 멈춰 있는가. 이 명령은 끝나지 않는 것이 정상이라 터미널 하나를 여기에 내준다. 브라우저를 열면 그 아래에 `Handling connection` 줄이 하나씩 붙는데, 그것이 안 붙으면 브라우저가 다른 곳을 보고 있다.
**이 결과가 의미하는 것** — 이 터널은 명령을 실행한 기계에서만 열린다. 워크스테이션에서 쳤으면 워크스테이션 브라우저로, lab host 에서 쳤으면 lab host 에서 봐야 한다. `bind: address already in use` 면 3000 을 이미 누가 쓰는 것이니 왼쪽만 바꾼다. Ctrl+C 로 끊으면 터널도 사라진다 — 밖에 포트를 여는 것이 아니라 보는 동안만 뚫는 것이라 실험대의 노출면이 늘지 않는다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| 파드 | `kubectl -n observability get pods` | 네 줄 · `node-exporter` 가 둘 |
| 대상 | `… /api/v1/targets \| grep -o '"job":"[^"]*"' \| sort -u` | `keycloak` 이 목록에 있다 |
| 상태 | `… /api/v1/targets \| tr ',' '\n' \| grep -E …` | `"health":"up"` 아닌 줄이 없다 |
| 클러스터 | `… query=vendor_cluster_size` | 원소 둘 · 값 둘 다 2 |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| Keycloak 지표가 안 보임 | 9000 이 안 열렸거나 스크레이프 설정 누락 | 확인 ① 의 targets |
| 값이 한 노드만 나옴 | 다른 노드 스크레이프 실패 | targets 의 `health` 필드 |
| 컨테이너 안에서 curl 실패 | Keycloak 이미지에 curl 이 없다 | 밖에서 Prometheus 로 묻는다 |
| Grafana 에 데이터 없음 | 데이터소스 주소 오류 | Prometheus 서비스 이름 확인 |
| `node-exporter` 가 한 줄 | 노드 하나가 빠졌다 | k3s 단계의 `kubectl get nodes` |
| `"result":[]` | 그런 지표가 없다 — 0 이 아니다 | 확인 ① 에 그 job 이 있는가 |
| `bind: address already in use` | 3000 을 이미 누가 쓴다 | `3001:3000` 처럼 왼쪽만 바꾼다 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) 파드 네 줄과 job 네 개, `vendor_cluster_size` 가 둘 다 2, 503 중에도 `up` 이 1 이었던 것.
- (observed) Redis 와 BFF 와 PostgreSQL 이 스크레이프 대상에 없다는 것. 안 찍은 것이 아니라 지표가 없는 것이다.
- (observed) `ClusterRole` 에서 `nodes/proxy` 를 빠뜨렸을 때 kubelet 타깃만 403 으로 실패하고 나머지 잡은 정상이었던 것. 그때의 화면은 남아 있지 않다(unknown).
- 관측 스택을 `kc-lab-1` 에 두고 `kc-lab-2` 를 장애 주입 대상으로 삼는 것은 이 실험대가 정한 규칙이다. `observability.yaml` 원문이 없어 `nodeSelector` 가 거기 어떻게 적혀 있는지는 대조하지 못했다(unknown).
- (unknown) `observability.yaml` 원문이 반입되지 않아 Prometheus 와 Grafana 와 node-exporter 의 이미지 태그, 스크레이프 주기, 보존 기간, Grafana 대시보드 구성은 대조하지 못했다. 위에 고정한 버전은 이 스택이 올라탄 k3s 와 게스트 OS 까지다.
- (unknown) `get pods -o wide` 와 targets 의 `health` 훑기, `query=up`, `port-forward` 의 출력은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다.
- (unknown) 원본 가이드 06 에 되돌리는 절차가 없다. DaemonSet 이 노드마다 남긴 것을 걷어내 본 적이 없다.
- (unknown) 이 단계에는 「이 실험대는 이렇게 했다」와 「따라 하는 사람은」 두 갈래가 없다. 06 은 파일을 직접 쓰지 않고 저장소의 매니페스트를 적용한다.
- (inferred) node-exporter 가 재는 것은 게스트 안에서 본 값이다. 호스트에서 같은 것을 재면 다른 수가 나올 수 있는데, 스크레이프 대상 넷이 전부 클러스터 안이라 이 실험대는 호스트 쪽 지표를 긁지 않는다.
- (unknown) 만드는 명령은 구축할 때 친 것을 옮긴 것이라 다시 쳐서 같은 상태가 되는지는 확인되지 않았다.
<!-- body:end -->
@@ -0,0 +1,235 @@
---
kind: SETUP
slug: tear-down-the-lab-and-know-what-survives
title: 실험대를 철거하고 무엇이 남는지 확인한다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
pinnedVersions:
- name: libvirt
version: 12.7.0
- name: QEMU
version: 11.1.1
- name: 커널
version: 7.2.2-arch1-1
- name: 호스트
version: Arch Linux · i5-1135G7 · RAM 11,648MiB
- name: 게스트
version: Debian 12 genericcloud
source:
- final/document.md#202-철거-실제-출력-전문
- final/document.md#204-재구축할-때-무엇이-남아-있나
- final/document.md#195-이-부의-출처와-범위
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
---
# 실험대를 철거하고 무엇이 남는지 확인한다
게스트 세 대와 DHCP 예약을 지우고 무엇이 남았는지까지 확인하는 절차다. 세우는 가이드에는 이 부분이 없어서 2026-09-10 에 직접 돌리며 명령과 출력을 적었다. 되돌리는 명령은 이 절차에 없다.
## 관계
- **cloud-init 시드로 게스트 세 대를 만들고 SSH 가 키로 붙게 한다**
여기서 지우는 것을 그 절차가 만들고, 다시 세울 때 돌아갈 곳도 거기다.
- **실험대를 껐다 켜고 게스트 메모리를 다시 나눈다**
워크로드를 정상으로 내리고 싶으면 그 편의 종료 순서를 먼저 돌고 온다. 여기서는 지우는 것이 목적이라 전원을 뽑는다.
- **5120MB 를 줬는데 353MB 를 쓰고 있었다 — 선언한 양과 실제로 드는 양**
회수된 디스크 3.1GB 를 읽는 방법이 그 기록과 같다. 선언한 크기와 실제로 차지한 크기는 다른 값이다.
- **이 실험대의 certbot 은 HTTP-01 로 받고 있는가 DNS-01 로 받고 있는가**
인증서를 지우지 않고 남기는 까닭이 이 물음이 안 닫혔기 때문이다. 답이 나오면 정책이 바뀐다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
철거는 그 물음을 재기 위한 전제라, 지우고 다시 세워 봐야 답이 나온다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
`virbr0``DOWN` 으로 보이는 것을 고장으로 읽지 않는 근거가 그 기준이다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
전부 `[lab host]` 다. 게스트를 통째로 지우는 절차라 게스트 안에서 칠 명령이 하나도 없다.
조회와 삭제뿐이라 편집기를 여는 곳도 없고 CLI 를 그대로 쓴다.
## 되돌리기가 없다
`virsh undefine --remove-all-storage` 는 도메인 정의와 오버레이 디스크와 시드 ISO 를 한 번에 지운다. 지운 뒤에 되살리는 명령이 libvirt 에 없다. 실험대를 다시 쓰려면 `base.qcow2` 위에 오버레이를 새로 만들고 시드 ISO 를 다시 구워야 하고, 그 절차는 게스트 세 대를 만드는 편이 받는다.
지우기 전에 알아 둘 것은 이 절차가 건드리지 않는 쪽이다. `base.qcow2``~/workspace/cloud/kc-lab-{1,2}.yaml``~/.ssh/config``kc-lab-*` 항목은 그 자체로 살아 있어서 재구축 때 다시 쓴다. 무엇이 어느 쪽인지는 아래 표가 아홉 행으로 가른다.
## 지우기 전에 먼저 본다
**무엇을 확인하는가** — 지금 무엇이 있는지. 철거 뒤와 견줄 값을 여기서 받아 둔다.
```bash label="[lab host] 도메인 · 볼륨 · 예약 · 디스크 사용량을 한 번에 본다"
virsh list --all
virsh vol-list default
virsh net-dumpxml default | grep -A5 dhcp
df -h /
```
**어디를 봐야 하는가** — 네 값이다. 도메인이 몇 대인가, 볼륨이 몇 개인가, `<host>` 예약이 몇 줄인가, 루트 파일시스템이 얼마나 찼는가. 2026-09-10 의 이 호스트에서는 도메인 3 대에 볼륨 7 개, 예약 3 줄, `df -h /` 가 11G 였다.
**이 결과가 의미하는 것** — 이 네 값이 철거의 성공 판정 기준이 된다. 여기를 건너뛰면 지운 뒤에 「원래 몇 개였지」를 되짚을 방법이 없다.
## 실행 절차
### 1. 게스트 세 대를 지운다
**목적** — 도메인 정의와 그 게스트가 쓰던 디스크를 함께 없앤다.
세 게스트의 전원을 뽑고 정의와 디스크를 지운 뒤, 무엇이 없어졌는지 바로 센다.
```bash label="[lab host] ① 게스트 셋을 전원부터 뽑고 정의와 디스크를 함께 지운다"
for v in kc-lab-edge kc-lab-2 kc-lab-1; do
virsh destroy "$v"
virsh undefine "$v" --remove-all-storage
done
```
```bash label="[lab host] ② 남은 도메인과 볼륨을 센다"
virsh list --all
virsh vol-list default
```
**예상 결과** — 게스트 한 대마다 네 줄이 나온다. 아래는 `kc-lab-edge` 한 대분이다(observed).
```text label="[lab host] ① 의 출력 — 한 대분"
Domain 'kc-lab-edge' destroyed
Domain 'kc-lab-edge' has been undefined
Volume 'vda'(/var/lib/libvirt/images/kc-lab-edge.qcow2) removed.
Volume 'vdb'(/var/lib/libvirt/images/seed-kc-lab-edge.iso) removed.
```
**왜 필요한가** — `Volume` 줄이 두 개 나오는지가 끝났다는 판정이다. `vda` 가 오버레이 디스크이고 `vdb` 가 시드 ISO 다. `--remove-all-storage` 를 빠뜨리면 도메인만 사라지고 디스크 파일은 지워지지 않아, 같은 이름으로 다음 `virt-install` 을 돌릴 때 「이미 있다」로 실패한다. 그리고 `virsh destroy` 는 종료 신호를 보내지 않고 전원을 뽑는다. 워크로드를 정상으로 내리고 싶으면 종료 순서를 다루는 편을 먼저 돌고 온다.
**문제가 생기면** — ② 의 `virsh vol-list default` 에 게스트 디스크나 시드 ISO 가 보이면 `--remove-all-storage` 가 빠진 것이다. 남은 볼륨은 `virsh vol-delete --pool default seed-kc-lab-1.iso` 처럼 이름을 하나씩 대서 지운다.
### 2. DHCP 예약 세 줄을 지운다
**목적** — libvirt `default` 네트워크에서 게스트 주소 예약을 빼고 동적 대역만 남긴다.
세 예약을 값 그대로 지운 뒤 네트워크 정의에 무엇이 남았는지 본다. 삭제할 때도 `mac` 과 `name` 과 `ip` 세 속성을 다 준다.
```bash label="[lab host] ① 예약 세 줄을 값 그대로 지운다"
virsh net-update default delete ip-dhcp-host \
"<host mac='52:54:00:aa:bb:10' name='kc-lab-edge' ip='192.168.122.10'/>" \
--live --config
virsh net-update default delete ip-dhcp-host \
"<host mac='52:54:00:aa:bb:11' name='kc-lab-1' ip='192.168.122.11'/>" \
--live --config
virsh net-update default delete ip-dhcp-host \
"<host mac='52:54:00:aa:bb:12' name='kc-lab-2' ip='192.168.122.12'/>" \
--live --config
```
```bash label="[lab host] ② 예약이 빠지고 동적 대역만 남았는지 본다"
virsh net-dumpxml default | grep -E "host mac|range start"
```
**예상 결과** — ① 은 삭제마다 `Updated network default persistent config and live state` 를 낸다. ② 에는 `<range>` 한 줄만 남는다.
```text label="[lab host] ② 의 출력 — 동적 대역만 남았다"
<dhcp>
<range start='192.168.122.2' end='192.168.122.254'/>
</dhcp>
```
**왜 필요한가** — 예약을 남겨 두면 다음 구축에서 예약이 두 벌이 되거나, 넣으려 할 때 이미 있다고 거부당한다. `--live` 와 `--config` 를 둘 다 주는 까닭도 넣을 때와 같다. `--live` 만 주면 재부팅에 예약이 되살아나고 `--config` 만 주면 지금 돌고 있는 dnsmasq 가 아직 예약을 들고 있다.
**문제가 생기면** — 속성이 하나라도 비면 이렇게 거부된다.
```text label="[lab host] 이름이 빈 채로 삭제를 시도했을 때"
error: Failed to update network default
error: XML error: Cannot use host name '' in network 'default'
```
zsh 에서 루프로 돌리면 이 오류를 만난다. zsh 는 따옴표 없는 변수를 단어 분리하지 않아서, bash 에서 되던 `set -- $entry` 가 `$1` 에 문자열 전체를 넣고 `$2` 와 `$3` 을 비워서 `name=""` 이 된다. 세 줄을 값 그대로 쓰는 편이 안전하다.
## 인증서는 건드리지 않는다
`/etc/letsencrypt/` 는 정책으로 남긴다. 한도 때문이 아니다 — Let's Encrypt 의 「같은 이름 조합에 주당 중복 5장」 제한은 가끔 재구축하는 정도로는 근처에도 못 간다.
남기는 까닭은 지금 재발급이 되는지를 모르기 때문이다. 이 실험대의 이름 셋은 tailnet 주소를 가리키고, `100.64.0.0/10` 은 CGNAT(Carrier-Grade NAT, 통신사 공용 주소 변환)용 예약 대역이라 공개 인터넷에서 라우팅되지 않는다. HTTP-01 검증은 Let's Encrypt 가 우리 서버로 들어오는 방식이므로 그 주소로는 검증이 성립하지 않는다. 지금 설정이 DNS-01 이면 지우고 다시 받으면 끝이고, HTTP-01 이면 검증 방식부터 손봐야 한다. 어느 쪽인지는 certbot 설정을 읽는 열린 물음이 한 줄로 닫는다.
§204 는 HTTP-01 인 경우를 「못 한다」가 아니라 「일이 하나 생긴다」로 적었다. 최악이라도 A 레코드를 공인 IP 로 잠깐 돌려 받거나 DNS-01 로 전환하면 되고, 다만 재구축을 시작하자마자 그 일부터 하게 된다. 그래서 이 정책은 「어느 쪽인지 모르는 채로는 지우지 않는다」가 전부다.
그래도 지워야 한다면 먼저 백업한다.
```bash label="[lab host] 인증서를 통째로 묶어 둔다"
sudo tar czf ~/letsencrypt-backup-$(date +%Y%m%d-%H%M%S).tgz -C /etc letsencrypt
```
복원은 반대로 한 줄이다. `{{STAMP}}` 는 위 명령이 만든 파일 이름에 찍힌 시각을 그대로 옮겨 넣는다.
```bash label="[lab host] 묶어 둔 인증서를 되돌린다"
sudo tar xzf ~/letsencrypt-backup-{{STAMP}}.tgz -C /etc
```
## 구성 값
지우는 대상은 게스트 셋과 그 셋의 DHCP 예약이다.
| 게스트 | 예약의 MAC · IP | 볼륨 둘 |
|---|---|---|
| `kc-lab-edge` | `52:54:00:aa:bb:10` · 192.168.122.10 | `kc-lab-edge.qcow2` · `seed-kc-lab-edge.iso` |
| `kc-lab-1` | `52:54:00:aa:bb:11` · 192.168.122.11 | `kc-lab-1.qcow2` · `seed-kc-lab-1.iso` |
| `kc-lab-2` | `52:54:00:aa:bb:12` · 192.168.122.12 | `kc-lab-2.qcow2` · `seed-kc-lab-2.iso` |
지우는 순서는 `kc-lab-edge` · `kc-lab-2` · `kc-lab-1` 이다. 1번의 `for` 목록이 그 순서로 적혀 있다.
## 확인 방법
**무엇을 확인하는가** — 철거 전에 받아 둔 네 값이 전부 내려갔는지.
```bash label="[lab host] 철거 뒤 같은 네 값을 다시 본다"
virsh list --all
virsh vol-list default
virsh net-dumpxml default | grep -A5 dhcp
df -h /
ip -br addr show virbr0
```
**어디를 봐야 하는가** — 다섯 줄을 전후로 견준다(observed).
| 무엇을 보는가 | 철거 전 | 철거 후 |
|---|---|---|
| `virsh list --all` | 3 대 running | (없음) |
| `virsh vol-list default` | 7 개 | `base.qcow2` 1 개 |
| DHCP 예약 | 3 줄 | 0 줄 |
| `df -h /` | 11G | 7.9G |
| `virbr0` | UP | DOWN |
**이 결과가 의미하는 것** — 디스크에서 3.1GB 가 회수됐다. 잰 내역은 `kc-lab-1` 1.4GB 와 `kc-lab-2` 665MB 와 시드 ISO 세 개(각 370KB)이고, `kc-lab-edge` 의 디스크 크기는 재 두지 않았다. 합계에서 빼면 1GB 안팎인데 그것은 잰 값이 아니라 역산한 값이다.
`virbr0` 가 `DOWN` 인 것은 고장이 아니다. 브리지에 붙은 tap 인터페이스가 하나도 없어서 캐리어가 없는 것으로 표시될 뿐이고, 주소 `192.168.122.1/24` 는 그대로 있다. VM 을 다시 띄우면 그 VM 의 `vnetN` 인터페이스가 브리지에 붙으면서 `UP` 이 된다. `virsh net-start` 를 찾아 헤매지 않는다.
## 철거해도 남는 것과 사라지는 것
이걸 모르면 재구축이 「왜 이건 이미 있지」와 「왜 이건 없지」의 반복이 된다.
| 무엇 | 어떻게 되나 | 왜 |
|---|---|---|
| `base.qcow2` (335MB) | 남는다 | 다음 오버레이의 바닥 |
| 패키지 (libvirt · qemu · nginx · certbot · kubectl) | 남는다 | 재설치가 무의미 |
| `~/workspace/cloud/kc-lab-{1,2}.yaml` | 남는다 | 키와 비밀번호가 들어 있다 |
| `~/.ssh/config` 의 `kc-lab-*` 항목 | 남는다 | 재구축해도 IP 가 같다 |
| libvirt `default` 네트워크 정의 | 남는다 | 예약만 지웠다 |
| `/etc/letsencrypt/` | 남긴다 (정책) | 재발급이 되는지를 아직 모른다 |
| 게스트 디스크 · 시드 ISO | 사라진다 | `--remove-all-storage` |
| DHCP 예약 | 사라진다 | `net-update delete` |
| k3s · Keycloak · 모든 워크로드 | 사라진다 | 게스트와 함께 |
`~/workspace/cloud/kc-lab-{1,2}.yaml` 이 남는다는 것은 편한 일이면서 위험한 일이다. 그 파일에 SSH 공개키와 콘솔 로그인용 비밀번호가 들어 있으므로, 실험대를 접고 기계를 넘길 때는 이 절차만으로 끝났다고 보지 않는다.
## 이 절차가 감당하지 않는 것
호스트 계층 철거는 여기 없다. 원본이 `deploy/lab/host/teardown-host.sh` 를 가리키지만 그 스크립트가 반입되지 않아 내용을 모른다. nginx 설정과 DNAT 유닛과 certbot 훅을 어디까지 걷어내는지는 확인하지 못했다.
다시 세우는 쪽도 여기 없다. 게스트를 만드는 편부터 일곱 편이 그 순서를 받는다.
이 명령들은 2026-09-10 에 이 호스트에서 실제로 돌려 받은 출력이고, 그 뒤에 다시 돌려 검증하지는 않았다. 다시 돌리면 실험대가 없어지기 때문이다.
<!-- body:end -->
@@ -0,0 +1,574 @@
---
id: 975a6d61-4e34-4034-a0d2-01fea3b498a3
kind: SETUP
slug: wildcard-certificate-with-dns-01-and-a-deploy-hook
title: DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다
topic: lab-environment-build
topicName: 실험대 환경 구성
project: virtualization
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/975a6d61-4e34-4034-a0d2-01fea3b498a3/edit"
pinnedVersions:
- name: nginx (엣지 게스트)
version: 1.22.1
- name: nginx (물리 호스트)
version: 1.30.4
- name: Debian GNU/Linux
version: 12 (bookworm)
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
- final/document.md#184-이-부의-출처와-범위
---
# DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다
엣지 게스트에 certbot 과 Cloudflare 플러그인을 깔고 DNS-01 로 와일드카드 인증서를 받아 nginx 에 443 을 얹는 절차다. 갱신이 서빙하는 인증서까지 닿게 하는 deploy 훅도 넣는다. 물리 호스트에는 아무것도 두지 않는다.
## 관계
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
왜 이 방식이어야 했는지와 토큰이 평문으로 놓이는 비용을 그 결정이 갖고, 여기는 그것을 실행하는 명령만 갖는다.
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
훅이 없을 때 갱신과 서빙이 얼마나 벌어지는지를 그 기록이 쟀다. 여기서 훅을 넣는 다섯째 단계가 그 측정에서 나왔다.
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
이 절차가 고쳐 쓰는 nginx 파일과 80 서버 블록을 그 단계가 먼저 세운다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
갱신 로그의 `SUCCESS` 도 훅 로그의 `error output` 도 서버가 지금 무엇을 내보내는지를 말하지 않는다. 그래서 판정을 워커 PID 와 체인 단계 수로 한다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
확인 명령을 엣지 안에서 치면 층의 답이 아니라 친 곳의 답이 돌아온다. 어느 기계에서 치는지가 이 단계의 판정을 가른다.
- **Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다**
다음 단계다. 여기서 잰 `404 tls=0` 이 거기서 `200` 으로 바뀌는 것이 그 단계가 만든 변화다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
이 단계도 셸이 갈린다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 1 | certbot 설치 | `[kc-lab-edge]` |
| 2 | Cloudflare 토큰 발급 | 브라우저 |
| 3 | 토큰 파일 · 토큰 검증 | `[kc-lab-edge]` |
| 4 | 시험 발급 · 실제 발급 · 확인 | `[kc-lab-edge]` |
| 5 | nginx 에 443 을 얹는다 | `[kc-lab-edge]` |
| 6 | 갱신이 서빙까지 닿게 한다 | `[kc-lab-edge]` |
| 판정 | 열리는가 · 체인이 완전한가 | **tailnet 에 붙은 다른 머신** — 여기만 엣지가 아니다 |
**판정만 엣지에서 치지 않는 까닭**을 가이드가 한 줄로 적었다 — 엣지에는 Tailscale 이 없어 자기 자신을 밖에서 들어오는 경로로 부를 수 없다. 엣지 안에서 치면 이렇게 막힌다.
```text
* connect to 100.83.212.4 port 443 failed: Connection refused
```
`auth.hyeonworks.com` 은 호스트의 tailnet 주소로 풀리는데 엣지 게스트에는 Tailscale 이 없다. 엣지에서 나간 패킷은 호스트의 `virbr0` 으로 들어가고 호스트의 DNAT 규칙은 `iifname "tailscale0"` 만 매칭하므로 그 패킷은 규칙에 안 걸리고, 호스트 443 에 리스너가 없어 거절된다. 설정이 틀린 것이 아니라 친 곳이 틀렸다. 가이드는 그 기계를 「tailnet 에 붙은 다른 머신」이라고만 부르고 다섯 셸 이름 중 어느 것인지 짚지 않았다(unknown).
편집기로 여는 파일은 셋이다 — Cloudflare 자격증명, 443 블록을 넣은 nginx 설정, deploy 훅 스크립트. 설치와 발급과 갱신은 certbot 명령을 그대로 쓴다. 비밀은 값을 화면에 찍지 않는다. 토큰이 제대로 들어갔는지는 파일 권한과 크기로 보고, 값이 맞는지는 Cloudflare 에 물어 응답의 상태 문자열로 가른다.
## 이 단계가 세우는 것
가이드 04 의 「이 단계가 끝나면」은 한 줄이다.
> `https://` 가 열리고 체인이 완전하며, 갱신이 자동으로 **서빙까지** 닿는다.
세 마디가 각각 다른 확인을 요구한다. 「열린다」는 `curl -v`, 「체인이 완전하다」는 `openssl s_client`, 「서빙까지 닿는다」는 워커 PID 로 판정한다. 앞의 둘까지만 보고 끝내는 문서가 많고, 이 실험대가 재 둔 결함은 정확히 세 번째에 있다.
| 무엇 | 값 |
|---|---|
| 패키지 | `certbot` · `python3-certbot-dns-cloudflare` |
| 자격증명 | `/etc/letsencrypt/cloudflare.ini``600`, root 만 읽기 |
| 발급 대상 | `-d hyeonworks.com -d '*.hyeonworks.com'` |
| lineage 디렉터리 | `/etc/letsencrypt/live/hyeonworks.com/` — 첫 번째 `-d` 에서 따온 라벨 |
| nginx 가 읽는 것 | `fullchain.pem` · `privkey.pem` |
| 유효기간 | 오늘 + 90일 |
| deploy 훅 | `/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh` (`chmod +x`) |
| 타이머 | `certbot-renew.timer``certbot-renew.service` |
**제약이 검증 방식을 정했다**(observed). 같은 Let's Encrypt 인증서인데 「이 도메인이 네 것이냐」를 증명하는 방법만 다르고, 이 실험대에는 선택의 여지가 없다.
| 무엇 | HTTP-01 | DNS-01 |
|---|---|---|
| 검증 방향 | Let's Encrypt 가 우리 서버로 (인바운드) | certbot 이 DNS 공급자 API 로 (아웃바운드) |
| 공개 인터넷에서 보여야 하나 | 그렇다 | 아니다 |
| 와일드카드 | 불가 | 가능 |
| 필요한 것 | 80 포트 · 공개 A 레코드 | DNS 공급자 API 토큰 |
가이드는 이 선택을 우열로 적지 않는다. 공개 서버라면 HTTP-01 이 맞고, 토큰도 DNS 연동도 없어 관리할 것이 적다.
아래 절차는 DNS-01 로 받는 형태다. 다만 **지금 돌고 있는 이 실험대의 certbot 이 어느 쪽으로 등록되어 있는지는 아직 읽지 못했다**(unknown) — 호스트의 `sudo` 가 비밀번호를 요구해 `/etc/letsencrypt/renewal/*.conf``authenticator` 를 비대화식으로 읽을 수 없었다. 이 실험대의 문서 두 개도 이 대목에서 서로 어긋나, 한쪽은 DNS-01 로 결론냈고 다른 한쪽은 같은 04 단계를 `certbot certonly --webroot` 로 적어 두었다. 어느 쪽이 실제로 등록되어 있는지는 그 한 줄을 읽어야 갈린다.
## 전제와 되돌리기
전제는 한 줄이다 — 앞 단계가 끝나 엣지 nginx 가 Traefik 으로 프록시한다.
**되돌리는 절차는 원본 가이드 04 에 없다**(unknown). 되돌리기에 가장 가깝게 적힌 문장은 배치의 까닭 한 줄뿐이다.
> 인증서·certbot·갱신 타이머·deploy 훅이 전부 엣지 게스트에 산다. 물리 호스트에는 아무것도 두지 않는다 —
> 그래야 `virsh undefine kc-lab-edge` 한 줄로 이 계층을 통째로 되돌릴 수 있다.
이 문장은 「게스트를 통째로 지우면 같이 없어진다」이고 걷어내는 절차가 아니다. 순서도, 사전 조건도, 지운 뒤 무엇을 확인하는지도 없고 이 실험대가 그렇게 지워 본 기록도 없다. 이 단계가 엣지에 남기는 것은 넷이다.
| 남는 것 | 어디에 |
|---|---|
| 패키지 둘 | 게스트 패키지 데이터베이스 |
| `cloudflare.ini` · 인증서 묶음 · deploy 훅 | `/etc/letsencrypt/` 아래 |
| `certbot-renew.timer` 활성화 | systemd |
| 443 블록 | `/etc/nginx/sites-available/keycloak-lab` |
**Cloudflare 쪽 토큰은 엣지를 지워도 계정에 남는다**(external). 토큰은 그쪽 서버가 들고 있고 엣지에 있던 것은 사본이다. 가이드는 그 폐기를 적지 않았다(unknown).
## 세우기 전에 먼저 본다
두 확인은 아무것도 바꾸지 않는다. 하나는 검증 방식을 정하고, 하나는 5번에서 쓸 설정 문법을 가른다.
여기부터 6번까지가 엣지 게스트 셸이다. lab host 에서 들어간다.
```bash label="[lab host] 엣지 게스트에 들어간다"
ssh kc-lab-edge
```
### 확인 ① 이 도메인이 무엇으로 풀리는가
```bash label="[kc-lab-edge] 도메인이 어느 주소로 풀리는지 본다"
dig +short auth.hyeonworks.com
```
**실측**(observed)
```text
100.83.212.4
```
**어디를 봐야 하는가** — 주소 한 줄. 그 주소가 공개 인터넷 대역인가, `100.64.0.0/10` 안인가.
**이 결과가 의미하는 것** — `100.64.0.0/10` 은 CGNAT 용으로 예약된 대역이라 공개 인터넷에서 라우팅 자체가 되지 않는다(external). 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지 않고, Let's Encrypt 를 tailnet 에 초대할 방법도 없다. 그래서 HTTP-01 은 쓸 수 없고 DNS-01 을 쓴다. 여기서 공개 주소가 나오는 환경이라면 위 표의 왼쪽을 고르는 편이 낫다.
### 확인 ② nginx 판 번호는 몇인가
```bash label="[kc-lab-edge] 이 게스트의 nginx 판 번호"
nginx -v
```
**실측**(observed) — 같은 설정인데 배포판에서 갈린다.
```text
엣지 (Debian 12): nginx version: nginx/1.22.1
물리 호스트 (Arch): nginx version: nginx/1.30.4
```
**어디를 봐야 하는가** — `1.25.1` 이 경계다. 지금 셸의 번호가 그 위인가 아래인가.
**이 결과가 의미하는 것** — 1.25.1 미만이면 `http2 on;` 지시어가 없다. 5번의 설정처럼 `listen` 의 파라미터로 쓰면 1.22 와 1.30 양쪽에서 다 돈다. 지시어 형태로 쓰면 이렇게 막힌다.
```text
[emerg] 1287#1287: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:14
nginx: configuration file /etc/nginx/nginx.conf test failed
```
## 실행 절차
### 1. certbot 과 Cloudflare 플러그인을 깐다
**목적** — DNS-01 검증을 할 수 있는 상태로 만든다.
```bash label="[kc-lab-edge] ① 두 패키지를 깐다"
sudo apt install -y certbot python3-certbot-dns-cloudflare
```
```bash label="[kc-lab-edge] ② 쓸 수 있는 검증 방식이 무엇인지 본다"
certbot plugins 2>/dev/null | grep -E '^\*'
```
**예상 결과**(observed)
```text
* dns-cloudflare
* standalone
* webroot
```
`^\*` 로 거른 것은 certbot 이 쓸 수 있다고 표시한 플러그인 앞에 별표를 붙이기 때문이다.
**왜 필요한가** — `dns-cloudflare` 가 이 목록에 없으면 4번의 발급이 `unrecognized arguments: --dns-cloudflare` 로 끝난다. 플러그인은 `certbot` 본체와 별개 패키지라 한쪽만 깔려 있어도 `certbot` 명령 자체는 돈다.
**문제가 생기면** — 목록에 없으면 ① 의 뒤엣것이 깔렸는지 본다. cloud-init 이 이미 깔았다면 이 단계를 건너뛴다.
### 2. Cloudflare 토큰을 발급받는다
**목적** — certbot 이 인증용 TXT 레코드를 직접 만들었다 지운다. 그래서 DNS 쓰기 권한이 필요하다.
브라우저에서 다섯 단계다.
1. `https://dash.cloudflare.com/profile/api-tokens` 에서 **Create Token**
2. **`Edit zone DNS`** 템플릿을 고르고 **Use template**
3. **Permissions** 는 `Zone` · `DNS` · `Edit` — 템플릿이 채워 준 그대로
4. **Zone Resources** 는 `Include` · `Specific zone` · **`hyeonworks.com`**
5. **Continue to summary** 다음 **Create Token**
**예상 결과** — 토큰 값이 화면에 한 번 나온다. 창을 닫으면 복구가 없으므로 바로 3번으로 넘어간다.
**왜 필요한가** — `All zones` 로 두면 계정의 모든 도메인에 대한 DNS 수정 권한이 엣지 게스트 안 평문 파일에 놓인다. Global API Key 는 더 나쁘다 — 계정 전체 만능 키라 폐기하면 그 키를 쓰던 다른 것들이 전부 같이 죽는다. 권한을 어디까지 좁히고 무엇을 감수했는지는 이 절차를 부른 결정이 갖고 있다.
**문제가 생기면** — 값을 놓쳤으면 다시 발급받는다. 이 문서에도 터미널에도 값을 적지 않는다.
### 3. 자격증명 파일을 만들고 권한부터 좁힌다
**목적** — certbot 이 읽을 자격증명을 root 만 읽을 수 있는 파일에 둔다.
```bash label="[kc-lab-edge] ① 빈 파일을 600 으로 먼저 만든다"
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
```
```bash label="[kc-lab-edge] ② 그다음에 토큰을 쓴다"
sudo nano /etc/letsencrypt/cloudflare.ini
```
③ 아래의 `{{CLOUDFLARE_API_TOKEN}}` 을 2번에서 화면에 한 번 나온 토큰 값으로 바꿔 쓴다. 발급받을 때마다 값이 달라서 여기 적을 수 없다. 저장은 `Ctrl+O` 다음 `Enter`, 나가기는 `Ctrl+X`.
```ini label="③ cloudflare.ini 에 쓸 내용"
# file: /etc/letsencrypt/cloudflare.ini
dns_cloudflare_api_token = {{CLOUDFLARE_API_TOKEN}}
```
```bash label="[kc-lab-edge] ④ 권한과 크기만 본다"
ls -l /etc/letsencrypt/cloudflare.ini
sudo wc -c /etc/letsencrypt/cloudflare.ini
```
```bash label="[kc-lab-edge] ⑤ 토큰이 살아 있고 권한 범위가 맞는지 묻는다"
CF=$(sudo awk -F'= *' '/api_token/{print $2}' /etc/letsencrypt/cloudflare.ini)
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify -H "Authorization: Bearer $CF"
```
**예상 결과** — ④ 는 `-rw-------` 이고 바이트 수가 0 이 아니다. `sudo` 없이 `wc` 를 치면 `Permission denied` 가 나는데 그것이 정상이다. ⑤ 의 응답에는 `"status":"active"` 와 `"success":true` 가 들어 있다. 값 자체는 셸 변수에만 담기고 화면에 찍히지 않는다.
**왜 필요한가** — 두 명령의 순서가 그 자체로 보안 조치다. `install -m 600 /dev/null` 은 빈 파일을 `600` 으로 만드는 명령이고, 토큰을 먼저 쓰고 나서 권한을 고치면 그사이에 파일이 열려 있다. ⑤ 를 여기서 해 두면 뒤에서 실패했을 때 DNS 문제인지 토큰 문제인지를 헷갈리지 않는다.
**문제가 생기면** — 권한이 `-rw-r--r--` 면 같은 게스트의 다른 사용자도 토큰을 읽는다. 바이트 수가 0 이면 편집기에서 저장을 안 했거나 다른 경로에 썼다. ⑤ 의 응답에 `"code":6003` 이 보이면 토큰 값이 틀렸거나 잘렸고, `"code":9109` 면 권한 범위가 모자라다.
### 4. dry-run 을 먼저 돌리고 그다음에 발급한다
**목적** — 와일드카드를 포함한 인증서를 받는다. 실패를 시험용 한도에서 먼저 만난다.
```bash label="[kc-lab-edge] ① 먼저 시험한다"
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d hyeonworks.com -d '*.hyeonworks.com' --dry-run
```
```bash label="[kc-lab-edge] ② 실제로 받는다"
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d hyeonworks.com -d '*.hyeonworks.com'
```
```bash label="[kc-lab-edge] ③ 무엇을 받았는지 본다"
sudo certbot certificates
```
```bash label="[kc-lab-edge] ④ 인증서가 실제로 어떤 이름에 유효한지 본다"
sudo openssl x509 -noout -ext subjectAltName -in /etc/letsencrypt/live/hyeonworks.com/fullchain.pem
```
**예상 결과** — ① 의 마지막 줄이 `The dry run was successful.` 이고, ② 는 `Successfully received certificate.` 와 저장 경로를 찍는다. ③ 에서 볼 것은 네 줄이다.
| 줄 | 값 |
|---|---|
| `Domains:` | `hyeonworks.com *.hyeonworks.com` — 한 줄에 둘 다 |
| `Expiry Date:` | 오늘 + 90일, `VALID` |
| `Certificate Path:` | `/etc/letsencrypt/live/hyeonworks.com/fullchain.pem` |
| `Private Key Path:` | `/etc/letsencrypt/live/hyeonworks.com/privkey.pem` |
④ 는 `DNS:hyeonworks.com, DNS:*.hyeonworks.com` 두 개를 내놓고 `auth.hyeonworks.com` 은 두 번째에 걸린다.
**왜 필요한가** — dry-run 을 먼저 도는 것은 Let's Encrypt 의 주당 중복 인증서 5장 한도를 dry-run 이 쓰지 않기 때문이다(external). dry-run 은 스테이징 서버에 대고 시험만 하므로 `/etc/letsencrypt/live/` 에는 아무것도 안 생기고, 그 직후 `certbot certificates` 가 `No certificates found` 를 내는 것이 정상이다. `-d` 를 둘 주는 것은 와일드카드가 한 단계만 덮기 때문이고, `a.b.hyeonworks.com` 도 apex 인 `hyeonworks.com` 자신도 `*.hyeonworks.com` 에 들어가지 않는다.
디렉터리 이름과 인증서가 덮는 이름은 별개다(observed). 여기서 가장 많이 헷갈린다.
| 무엇 | 정해지는 방식 |
|---|---|
| 디렉터리 이름 `live/hyeonworks.com/` | certbot 이 이 묶음을 관리하려고 붙인 라벨. 첫 번째 `-d` 에서 따오고 서빙과 무관하다 |
| SAN 목록 | 브라우저가 보는 실제 유효 호스트명. `-d` 로 준 이름 전부 |
**문제가 생기면** — 최초 실행이면 계정 등록 대화가 먼저 뜬다. 이메일을 비우면 `Invalid email address: .` 로 되묻고, 약관은 `Y`, 뉴스레터는 발급과 무관하므로 `N` 이다. 프롬프트 없이 돌리려면 `-m <이메일> --agree-tos --no-eff-email` 을 붙인다. `--register-unsafely-without-email` 은 쓰지 않는다 — 갱신 실패를 알려 줄 통로가 사라지는데, 6번이 재는 것이 바로 그 갱신이다. DNS-01 은 TXT 레코드가 퍼질 때까지 기다리느라 수십 초 걸리므로 중간에 끊지 않는다.
### 5. nginx 에 443 을 얹는다
**목적** — 앞 단계에서는 80 만 세웠다. 인증서가 생겼으니 443 블록을 더하고 80 은 리다이렉트로 바꾼다.
```bash label="[kc-lab-edge] ① 앞 단계에서 쓴 그 파일을 연다"
sudo nano /etc/nginx/sites-available/keycloak-lab
```
```nginx label="② keycloak-lab 을 이 내용으로 바꾼다"
# file: /etc/nginx/sites-available/keycloak-lab
upstream k3s_traefik {
server 192.168.122.11:80;
server 192.168.122.12:80;
}
server {
listen 80 default_server;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2 default_server;
server_name _;
ssl_certificate /etc/letsencrypt/live/hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/hyeonworks.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://k3s_traefik;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
```bash label="[kc-lab-edge] ③ 앞 단계에서 쓴 그 두 줄로 다시 읽힌다"
sudo nginx -t && sudo systemctl reload nginx
```
**예상 결과** — `syntax is ok` 와 `test is successful` 두 마디가 나오고 reload 가 돈다. 앞에 붙는 `types_hash` 경고는 통과를 막지 않는다.
**왜 필요한가** — 앞 단계의 파일에서 바뀐 곳이 셋이다.
| 줄 | 앞 단계에서는 | 지금 |
|---|---|---|
| 80 블록 | `location / { proxy_pass … }` | 리다이렉트만 |
| 443 블록 | 없었다 | 인증서와 함께 새로 |
| `X-Forwarded-Proto` | `http` | `https` |
`ssl_certificate` 에 적는 것은 `cert.pem` 이 아니라 `fullchain.pem` 이다. 서버 인증서만 보내면 중간 인증서가 빠져 체인이 끊기는데, 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이고 캐시가 없는 클라이언트에서만 깨지므로 발견이 늦다. 4번 ③ 에서 certbot 이 찍어 준 경로와 한 글자도 다르면 안 된다.
**문제가 생기면** — `cannot load certificate` 로 막히면 경로를 본다. `live/auth.hyeonworks.com/` 이라고 적으면 그 디렉터리가 없다. 확인 ② 에서 본 판 번호가 1.25.1 미만인데 `http2` 를 지시어로 썼다면 여기서 `unknown directive "http2"` 가 나온다.
### 6. 갱신이 서빙까지 닿게 한다
**목적** — 타이머가 도는 것만으로는 부족하다. 갱신된 인증서를 누가 읽게 만드는 일까지 세운다. 여기가 이 단계의 알맹이다.
```bash label="[kc-lab-edge] ① 타이머가 있는지 본다"
systemctl list-timers certbot-renew.timer
```
```bash label="[kc-lab-edge] ② 표가 비어 나오면 이름이 다른지 찾는다"
systemctl list-timers --all | grep -i certbot
```
```bash label="[kc-lab-edge] ③ 배포판 기본 유닛이 reload 를 부르는지 본다"
systemctl cat certbot-renew.service
```
```text
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
```
```bash label="[kc-lab-edge] ④ 훅 스크립트를 연다"
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
```sh label="⑤ reload-nginx.sh 에 쓸 내용"
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
```
```bash label="[kc-lab-edge] ⑥ 실행 권한을 준다"
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
```bash label="[kc-lab-edge] ⑦ 실행 파일이 됐는지 본다"
ls -l /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
**예상 결과** — ① 은 `NEXT` 와 `LEFT` 가 채워져 있고 `ACTIVATES` 가 `certbot-renew.service` 를 가리킨다. ③ 에는 `ExecStartPost` 도 `--deploy-hook` 도 없고, 여기 없는 것을 보는 것이 그 명령의 목적이다. ⑦ 의 권한 문자열에는 `x` 가 세 번 보인다.
**왜 필요한가** — 타이머가 `active` 여도 갱신된 인증서가 서빙되지는 않는다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 `live/` 심볼릭 링크만 갈아 끼우므로, 설정에 적힌 경로는 그대로이고 그 경로가 가리키는 파일만 바뀌어 nginx 에는 다시 읽을 계기가 생기지 않는다. 배포판 기본 유닛은 인증서를 새로 받는 데까지만 책임지고 받은 것을 누가 읽게 만드는 일은 아무도 하지 않는다. 훅이 그 둘을 잇는다. 훅을 `post/` 가 아니라 `deploy/` 에 넣는 것은 `post/` 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이고, `deploy/` 는 실제로 갱신됐을 때만 실행된다. 실행 권한이 없으면 certbot 이 이 훅을 조용히 건너뛴다.
**문제가 생기면** — ③ 에 무엇인가 적혀 있는 배포판이라면 이 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만 확인한다. 갱신은 됐는데 옛 인증서가 나가면 ⑦ 부터 다시 본다.
## 구성 값
이 훅이 있고 없고의 차이를 이 실험대가 쟀다(observed).
| 무엇 | 훅 없음 | 훅 있음 |
|---|---|---|
| 갱신에서 서빙까지 | 2305초 (38분 25초) | 1~2초 |
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — |
88일 동안 이 결함이 보이지 않는다. 타이머는 정상이고 매번 `SUCCESS` 로 끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있다.
reload 자체는 무중단이었다(observed). 새 연결 8856건 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었으며, 845KB 를 받는 중이던 요청이 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다. 옛 워커가 그 요청을 끝까지 책임진다.
## 끝났는지 판정한다
세 확인은 tailnet 에 붙은 다른 머신에서 친다. 1번부터 6번까지는 전부 엣지에서 쳤지만 판정은 밖에서 들어와야 의미가 있다.
### 확인 ① 열리나 — 처음 한 번은 협상 과정을 읽는다
**무엇을 확인하는가** — TLS 가 붙었는지. 응답 코드가 아니다.
```bash label="[워크스테이션] 협상 과정을 그대로 읽는다"
curl -v https://auth.hyeonworks.com/ -o /dev/null
```
경로는 `/` 다. Keycloak 은 다음 단계에서 올리므로 아직 Ingress 가 없고 `404` 가 정상이다. `/realms/master` 같은 Keycloak 경로를 여기서 쓰면 TLS 가 안 된 건지 Keycloak 이 없는 건지가 섞인다.
읽어야 할 줄만 옮긴다. 전체 출력은 이 실험대에서 캡처해 두지 않았다(unknown).
```text
* SSL connection using TLSv1.3 / ...
* subject: CN=hyeonworks.com
* issuer: C=US; O=Let's Encrypt; CN=...
* SSL certificate verify ok.
< HTTP/1.1 404 Not Found
```
**어디를 봐야 하는가** — 별표로 시작하는 줄 넷이다. 어떤 TLS 판으로 협상했는가, `subject` 의 CN 이 무엇인가, `issuer` 가 Let's Encrypt 인가, 그리고 `SSL certificate verify ok.` 가 있는가. 그 아래 `<` 로 시작하는 첫 줄이 응답 상태다. `subject` 가 `hyeonworks.com` 인 것이 맞다 — 와일드카드 인증서라 CN 은 apex 이름이고 `auth.hyeonworks.com` 은 SAN 의 `*.hyeonworks.com` 에 걸린다.
**이 결과가 의미하는 것** — 네 줄이 다 나오면 인증서가 붙었고 체인이 클라이언트 기준으로 검증됐다. 인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을 본다. `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면 중간 인증서가 빠진 것이고, 원인은 5번의 인증서 파일 이름이라 확인 ② 로 간다.
같은 것을 반복해서 재거나 앞뒤 단계의 값과 나란히 비교할 때만 값만 뽑는 형태로 줄인다.
```bash label="[워크스테이션] 코드와 검증 결과만 뽑는다"
curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/
```
**실측**(observed) — 2026-09-11, tailnet 클라이언트에서
```text
404 tls=0
```
`tls=0` 이 인증서 검증 통과다. 앞의 `404` 는 다음 단계 이후에 `200` 으로 바뀐다.
### 확인 ② 체인 단계와 검증
**무엇을 확인하는가** — 서버가 중간 인증서까지 보내는지.
```bash label="[워크스테이션] 체인 단계를 센다"
echo | openssl s_client -connect auth.hyeonworks.com:443 \
-servername auth.hyeonworks.com 2>/dev/null \
| grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
```
**실측**(observed) — 이름을 따로 받던 시절의 기록이라 0번 `s:` 가 `auth.hyeonworks.com` 이다. 와일드카드로 받으면 `CN = hyeonworks.com` 이 되고, 봐야 할 구조는 똑같다.
```text
0 s:CN = auth.hyeonworks.com
i:C = US, O = Let's Encrypt, CN = YE2
1 s:C = US, O = Let's Encrypt, CN = YE2
i:C = US, O = ISRG, CN = Root YE
2 s:C = US, O = ISRG, CN = Root YE
i:C = US, O = Internet Security Research Group, CN = ISRG Root X2
3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
Verify return code: 0 (ok)
```
**어디를 봐야 하는가** — 왼쪽의 번호가 몇까지 가는가, 그리고 각 단계의 발급자 줄이 다음 단계의 주체 줄과 같은가. 마지막이 `Verify return code: 0 (ok)` 인가.
**이 결과가 의미하는 것** — 단계가 1개면 `cert.pem` 을 쓴 것이다. 서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태인데, 이때 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이므로 이 명령이 유일하게 믿을 수 있는 판정이 된다. 고치는 곳은 5번의 `ssl_certificate` 한 줄이고, 고친 뒤 문법 검사와 reload 를 하고 여기서 다시 잰다. `Verify return code` 가 0 이 아니면 숫자마다 뜻이 다르다 — `10` 은 만료, `20` 은 발급자를 못 찾음, `21` 은 첫 인증서를 검증 못 함.
### 확인 ③ 이름 세 개가 한 인증서인가
**무엇을 확인하는가** — 이름마다 다른 인증서가 붙어 있지는 않은지.
```bash label="[워크스테이션] 세 이름의 일련번호를 나란히 뽑는다"
for H in auth app1 app2; do
echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial
done
```
**어디를 봐야 하는가** — 찍히는 세 줄의 일련번호가 서로 같은가. 값 자체에는 뜻이 없고 셋이 일치하는지만 본다.
**이 결과가 의미하는 것** — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는 인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 갱신 훅도 장마다 따로 돌고, 한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.
```bash label="[워크스테이션] 지금 서빙되는 인증서의 SAN 을 편다"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
```
### 확인 ④ 갱신이 서빙까지 닿는가 — 워커 PID 로 본다
**무엇을 확인하는가** — 훅이 호출되는지, 그리고 호출된 훅이 nginx 를 정말 갈아 끼웠는지.
```bash label="[kc-lab-edge] 훅이 불리는지 먼저 본다. 상태를 바꾸지 않는다"
sudo certbot renew --dry-run
```
출력 끝의 `Running deploy-hook command` 줄과 `simulated renewals` 요약을 본다. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌 곳에 있거나 실행 권한이 없다. dry-run 은 훅이 호출되는지까지만 말해 준다.
```bash label="[kc-lab-edge] 강제 갱신 전후로 워커를 비교한다. 이 확인은 상태를 바꾼다"
# 강제 갱신 전에 워커 PID 를 적어 둔다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
sudo certbot renew --force-renewal
# 워커 PID 가 바뀌었으면 reload 된 것이다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
```
`--force-renewal` 은 인증서를 실제로 새로 받으므로 발급 한도를 깎는다. 진짜 판정이 필요할 때만 한 번 쓴다.
**어디를 봐야 하는가** — 두 출력의 첫 열과 둘째 열 묶음이다. 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. `lstart` 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터 프로세스는 그대로이고 워커만 갈리는 쪽이 정상이다.
**이 결과가 의미하는 것** — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를 읽었다. 안 바뀌었으면 인증서는 갱신됐는데 서빙되는 것은 옛것이다. 판정은 로그 문구가 아니라 워커 PID 로 한다 — certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍는데 실패가 아니다. nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful` 과 `signal process started` 다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
## 통과 조건을 한 번에 다시 본다
| 무엇 | 명령 | 통과 |
|---|---|---|
| 열리는가 | `curl -v https://auth.hyeonworks.com/ -o /dev/null` | `SSL certificate verify ok.` |
| 검증값 | `curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' …` | `404 tls=0` |
| 체인 | `openssl s_client … \| grep …` | 번호가 3까지 · `Verify return code: 0 (ok)` |
| 이름 셋 | `for H in auth app1 app2; …` | 일련번호 세 줄이 같다 |
| 서빙까지 | 강제 갱신 앞뒤의 `ps … 'nginx: worker'` | 워커 PID 집합이 바뀐다 |
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short auth.hyeonworks.com` · 밖에서 `curl -I http://auth.hyeonworks.com` |
| 체인 단계가 1개 | `cert.pem` 을 씀 | 확인 ② |
| 갱신은 됐는데 옛 인증서가 나감 | deploy 훅 없음 | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` |
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 워커 PID |
| 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 |
| `unrecognized arguments: --dns-cloudflare` | 플러그인 미설치 | `certbot plugins \| grep '^\*'` |
| 토큰 응답이 `"code":6003` · `"code":9109` | 토큰 값이 잘렸거나 권한 범위가 좁다 | Cloudflare 에서 토큰을 다시 발급한다 |
| DNS-01 이 오래 걸림 | TXT 전파 대기 | 정상이다. 끊지 않는다 |
| 재구축 뒤 인증서가 없음 | 발급하지 말고 백업을 되돌린다 | 한도를 아끼는 길이다 |
| `cannot load certificate` | 설정에 lineage 디렉터리 이름을 잘못 적었다 | `sudo certbot certificates` 의 `Certificate Path:` 와 대조 |
| 엣지에서 친 `curl` 이 `Connection refused` | 친 곳이 틀렸다 | tailnet 에 붙은 다른 머신에서 다시 친다 |
## 무엇이 관측이고 무엇이 아닌가
- (observed) `dig` 가 낸 `100.83.212.4`, `certbot plugins` 세 줄, `nginx -v` 두 줄, 체인 4단계와 `Verify return code: 0 (ok)`, `404 tls=0`, 배포판 기본 유닛에 훅이 없다는 것, 갱신에서 서빙까지 2305초 대 1~2초, reload 무중단 측정값.
- (external) `100.64.0.0/10` 이 CGNAT 예약 대역이라는 것과 Let's Encrypt 의 발급 한도와 유효기간 90일은 규격이고 이 실험대가 잰 값이 아니다.
- (external) Cloudflare 계정에 남는 토큰은 엣지를 지운다고 없어지지 않는다. 가이드는 폐기를 적지 않았다.
- (inferred) 2305초 측정은 훅이 물리 호스트에만 있던 시절의 기록이라, 엣지 게스트에서 다시 잰 값이 아니다. 지금 배치에서 같은 수가 나오는지는 재지 않았다.
- (unknown) 체인 실측은 이름을 따로 받던 시절 것이고, 와일드카드로 받은 지금의 출력은 싣지 않았다.
- (unknown) `curl -v` 의 전체 출력, `certbot certificates` 의 실제 화면, 타이머 목록의 출력, 확인 ③ 의 일련번호 세 줄은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다.
- (unknown) 원본 가이드 04 에 되돌리는 절차가 없다. 패키지와 자격증명과 인증서와 훅과 타이머와 443 블록을 걷어내 본 적이 없다.
- (unknown) 이 단계에는 「이 실험대는 이렇게 했다」와 「따라 하는 사람은」 두 갈래가 없다. 04 는 설정 파일 셋을 전부 편집기로 쓴다.
<!-- body:end -->