docs(guides): bring the seven setup guides in line with the practitioner skill

The guides were written before the skill existed and a scored audit found
three gaps. Fixed by a subagent running under the skill, with measured
values, versions, IPs and quoted output declared off limits.

The big one: every step showed a command and its output, and almost none
said which line to look at or what it meant. 64 interpretation pairs added
across the seven files, weighted where the reading is hardest — 20 in the
Keycloak stage, where a Secret existing and a pod having received it are
different facts.

Only the extracting form of curl appeared. Where the reader meets a response
for the first time the guides now open with curl -I or curl -v and name the
lines worth reading; -w '%{http_code}' survives only where the code is a
value being compared — two upstream nodes against each other, or the 900-run
control loop.

Listing Secret keys went from a three-stage pipe to kubectl describe secret,
which prints the key names and their byte counts in one native command
without exposing a value.

And a tool assumption: jq and yamllint are installed on neither the lab host
nor the guests. The guides now say so where JSON is read by eye, rather than
sending the reader to install something mid-diagnosis. cloud-init schema is
on the guests and is now the guest-side check.

Also removes a stray Playwright screenshot committed at the repository root
in 919547a; the evidence copy under docs/evidence/b7a-orphan-session/ is the
one the document references.

Four things the audit left standing are recorded in the agent's report rather
than papered over — notably that 04's reload measurements are stated without
a reproduction procedure, and that 05 and 06 reference each other as
prerequisites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-07 17:33:31 +09:00
co-authored by Claude Opus 5
parent 88b7bd4bf0
commit 8062cc9a19
8 changed files with 816 additions and 87 deletions
+144 -12
View File
@@ -30,11 +30,23 @@ sudo certbot certonly --webroot -w /var/www/html \
-d auth.hyeonworks.com -d app1.hyeonworks.com -d app2.hyeonworks.com
```
**확인**
**확인** — 인증서가 실제로 생겼고 이름 셋이 다 들어갔는가
```bash
sudo certbot certificates
```
**어디를 봐야 하는가** — 네 줄이다. `Domains:`**이름 세 개가 한 줄에**
나열되어 있는가(줄이 두 개로 갈라져 있으면 인증서가 두 장 만들어진 것이다),
`Expiry Date:` 가 오늘로부터 90일 뒤이고 `VALID` 인가, `Certificate Path:`
`/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem` 인가, 그리고
`Private Key Path:` 가 그 옆의 `privkey.pem` 인가. **경로 두 개가 03 의
nginx 설정에 적은 것과 한 글자도 다르면 안 된다.**
**이 결과가 의미하는 것** — 여기 나온 경로가 곧 nginx 가 읽을 파일이다.
발급은 성공했는데 `No certificates found` 가 나오면 `sudo` 없이 쳐서 다른
사용자의 설정 디렉터리를 본 것이다. 발급 자체가 실패했다면 검증 단계에서
막힌 것이므로 DNS 와 80 포트부터 본다(아래 「막히면」).
> **이 실험대는 와일드카드를 쓰지 않았고, 그 비용이 나중에 청구됐다.**
> B-7 에서 oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 `app2` 를
> 빌려야 했다. 와일드카드는 DNS-01 검증이 필요하고 그건 DNS 공급자 API 를
@@ -60,7 +72,37 @@ sudo nginx -t && sudo systemctl reload nginx
## 4. 확인 — 열리는가, 체인이 완전한가
**확인 ①** 열리나
**확인 ①** 열리나**처음 한 번은 협상 과정을 읽는다**
```bash
curl -v https://auth.hyeonworks.com/realms/master -o /dev/null
```
**형태** (이 실험대에서 캡처해 두지 않았다 — 읽어야 할 줄만)
```
* SSL connection using TLSv1.3 / ...
* subject: CN=auth.hyeonworks.com
* issuer: C=US; O=Let's Encrypt; CN=...
* SSL certificate verify ok.
> GET /realms/master HTTP/1.1
< HTTP/1.1 200 OK
```
**어디를 봐야 하는가**`*` 로 시작하는 줄 넷이다. 어떤 TLS 판으로
협상했는가, `subject` 의 CN 이 지금 친 이름과 같은가, `issuer` 가 Let's
Encrypt 인가, 그리고 **`SSL certificate verify ok.`** 가 있는가. 그 아래
`<` 로 시작하는 첫 줄이 응답 상태다. `-o /dev/null` 은 본문만 버리는 것이라
이 줄들은 그대로 남는다.
**이 결과가 의미하는 것** — 이 네 줄이 다 나오면 인증서가 붙었고 체인이
클라이언트 기준으로 검증됐다. TLS 에서 막힐 때 봐야 할 것이 전부 여기
있으므로, **인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을
본다.** `verify` 줄 대신 `unable to get local issuer certificate` 가 나오면
중간 인증서가 빠진 것이고, 그 원인은 3번의 `cert.pem`/`fullchain.pem`
이다 — 확인 ②로 간다.
같은 것을 반복해서 재거나 03·05 의 값과 나란히 비교할 때만 값만 뽑는
형태로 줄인다.
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
@@ -88,7 +130,19 @@ echo | openssl s_client -connect auth.hyeonworks.com:443 \
Verify return code: 0 (ok)
```
**단계가 1개면 `cert.pem` 을 쓴 것이다.**
**어디를 봐야 하는가** — 왼쪽의 **번호(0·1·2·3)가 몇까지 가는가**, 그리고
각 단계의 `i:`(발급자)가 **바로 다음 단계의 `s:`(주체)와 같은가**. 0번이
우리 서버 인증서이고, 위 실측에서 0의 `i:``CN = YE2` 인데 1의 `s:`
같은 `CN = YE2` 다 — 사슬이 이어져 있다는 뜻이다. 마지막이
`Verify return code: 0 (ok)`.
**이 결과가 의미하는 것****단계가 1개면 `cert.pem` 을 쓴 것이다.**
서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태다. 이때 브라우저는
대개 캐시나 AIA 로 보완해서 **정상으로 보이므로**, 이 명령이 유일하게
믿을 수 있는 판정이다. 고치는 곳은 03 의 `ssl_certificate` 한 줄이고,
고친 뒤 `nginx -t && systemctl reload nginx` 하고 여기서 다시 잰다.
`Verify return code` 가 0 이 아니면 숫자마다 뜻이 다르다 —
`10` 은 만료, `20` 은 발급자를 못 찾음, `21` 은 첫 인증서를 검증 못 함.
**확인 ③** 이름 세 개가 한 인증서인가
```bash
@@ -98,7 +152,18 @@ for H in auth app1 app2; do
done
```
일련번호가 셋 다 같으면 SAN 하나에 들어 있는 것이다.
**어디를 봐야 하는가** — 찍히는 세 줄의 **일련번호가 서로 같은가**. 값 자체는
아무 의미가 없고 **셋이 일치하는지만** 본다.
**이 결과가 의미하는 것** — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는
인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라
**갱신 훅도 장마다 따로 돌고**, 한 장만 갱신됐을 때 나머지 이름이 만료되는
상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.
```bash
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
```
## 5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다
@@ -109,7 +174,15 @@ done
systemctl list-timers certbot-renew.timer
```
이것이 `active` 여도 갱신된 인증서가 **서빙되지는 않는다.** nginx 는 인증서를
**어디를 봐야 하는가** — 네 칸이다. `NEXT`(다음 실행 시각)와 `LEFT`(남은
시간)가 채워져 있는가, `LAST`/`PASSED` 가 하루 안쪽인가, `UNIT` 옆의
`ACTIVATES``certbot-renew.service` 를 가리키는가. **표가 통째로 비어
나오면 타이머가 없는 것이다** — 이름이 배포판마다 다르니
`systemctl list-timers --all | grep -i certbot` 로 찾는다.
**이 결과가 의미하는 것** — 여기까지가 「갱신이 돌기는 하는가」의 답이고,
대부분의 문서가 여기서 끝난다. **그런데 이것이 `active` 여도 갱신된 인증서가
서빙되지는 않는다.** nginx 는 인증서를
기동 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 심볼릭 링크만 갈아
끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.**
@@ -125,7 +198,16 @@ ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
```
`ExecStartPost``--deploy-hook` 도 없다.
**어디를 봐야 하는가**`ExecStart=` 한 줄과, 그 아래에 `ExecStartPost=`
**있는지 없는지**. 그리고 `ExecStart` 의 인자에 `--deploy-hook` 이 붙어
있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.
**이 결과가 의미하는 것**`ExecStartPost``--deploy-hook` 도 없다.
즉 이 유닛은 **인증서를 새로 받는 데까지만** 책임지고, 받은 것을 누가
읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 —
「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는
배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만
확인하면 된다.
**하기** — 훅 하나를 넣는다
```bash
@@ -140,6 +222,23 @@ sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
갈아치운다. `deploy/`**실제로 갱신됐을 때만** 실행된다.
**확인** — 실제로 도는지
이 확인은 **상태를 바꾼다.** `--force-renewal` 은 인증서를 실제로 새로
받으므로 발급 한도(주당 중복 5장)를 깎는다. 먼저 `--dry-run` 으로 훅이
호출되는 것까지만 보고, 진짜 판정이 필요할 때만 강제 갱신을 한 번 쓴다.
```bash
sudo certbot renew --dry-run
```
**어디를 봐야 하는가** — 출력 끝의 `Running deploy-hook command` 줄과
`simulated renewals` 요약. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌
곳에 있거나 실행 권한이 없는 것이다(`ls -l``x` 를 본다).
**이 결과가 의미하는 것** — dry-run 은 훅이 **호출되는지**까지만 말해 준다.
호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 —
그래서 아래를 한 번 한다.
```bash
# 강제 갱신 전에 워커 PID 를 적어 둔다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
@@ -150,6 +249,15 @@ sudo certbot renew --force-renewal
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
```
**어디를 봐야 하는가** — 두 출력의 **첫 열(PID)과 둘째 열 묶음(lstart, 프로세스
시작 시각)**. 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. `lstart`
같이 뽑는 이유는 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터
프로세스는 그대로이고 **워커만** 갈리는 것이 정상이다.
**이 결과가 의미하는 것** — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를
읽었다. 안 바뀌었으면 인증서는 갱신됐는데 **서빙되는 것은 옛것**이고,
이 상태가 아래 표의 왼쪽 칸이다.
**판정은 로그 문구가 아니라 워커 PID 로 한다.** certbot 이
`Hook 'deploy-hook' ran with error output` 이라고 찍는데 **실패가 아니다**
nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은
@@ -188,7 +296,7 @@ nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은
| 증상 | 원인 | 확인 |
|---|---|---|
| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short <name>` · 밖에서 `curl http://<name>` |
| 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** |
@@ -213,15 +321,39 @@ done > /tmp/control.txt
awk '{print $1}' /tmp/control.txt | sort | uniq -c
```
이 실험대의 대조군은 **900건 전부 200, 오류 0** 이었다. 그래서 갱신 중 비200 이
한 번이라도 나오면 갱신 탓으로 귀속할 수 있었다.
**여기서는 값만 뽑는 형태가 맞다.** 900번을 재서 코드별로 세는 것이 목적이고,
헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.
**그리고 시각을 비교할 때 시계를 확인한다.** 이 실험대는 test-server 가 NTP
미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초
먼저 실행된 것이 되어 물리적으로 불가능했다.
**어디를 봐야 하는가**`uniq -c` 가 내놓는 **줄이 몇 개인가**. 한 줄이면
900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다.
두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면
둘째 열을 따로 본다.
```bash
awk '{print $2}' /tmp/control.txt | sort -n | tail -1 # 최악값
```
**이 결과가 의미하는 것** — 이 실험대의 대조군은 **900건 전부 200, 오류 0**
이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수
있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도
증명하지 못하므로, **대조군이 깨끗해질 때까지는 주입을 하지 않는다.**
**그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.**
```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
```
**어디를 봐야 하는가** — 세 수 `A`·`B`·`C` 를 눈으로 빼서 **초 단위 차이가
몇인가**. `A``C` 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그
가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 `Date:` 헤더의
시각이 둘 중 어느 쪽에 가까운가. 마지막으로 `NTPSynchronized=yes` 인가.
**이 결과가 의미하는 것** — 차이가 수초 이내면 두 기계의 로그를 그대로
나란히 놓아도 된다. 크면 **먼저 어느 쪽이 틀렸는지 가른 다음** 보정한다.
이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫
계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로
불가능했다 — **음수 지연이 나오면 계산이 아니라 시계를 의심한다.**