Files
keycloak-pattern/docs/guides/04-tls/README.md
T
DongHyeonkaandClaude Opus 5 88b7bd4bf0 docs(guides): hands-on guides for the seven setup stages
The experiment documents record what was found. These record what to type to
reproduce it, in folders per stage.

Two kinds of command are kept apart. 하기/확인 is what somebody actually types
at a terminal — short, one at a time. 근거를 재려면 is the long measuring form
this lab used to put evidence in a document, marked as not needed day to day.
The same split applies to curl: -I to look once, -w '%{http_code}' only when
comparing across repetitions.

No placeholders. Where a value is needed the command that produces it is
given, and secrets are checked by length rather than printed:

  TOKEN=$(ssh kc-lab-1 'sudo cat /var/lib/rancher/k3s/server/node-token')
  echo "${#TOKEN} 자"

Stage 05 verifies resources in layers, because a Secret existing and a pod
having received it are different facts: keys, then length, then the value
inside the container, then which env var came from which Secret. Same for
workloads — Deployment to ReplicaSet to Pod, with the seven ReplicaSets this
cluster actually carries as the worked example.

Two commands were wrong and re-running them caught it. kubectl get endpoints
prints a deprecation warning on v1.33+, so the guide uses describe svc and
EndpointSlice. And the Keycloak image has no curl, so reading metrics from
inside the container fails with exit 127 — the guide asks Prometheus instead,
or runs a throwaway curl pod.

Read-only checks were executed against the running lab and their output is
quoted verbatim. Creating commands could not be re-run without destroying the
lab, so they are the ones used at build time; the README says which is which.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:48:10 +09:00

228 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04 — TLS
## 이 단계가 끝나면
`https://` 가 열리고 체인이 완전하며, 갱신이 자동으로 **서빙까지** 닿는다.
## 전제
[03](../03-nginx/) 이 끝나 nginx 가 Traefik 으로 프록시한다. 그리고 **공개
DNS 에 이름 세 개가 이 호스트를 가리키고 있어야 한다** — Let's Encrypt 가
HTTP-01 로 검증하러 오기 때문이다.
---
## 1. certbot 을 깐다
**하기**
```bash
sudo pacman -S certbot certbot-nginx # Arch
sudo apt install certbot python3-certbot-nginx # Debian/Ubuntu
```
## 2. 인증서를 받는다
이름 세 개를 **한 인증서**에 넣는다.
**하기**
```bash
sudo certbot certonly --webroot -w /var/www/html \
-d auth.hyeonworks.com -d app1.hyeonworks.com -d app2.hyeonworks.com
```
**확인**
```bash
sudo certbot certificates
```
> **이 실험대는 와일드카드를 쓰지 않았고, 그 비용이 나중에 청구됐다.**
> B-7 에서 oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 `app2` 를
> 빌려야 했다. 와일드카드는 DNS-01 검증이 필요하고 그건 DNS 공급자 API 를
> 붙여야 한다 — 그 절충을 안 한 결과다.
## 3. nginx 가 `fullchain` 을 보게 한다
[03](../03-nginx/) 의 설정에 이미 있다.
```
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
```
**`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가
빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**,
캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다.
**하기**
```bash
sudo nginx -t && sudo systemctl reload nginx
```
## 4. 확인 — 열리는가, 체인이 완전한가
**확인 ①** 열리나
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
```
```
200
```
**확인 ②** 체인 단계와 검증
```bash
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'
```
**실측**
```
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)
```
**단계가 1개면 `cert.pem` 을 쓴 것이다.**
**확인 ③** 이름 세 개가 한 인증서인가
```bash
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 하나에 들어 있는 것이다.
## 5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다
**타이머가 도는 것만으로는 부족하다.**
**확인** — 타이머
```bash
systemctl list-timers certbot-renew.timer
```
이것이 `active` 여도 갱신된 인증서가 **서빙되지는 않는다.** nginx 는 인증서를
기동 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 심볼릭 링크만 갈아
끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.**
배포판 기본 유닛에는 reload 를 부르는 것이 없다.
```bash
systemctl cat certbot-renew.service
```
```
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
```
`ExecStartPost``--deploy-hook` 도 없다.
**하기** — 훅 하나를 넣는다
```bash
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/sh
nginx -t && nginx -s reload
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
```
`deploy/` 에 넣는다. `post/` 는 갱신이 없어도 매번 돌아 하루 두 번 워커를
갈아치운다. `deploy/`**실제로 갱신됐을 때만** 실행된다.
**확인** — 실제로 도는지
```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
```
**판정은 로그 문구가 아니라 워커 PID 로 한다.** certbot 이
`Hook 'deploy-hook' ran with error output` 이라고 찍는데 **실패가 아니다**
nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은
`test is successful` · `signal process started` 다.
> **로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.**
**실측** — 이 실험대에서 잰 차이
| | 훅 없음 | 훅 있음 |
|---|---|---|
| 갱신 → 서빙 | **2305초 (38분 25초)** | **1~2초** |
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — |
**88일 동안 이 결함이 보이지 않는다.** 타이머는 정상이고 매번 `SUCCESS`
끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다.
발현하는 날의 증상은 **인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고
적혀 있다.
원문: [D-4](../../experiment-d4-certificate-renewal.md) ·
[D-4a](../../experiment-d4a-deploy-hook.md) ·
증거 [`evidence/d4-certificate-renewal/`](../../evidence/d4-certificate-renewal/)
## 6. reload 는 무중단인가 — 쟀다
궁금할 것이므로 결과만 적는다. **무중단이다.**
새 연결 8856건 전부 200, p95 는 205.7ms 대 204.3ms 로 변화 없음. 그리고
845KB 를 20k/s 로 받는 중이던 요청이 **전송 12초째에 reload 를 맞고도**
845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
---
## 막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short <name>` · 밖에서 `curl http://<name>` |
| 체인 단계가 1개 | `cert.pem` 을 씀 | 위 확인 ② |
| 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` |
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** |
| 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 |
---
## 근거를 재려면 (선택)
평소에는 필요 없다. **문서에 남길 근거가 필요할 때만** 이렇게까지 한다.
갱신 중 가용성을 재려면 **주입 전에 대조군부터** 잡는다. 평시 오류율을 모르면
갱신 중에 나온 실패 한 건을 해석할 수 없다.
```bash
# 대조군 — 0.2초 × 900회 = 180초
i=0; while [ $i -lt 900 ]; do
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \
https://auth.hyeonworks.com/realms/master
i=$((i+1)); sleep 0.2
done > /tmp/control.txt
awk '{print $1}' /tmp/control.txt | sort | uniq -c
```
이 실험대의 대조군은 **900건 전부 200, 오류 0** 이었다. 그래서 갱신 중 비200 이
한 번이라도 나오면 갱신 탓으로 귀속할 수 있었다.
**그리고 시각을 비교할 때 시계를 확인한다.** 이 실험대는 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:'
```