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,165 @@
---
kind: CASE
slug: an-empty-token-installed-the-agent-anyway
title: 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#188-단계-02-k3s-server-와-agent
- final/document.md#184-이-부의-출처와-범위
---
# 빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다
k3s agent 설치는 오류 한 줄 없이 끝났는데 kubectl get nodes 에는 노드가 하나뿐이었다. 게스트 안에서 친 ssh 가 실패해 토큰이 빈 문자열로 넘어갔고, 설치 스크립트는 그 전까지를 성공으로 찍었다. 값을 찍지 않고 길이만 재는 한 줄을 설치 앞에 두면 잡힌다.
## 관계
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
설치 스크립트의 출력을 노드가 붙었다는 뜻으로 읽은 사건이라 그 규칙의 사례가 된다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
설치가 끝났다는 출력 대신 각 층에서 무엇이 성공 신호인지로 판정을 옮기는 절차다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
같은 구축의 한 단계 앞에서 벌어진 일이고, 거기서도 실패가 아무 오류 없이 지나갔다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
여기 적은 설치 명령을 다시 쳐서 같은 상태가 되는지는 확인된 적이 없다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
명령을 어느 셸에서 치는지를 단계마다 확인하라는 규칙이고, 그 확인이 빠졌을 때 나온 실패가 이것이다.
## 문제
k3s agent 설치가 끝까지 돌았고 출력에 오류가 없었다. 그런데 두 노드가 Ready 로 나와야 할 kubectl get nodes 에 두 번째 노드가 나타나지 않았다.
설치 출력의 오류 : x
kubectl get nodes 의 노드 수 : 1
k3s-agent 유닛 : 5초마다 재시작
journalctl -u k3s-agent : level=fatal msg="Error: --token is required"
실패가 화면에 나오지 않아 설치는 끝난 것으로 읽힌다.
## 결론
토큰을 꺼내는 ssh 를 게스트 안에서 쳤고 그 명령이 Host key verification failed. 로 끝났다. 명령 치환으로 감싸면 오류는 stderr 로 흘러가고 변수에는 빈 문자열이 담긴다. 셸은 아무 불평도 하지 않는다.
agent 는 --token '' 을 받아 level=fatal msg="Error: --token is required" 로 죽지만, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝난다. 그래서 설치 출력만 보면 성공이고, 유닛이 Restart=always 라 5초마다 조용히 재시도한다.
해결 : 게스트에 들어가지 않고 lab host 한 셸에서 ssh kc-lab-1 '...' 형태로 친다
가드 : 값을 쓰기 전에 길이로 가른다. 이 실험대의 토큰은 108자였다
히스토리에 남기지 않으려면 : --token-file 로 넘긴다. 토큰을 꺼낸 셸과 같은 셸에서 쳐야 한다는 제약도 없어진다
## 검증 환경
호스트 : test-server, Arch Linux
CPU : i5-1135G7, 논리 코어 8
RAM : 11,648MiB
QEMU : 11.1.1
libvirt : 12.7.0
게스트 : Debian 12 genericcloud
k3s : v1.36.4+k3s1
server 노드 : kc-lab-1, 192.168.122.11
agent 노드 : kc-lab-2, 192.168.122.12
토큰 길이 : 108자
토큰 형식 : K10<해시>::server:<비밀번호>
## 재현 조건
1. lab host 에서 게스트 세 대를 세우고 kc-lab-1 에 k3s server 를 깐다.
2. lab host 에서 ssh kc-lab-1 로 게스트에 로그인한다.
3. 게스트 프롬프트에서 TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token') 을 친다.
4. echo "${#TOKEN} 자" 가 0 을 낸다.
5. 같은 셸에서 agent 설치 명령에 --token "$TOKEN" 을 넘긴다. 설치 출력은 오류 없이 끝난다.
6. lab host 로 돌아와 kubectl get nodes 를 친다. 노드가 하나뿐이다.
7. ssh kc-lab-2 'journalctl -u k3s-agent' 에서 --token is required 를 찾는다.
## 본문
<!-- body:start -->
## 토큰이 지나는 길과 명령을 치는 셸
k3s server 는 `kc-lab-1` 에, agent 는 `kc-lab-2` 에 깔고 둘 다 lab host 에서 `ssh` 로 원격 실행한다. 가이드가 코드 블록마다 어느 기계에서 치는지를 붙여 둔 까닭이 여기에 있다 — 게스트는 libvirt NAT(`192.168.122.0/24`) 안에 있어 워크스테이션에서 직접 닿지 않고, `ssh kc-lab-1` 이라는 별칭도 lab host 의 `~/.ssh/config` 에만 있다.
agent 가 server 에 붙으려면 server 가 만든 node-token 이 필요하고, 그 값을 lab host 에서 꺼내 같은 셸에서 설치 명령에 넘긴다.
```bash
TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
echo "${#TOKEN}" # 값이 아니라 길이만 확인한다
```
비밀은 길이나 존재 여부만 확인하고 값을 찍지 않는다. 가이드가 먼저 정한 표기 규약이고, 값을 찍으면 터미널 스크롤백과 화면 공유에 그대로 남기 때문이다.
```bash
ssh kc-lab-2 "curl -sfL https://get.k3s.io | sudo sh -s - agent \
--server https://192.168.122.11:6443 \
--token '$TOKEN' \
--node-ip 192.168.122.12"
```
## 게스트 안에서 친 ssh 는 빈 문자열이 된다
토큰을 꺼내려고 게스트에 먼저 들어가면 같은 명령이 다르게 끝난다. 게스트에는 lab host 의 개인키도 `~/.ssh/config` 도 없다.
```bash
[kc-lab-1] $ ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token'
Host key verification failed.
```
여기까지는 오류 문구가 찍힌다. 문제는 이 명령을 `TOKEN=$(...)` 로 감쌌을 때다. 명령 치환은 표준 출력만 변수에 담으므로 오류는 stderr 로 흘러가고 `TOKEN` 에는 빈 문자열이 담기며, 셸은 아무 불평도 하지 않는다. 뒤이어 치는 설치 명령은 `--token ''` 을 넘긴 것과 같아진다.
## 설치 출력이 성공으로 끝나는 경로
빈 문자열을 받은 설치도 끝까지 돈다. agent 가 `--token ''` 을 받아 `level=fatal msg="Error: --token is required"` 로 죽는데, 설치 스크립트는 그 전까지를 다 성공으로 찍고 끝나므로 설치 출력만 보면 성공이다. 성공으로 찍힌 것은 내려받기와 유닛 생성과 `enable` 까지다. 유닛은 `Restart=always` 라 5초마다 조용히 재시도하고, 그래서 실패가 화면이 아니라 `journalctl -u k3s-agent` 안에서만 되풀이된다.
k3s server 와 agent 를 세우는 단계가 끝났다는 판정은 lab host 에서 노드 목록을 쳐서 두 노드가 `Ready` 로 나오는 것이다.
```bash
kubectl get nodes -o wide
```
```
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
```
빈 토큰으로 깔린 agent 는 이 목록에 올라오지 않는다.
## 값을 찍지 않고 길이만 잰다
토큰을 화면에 찍어 눈으로 대조하는 방법은 표기 규약이 막아 두었다. 그래서 설치 앞에 길이를 재는 한 줄을 둔다.
```bash
[ ${#TOKEN} -ge 50 ] || echo "TOKEN 이 비었다 — 3번으로 돌아간다"
```
이 실험대의 토큰은 108자였고 형식이 `K10<해시>::server:<비밀번호>` 라 k3s 판올림에 따라 자릿수가 달라진다. 그래서 이 가드는 값을 맞춰 보지 않고 길이가 `0` 이 아닌지만 본다.
길이가 `0` 으로 나오는 길은 셋이다. 가이드는 게스트 안에서 친 경우를 그중 가장 흔하다고 적어 두었고, 나머지 둘은 server 가 아직 안 떠서 토큰 파일이 없거나 새 셸을 열어 변수가 사라진 경우를 가리킨다. 가드 한 줄은 그 몇 분을 막는다 — 설치가 성공으로 끝나고, 노드 목록에서 한 줄이 빠진 것을 알아채고, `journalctl` 까지 가는 동안이다.
## 토큰을 파일로 넘기면 같은 셸 제약이 없어진다
위 가드는 `TOKEN` 이 셸 변수라 토큰을 꺼낸 셸과 같은 셸에서 쳐야 하고, 다른 창에서는 비어 있다. 토큰이 명령줄에 들어가는 것도 걸리면 파일로 넘긴다.
```bash
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"
```
토큰이 셸 히스토리에 남지 않고, 변수를 쓰지 않으니 셸이 달라도 된다.
## 확인하지 못한 것
설치 출력도 `journalctl` 출력도 `final/evidence/` 에 남기지 않았다. `level=fatal msg="Error: --token is required"``Host key verification failed.` 는 SSOT 본문에서 옮긴 것이고 명령 출력 파일이 근거가 아니다. 토큰 108자도 같다.
이 실패를 일부러 다시 만들어 본 기록도 없어서, 빈 토큰으로 설치하면 설치 출력이 성공으로 끝난다는 것은 한 번의 관측이다. 재현이 쉽지 않은 까닭은 이 부의 검증 방식에 있다 — 만드는 명령은 다시 치면 돌고 있는 실험대가 없어지므로 구축할 때 쓴 것을 옮기고 결과 상태를 확인하는 것으로 대신했다. 재현 없이 남길 수 있는 것은 `journalctl` 쪽이고, 그 출력을 `final/evidence/raw/` 에 남기면 진단 절에 증거가 붙는다.
가드 한 줄이 실제로 빈 토큰을 잡아 본 기록도 없다.
<!-- body:end -->
@@ -0,0 +1,196 @@
---
kind: CASE
slug: cloud-init-failures-all-look-like-ssh-refused
title: cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#187-단계-01-게스트-세-대
- final/document.md#186-단계-00-lab-host-가상화-준비
---
# cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다
SSH 가 안 붙는 원인은 넷이고 넷 다 게스트 밖에서 정해진다. 그래서 SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. 시드를 붙인 방식, YAML 파싱, vol-upload 누락, 가상 네트워크 autostart 가 그 넷이고, cloud-init 은 어느 쪽이든 오류를 남기지 않는다.
## 관계
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
바로 다음 단계에서 벌어진 같은 모양의 실패이고, 거기서도 성공한 출력이 실패를 덮었다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
스키마 검사기의 통과와 거부를 게스트의 상태로 읽으면 양쪽 방향으로 다 틀린다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
게스트를 만드는 명령은 다시 쳐 본 적이 없어 여기 적은 원인 넷도 그 물음에 걸린다.
- **qcow2 파일 한 장이 담는 것 — 매핑표와 데이터 클러스터가 같은 파일 안에 있고, 파일 밖을 가리키는 것은 백킹 파일 경로 하나다**
게스트 디스크가 base 이미지 위의 오버레이라 `virt-install` 이 10GB 를 즉시 할당한 것으로 찍힌다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
시드를 만드는 순서와 DHCP 예약을 넣는 순서가 결과를 가르는 단계라 그 규칙이 받는다.
## 문제
게스트 세 대가 running 인데 lab host 에서 SSH 가 키로 붙지 않는다.
virsh list --all : kc-lab-1 · kc-lab-2 · kc-lab-edge 모두 running
ssh donghyeon@192.168.122.11 : Permission denied (publickey)
게스트의 hostname : localhost
cloud-init 이 남긴 오류 : x
cloud-init 이 돌지 않으면 사용자도 SSH 키도 들어가지 않는다. 그런데 cloud-init 은 데이터소스를 못 찾아도 YAML 파싱에 실패해도 오류를 남기지 않아서, 원인 넷이 전부 SSH 하나로만 나타난다.
## 결론
증상 하나 뒤에 원인이 넷이고 넷 다 게스트 밖에서 정해진다.
시드를 --cloud-init 으로 붙였다 : SATA CD-ROM 으로 붙는데 Debian genericcloud 이미지에는 그 드라이버가 없다. 디스크로, bus=virtio 로 붙인다
YAML 파싱에 실패했다 : cloud-init 이 아무 오류를 남기지 않는다
vol-upload 를 빠뜨렸다 : 목록에는 이름이 보이는데 안이 0 으로 채워져 있어 cidata 라벨을 못 찾는다
default 네트워크의 autostart 가 no 다 : 지금은 되고 호스트를 재부팅한 다음에야 세 게스트의 SSH 가 한꺼번에 실패한다
판정 : SSH 설정을 고치기 전에 호스트명 한 낱말을 본다. ssh kc-lab-edge 'hostname' 이 kc-lab-edge 를 내면 시드가 읽혔고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다
못 들어가면 : virsh screenshot 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다
키가 안 들어갔을 때의 탈출구 : cloud-init 의 plain_text_passwd 로 콘솔 로그인
반대 방향도 하나 있다. 게스트의 cloud-init 22.4.2 스키마 검사기는 sudo 를 리스트로 쓴 것을 거부한다. 그런데 그 표기로도 부팅은 되고, kc-lab-1 과 kc-lab-2 에서 그 상태로 NOPASSWD sudo 가 돌고 있다. 검사가 통과해도 안 도는 쪽이 넷이고 검사에 걸려도 도는 쪽이 하나라, 어느 방향이든 검사 결과를 게스트의 상태로 읽으면 틀린다.
## 검증 환경
호스트 : test-server, Arch Linux
RAM : 11,648MiB
QEMU : 11.1.1
libvirt : 12.7.0
base 이미지 : Debian 12 genericcloud amd64
게스트 OS : Debian GNU/Linux 12 (bookworm)
게스트 : kc-lab-edge 192.168.122.10 · kc-lab-1 192.168.122.11 · kc-lab-2 192.168.122.12
시드 : seed-<이름>.iso, CIDATA 라벨, bus=virtio 로 붙임
게스트의 cloud-init : 22.4.2
cloud-init 이 깐 패키지 : curl · nftables
running 에서 done 까지 : 약 50초
## 재현 조건
1. base 이미지를 받고 게스트마다 cloud-init user-data 를 쓴다.
2. 시드 iso 를 만들고 virsh vol-create-as 로 볼륨을 만든 뒤 virsh vol-upload 로 채운다.
3. DHCP 예약을 먼저 넣고 virt-install 로 게스트를 만든다. 시드는 bus=virtio 로 붙인다.
4. virsh list --all 로 세 게스트가 running 인지 본다.
5. lab host 에서 ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status' 를 친다.
6. 호스트명이 게스트 이름이고 cloud-init status 가 done 이면 통과다. localhost 가 나오면 2번과 3번을 다시 본다.
7. SSH 가 안 붙으면 virsh screenshot kc-lab-1 /tmp/kc1.ppm 으로 화면을 떠서 로그인 프롬프트 앞의 호스트명을 읽는다.
## 본문
<!-- body:start -->
## 시드가 게스트에 들어가는 길
cloud-init 이 게스트 안에서 사용자를 만들고 SSH 키를 넣으려면 먼저 데이터소스를 찾아야 한다. 이 실험대는 그것을 `cidata` 라벨이 붙은 볼륨으로 준다. 그 안에는 `user-data``meta-data` 라는 정확한 이름의 파일 둘이 들어 있다.
```bash
printf 'instance-id: kc-lab-1-%s\nlocal-hostname: kc-lab-1\n' "$(date +%s)" > meta-kc-lab-1
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
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
```
볼륨을 만드는 것과 채우는 것이 다른 명령이다. `vol-create-as` 는 빈 볼륨을 만들 뿐이고 내용은 `vol-upload` 가 넣는다. 뒤엣것을 빠뜨리면 목록에는 이름이 보이는데 안이 0 으로 채워져 있고, cloud-init 은 `cidata` 라벨을 못 찾은 채 끝난다.
`instance-id` 에 타임스탬프를 넣는 것은 cloud-init 이 인스턴스마다 한 번만 초기화 모듈을 돌리기 때문이다. id 가 같으면 user-data 를 고쳐도 반영되지 않는다.
## 증상 하나에 원인 넷
| 무엇이 어긋났나 | 게스트에서 무엇으로 나타나나 |
|---|---|
| 시드를 `--cloud-init` 으로 붙였다 | SSH 가 `Permission denied (publickey)` · 호스트명이 `localhost` |
| YAML 파싱에 실패했다 | 같다 |
| `vol-upload` 를 빠뜨렸다 | 같다 |
| `default` 네트워크의 autostart 가 `no` 다 | 호스트를 재부팅한 뒤 세 게스트가 한꺼번에 |
넷 가운데 이 실험대에서 관측으로 적힌 것은 시드를 붙인 방식이다. `--cloud-init` 옵션은 시드를 SATA CD-ROM 으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리 하드웨어 드라이버를 뺐다. AHCI 장치가 보이지 않아 cloud-init 이 데이터소스를 못 찾고 조용히 끝난다. 그래서 시드를 디스크로, `bus=virtio` 로 붙인다.
```bash
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
```
마지막 원인은 한 단계 앞에서 만들어진다. lab host 를 준비할 때 `default` 네트워크의 autostart 를 켜지 않으면 지금은 세 게스트가 다 붙고, 호스트를 재부팅한 다음에야 SSH 가 한꺼번에 실패한다. 그때 원인을 게스트 안에서 찾게 된다.
## 호스트명 한 낱말이 시드와 SSH 를 가른다
게스트에 SSH 가 붙는다면 확인은 한 줄로 끝난다.
```bash
ssh kc-lab-edge 'hostname; ip -4 -br addr show enp1s0; cloud-init status'
```
```
kc-lab-edge
enp1s0 UP 192.168.122.10/24 metric 100
status: done
```
호스트명이 바뀌었다는 것은 시드가 읽혔다는 뜻이고, 시드가 읽혔으면 같은 파일에 있던 SSH 키도 들어갔다. `localhost` 가 나오면 SSH 설정을 고치지 말고 시드부터 의심한다. `cloud-init status``running` 이면 아직 패키지를 받는 중이고, 이 실험대에서는 `done` 까지 약 50초 걸렸다.
게스트에 못 들어가면 화면을 직접 뜬다.
```bash
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
```
로그인 프롬프트 앞의 호스트명 한 낱말만 읽는다. `localhost login:` 이면 cloud-init 이 아예 안 돌았으므로 SSH 쪽은 볼 것이 없다. `kc-lab-1 login:` 이면 cloud-init 은 돌았고 그 안의 사용자·키 단계에서 틀린 것이라 콘솔로 들어가 로그를 본다. 콘솔 로그인에 쓰는 비밀번호가 cloud-init 의 `plain_text_passwd` 이고, 키가 안 들어갔을 때 게스트로 들어가는 길이 이것 하나다.
순서를 이렇게 정한 까닭이 여기에 있다. 화면 한 장과 콘솔에서 보는 로그 두 줄이 「SSH 가 안 되는 이유」를 절반으로 줄인다. 호스트명이 그 절반을 가르므로, 어느 쪽 절반을 볼지가 정해지기 전에는 SSH 설정을 열 이유가 없다.
## 검사가 거부해도 부팅은 된다
게스트의 cloud-init `22.4.2` 스키마 검사기는 `sudo` 를 리스트로 쓴 것을 거부한다.
```yaml
sudo: ['ALL=(ALL) NOPASSWD:ALL'] # 거부된다
sudo: "ALL=(ALL) NOPASSWD:ALL" # 통과한다
```
```
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 가 멀쩡히 돌고 있다.
`NOPASSWD:ALL` 을 넣은 것은 k3s 설치와 장애 주입이 비대화식으로 돌아야 해서다. 비밀번호를 물으면 원격 실행이 거기서 멈춘다. 검사기가 거부한 줄이 바로 그 줄이다.
검사가 통과해도 cloud-init 이 안 도는 경로가 넷이고, 검사가 거부해도 도는 경로가 하나다.
## 시드를 만들기 전에 세 줄로 거른다
```bash
grep -c '__' kc-lab-1.yaml # 0 이어야 한다
grep -c 'ssh-ed25519\|ssh-rsa' kc-lab-1.yaml # 2 여야 한다
python3 -c 'import yaml,sys; yaml.safe_load(open("kc-lab-1.yaml")); print("YAML OK")'
```
`0``2``YAML OK` 셋이 맞아야 시드를 만든다. 셋이 맞아도 cloud-config 로 유효하지는 않다 — 키 이름을 `users` 대신 `user` 로 친 오타는 이 세 줄을 그냥 통과한다. 그래서 게스트가 한 대라도 떠 있으면 cloud-init 자신의 스키마 검사기를 쓴다.
```bash
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'
```
이 파일에는 콘솔 비밀번호가 평문으로 들어 있어 `/tmp` 가 아니라 자기 홈에 `600` 으로 두고, 검사가 끝나면 바로 지운다.
## 확인하지 못한 것
넷 가운데 SSOT 가 관측으로 표시한 것은 시드를 SATA 로 붙였을 때 AHCI 장치가 보이지 않는 것과 스키마 검사기의 거부 문구뿐이다. YAML 파싱 실패와 `vol-upload` 누락과 네트워크 autostart 셋은 가이드가 막히면 표에 적어 둔 항목이라, 이 실험대에서 실제로 그 증상을 본 것인지 예상해 적은 것인지 SSOT 가 가르지 않았다. 이 글에서 그 셋은 그렇게 되는 구조까지이고 그렇게 됐다가 아니다.
명령 출력은 원문으로 남아 있지 않다. `Permission denied (publickey)``cloud-init status: done` 도 약 50초도 SSOT 본문이 근거다. `final/evidence/` 에 그 출력을 담은 파일이 없다. `virsh screenshot` 으로 뜬 화면도 저장해 두지 않았다.
만드는 명령은 재실행으로 검증되지 않았다. 게스트를 다시 만들면 돌고 있는 실험대가 없어지므로 시드와 관련된 셋은 다시 재현하기 어렵다. 네트워크 autostart 만은 호스트를 재부팅해 확인할 수 있는데 그 기록도 없다.
<!-- body:end -->
@@ -0,0 +1,186 @@
---
kind: CASE
slug: renewal-succeeded-while-the-old-certificate-kept-serving
title: 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
- final/document.md#194-이-부에서-파생될-open-question
---
# 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초
갱신은 매번 SUCCESS 로 끝나는데 nginx 는 옛 인증서를 계속 내보냈다. certbot-renew.service 에 훅이 없어 갱신에서 서빙까지 2305초 걸렸고, deploy 훅을 두니 1~2초였다. 만료 30일 전까지는 갱신을 안 해 88일 동안 아무 표시도 나지 않는다.
## 관계
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
이 인증서를 어떻게 받기로 했는지가 그 결정이고, 여기서는 받은 뒤의 갱신을 다룬다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
갱신 로그의 SUCCESS 와 훅 로그의 error output 이 둘 다 상태를 잘못 말한 사건이다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
이 단계의 확인을 어느 기계에서 치느냐가 결과를 바꾸고, 층별 확인이 그 순서를 정한다.
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
훅을 호스트에 두었다가 잃은 것도, 확인 명령을 엣지 안에서 친 것도 그 규칙이 받는 결함이다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
2305초를 잰 배치와 지금 배치가 다르므로 다시 세운 실험대에서 재야 할 값이 여기 있다.
## 문제
인증서 갱신은 매번 성공으로 끝나는데 서버가 내보내는 인증서는 옛것이었다.
certbot-renew.timer : 정상
certbot-renew.service 의 결과 : SUCCESS
유닛의 ExecStartPost 또는 --deploy-hook : x
nginx 가 새 인증서를 읽었나 : x
갱신에서 서빙까지 : 2305초 (38분 25초)
무엇이 reload 했나 : 사람이 직접
발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.
## 결론
certbot-renew.service 는 /usr/bin/certbot -q renew 한 줄이고 인증서를 새로 받는 데까지만 책임진다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 live/ 심볼릭 링크만 갈아 끼우므로, 경로는 그대로이고 내용만 바뀌어 nginx 는 바뀐 줄 모른다.
훅도 사람도 없으면 다음 nginx 재시작까지, 사실상 무기한으로 옛 인증서가 나간다.
해결 : /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 에 nginx -t && nginx -s reload 를 두고 chmod +x 를 준다
실행 권한이 없으면 : certbot 이 조용히 건너뛴다
post/ 가 아니라 deploy/ 인 까닭 : post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다
훅을 저장소에 두는 까닭 : 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌다
판정 : 로그 문구가 아니라 강제 갱신 전후의 nginx 워커 PID 로 한다
훅을 넣어도 안전했다. reload 는 무중단이었고 새 연결 8856건이 전부 200, p95 는 205.7ms 대 204.3ms 로 변화가 없었다.
## 검증 환경
엣지 게스트 : kc-lab-edge, 192.168.122.10, Debian 12
nginx : nginx/1.22.1
certbot 플러그인 : dns-cloudflare
발급 대상 : -d hyeonworks.com -d '*.hyeonworks.com'
lineage 디렉터리 : /etc/letsencrypt/live/hyeonworks.com/
nginx 가 읽는 파일 : fullchain.pem · privkey.pem
타이머 : certbot-renew.timer → certbot-renew.service
인증서 유효기간 : 오늘 + 90일
2305초를 잴 때의 배치 : 훅이 물리 호스트에 있었다
지금의 배치 : certbot · 인증서 · 갱신 타이머 · deploy 훅이 전부 엣지 게스트에 있다
## 재현 조건
1. 엣지 게스트에 certbot 과 python3-certbot-dns-cloudflare 를 깔고 와일드카드 인증서를 받는다.
2. nginx 443 블록에 fullchain.pem 과 privkey.pem 경로를 적고 reload 한다.
3. systemctl cat certbot-renew.service 로 ExecStartPost 와 --deploy-hook 이 없는지 본다.
4. ps -eo pid,lstart,args 로 nginx 워커 PID 를 적어 둔다.
5. sudo certbot renew --force-renewal 을 친다.
6. 워커 PID 를 다시 본다. 바뀌지 않았으면 옛 인증서가 계속 나가고 있다.
7. deploy/reload-nginx.sh 를 두고 chmod +x 를 준 뒤 4번부터 6번까지를 다시 한다.
## 본문
<!-- body:start -->
## 배포판 유닛이 어디까지 책임지나
Let's Encrypt 인증서는 90일짜리이고 갱신은 `certbot-renew.timer` 가 건다. 타이머가 부르는 유닛을 열어 보면 한 줄이다.
```bash
systemctl cat certbot-renew.service
```
```
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
```
`ExecStartPost``--deploy-hook` 도 없다. 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 nginx 가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이라, 「기본값이니 괜찮겠지」가 이 결함이 사는 곳이다.
그래서 이 글의 훅이 모든 배포판에 필요하지는 않다. 위 유닛에 `ExecStartPost``--deploy-hook` 이 이미 적혀 있는 배포판이라면 훅을 새로 넣을 것이 아니라 거기 적힌 명령이 nginx 를 reload 하는지만 본다.
## nginx 는 인증서를 언제 읽나
nginx 는 `ssl_certificate` 에 적힌 파일을 기동과 reload 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 아래의 심볼릭 링크를 새 파일로 갈아 끼운다. 설정에 적힌 경로는 한 글자도 바뀌지 않고 그 경로가 가리키는 파일만 바뀌므로, nginx 쪽에서는 다시 읽을 계기가 생기지 않는다. 갱신이 끝난 뒤에 nginx 로 reload 를 걸어 주는 것이 없으면 옛 인증서가 계속 나간다.
## 88일 동안 이 결함이 보이지 않는다
타이머는 정상이고 갱신은 매번 `SUCCESS` 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 88일 동안 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있다.
훅을 저장소에 두는 까닭도 여기에 있다. 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌고, 사라진 것도 갱신이 실제로 일어나는 날까지는 드러나지 않는다.
## 훅 두 줄과 실행 권한
```sh
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
```
```bash
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
실행 권한이 없으면 certbot 이 조용히 건너뛴다. `post/` 가 아니라 `deploy/` 에 넣는 것은 `post/` 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이다.
이 실험대에서 잰 차이는 이렇다.
| | 훅 없음 | 훅 있음 |
|---|---|---|
| 갱신 → 서빙 | 2305초 (38분 25초) | 1~2초 |
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
훅도 사람도 없었다면 다음 nginx 재시작까지, 사실상 무기한이다.
## 판정은 로그 문구가 아니라 워커 PID 로 한다
```bash
sudo certbot renew --dry-run
```
dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 따로 본다.
순서가 dry-run 먼저인 것은 뒤엣것이 상태를 바꾸기 때문이다. `--force-renewal` 은 인증서를 실제로 새로 받아 발급 한도(주당 중복 5장)를 깎으므로, 진짜 판정이 필요할 때 한 번만 쓴다.
```bash
# 강제 갱신 전에 워커 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
```
reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용됐다. `lstart` 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다.
certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍는데 실패가 아니다. nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful``signal process started` 다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
## 훅을 넣어도 안전한가
갱신 때마다 reload 가 도는 것이 서비스에 영향을 주는지도 쟀다. 새 연결 8856건이 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었다. 845KB 를 20k/s 로 받는 중이던 요청은 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
## 수치를 내기 전에 시계를 쟀다
갱신 시각과 reload 시각이 다른 기계에 찍히므로, 두 시각을 빼기 전에 시계부터 쟀다. test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다.
```bash
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
# 왜곡 ≈ B (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
curl -sI https://www.google.com | grep -i '^date:'
timedatectl show -p NTP -p NTPSynchronized
```
## 확인하지 못한 것
2305초는 훅이 물리 호스트에만 있던 시절의 값이다. 지금은 certbot 과 인증서와 갱신 타이머와 deploy 훅이 전부 엣지 게스트에 있고, 그 배치에서 다시 재면 같은 수가 나오는지는 재지 않았다. 그래서 이 글의 2305초는 훅이 없으면 이만큼 벌어진다는 한 번의 측정이고 지금 배치의 값이 아니다. 1~2초 쪽도 같은 시기에 잰 값이다.
측정 출력을 `final/evidence/` 에 남기지 않았다. 2305초도 8856건도 p95 두 값도 845361바이트도 106초도 SSOT 본문에만 있다. 워커 PID 를 전후로 비교한 출력도 저장해 두지 않았다.
88일이라는 잠복 기간은 만료 30일 전에야 갱신을 시작한다는 동작에서 끌어낸 것이지, 한 주기를 실제로 돌려 본 값이 아니다. 훅을 뺀 채 만료일까지 가 본 기록은 없고 그런 기록이 있을 까닭도 없다.
<!-- body:end -->
@@ -0,0 +1,96 @@
---
kind: QUESTION
slug: does-the-guide-rebuild-this-lab
title: 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
questionStatus: OPEN
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#194-이-부에서-파생될-open-question
- final/document.md#184-이-부의-출처와-범위
---
# 가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가
생성 명령을 다시 쳐서 같은 실험대가 서는지는 확인된 적이 없다. 돌고 있는 실험대를 멈출 수 없어 구축할 때 쓴 명령을 옮기고 결과 상태를 확인하는 것으로 대신했다. 다시 세우는 데 필요한 매니페스트 둘과 cloud-init 템플릿도 저장소에 없다.
## 관계
- **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다**
그 기준이 요구하는 검증을 이 물음이 실제로 실행한다. 그 기록은 스스로 「이 기준으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없다」고 적었다.
- **WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가**
같은 물음의 다른 절반이다. 옮기는 것과 다시 세우는 것 가운데 어느 쪽이 복원 경로인지는 두 값이 다 나와야 정해진다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
재구축에서 01 단계가 가장 먼저 걸린다. 그 기록이 센 네 원인이 다시 나오는지가 이 검증의 한 칸이다.
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
02 단계에서 같은 일이 벌어진다. 길이 가드 한 줄이 실제로 빈 토큰을 잡아 본 기록이 없으므로, 재구축이 그 가드를 처음으로 시험하게 된다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
다시 선 실험대가 같은 상태인지를 판정할 근거가 전부 그런 출력이다. 통과 조건 일곱을 그대로 믿을지 여러 층으로 견줄지가 그 기준에 걸려 있다.
## 사실
- §184 가 이 부의 검증 방식을 갈라 적었다. 읽기 전용 확인은 돌아가는 실험대에서 실제로 실행해 출력을 그대로 실었다.
- 만드는 명령은 그렇게 하지 못했다. VM 을 다시 만들거나 k3s 를 다시 깔면 돌고 있는 실험대가 없어지므로, 구축할 때 쓴 명령을 그대로 옮기고 결과 상태를 확인하는 것으로 대신했다.
- §186 부터 §192 까지의 생성 명령에는 unknown 이 붙어 있다. 「지금 다시 쳐도 같은 상태가 된다」가 확인되지 않았다.
- 세울 대상과 단계마다의 통과 조건은 §185 의 표에 일곱 줄로 적혀 있다. virsh list 가 돈다 · 세 게스트에 SSH 가 붙는다 · kubectl get nodes 에 둘 다 Ready · 밖에서 요청이 파드까지 닿는다 · https 가 열리고 체인이 4단계 · 관리 콘솔 로그인이 된다 · vendor_cluster_size 가 2.
- 다시 세울 때 필요한 것 가운데 일부가 저장소에 없다. 매니페스트 둘(keycloak-cluster.yaml · observability.yaml)과 cloud-init 템플릿 kc-lab.yaml.example 이 source/ 에 반입되지 않았고, 가이드가 화면에 옮겨 적은 만큼만 있다.
- 가이드를 순서대로 따라가다 나온 결함 여섯을 §182 가 이미 표로 적었고, 공통 원인 하나를 inferred 로 붙였다.
- 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」가 스스로 밝혔다. 그 규칙으로 가이드를 고친 뒤 처음부터 다시 따라가 본 기록이 아직 없고, 규칙이 결함을 막아 냈다는 관측도 아직 없다.
## 가정
- 검증을 시작하는 판이 지금 source/ 에 있는 가이드라고 본다. 그 가이드가 §182 의 결함 여섯을 고친 판인지는 대조하지 않았다.
- 밖에서 받아 오는 것들이 그때와 같은 판이라고 전제한다. Debian 12 genericcloud 이미지와 get.k3s.io 설치 스크립트와 apt 저장소의 nginx 와 certbot 이 그것이다. 판 번호가 달라지면 같은 명령이 다른 상태를 만든다.
- 도메인과 Cloudflare 토큰과 tailnet 주소는 다시 세울 때도 그대로 쓴다고 본다. 04 단계 전체가 그 셋에 묶여 있다.
- 새로 세우는 기계의 CPU 가 하드웨어 가상화를 지원한다고 전제한다. 아니면 00 단계부터 다른 이유로 막히고, 그 막힘은 가이드의 결함이 아니다.
## 미지수
- 지금의 가이드 7단계를 빈 호스트에서 처음부터 순서대로 쳤을 때 어느 단계에서 멈추는지. 멈춘다면 그것이 §182 가 이미 센 여섯 중 하나인지 그때는 안 보이던 새 결함인지.
- source/ 에 없는 매니페스트 둘과 cloud-init 템플릿 없이 02 와 05 와 06 단계가 문서만으로 서는지.
- 다시 선 실험대가 지금과 같은 상태인지를 무엇으로 판정할지. 통과 조건 일곱이 같은 값을 내는 것으로 충분한지, 판 번호까지 같아야 하는지 — libvirt 12.7.0 · QEMU emulator version 11.1.1 · v1.36.4+k3s1 · nginx/1.22.1 이 지금 값이다.
## 제약
- 지금 돌고 있는 실험대를 멈출 수 없다. §184 가 생성 명령을 재실행으로 검증하지 않은 이유가 그것이고, 이 물음도 같은 제약 아래에서 답해야 한다.
- 이 호스트에 한 벌 더 세우기에는 메모리가 모자란다. §187 의 배치는 세 게스트 합이 10240MB 이고 §178 의 호스트 RAM 은 11,648MiB 다.
- 04 단계는 공개 인터넷이 아니라 tailnet 과 Cloudflare 계정에 묶여 있다. 다른 기계에서 재려면 그 둘에 닿아야 한다.
- Let's Encrypt 의 주당 중복 인증서 5장 한도가 있다. 재구축을 여러 번 돌리면 04 단계가 거기 걸린다.
- 한 번 끝까지 따라가는 것으로 답한다. 여러 번 돌려 분포를 보는 것은 이 물음의 범위 밖이다.
## 선택지
### 1. 다른 기계에 빈 호스트를 두고 00 부터 06 까지 순서대로 친다
돌고 있는 실험대를 건드리지 않고 가이드만 시험한다. 하드웨어가 달라도 상관없는 대신, 막힌 단계가 가이드의 결함인지 하드웨어 차이인지를 가르는 일이 따로 붙는다.
결과는 네 줄로 적는다.
멈춘 단계 : 00 부터 06 까지 중 어디인가
멈춘 이유 : 그 시점에 리소스가 없어서인가, 그 셸에서 안 도는 명령이라서인가, 둘 다 아닌가
§182 의 여섯과 겹치나 : o 또는 x
통과 조건 일곱 : 단계마다 같은 값이 나왔는가
### 2. 이 호스트에 게스트 세 대를 새 이름으로 한 벌 더 세운다
하드웨어 차이가 없으므로 막힌 단계를 가이드 쪽으로 좁힐 수 있다. 대신 §187 의 배치로는 메모리가 모자라니 게스트 크기를 줄여 돌리고, 그 사실을 결과에 함께 적는다. 크기를 줄인 채로 나온 값은 05 단계와 06 단계에서 지금 실험대와 다를 수 있다.
### 3. 문서만 읽어 빠진 단계를 찾는다 — 제외
§182 가 이미 답을 적었다. 개별 명령은 전부 실제로 돌았던 것이고 틀린 것은 명령이 아니라 그 명령이 놓인 위치라, 각 줄은 참인데 순서대로 따라가면 막힌다. 그런 결함은 문서를 읽어서는 안 나오고 실행해야 나온다.
## 다음 검증
1. 매니페스트 둘과 cloud-init 템플릿을 source/ 로 반입해 final/ 에 넣는다. 없이 시작하면 이 검증이 재는 것이 「가이드가 서는가」가 아니라 「빠진 파일을 다시 만들 수 있는가」로 바뀐다.
2. 대상을 정한다. 다른 기계면 1번, 이 호스트에 한 벌 더면 2번이고, 2번을 고르면 게스트 메모리를 줄인 값을 먼저 적는다.
3. 가이드 그대로 00 부터 06 까지 순서대로 친다. §184 가 명령을 두 이름으로 갈라 적어 둔 곳에서는 어느 쪽을 칠지부터 정한다 — 「이 실험대는 이렇게 했다」는 실제로 친 명령 그대로이고, 「따라 하는 사람은」 쪽은 이 실험대에서 한 번도 치지 않았다. 각 단계의 「이 단계가 끝나면」 명령을 치고 출력을 final/evidence/raw/ 에 원문으로 남기고, meta/ 에 명령과 cwd 와 실행 시각과 종료 코드를 적는다.
4. 04 단계는 --dry-run 을 먼저 돌린다. 주당 중복 인증서 5장 한도를 dry-run 은 쓰지 않는다.
5. 막힌 단계마다 무엇이 없어서 막혔는지를 §182 의 두 축으로 분류해 적는다. 그 시점에 리소스가 없었나, 그 셸에서 안 도는 명령이었나. 어느 축에도 안 들어가면 그것을 새 축으로 적는다.
6. 끝나면 판 번호 넷을 적어 지금 실험대의 값과 나란히 둔다.
닫는 조건 : 00 부터 06 까지 통과 조건 일곱이 전부 같은 값을 내면 「가이드만으로 이 실험대가 다시 선다」고 적고 닫는다. 그러면 「WiFi 전용 호스트에서 대용량 qcow2 를 옮기는 데 실제로 몇 시간이 걸리는가」가 재는 이동 시간과 견줄 대상이 생겨, 옮기는 편이 빠른지 다시 세우는 편이 빠른지가 그때 정해진다. 옮기는 것이 현실적이지 않다면 이 실험대의 복원 경로는 문서 하나가 된다.
어느 단계에서든 막히면 그 단계를 §182 의 결함 표에 행으로 더하고, 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」의 「규칙이 결함을 막아 냈다는 관측은 아직 얻지 못했다」를 그 결과로 바꾼다. 막힌 단계가 그 규칙의 두 축 안이면 규칙이 통한 것이고, 밖이면 축이 모자란 것이라 규칙을 고친다. 어느 쪽이든 §186 부터 §192 까지의 생성 명령에 붙은 unknown 이 그때 확인이나 반증으로 바뀐다.
@@ -0,0 +1,92 @@
---
kind: REFERENCE
slug: check-the-nearest-layer-first
title: 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat
- final/document.md#185-가이드-묶음이-스스로-정한-규약
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
---
# 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다
가장 가까운 층부터 치고 한 칸씩 밖으로 나오며, 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다 — 03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 밖에서 한 번 쳐서 받은 값 하나로는 여섯 층 가운데 어디서 끊겼는지가 나오지 않는다.
## 관계
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
가장 안쪽 칸에서 연결 거부나 타임아웃이 나오면 02 단계로 돌아가는데, 그 기록이 거기서 노드가 안 붙는 이유 하나를 끝까지 따라간다.
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
04 단계의 판정도 같은 모양으로 갈린다. 그 기록은 로그 문구가 아니라 워커 PID 로 판정하라는 결론을 낸다.
- **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다**
여기서는 어디서 끊겼는지까지만 좁힌다. 좁힌 층의 출력을 어떻게 읽고 무엇을 잘못 읽기 쉬운지는 그쪽에 적혀 있다.
- **packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다**
같은 「어디서 끊겼나」를 응답 코드가 아니라 네 지점의 capture 로 좁힌다. 응답 코드로는 가릴 수 없을 때 그쪽으로 넘어간다.
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
가장 안쪽 칸이 404 인데 밖에서만 막히는 상태를 그 기록이 다룬다. 층을 좁힌 뒤 남은 원인이 방화벽이었던 경우다.
## 목적
밖에서 한 번 친 값 하나로는 고칠 층을 고를 수 없다. §189 은 03 단계의 확인을 네 칸으로 나누고 칸마다 건너뛰는 층을 하나씩 늘렸다. 첫 칸은 엣지 nginx 를 건너뛰고 게스트의 80 을 직접 치고, 둘째 칸은 호스트 DNAT 을 건너뛰고 엣지를 직접 치고, 셋째 칸은 밖에서 도메인으로 치고, 넷째 칸은 TLS 가 붙은 뒤를 친다. 둘째 칸이 통과하는데 셋째 칸이 안 되면 막힌 곳은 DNAT 이고, 둘째 칸에서 막히면 막힌 곳은 엣지 안이다. 이 한 칸을 끼워 두면 그 둘이 섞이지 않는다.
이 순서를 쓰려면 층마다 무엇이 성공인지를 먼저 적어야 한다. 첫 칸의 404 는 게스트의 80 을 Traefik 이 듣고 있고 매칭되는 Ingress 규칙이 없다고 답한 것이라 성공이다. 502 면 Traefik 은 떴는데 뒤에 백엔드가 없는 것이고, 연결 거부나 타임아웃이면 02 단계의 노드 상태로 돌아간다. 미리 적어 두지 않으면 404 를 보고 nginx 설정부터 고치기 시작한다. 성공 신호를 적어 두지 않아 통과한 출력을 실패로 읽는 일은 이 실험대에서 이미 있었다. §189 은 nginx -t 가 Debian 12 에서 늘 같이 내놓는 경고 한 줄을 04 단계에서 실패로 오독하는 일이 실제로 벌어졌다고 적고, 그래서 경고와 오류를 구분하는 눈을 03 단계에서 들여 둔다.
05 단계도 같은 순서로 판정한다. 밖에서 200 이면 nginx 에서 Traefik, Ingress, Service 를 지나 파드까지 전부 이어졌다. 502 나 503 이면 Ingress 가 있는지, Service 뒤에 파드가 있는지, 파드가 Ready 인지를 뒤에서부터 되짚는다.
## 규칙
### 1. 가장 가까운 층에서 시작해 한 칸씩 밖으로 나오며 친다
밖에서 시작하면 응답 하나에 여섯 층이 전부 들어가 있어 어느 층이 답한 것인지 가릴 수 없다. 안쪽에서 시작해 한 칸씩 층을 더하면, 값이 처음 달라지는 칸에서 더한 층이 끊긴 층이다.
### 2. 층마다 성공 신호를 미리 적는다. 200 이 아닌 층이 있다
03 단계 네 칸의 실측은 404 와 301 과 301 과 200 이었다. 첫 칸의 404 와 둘째·셋째 칸의 301 은 그 층이 제 일을 했다는 뜻이라, 성공 신호를 적어 두지 않으면 이 셋이 전부 실패로 읽힌다.
### 3. 무엇이 잘못됐는지 모르는 동안에는 값만 뽑는 명령을 쓰지 않는다
§185 의 ① 이 확인 명령을 두 종류로 갈라 적는다. 실무자가 한 번 볼 때 치는 curl -I 는 헤더를 통째로 내놓고, 여러 번 재서 비교할 때 쓰는 curl -s -o /dev/null -w 는 골라 놓은 한 칸 말고 전부 버린다. 그래서 03 단계의 확인이 첫 칸에서 -I 로 시작해 넷째 칸에서 http_code 한 칸으로 줄어든다. 순서가 반대면 첫 칸에서 무엇이 잘못됐는지 알려 줄 헤더를 스스로 버리게 된다.
### 4. 층을 좁힌 뒤에는 그 층이 내는 문구와 errno 와 종료 코드를 읽는다
같은 「안 된다」가 층마다 다른 낱말로 나온다. nginx upstream 의 connect() failed (113: No route to host) 는 네트워크 쪽이고, (111: Connection refused) 는 프로세스 쪽이며, no live upstreams 는 둘 다 죽었다는 판단이다. 노드를 잃었을 때 이 세 줄이 1분 안에 순서대로 나왔다. k3s agent 노드의 dial tcp [::1]:8080: connect: connection refused 는 네트워크 문제가 아니라 kubeconfig 을 하나도 못 찾아 하드코딩된 기본값으로 넘어간 것이다. 파드의 Exit Code 도 그것만으로 말이 된다 — 137 은 OOM 이나 강제 종료, 1 은 애플리케이션이 스스로 끝낸 것, 127 은 명령을 못 찾은 것이다.
### 5. 그 층 안에 물어볼 도구가 없으면 밖에서 묻는다
Keycloak 컨테이너에는 curl 이 없다. 공식 이미지가 최소 구성이라 wget 도 nc 도 없고, 그때 나오는 것이 curl: command not found 와 command terminated with exit code 127 이다. 127 을 규칙 4 대로 읽으면 서버가 내려간 것이 아니라 명령이 없는 것이므로, 안에서 묻기를 그만두고 Prometheus 로 묻거나 curlimages/curl 임시 파드를 띄워 밖에서 묻는다.
## 적용 조건
- 프록시나 컨트롤러가 겹쳐 있어 밖에서 한 번 쳐서는 어디서 끊겼는지 알 수 없는 스택. 이 실험대의 요청 경로는 여섯 층이다
- 층마다 성공 신호를 미리 적을 수 있을 때. 모르면 그것부터 한 층씩 재서 적는다
- 각 칸을 어느 기계에서 치는지가 이미 정해져 있을 때
- 밖에서 200 이 나오면 전부 이어졌다고 말할 수 있는 스택. 05 단계가 그렇다
## 예외
치는 위치가 틀리면 층 판정이 통째로 무의미해진다. 04 단계의 확인을 엣지 게스트 안에서 치면 connect to 100.83.212.4 port 443 failed: Connection refused 가 돌아오는데, 이것은 어느 층의 답도 아니다. 엣지에서 나간 패킷은 호스트의 virbr0 으로 들어가고 DNAT 규칙은 tailscale0 으로 들어온 것만 매칭하므로 규칙에 안 걸리고, 호스트 443 에 리스너가 없어 거절된다. 그래서 이 절차를 쓰기 전에 각 칸을 어느 기계에서 치는지가 정해져 있어야 한다. 문서가 그것을 매번 적게 만드는 방법은 「단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다」에 있다.
네 칸이 다 통과하고도 나중에 터지는 것이 있다. DNAT 규칙의 443 을 433 으로 친 오타가 이 실험대에서 실제로 나왔는데, 433 도 유효한 포트라 nft 가 군말 없이 받고 80 은 멀쩡히 넘어가므로 03 단계의 확인은 다 통과하고 04 단계에서 443 쪽만 안 되는 형태로 뒤늦게 터진다.
이 절차는 어디서 끊겼는지를 좁힐 뿐 왜 끊겼는지를 말하지 않는다. 첫 칸에서 404 가 나와도 그 뒤의 값이 틀렸을 수 있고, 02 단계의 INTERNAL-IP 가 그렇다 — 두 노드가 Ready 인데 보고된 IP 가 우리가 준 값과 다르면 지금은 아무 증상이 없다가 03 단계의 upstream 에서 어긋난다. 로그를 읽어 좁히려다 잘린 문구를 붙들 수도 있다. nginx 에러 로그는 2048바이트에서 잘린다. 이 실험대에서도 502 원인이 error 로그에서는 잘린 채로 있었고, access 로그에는 3492자로 온전히 남아 있었다. 그쪽은 「도구가 낸 출력은 대상의 상태가 아니다」가 받는다.
제3부의 「packet 이 어디서 끊겼는지는 계층마다 capture 해서 가른다」와는 재는 것이 다르다. 그쪽은 네 지점에서 capture 를 떠 패킷이 사라진 구간을 좁히고, 이쪽은 층을 건너뛴 요청의 응답 코드로 좁힌다. 응답이 아예 안 돌아오고 패킷도 안 보이는 상태에서는 이 절차가 답을 못 내므로 그때 그쪽으로 넘어간다.
## 예시
- 첫 칸 : nginx 를 건너뛰고 curl -I http://192.168.122.11 을 쳐서 404. 이것이 성공 신호다
- 둘째 칸 : DNAT 을 건너뛰고 http://192.168.122.10 을 쳐서 301
- 셋째 칸 : 밖에서 http://auth.hyeonworks.com 을 쳐서 301 https://auth.hyeonworks.com/
- 넷째 칸 : TLS 이후 https://auth.hyeonworks.com/realms/master 를 쳐서 200
- 둘째 칸은 되는데 셋째 칸이 안 된다 : 막힌 곳이 DNAT 이다
- 둘째 칸에서 막힌다 : 막힌 곳이 엣지 안이다
- 첫 칸이 502 : Traefik 은 떴고 뒤에 백엔드가 없다
- 첫 칸이 연결 거부나 타임아웃 : 02 단계의 노드 상태로 돌아간다
- 노드를 잃었을 때 1분 안에 순서대로 나온 세 줄 : 113 은 네트워크, 111 은 프로세스, no live upstreams 는 둘 다 죽었다는 판단
- Keycloak 컨테이너 안에서 curl 이 exit code 127 : 서버가 아니라 명령이 없다. 밖에서 묻는다
- 04 단계 확인을 엣지 안에서 쳤을 때의 connection refused : 층의 답이 아니라 친 위치의 답이다
@@ -0,0 +1,115 @@
---
kind: REFERENCE
slug: tool-output-is-not-the-subject-state
title: 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
topic: build-completion-judgment
topicName: 끝났다는 판정
project: virtualization
status: 게시 전
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
source:
- final/document.md#191-단계-05-keycloak-2노드와-postgresql
- final/document.md#192-단계-06-prometheus-와-grafana
- final/document.md#186-단계-00-lab-host-가상화-준비
---
# 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다
상태를 묻는 명령의 출력을 대상의 상태로 바로 읽지 않고, 그 명령이 무엇을 세고 무엇을 안 세는지 먼저 적는다. 7800 포트를 끊었을 때 외부 응답은 전부 200 이었고, 503 이 나는 동안에도 up 은 1 이었다.
## 관계
- **빈 토큰이 조용히 흘러갔다 — 설치 출력은 성공이었고 agent 만 5초마다 다시 죽었다**
설치 명령의 출력과 설치된 상태가 어긋났다. 출력은 끝까지 성공이었고 실패는 journalctl 안에만 있었다.
- **cloud-init 이 안 도는 원인은 넷인데 증상은 「SSH 가 안 붙는다」 하나였다**
검사가 통과해도 안 도는 쪽과 검사에 걸려도 도는 쪽이 한 사건 안에 같이 있다. 검사 결과를 상태로 읽으면 어느 방향이든 틀린다는 것이 그 기록의 결론이다.
- **갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초**
로그 문구가 SUCCESS 인데 서빙되는 인증서는 옛것이었다. 판정을 문구가 아니라 워커 PID 로 옮긴 기록이다.
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
어디서 끊겼는지를 그 기준이 좁히고, 좁힌 층의 출력을 어떻게 읽는지를 이쪽이 받는다.
- **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가**
단계마다의 통과 조건 일곱이 전부 이런 출력이라, 다시 세운 실험대가 같은 상태인지를 무엇으로 판정할지가 그 물음에 걸려 있다.
## 목적
판정을 밖에서만 하면 놓친다. §192 는 그래서 클러스터 안을 보는 관측대(Prometheus 와 Grafana)를 따로 세웠다. 7800 포트를 끊었을 때 외부 응답이 전부 200 이었고, 분단된 노드가 스스로 로드밸런서에서 빠져 밖에서는 아무 일도 없어 보였다.
안쪽에 세운 관측대도 지표 하나로는 같은 실패를 되풀이한다. 503 이 나는 동안에도 up 은 1 이었다. 프로세스가 살아 있고 metrics 경로가 응답하기만 하면 1 이 되므로, 살아 있지만 쓸모없는 상태를 up 은 보지 못한다. 경보를 up 이 0 인지 하나로 걸면 그 상태를 통째로 놓친다.
같은 일이 구축 7단계 전체에서 되풀이된다. 목록이 비어 있어서 없다고 읽은 것, 검사기가 통과해서 동작한다고 읽은 것, 아무것도 안 찍혀서 멈췄다고 읽은 것이 전부 같은 오독이다. 이 기준은 그 셋을 하나로 묶고, 판정하기 전에 그 명령이 무엇을 세는지 적게 한다.
## 규칙
### 1. 그 명령이 무엇을 세는지 먼저 적는다
어느 연결에 붙어 있나. virsh 는 기본으로 qemu:///session 에 붙는데 VM 은 qemu:///system 에 만들므로, 어긋나면 VM 은 만들어졌는데 virsh list 에 안 나온다. 빈 목록이 VM 의 부재가 아니라 다른 연결을 보고 있다는 뜻이다.
꺼진 것도 세나. net-list 는 --all 을 빼면 inactive 인 네트워크가 아예 안 나와 「없음」과 「꺼짐」이 구분되지 않는다. 이 실험대에서 default 네트워크의 autostart 가 no 면 지금은 되고 호스트를 재부팅한 다음 01 단계의 SSH 가 전부 실패하는데, 그때는 원인을 게스트에서 찾게 된다.
이름대로 다 내놓나. kubectl get all 은 이름과 달리 Secret 과 ConfigMap 과 PVC 와 Ingress 를 내놓지 않으므로, 그 넷이 빠진 줄 모르고 다 만들어졌다고 판정하게 된다. -l app=postgres 에 Deployment 줄이 없는 것도 라벨을 파드 템플릿에만 달았기 때문이지 Deployment 가 없는 것이 아니다.
어디까지 남기나. nginx 에러 로그는 2048바이트에서 잘리고 쿠버네티스 이벤트는 기본 한 시간만 남는다. 이 실험대에서 502 원인이 잘린 채로 error 로그에 있었고 access 로그에는 3492자로 온전히 남아 있었다.
### 2. 빈 출력을 낼 때 「없다」와 「못 봤다」를 갈라 적는다
Prometheus 질의가 빈 배열을 내면 그 값이 0 이라는 뜻이 아니라 그런 지표가 없다는 뜻이다. 스크레이프 대상 목록에서는 거기 없는 이름이 답을 준다 — 이 실험대는 Redis 와 BFF 와 PostgreSQL 을 긁지 않으므로 그 지표가 안 나오는 것이 측정 실패가 아니라 측정된 공백이다. §192 는 그것을 스크린샷 누락이 아니라 측정된 공백으로 적었다. grep 이 아무것도 안 내놓을 때도 같다. ip-dhcp-host 로 grep 하면 DHCP 예약이 멀쩡히 들어가 있어도 아무것도 안 나오는데, 그것은 net-update 의 섹션 이름이라 XML 안에 그 문자열이 없기 때문이다. 이벤트가 하나도 없는 것도 무사하다는 뜻이 아니라 한 시간이 지났다는 뜻일 수 있다.
### 3. 한 근거로 판정하지 않고 시제나 층이 다른 것을 함께 본다
§191 이 클러스터가 섰는지를 근거 셋으로 보고, 그 셋이 서로 다른 것을 본다고 적었다. 로그 ISPN000094 는 「그때 그렇게 보였다」이고, 테이블 jgroups_ping 은 「지금 등록되어 있다」이며, 지표 vendor_cluster_size 는 「지금 그 노드가 그렇게 안다」다. 테이블에는 둘 다 있는데 로그가 (1) 이면 서로를 찾기는 했는데 7800 포트로 메시지가 안 가는 것이고, 이 실험대에서 실제로 그 일이 벌어졌다. 각 노드가 자기가 아는 멤버 수를 보고하므로 한 노드만 보면 분단을 놓친다.
저장과 주입도 다른 층이다. describe 가 보여 주는 19 bytes 와 22 bytes 는 Secret 에 저장된 값이고, 파드 안에서 잰 길이 19 는 그 파드가 받은 값이다. 두 수가 같아야 Secret 에서 파드 환경변수까지 이어진 것이고, 길이가 0 이면 Secret 에는 있는데 이 파드가 그것을 안 받았다.
보고된 값과 준 값도 다르다. kubectl get nodes -o wide 의 INTERNAL-IP 는 k3s 가 보고한 값이고 systemctl cat 의 ExecStart 줄은 우리가 준 값이라 둘을 견준다. 값을 안 찍고 길이만으로 확인하는 §185 의 ② 도 같은 갈래다. 비밀은 값을 보지 않고 0 이 아니라는 것만 확인한다.
### 4. 검사기가 통과한 것을 동작하는 상태로 읽지 않는다
sites-available 을 site-available 로 잘못 치면 빈 새 파일이 열리고, 저장해도 nginx 는 그 파일을 읽지 않는데 nginx -t 는 멀쩡히 통과한다. 아무 에러 없이 아무 일도 안 일어나므로, 설정을 썼는데 변화가 없으면 경로 오타부터 의심한다. nft 도 같다. .nft 의 포트를 433 으로 쳐도 433 이 유효한 포트라 군말 없이 받고 80 은 멀쩡히 넘어가므로, 03 단계는 다 통과한 뒤 04 단계에서 HTTPS 만 안 되는 형태로 드러난다. 이 실험대에서 실제로 나왔던 오타다.
노드도 그렇다. kubectl get nodes 두 줄이 Ready 여도 -o wide 의 INTERNAL-IP 는 --node-ip 로 준 값과 다를 수 있다. 그때는 지금 아무 증상이 없다가 03 단계의 upstream 과 노드 상실 실험에서 어긋난다. 유닛 이름이 노드마다 달라 agent 노드에서 systemctl stop k3s 를 치면 아무 일도 일어나지 않고, 그것이 「주입했는데 증상이 없다」로 읽힌다.
파드 둘이 Running 이어도 Endpoints 가 하나면 트래픽은 이미 한쪽으로만 가고 있고, 그 상태에서 이중화 실험을 하면 그것을 이중화 실패로 오독하게 된다. node-exporter 로 시작하는 줄이 하나뿐일 때도 같다. 그 노드의 CPU 와 메모리와 디스크 지표가 통째로 없는 채로 실험을 하게 된다.
인증서도 같다. cert.pem 을 쓰면 중간 인증서가 빠져 체인이 끊긴다. 그런데 브라우저는 대개 캐시나 AIA(Authority Information Access) 로 보완해서 정상으로 보이고, 캐시가 없는 클라이언트에서만 깨진다. 그래서 체인이 이어졌는지는 openssl s_client 의 단계 수로 판정한다. 이 실험대의 실측은 0 부터 3 까지 네 단계와 Verify return code: 0 (ok) 였고, 단계가 1개면 cert.pem 을 쓴 것이다.
### 5. 침묵과 경고도 상태가 아니다
kubectl rollout status 는 끝날 때까지 아무것도 안 찍고 그 침묵이 정상이다. StatefulSet 은 파드를 하나씩 순서대로 띄우므로 중간에 0/2 로 한참 멈춰 있는 것도 정상이고, 300초를 다 쓰고 타임아웃으로 끝나면 그것도 답이다 — 안 떴다가 확정된다. 반대쪽에서는 경고가 실패로 읽힌다. nginx -t 의 [warn] could not build optimal types_hash 줄은 통과를 막지 않고, 실패는 [emerg] 줄에 파일과 줄 번호로 나온다. 04 단계에서 이 경고를 실패로 오독하는 일이 실제로 벌어졌다.
## 적용 조건
- 상태를 묻는 명령의 출력으로 구축 단계의 통과를 판정할 때
- 같은 대상을 보는 명령이 여럿이고 서로 다른 시제나 층을 볼 때. 로그와 테이블과 지표가 그런 셋이다
- 검사기나 문법 검사가 앞에 있는 단계. nginx -t 와 nft 와 cloud-init 스키마 검사기가 그렇다
- 목록이나 질의 결과가 비어 있을 때. 판정하기 전에 그 명령이 무엇을 세는지 먼저 적는다
## 예외
이 기준은 출력을 상태로 읽는 오독을 잡고, 출력 자체가 정확한지는 보지 않는다. 세 근거가 다 통과로 나와도 그 셋이 다 같은 층에서 나왔으면 여전히 한 근거다. 밖에서 친 200 이 분단을 가린 것이 그런 경우이고, 그래서 관측대를 클러스터 안쪽에 따로 세웠다. 그 관측대도 up 하나로는 같은 실패를 되풀이하므로 기능 지표를 함께 본다.
근거를 늘리는 데는 비용이 든다. 명령이 늘고 손으로 치는 선을 넘으면 파서를 짜게 되는데, §192 는 그 선을 grep -o 와 tr 로 쉼표마다 줄을 나누는 데까지로 그었다. 그 이상 가공해야 하면 파서를 짜지 않고 화면에 나온 JSON 을 그대로 읽는다.
「없다」와 「못 봤다」를 가르는 일도 도구가 대신해 주지 않는다. 스크레이프 대상 목록에서 없는 이름을 알아보려면 사람이 그 이름을 미리 알고 있어야 한다. Redis 와 BFF 와 PostgreSQL 이 빠진 것을 그 목록만 보고 알아낼 방법은 없다.
이 기준의 근거는 대부분 이 실험대 한 대에서 한 번씩 본 것이다. 다른 판 번호나 다른 배포판에서 같은 명령이 같은 것을 세는지는 재지 않았다.
가이드에 실린 출력이 이 호스트의 것인지도 한 군데에서 어긋난다. §186 의 실측 줄은 이 호스트가 16 코어 전부에서 지원한다고 적었는데 §178 의 대상 환경은 논리 코어 8(i5-1135G7)이다. 어느 쪽이 이 호스트의 값인지는 재지 않았다.
## 예시
- virsh list 가 비었다 : VM 이 없는 것인가 qemu:///session 에 붙은 것인가. virsh uri 로 가른다
- net-list 에 그 네트워크가 없다 : --all 을 줬는가. 없음과 꺼짐이 구분되나
- kubectl get all 이 다 나왔다 : Secret · ConfigMap · PVC · Ingress 는 거기 없다. 따로 한 번 더 친다
- Prometheus 질의가 빈 배열 : 0 이 아니라 그런 지표가 없다
- 스크레이프 대상에 Redis · BFF · PostgreSQL 이 없다 : 측정 실패가 아니라 측정된 공백이다
- ip-dhcp-host 로 grep 해서 아무것도 안 나온다 : 섹션 이름이라 XML 에 그 문자열이 없다. host mac 으로 찾는다
- 이벤트가 하나도 없다 : 기본 한 시간만 남는다. 무사하다는 뜻이 아니다
- 로그는 (1) 인데 jgroups_ping 에는 둘 다 있다 : 서로를 찾았고 7800 으로 메시지가 안 간다
- describe 가 19 bytes 인데 파드 안 길이가 0 : 저장은 됐고 주입이 안 됐다
- 두 노드가 Ready 인데 INTERNAL-IP 가 --node-ip 와 다르다 : 지금 증상 없음. 03 과 노드 상실 실험에서 터진다
- nginx -t 통과 : site-available 로 잘못 쳐도 통과한다. 설정을 썼는데 변화가 없으면 경로 오타다
- nft 가 433 을 받았다 : 80 은 되고 04 에서 HTTPS 만 안 된다
- 파드 둘이 Running 인데 Endpoints 가 하나 : 이미 한쪽으로만 가고 있다
- node-exporter 줄이 하나 : 그 노드의 지표가 통째로 없다
- up 이 1 : 503 중에도 1 이었다. 기능 지표를 함께 본다
- rollout status 가 아무것도 안 찍는다 : 정상이다. 타임아웃으로 끝나는 것도 답이다