Files
keycloak-pattern/docs/guides/04-tls/README.md
T
DongHyeonkaandClaude Opus 5 8062cc9a19 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>
2026-09-07 17:33:31 +09:00

16 KiB
Raw Blame History

04 — TLS

이 단계가 끝나면

https:// 가 열리고 체인이 완전하며, 갱신이 자동으로 서빙까지 닿는다.

전제

03 이 끝나 nginx 가 Traefik 으로 프록시한다. 그리고 공개 DNS 에 이름 세 개가 이 호스트를 가리키고 있어야 한다 — Let's Encrypt 가 HTTP-01 로 검증하러 오기 때문이다.


1. certbot 을 깐다

하기

sudo pacman -S certbot certbot-nginx      # Arch
sudo apt install certbot python3-certbot-nginx   # Debian/Ubuntu

2. 인증서를 받는다

이름 세 개를 한 인증서에 넣는다.

하기

sudo certbot certonly --webroot -w /var/www/html \
  -d auth.hyeonworks.com -d app1.hyeonworks.com -d app2.hyeonworks.com

확인 — 인증서가 실제로 생겼고 이름 셋이 다 들어갔는가

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 를 붙여야 한다 — 그 절충을 안 한 결과다.

3. nginx 가 fullchain 을 보게 한다

03 의 설정에 이미 있다.

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 로 보완해서 정상으로 보이고, 캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다.

하기

sudo nginx -t && sudo systemctl reload nginx

4. 확인 — 열리는가, 체인이 완전한가

확인 ① 열리나 — 처음 한 번은 협상 과정을 읽는다

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 의 값과 나란히 비교할 때만 값만 뽑는 형태로 줄인다.

curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
200

확인 ② 체인 단계와 검증

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)

어디를 봐야 하는가 — 왼쪽의 번호(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 은 첫 인증서를 검증 못 함.

확인 ③ 이름 세 개가 한 인증서인가

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 을 직접 편다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName

5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다

타이머가 도는 것만으로는 부족하다.

확인 — 타이머

systemctl list-timers certbot-renew.timer

어디를 봐야 하는가 — 네 칸이다. NEXT(다음 실행 시각)와 LEFT(남은 시간)가 채워져 있는가, LAST/PASSED 가 하루 안쪽인가, UNIT 옆의 ACTIVATEScertbot-renew.service 를 가리키는가. 표가 통째로 비어 나오면 타이머가 없는 것이다 — 이름이 배포판마다 다르니 systemctl list-timers --all | grep -i certbot 로 찾는다.

이 결과가 의미하는 것 — 여기까지가 「갱신이 돌기는 하는가」의 답이고, 대부분의 문서가 여기서 끝난다. 그런데 이것이 active 여도 갱신된 인증서가 서빙되지는 않는다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고, certbot 은 live/ 심볼릭 링크만 갈아 끼운다. 경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.

배포판 기본 유닛에는 reload 를 부르는 것이 없다.

systemctl cat certbot-renew.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true

어디를 봐야 하는가ExecStart= 한 줄과, 그 아래에 ExecStartPost=있는지 없는지. 그리고 ExecStart 의 인자에 --deploy-hook 이 붙어 있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.

이 결과가 의미하는 것ExecStartPost--deploy-hook 도 없다. 즉 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 누가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 — 「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는 배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만 확인하면 된다.

하기 — 훅 하나를 넣는다

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/실제로 갱신됐을 때만 실행된다.

확인 — 실제로 도는지

이 확인은 상태를 바꾼다. --force-renewal 은 인증서를 실제로 새로 받으므로 발급 한도(주당 중복 5장)를 깎는다. 먼저 --dry-run 으로 훅이 호출되는 것까지만 보고, 진짜 판정이 필요할 때만 강제 갱신을 한 번 쓴다.

sudo certbot renew --dry-run

어디를 봐야 하는가 — 출력 끝의 Running deploy-hook command 줄과 simulated renewals 요약. 훅 줄이 아예 안 나오면 파일이 deploy/ 가 아닌 곳에 있거나 실행 권한이 없는 것이다(ls -lx 를 본다).

이 결과가 의미하는 것 — dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 — 그래서 아래를 한 번 한다.

# 강제 갱신 전에 워커 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)과 둘째 열 묶음(lstart, 프로세스 시작 시각). 앞뒤로 놓고 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 하는 감시를 걸면 성공한 훅을 실패로 오독한다.

실측 — 이 실험대에서 잰 차이

훅 없음 훅 있음
갱신 → 서빙 2305초 (38분 25초) 1~2초
무엇이 reload 했나 사람이 직접 certbot deploy 훅
아무도 안 했다면 다음 nginx 재시작까지 = 사실상 무기한

88일 동안 이 결함이 보이지 않는다. 타이머는 정상이고 매번 SUCCESS 로 끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.

원문: D-4 · D-4a · 증거 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 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 으로 먼저 시험

근거를 재려면 (선택)

평소에는 필요 없다. 문서에 남길 근거가 필요할 때만 이렇게까지 한다.

갱신 중 가용성을 재려면 주입 전에 대조군부터 잡는다. 평시 오류율을 모르면 갱신 중에 나온 실패 한 건을 해석할 수 없다.

# 대조군 — 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번을 재서 코드별로 세는 것이 목적이고, 헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.

어디를 봐야 하는가uniq -c 가 내놓는 줄이 몇 개인가. 한 줄이면 900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다. 두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면 둘째 열을 따로 본다.

awk '{print $2}' /tmp/control.txt | sort -n | tail -1     # 최악값

이 결과가 의미하는 것 — 이 실험대의 대조군은 900건 전부 200, 오류 0 이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수 있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도 증명하지 못하므로, 대조군이 깨끗해질 때까지는 주입을 하지 않는다.

그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.

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 를 눈으로 빼서 초 단위 차이가 몇인가. AC 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그 가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 Date: 헤더의 시각이 둘 중 어느 쪽에 가까운가. 마지막으로 NTPSynchronized=yes 인가.

이 결과가 의미하는 것 — 차이가 수초 이내면 두 기계의 로그를 그대로 나란히 놓아도 된다. 크면 먼저 어느 쪽이 틀렸는지 가른 다음 보정한다. 이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다 — 음수 지연이 나오면 계산이 아니라 시계를 의심한다.