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>
228 lines
7.8 KiB
Markdown
228 lines
7.8 KiB
Markdown
# 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:'
|
||
```
|