Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.
Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.
The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:
A-0 emptying the session table without a restart leaves cache entries
that get counted as replication arriving
A-2 dropping -o /dev/null fuses body and status into one string
A-3 presence of "ready to accept connections" instead of its timestamp
B-2 row count alone reads an UPDATE as nothing having happened
B-4 tr ',' '\n' splits ["admin","editor"] so only admin is seen
B-7 no login screen means the cookie died and SSO re-authenticated
C-1 counting sessions without joining realm counts your own kcadm one
D-1 kubectl exec without -i restores nothing and still exits 0
D-4a "ran with error output" is what success looks like
Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.
Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.
Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1164 lines
50 KiB
Markdown
1164 lines
50 KiB
Markdown
# D-4 재현 가이드 — 갱신은 성공했는데 왜 옛 인증서가 나가는지 직접 본다
|
||
|
||
해설 문서: [`docs/experiment-d4-certificate-renewal.md`](../../experiment-d4-certificate-renewal.md) ·
|
||
증거 원문: [`docs/evidence/d4-certificate-renewal/`](../../evidence/d4-certificate-renewal/)
|
||
|
||
## 이 가이드가 끝나면
|
||
|
||
당신 터미널에서 이것들을 **직접 본다.**
|
||
|
||
| 보게 되는 것 | 어디서 |
|
||
|---|---|
|
||
| 이름 세 개가 한 인증서에 들어 있는 것 (와일드카드 아님) | `openssl s_client` |
|
||
| 체인이 4단계이고 `Verify return code: 0` 인 것 | 같은 명령 |
|
||
| 타이머는 `SUCCESS` 인데 **reload 를 부르는 것이 아무 데도 없는** 것 | `systemctl cat` · 훅 3개 디렉터리 |
|
||
| nginx 워커가 **22.4시간째 그대로**인 것 = reload 가 한 번도 없었다 | `ps -eo lstart` |
|
||
| 두 기계 시계가 **106초** 어긋나 있는 것 | `date` · `Date:` 헤더 |
|
||
| 갱신에 성공했는데 밖에서 본 일련번호가 **안 바뀌는** 것 | 5초 간격 감시 |
|
||
| 사람이 reload 한 **그 순간** 바뀌는 것 | 같은 감시 |
|
||
| reload 가 **정말 무중단**인 것 | 0.2초 폴링 · 42초짜리 전송 중 요청 |
|
||
|
||
## 전제
|
||
|
||
- **이 실험만은 클러스터가 아니라 호스트를 본다.** `kubectl` 은 한 번도 안 쓴다.
|
||
- 관찰은 **당신 개발 머신(dev)에서** 한다. 밖에서 본 것이 이 실험의 답이고,
|
||
**dev 의 시계가 이 실험대에서 유일하게 정확한 시계**이기 때문이다(1-7).
|
||
- 호스트(`test-server`)에는 `ssh test-server` 로 붙는다.
|
||
- **호스트의 `sudo` 는 비밀번호를 요구한다.** 게스트(`kc-lab-1`/`2`)는 무암호지만
|
||
호스트는 다르다. **그래서 몇 단계는 사람이 직접 쳐야 한다** — 2-1 에 표로 있다.
|
||
- 이 호스트의 certbot 은 **5.7.0**, 플러그인은 `dns-cloudflare` · `manual` ·
|
||
`null` · `standalone` · `webroot` 다. **`nginx` 플러그인은 없다.**
|
||
- [`04-TLS`](../04-tls/) 단계를 이미 밟았다면 여기 나오는 결론이 그 문서의
|
||
5절에 요약되어 있다. 이 가이드는 그것을 **어떻게 측정했는지**를 적는다.
|
||
|
||
## 주의 — 이건 진짜 인증서를 발급하는 실험이다
|
||
|
||
`certbot renew --force-renewal` 은 **되돌릴 수 없다.** 새 인증서가 실제로
|
||
발급되고, **Let's Encrypt 의 발급 한도(주당 중복 인증서 5장)를 한 장 깎는다.**
|
||
그러므로
|
||
|
||
- **먼저 `--dry-run` 으로 절차만 확인한다**(2-2),
|
||
- 강제 갱신은 **이 실험 전체에서 한 번만** 쓴다,
|
||
- 그 한 번을 헛되게 쓰지 않도록 **대조군을 먼저 잡는다**(1-8).
|
||
|
||
옛 인증서는 무효가 되지 않는다. 만료 전까지는 그대로 유효하므로 **서비스가
|
||
깨지지는 않는다.** 다만 되돌릴 수 없으므로 순서를 지킨다. 밖에서 보이는
|
||
인증서를 디스크와 다시 맞추는 것은 [5-1. reload](#5-1-reload) 한 줄이다.
|
||
|
||
## 표시 규약
|
||
|
||
| 표시 | 뜻 |
|
||
|---|---|
|
||
| **실측** | 2026-09-04 감시 구간 08:10:51–09:02 UTC 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||
| **실측(호스트)** | 증거 파일이 아니라 **이 호스트에서 확인된 설정값** |
|
||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 숫자는 당신 것과 다르다 |
|
||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태이거나, 이 실험이 하지 않은 확장 |
|
||
|
||
## ★ 시각 표기 규약 — 이 가이드에만 있다
|
||
|
||
**이 실험은 두 기계의 시계를 섞어 빼는 바람에 숫자를 한 번 틀렸다.** 그래서
|
||
이 문서는 모든 시각에 **어느 시계인지** 붙인다.
|
||
|
||
| 표기 | 뜻 |
|
||
|---|---|
|
||
| `08:58:52 (dev)` | 개발 머신 시계. 외부 기준과 일치한다 |
|
||
| `17:22:13 KST (ts)` | test-server 시계. **106초 빠르다** |
|
||
| `08:20:27 (실제)` | 보정한 값 |
|
||
|
||
---
|
||
|
||
# 0. 왜 이 실험을 하는가
|
||
|
||
인증서 갱신 자동화는 대개 여기까지 확인하고 끝난다.
|
||
|
||
```bash
|
||
systemctl list-timers certbot-renew.timer # 돈다
|
||
journalctl -u certbot-renew.service # SUCCESS
|
||
```
|
||
|
||
**이 실험은 그 뒤를 묻는다.** 갱신된 인증서를 **누가 서버에 읽히는가.**
|
||
|
||
```
|
||
① certbot 이 새 인증서를 받는다 ← 타이머가 책임진다
|
||
② 파일이 디스크에 써진다 ← certbot 이 한다
|
||
③ nginx 가 그 파일을 다시 읽는다 ← ★ 누가?
|
||
```
|
||
|
||
**「갱신 성공」과 「새 인증서 서빙」은 다른 사건이다.** ③ 을 하는 것이
|
||
아무것도 없으면, ①②는 매번 성공하고 **사용자는 만료된 인증서를 본다.**
|
||
|
||
그리고 이 결함은 **88일 동안 보이지 않는다.** 타이머는 매일 두 번 돌고 매번
|
||
`SUCCESS` 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 발현할
|
||
기회가 없고, 발현하는 날의 증상은 **인증서 만료**다 — 그날에도 로그는
|
||
`SUCCESS` 라고 적혀 있다.
|
||
|
||
**부수 질문이 하나 더 있다.** ③ 을 실제로 하면(= nginx reload) **진행 중이던
|
||
요청은 어떻게 되는가.** 「nginx reload 는 무중단」이라고 다들 말하지만 이
|
||
실험대는 그것을 재 본 적이 없었고, **재 보지 않은 명제는 쓰지 않는다**는
|
||
규칙에 따라 유보해 뒀다. 여기서 잰다.
|
||
|
||
---
|
||
|
||
# 1. 기준선 — 강제 갱신을 걸기 전에
|
||
|
||
**사람이 칠 수 있는 명령은 사실상 한 번뿐이다**(강제 갱신). 그 한 번을 헛되게
|
||
쓰지 않으려면 **주입 전에 잴 것을 전부 재 둬야 한다.**
|
||
|
||
```
|
||
인증서 → 체인 → 이름 → 타이머 → ★ 누가 reload 하나 → 워커 PID → ★ 시계 → 대조군
|
||
```
|
||
|
||
## 1-1. 밖에서 본 인증서 — 읽는 형태부터
|
||
|
||
**확인** — 처음 한 번은 협상 과정을 통째로 읽는다
|
||
```bash
|
||
curl -v https://auth.hyeonworks.com/realms/master -o /dev/null
|
||
```
|
||
|
||
`*` 로 시작하는 줄에서 TLS 판·subject·issuer·`SSL certificate verify ok.` 를
|
||
본다. **TLS 에서 막힐 때 봐야 할 것이 전부 여기 있다.**
|
||
|
||
이제 인증서 자체를 뜯는다.
|
||
|
||
**확인**
|
||
```bash
|
||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||
| openssl x509 -noout -serial -dates -subject -ext subjectAltName
|
||
```
|
||
**실측** — [`01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt)
|
||
```
|
||
subject=CN = auth.hyeonworks.com
|
||
issuer=C = US, O = Let's Encrypt, CN = YE2
|
||
notBefore=Sep 3 00:47:23 2026 GMT
|
||
notAfter=Dec 2 00:47:22 2026 GMT
|
||
X509v3 Subject Alternative Name:
|
||
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
|
||
```
|
||
|
||
**어디를 봐야 하는가**
|
||
|
||
- **`serial`** — 이 값이 바뀌는 것이 「새 인증서를 서빙한다」의 정의다.
|
||
**적어 둔다.** 감시 전체가 이 값을 본다
|
||
- `notAfter` — 만료
|
||
- **SAN 이 세 줄이고 와일드카드가 아니다**
|
||
|
||
### ★ `notBefore` 를 발급 시각으로 읽지 않는다
|
||
|
||
**Let's Encrypt 는 `notBefore` 를 정확히 한 시간 백데이트한다.** 클라이언트
|
||
시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려는 것이다.
|
||
|
||
즉 위 인증서의 `notBefore=Sep 3 00:47:23` 은 **발급 시각이 아니다.**
|
||
그렇다고 여기에 한 시간을 더한 값을 발급 시각으로 그대로 쓰지도 않는다 —
|
||
이 실험대의 두 인증서에서 **CT 로그의 SCT 가 그보다 약 89초 앞선다**(1-1 뒤의
|
||
확인, 그리고 [D-4a](d4a-deploy-hook.md) 5절).
|
||
|
||
**시각의 외부 기준이 필요하면 SCT 를 본다.**
|
||
|
||
**확인**
|
||
```bash
|
||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||
| openssl x509 -noout -ext ct_precert_scts | grep Timestamp
|
||
```
|
||
**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt)
|
||
```
|
||
Log ID: C2:31:7E:57:...:52:CD Timestamp: Sep 3 01:45:53.183 2026 GMT
|
||
Log ID: 46:AF:86:3D:...:50:5F Timestamp: Sep 3 01:45:53.352 2026 GMT
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `Timestamp` 두 개. **이건 CT 로그가 자기 시계로 찍은
|
||
시각**이며, 이 실험대의 어느 기계와도 무관한 제3의 기준이다. 1-7 에서 시계가
|
||
어긋난 것이 드러났을 때 이 값이 심판이 된다.
|
||
|
||
## 1-2. 체인이 완전한가 — 흔한 실수 하나
|
||
|
||
**확인**
|
||
```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'
|
||
```
|
||
**실측** — [`01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt)
|
||
```
|
||
0 s:CN = auth.hyeonworks.com
|
||
1 s:C = US, O = Let's Encrypt, CN = YE2
|
||
2 s:C = US, O = ISRG, CN = Root YE
|
||
3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
|
||
Verify return code: 0 (ok)
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **번호가 몇까지 가는가**, 그리고 마지막 줄.
|
||
|
||
| 파일 | 내용 | nginx 에 넣으면 |
|
||
|---|---|---|
|
||
| `cert.pem` | **리프만** | **일부 클라이언트에서 검증 실패** |
|
||
| **`fullchain.pem`** | 리프 + 중간 | 정상 |
|
||
|
||
**단계가 1개면 `cert.pem` 을 쓴 것이다.** 브라우저는 중간 인증서를 캐시하거나
|
||
AIA 로 보완해서 **대개 정상으로 보이고**, 캐시가 없는 클라이언트(모바일 앱,
|
||
curl, 다른 서버)에서만 깨진다. **그래서 발견이 늦다.** 이 명령이 유일하게
|
||
믿을 수 있는 판정이다.
|
||
|
||
이 실험대는 4단계로 정상이다.
|
||
|
||
## 1-3. 이름 세 개가 한 장인가
|
||
|
||
**확인**
|
||
```bash
|
||
for H in auth app1 app2; do
|
||
echo "-- $H.hyeonworks.com"
|
||
echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
|
||
| openssl x509 -noout -serial
|
||
done
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 세 일련번호가 **서로 같은가.** 값 자체는 의미가 없고
|
||
일치 여부만 본다.
|
||
|
||
**이 결과가 의미하는 것** — 같으면 SAN 하나에 이름 셋이 든 **한 장**이고,
|
||
갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 **훅도 장마다 돌고**,
|
||
한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다.
|
||
|
||
> **이 제약이 B-7 에서 실제 비용을 만들었다.** oauth2-proxy 를 올릴 네 번째
|
||
> 호스트명이 없어 **Grafana 가 쓰던 `app2` 를 빌려야 했고**, 그동안 관측
|
||
> 스택의 웹 UI 가 내려가 있었다.
|
||
>
|
||
> **「인증서에 이름을 몇 개 넣을 것인가」는 TLS 설정이 아니라 나중에 무엇을
|
||
> 배포할 수 있는가를 정하는 결정이다.**
|
||
|
||
## 1-4. 갱신 자동화는 도는가
|
||
|
||
**여기까지는 sudo 없이 읽힌다.** 실제로 이 실험대가 그 범위에서 다 읽었다.
|
||
|
||
**확인**
|
||
```bash
|
||
ssh test-server 'systemctl list-timers certbot-renew.timer'
|
||
```
|
||
**실측** — [`01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt)
|
||
```
|
||
NEXT LEFT LAST PASSED UNIT
|
||
Fri 2026-09-04 17:03:46 KST 1h 54min Fri 2026-09-04 03:19:39 KST 11h ago certbot-renew.timer
|
||
타이머 enabled: enabled
|
||
타이머 active: active
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `NEXT`/`LEFT` 가 채워져 있는가, `LAST`/`PASSED` 가
|
||
하루 안쪽인가. **표가 통째로 비면 타이머가 없는 것이다**(이름이 배포판마다
|
||
다르다 — `systemctl list-timers --all | grep -i certbot`).
|
||
|
||
**확인** — 실제로 돌았고 성공했는가
|
||
```bash
|
||
ssh test-server 'systemctl status certbot-renew.service'
|
||
ssh test-server 'journalctl -u certbot-renew.service --since today'
|
||
```
|
||
**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt)
|
||
```
|
||
Active: inactive (dead) since Fri 2026-09-04 17:04:11 KST
|
||
Process: 28452 ExecStart=/usr/bin/certbot -q renew (code=exited, status=0/SUCCESS)
|
||
|
||
Sep 04 03:19:39 Starting Renew certificates acquired via Certbot...
|
||
Sep 04 03:19:41 Finished Renew certificates acquired via Certbot.
|
||
Sep 04 17:04:09 Starting Renew certificates acquired via Certbot...
|
||
Sep 04 17:04:11 Finished Renew certificates acquired via Certbot.
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `status=0/SUCCESS`, 그리고 오늘 **두 번** 돌았다는 것.
|
||
|
||
**이 결과가 의미하는 것** — **여기서 확인을 멈추면 「괜찮다」로 끝난다.**
|
||
대부분의 문서가 여기까지다. 그런데 남은 기간을 보면 **아직 갱신은 하지도
|
||
않았다.**
|
||
|
||
**확인**
|
||
```bash
|
||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||
| openssl x509 -noout -enddate
|
||
```
|
||
**실측**
|
||
```
|
||
만료: Dec 2 00:47:22 2026 GMT
|
||
남은 일수: 88일
|
||
```
|
||
|
||
Let's Encrypt 는 90일 발급이고 certbot 은 **30일 남았을 때** 갱신한다.
|
||
**즉 실제 갱신까지 약 58일 남았고, 그때까지 이 절차는 한 번도 시험되지
|
||
않는다.** 「타이머가 active 니까 괜찮다」가 확인이 아닌 이유가 이것이다.
|
||
|
||
## 1-5. ★ 그런데 무엇이 nginx 를 reload 하는가 — 세 곳을 본다
|
||
|
||
**갱신된 인증서를 서버에 읽히는 경로는 셋뿐이다.** 셋을 하나씩 연다.
|
||
|
||
### ① 갱신 유닛이 뭔가 더 하는가
|
||
|
||
**확인**
|
||
```bash
|
||
ssh test-server 'systemctl cat certbot-renew.service'
|
||
ssh test-server 'systemctl cat certbot-renew.timer'
|
||
```
|
||
**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt)
|
||
```
|
||
# /usr/lib/systemd/system/certbot-renew.service
|
||
[Unit]
|
||
Description=Renew certificates acquired via Certbot
|
||
[Service]
|
||
Type=oneshot
|
||
ExecStart=/usr/bin/certbot -q renew
|
||
PrivateTmp=true
|
||
|
||
OnCalendar=*-*-* 00/12:00:00
|
||
RandomizedDelaySec=12h
|
||
Persistent=true
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `ExecStart=` 한 줄, 그리고 그 아래에 **`ExecStartPost=`
|
||
가 있는지 없는지.** `ExecStart` 의 인자에 `--deploy-hook` 이 붙어 있는지도 본다.
|
||
**여기 없는 것을 보는 것이 이 명령의 목적이다.**
|
||
|
||
**이 결과가 의미하는 것** — `ExecStart` 가 전부다. **배포판(Arch)이 넣어준
|
||
기본 유닛이 그렇다.** 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은
|
||
것을 누가 읽게 만드는 일은 **아무도 하지 않는다.**
|
||
|
||
> `systemctl cat` 은 **유닛 파일에 적힌 것**을, `systemctl show` 는 **기본값까지
|
||
> 합쳐 실제 적용되는 것**을 보여 준다. 여기서는 「적혀 있지 않다」가 답이므로
|
||
> `cat` 이 맞다.
|
||
|
||
### ② 훅 디렉터리에 뭐가 있는가 — **여기부터 root 가 필요하다**
|
||
|
||
**확인** — sudo 없이 쳐 보면 이렇게 나온다
|
||
```bash
|
||
ssh test-server 'ls -laR /etc/letsencrypt/renewal-hooks/'
|
||
```
|
||
**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt)
|
||
```
|
||
ls: cannot access '/etc/letsencrypt/renewal-hooks/': Permission denied
|
||
```
|
||
|
||
**이 빈 출력을 「비어 있다」로 읽으면 안 된다.** 실제로 이 실험대는 B-7 에서
|
||
같은 실수를 했다 — nginx 설정을 읽으려던 시도가 계속 빈 결과였는데, 그게
|
||
sudo 의 조용한 실패였다는 것을 한참 뒤에 알았다.
|
||
|
||
**하기** — 사람이 비밀번호를 친다
|
||
```bash
|
||
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/ \
|
||
/etc/letsencrypt/renewal-hooks/post/ /etc/letsencrypt/renewal-hooks/pre/'
|
||
```
|
||
**실측** — [`12-certbot-state.txt`](../../evidence/d4-certificate-renewal/12-certbot-state.txt)
|
||
```
|
||
/etc/letsencrypt/renewal-hooks/deploy/:
|
||
total 8
|
||
drwxr-xr-x 2 root root 4096 2026-09-03 10:46:54.658474560 +0900 .
|
||
drwxr-xr-x 5 root root 4096 2026-09-03 10:46:54.658520760 +0900 ..
|
||
|
||
/etc/letsencrypt/renewal-hooks/post/:
|
||
total 8
|
||
...
|
||
/etc/letsencrypt/renewal-hooks/pre/:
|
||
total 8
|
||
...
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **`total 8` 과 `.` `..` 뿐.** 셋 다 비었다.
|
||
|
||
> **`ssh -t` 의 `-t` 가 필요하다.** tty 를 붙여 줘야 sudo 가 비밀번호를 물어볼
|
||
> 수 있다. 없으면 「비밀번호가 필요하다」에서 끝난다.
|
||
|
||
### ③ certbot 이 스스로 고칠 수 있는가
|
||
|
||
**하기** — 사람이 친다
|
||
```bash
|
||
ssh -t test-server 'sudo certbot plugins'
|
||
```
|
||
**실측** — [`13-verdict.txt`](../../evidence/d4-certificate-renewal/13-verdict.txt)
|
||
```
|
||
Discovered plugins: dns-cloudflare, manual, null, standalone, webroot
|
||
(certbot 5.7.0)
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 목록에 **`nginx` 가 없다.** `certbot --nginx` 로 받은
|
||
인증서라면 certbot 이 nginx 설정을 직접 만지고 reload 까지 하는데, 이 호스트는
|
||
`webroot` 로 받았고 nginx 플러그인 자체가 설치되어 있지 않다.
|
||
|
||
### 세 곳이 전부 비어 있다
|
||
|
||
| # | 경로 | 상태 |
|
||
|---|---|---|
|
||
| 1 | `certbot-renew.service` 의 `ExecStartPost` | **없다** |
|
||
| 2 | `renewal-hooks/{deploy,post,pre}/` | **셋 다 비었다** |
|
||
| 3 | certbot 의 nginx 플러그인 | **없다** |
|
||
|
||
**하나라도 있었으면 자동으로 반영됐을 것이다.** 이 표가 이 실험의 원인 진단이고,
|
||
아직 아무것도 주입하지 않은 상태에서 이미 나왔다.
|
||
|
||
## 1-6. ★ nginx 워커 PID — 판정 기준을 여기서 세운다
|
||
|
||
**「reload 됐는가」를 로그 문구로 판정하지 않는다.** 프로세스로 판정한다.
|
||
|
||
**확인**
|
||
```bash
|
||
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||
```
|
||
**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt)
|
||
```
|
||
585 1 80529 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
|
||
586 585 80529 Thu Sep 3 19:00:39 2026 nginx: worker process
|
||
```
|
||
|
||
**어디를 봐야 하는가 — 네 칸을 다 본다.**
|
||
|
||
```
|
||
585 1 80529 Thu Sep 3 19:00:39 nginx: master process
|
||
586 585 80529 Thu Sep 3 19:00:39 nginx: worker process
|
||
│ │ │ │
|
||
│ │ │ └─ lstart: 이 프로세스가 뜬 시각
|
||
│ │ └─ etimes: 떠 있는 초 (80529초 = 22.4시간)
|
||
│ └─ ppid: 부모. 워커의 부모가 마스터다
|
||
└─ pid
|
||
```
|
||
|
||
**reload 는 마스터를 유지한 채 워커만 새로 띄운다.** 그러므로
|
||
|
||
| 마스터 PID | 워커 PID | 판정 |
|
||
|---|---|---|
|
||
| 그대로 | **바뀜** | **reload 됐다** |
|
||
| 그대로 | 그대로 | reload 가 없었다 |
|
||
| 바뀜 | 바뀜 | reload 가 아니라 **재시작**이다 |
|
||
|
||
**이 결과가 의미하는 것** — 마스터 585, 워커 586. **번호가 붙어 있다** — 마스터
|
||
기동 직후의 첫 fork 그대로다. 둘의 `lstart` 가 같고 `etimes` 도 같다.
|
||
**즉 22.4시간 동안 reload 가 한 번도 없었다.**
|
||
|
||
**이 두 줄을 적어 둔다.** 4-2 와 5-2 에서 이 값과 비교한다.
|
||
|
||
## 1-7. ★ 시계를 먼저 잰다 — 나중에 재면 늦는다
|
||
|
||
**두 기계의 로그를 나란히 놓기 전에 확인한다.** 이 실험은 이걸 나중에 하는
|
||
바람에 공백 수치를 한 번 틀렸다.
|
||
|
||
**확인** — 왕복 사이에 상대 시각을 끼워 잰다
|
||
```bash
|
||
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
|
||
echo "$A"; echo "$B"; echo "$C"
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 세 수를 **눈으로 뺀다.** `A` 와 `C` 는 같은 기계에서
|
||
SSH 왕복 직전·직후에 찍은 것이므로, 그 가운데가 「저쪽 시각을 잰 순간의 이쪽
|
||
시각」이다. `B` 가 그보다 크면 저쪽이 빠른 것이다.
|
||
|
||
**확인** — 어느 쪽이 맞는지는 **외부 기준**으로 가른다
|
||
```bash
|
||
curl -sI https://www.google.com | grep -i '^date:'
|
||
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
|
||
date -u
|
||
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'
|
||
```
|
||
**실측** — [`d4a-deploy-hook/01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
|
||
```
|
||
dev → Google 차이 +0초
|
||
dev → Let's Encrypt ACME 차이 +0초
|
||
test-server → Google 차이 -105초 (즉 test-server 가 105초 빠르다)
|
||
ssh 왕복 왜곡 3회 측정: +106.1 / +106.1 / +106.1초 (안정적)
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **`NTPSynchronized`.** 이 호스트는 `no` 다.
|
||
그리고 세 번 재서 값이 흔들리지 않는 것.
|
||
|
||
**이 결과가 의미하는 것** — **dev 가 정확하고 test-server 가 106초 빠르다.**
|
||
|
||
```
|
||
실제 시각 = test-server 시계 − 106초
|
||
실제 시각 = dev 시계 (보정 불필요)
|
||
```
|
||
|
||
**그러므로 이 실험의 모든 관측은 dev 에서 한다.** 호스트에서만 알 수 있는
|
||
값(파일 mtime, 훅 로그)은 **보정해서** 쓴다.
|
||
|
||
> **왜 이걸 주입 전에 하나.** 주입 후에는 「그때 저 시계가 얼마나 어긋나
|
||
> 있었나」를 되짚을 수 없다. 그리고 이 실험은 실제로 **보정 없이 뺀 값
|
||
> 2199초를 문서에 적었다가 나중에 2305초로 정정했다.**
|
||
|
||
## 1-8. 대조군 — 근거를 재려면 (선택)
|
||
|
||
**여기부터는 「무중단인가」를 문서에 남길 근거가 필요할 때만 한다.**
|
||
일련번호가 언제 바뀌는지만 보려면 1-1 의 명령을 손으로 두 번 치면 된다.
|
||
|
||
주입 중에 오류가 한 번 나왔을 때 **평시 오류율을 모르면 아무것도 증명하지
|
||
못한다.** 그래서 대조군을 먼저 잡는다.
|
||
|
||
### 대조군 ① — 새 연결
|
||
|
||
**하기** — 0.2초 × 900회 = 180초
|
||
```bash
|
||
i=0
|
||
while [ $i -lt 900 ]; do
|
||
curl -s -o /dev/null -w '%{http_code} %{time_total} %{time_appconnect}\n' \
|
||
--max-time 5 https://auth.hyeonworks.com/realms/master
|
||
i=$((i+1)); sleep 0.2
|
||
done > /tmp/d4-control.txt
|
||
awk '{print $1}' /tmp/d4-control.txt | sort | uniq -c
|
||
```
|
||
**실측** — [`05-control-no-injection.txt`](../../evidence/d4-certificate-renewal/05-control-no-injection.txt)
|
||
```
|
||
표본 900 개
|
||
|
||
[상태코드 분포]
|
||
900 200
|
||
|
||
[응답시간 ms]
|
||
최소 67 중앙 98 p95 195 최대 1121 평균 106.9
|
||
|
||
[TLS 핸드셰이크 ms — 0 이면 연결 재사용, >0 이면 새 핸드셰이크]
|
||
핸드셰이크 발생 900회 / 900 평균 83 ms 최대 1100 ms
|
||
|
||
[비정상 응답 원문 — 있으면 아래에 전부]
|
||
비200 총 0
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `uniq -c` 의 줄이 **하나**이고 그 값이 `900 200` 인가.
|
||
그리고 **핸드셰이크가 900/900** 이라는 것.
|
||
|
||
**이 결과가 의미하는 것** — 대조군이 깨끗하다. 그래서 주입 중 비200 이 한 번만
|
||
나와도 주입 탓으로 귀속할 수 있다. **대조군에 이미 오류가 섞여 있으면 주입을
|
||
하지 않는다** — 판정할 수 없기 때문이다.
|
||
|
||
그리고 핸드셰이크 900/900 은 **매 요청이 새 연결**이라는 뜻이다. 즉 이 장치는
|
||
**「새 연결을 받아주는가」만 잰다.** 계획서가 물은 것은 「진행 중이던 요청은
|
||
어떻게 되는가」이므로 장치가 하나 더 필요하다.
|
||
|
||
### 대조군 ② — 진행 중이던 요청
|
||
|
||
**reload 순간에 실제로 전송 중인 요청이 있어야 한다.** 845KB 짜리 관리 콘솔
|
||
번들을 일부러 느리게 받아 요청 하나를 **42초 동안 살려 둔다.**
|
||
|
||
**하기** — 먼저 큰 파일의 경로를 찾는다(버전마다 달라진다)
|
||
```bash
|
||
JS=$(curl -s https://auth.hyeonworks.com/admin/master/console/ \
|
||
| grep -oE '/resources/[a-z0-9]+/admin/[^"]+\.js' | head -1)
|
||
echo "$JS"
|
||
```
|
||
**실측** — [`06-inflight-control.txt`](../../evidence/d4-certificate-renewal/06-inflight-control.txt)
|
||
```
|
||
대상: https://auth.hyeonworks.com/resources/55yjq/admin/keycloak.v2/assets/main-BbID33M6.js
|
||
```
|
||
|
||
**하기**
|
||
```bash
|
||
curl -s --limit-rate 20k -o /tmp/inflight.bin \
|
||
-w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}\n' \
|
||
"https://auth.hyeonworks.com$JS"
|
||
```
|
||
**실측** — [`06-inflight-control.txt`](../../evidence/d4-certificate-renewal/06-inflight-control.txt)
|
||
```
|
||
[대조군: 주입 없이 1회]
|
||
코드=200 받은바이트=845361 총시간=41.392198s 연결수=1 실효속도=20423B/s
|
||
기대 크기 845361 / 실제 845361 bytes
|
||
|
||
판정 기준 (주입 시 이 값들과 비교한다)
|
||
· 코드 200 + 크기 845361 = 진행 중이던 요청이 끝까지 살아남았다(graceful)
|
||
· 코드 000 또는 크기 부족 = reload 가 진행 중이던 연결을 끊었다
|
||
· 연결수 2 이상 = 중간에 끊겨 curl 이 다시 붙었다
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **`연결수=1`.** 이게 판정의 핵심이다. 끊겼다가 curl 이
|
||
다시 붙었으면 2 가 된다.
|
||
|
||
### 감시를 켠다 — 여기서부터는 파일로 만든다
|
||
|
||
**세 감시가 동시에 돌아야 하고, 각각 루프와 종료 조건이 있다.** 이쯤 되면
|
||
한 줄 명령이 아니라 프로그램이다. **파일로 쓴다.**
|
||
|
||
```bash
|
||
vim /tmp/d4-watch-serial.sh
|
||
```
|
||
```sh
|
||
#!/bin/sh
|
||
# file: /tmp/d4-watch-serial.sh
|
||
# 5초마다 밖에서 본 인증서의 일련번호와 만료일을 찍는다.
|
||
# /tmp/d4-stop 파일이 생기면 멈춘다.
|
||
HOST=auth.hyeonworks.com
|
||
while [ ! -f /tmp/d4-stop ]; do
|
||
S=$(echo | openssl s_client -connect "$HOST:443" -servername "$HOST" 2>/dev/null \
|
||
| openssl x509 -noout -serial -enddate | tr '\n' ' ')
|
||
echo "$(date -u +%H:%M:%S) $S"
|
||
sleep 5
|
||
done
|
||
```
|
||
|
||
```bash
|
||
vim /tmp/d4-poll.sh
|
||
```
|
||
```sh
|
||
#!/bin/sh
|
||
# file: /tmp/d4-poll.sh
|
||
# 0.2초마다 새 연결 하나. 상태코드와 소요 시간만 남긴다.
|
||
while [ ! -f /tmp/d4-stop ]; do
|
||
echo "$(date -u +%H:%M:%S.%2N) $(curl -s -o /dev/null \
|
||
-w '%{http_code} %{time_total}' --max-time 5 \
|
||
https://auth.hyeonworks.com/realms/master)"
|
||
sleep 0.2
|
||
done
|
||
```
|
||
|
||
```bash
|
||
vim /tmp/d4-inflight.sh
|
||
```
|
||
```sh
|
||
#!/bin/sh
|
||
# file: /tmp/d4-inflight.sh
|
||
# 42초짜리 요청을 끊김 없이 연달아 돌린다 — reload 순간에 반드시 하나가 떠 있게.
|
||
# ★ curl 의 종료 코드를 반드시 남긴다. 안 남기면 측정 장치의 실패와
|
||
# 서버의 실패를 구별할 수 없다 (08-inflight-artifact.txt).
|
||
URL="https://auth.hyeonworks.com$1"
|
||
while [ ! -f /tmp/d4-stop ]; do
|
||
R=$(curl -s --limit-rate 20k -o /dev/null \
|
||
-w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}' \
|
||
"$URL"); E=$?
|
||
echo "$(date -u +%H:%M:%S) $R curl종료=$E"
|
||
[ $E -ne 0 ] && sleep 1
|
||
done
|
||
```
|
||
|
||
**하기**
|
||
```bash
|
||
chmod +x /tmp/d4-watch-serial.sh /tmp/d4-poll.sh /tmp/d4-inflight.sh
|
||
rm -f /tmp/d4-stop
|
||
setsid /tmp/d4-watch-serial.sh > /tmp/d4-serial.txt 2>&1 < /dev/null &
|
||
setsid /tmp/d4-poll.sh > /tmp/d4-poll.txt 2>&1 < /dev/null &
|
||
setsid /tmp/d4-inflight.sh "$JS" > /tmp/d4-inflight.txt 2>&1 < /dev/null &
|
||
```
|
||
|
||
**되돌리기** — 셋 다 멈춘다
|
||
```bash
|
||
touch /tmp/d4-stop
|
||
```
|
||
|
||
> **`setsid` 가 필요하다.** 그냥 `&` 로 띄우면 부모 셸이 끝날 때 같이 죽는다
|
||
> (A-3 에서 파드 안 `&` 가 `exec` 종료와 함께 죽은 것과 같은 함정이다).
|
||
> 이 실험은 사람이 다른 창에서 sudo 를 치는 동안 감시가 살아 있어야 한다.
|
||
|
||
**확인** — 30초쯤 두고 감시가 실제로 쌓이는지 본다
|
||
```bash
|
||
tail -3 /tmp/d4-serial.txt
|
||
tail -3 /tmp/d4-poll.txt
|
||
tail -3 /tmp/d4-inflight.txt
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 세 파일 다 줄이 늘고 있는가. **여기서 비어 있으면
|
||
주입해도 아무것도 안 남는다.**
|
||
|
||
---
|
||
|
||
# 2. 주입 — 강제 갱신 (사람이 친다)
|
||
|
||
## 2-1. 무엇을 사람이 쳐야 하나
|
||
|
||
| 하는 일 | 어디서 | sudo |
|
||
|---|---|---|
|
||
| 밖에서 인증서·체인·SAN 읽기 | dev | 필요 없다 |
|
||
| 타이머·유닛·journal 읽기 | test-server | **필요 없다** (이 실험대에서 확인) |
|
||
| nginx 워커 PID 읽기 | test-server | 필요 없다 |
|
||
| nginx 설정에서 인증서 경로 찾기 | test-server | 필요 없다 |
|
||
| **훅 디렉터리 보기** | test-server | **비밀번호** |
|
||
| **`certbot certificates` · `archive/` 보기** | test-server | **비밀번호** |
|
||
| **`certbot renew --force-renewal`** | test-server | **비밀번호** |
|
||
| **`nginx -s reload`** | test-server | **비밀번호** |
|
||
|
||
**호스트에서 비대화 sudo 는 반드시 실패한다.**
|
||
|
||
**실측** — [`01-certificate-state.txt`](../../evidence/d4-certificate-renewal/01-certificate-state.txt)
|
||
```
|
||
$ sudo -n -l
|
||
sudo: a password is required
|
||
$ sudo -n systemctl reload nginx
|
||
sudo: a password is required
|
||
```
|
||
|
||
**그러므로 이 네 줄은 자동화할 수 없다.** `ssh -t` 로 tty 를 붙여 사람이
|
||
비밀번호를 친다. 이 실험이 처음에 강제 갱신을 못 하고 「미측정」으로 남긴
|
||
이유가 정확히 이것이다.
|
||
|
||
## 2-2. 먼저 `--dry-run`
|
||
|
||
**하기**
|
||
```bash
|
||
ssh -t test-server 'sudo certbot renew --dry-run'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 끝의 `simulated renewals` 요약. 그리고 훅을 넣었다면
|
||
`Running deploy-hook command` 줄(**미검증** — 이 실험대는 훅이 없는 상태에서
|
||
쟀다).
|
||
|
||
**이 결과가 의미하는 것** — dry-run 은 **인증서를 발급하지 않고 한도도 안
|
||
깎는다.** 절차가 도는지, 검증이 통과하는지까지만 말해 준다. **파일이 실제로
|
||
바뀌었을 때 nginx 가 그것을 집는지는 dry-run 으로 알 수 없다.**
|
||
|
||
## 2-3. 강제 갱신
|
||
|
||
**되돌리기 — 없다.** 새 인증서는 되돌릴 수 없고, 한도를 한 장 깎는다.
|
||
1-8 의 감시 세 개가 돌고 있는지 다시 확인하고 친다.
|
||
|
||
**하기**
|
||
```bash
|
||
date -u '+%H:%M:%S 갱신 시작 (dev)'
|
||
ssh -t test-server 'sudo certbot renew --force-renewal'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `Congratulations, all renewals succeeded:` 와
|
||
그 아래 `fullchain.pem (success)`.
|
||
|
||
**시각을 dev 시계로 적어 둔다.** 호스트가 찍는 시각은 106초 빠르다.
|
||
|
||
---
|
||
|
||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||
|
||
**「갱신 실패」와 「갱신은 됐는데 안 집었다」를 가르는 절이다.** 이 실험은
|
||
처음에 이 둘을 구별하지 못해 두 갈래로 적어 뒀었다.
|
||
|
||
## 3-1. 디스크에 새 파일이 써졌나
|
||
|
||
**하기** — 사람이 친다
|
||
```bash
|
||
ssh -t test-server 'sudo certbot certificates'
|
||
```
|
||
**실측** — [`12-certbot-state.txt`](../../evidence/d4-certificate-renewal/12-certbot-state.txt)
|
||
```
|
||
Found the following certs:
|
||
Certificate Name: auth.hyeonworks.com
|
||
Serial Number: 6c7cb6df1da8a6d7995d93c264bb9ecea1d
|
||
Key Type: ECDSA
|
||
Identifiers: auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com
|
||
Expiry Date: 2026-12-03 07:21:52+00:00 (VALID: 89 days)
|
||
Certificate Path: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem
|
||
Private Key Path: /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **`Serial Number` 와 `Expiry Date`.**
|
||
`6c7cb6df…ea1d` 는 1-1 에서 적어 둔 값과 **다르다.** 만료일도 하루 밀렸다
|
||
(`Dec 2` → `Dec 3`).
|
||
|
||
**이 결과가 의미하는 것** — **certbot 쪽에서는 갱신이 끝났다.**
|
||
|
||
## 3-2. 파일이 언제 써졌나
|
||
|
||
**하기** — 사람이 친다
|
||
```bash
|
||
ssh -t test-server 'sudo ls -la --time-style=full-iso /etc/letsencrypt/archive/auth.hyeonworks.com/'
|
||
```
|
||
**실측** — [`12-certbot-state.txt`](../../evidence/d4-certificate-renewal/12-certbot-state.txt)
|
||
```
|
||
-rw-r--r-- 1 root root 1359 2026-09-03 10:47:40.915923507 +0900 cert1.pem
|
||
-rw-r--r-- 1 root root 1359 2026-09-04 17:22:13.508494637 +0900 cert2.pem
|
||
-rw-r--r-- 1 root root 3523 2026-09-03 10:47:40.916215769 +0900 chain1.pem
|
||
-rw-r--r-- 1 root root 3523 2026-09-04 17:22:13.508658811 +0900 chain2.pem
|
||
-rw-r--r-- 1 root root 4882 2026-09-03 10:47:40.916339551 +0900 fullchain1.pem
|
||
-rw-r--r-- 1 root root 4882 2026-09-04 17:22:13.508821612 +0900 fullchain2.pem
|
||
-rw------- 1 root root 241 2026-09-03 10:47:40.916079294 +0900 privkey1.pem
|
||
-rw------- 1 root root 241 2026-09-04 17:22:13.507717972 +0900 privkey2.pem
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 번호가 **1 과 2 두 벌**이라는 것, 그리고 2 번들의
|
||
**mtime `2026-09-04 17:22:13`**.
|
||
|
||
**★ 이 시각은 `(ts)` 다.** test-server 시계이고 106초 빠르다.
|
||
실제로는 **`08:20:27 (실제)`** 이다. 4-5 에서 이 보정을 쓴다.
|
||
|
||
> `privkey2.pem` 의 권한이 `-rw-------` 인 것도 본다. **개인키는 D-3 의 주제와
|
||
> 같은 문제**를 안고 있다 — 파일 하나를 얻으면 끝이다.
|
||
|
||
---
|
||
|
||
# 4. 효과를 관찰한다
|
||
|
||
## 4-1. ★ 그런데 밖에서는 아무것도 안 바뀌었다
|
||
|
||
**확인**
|
||
```bash
|
||
tail -3 /tmp/d4-serial.txt
|
||
```
|
||
**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt)
|
||
```
|
||
serial=0520BB6416D569E26697B1691440F523B853
|
||
notBefore=Sep 3 00:47:23 2026 GMT ← 어제 것 그대로
|
||
notAfter=Dec 2 00:47:22 2026 GMT
|
||
|
||
일련번호 감시 161표본(약 13분) 동안 단 한 번도 바뀌지 않았다.
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 일련번호가 **1-1 에서 적어 둔 값 그대로**인가.
|
||
디스크(3-1)의 `6c7cb6df…` 와 **다르다.**
|
||
|
||
**이 결과가 의미하는 것**
|
||
|
||
```
|
||
디스크 새 인증서 (6c7cb6df…)
|
||
네트워크 옛 인증서 (0520BB…)
|
||
```
|
||
|
||
**두 사건이 갈라졌다.** 여기서 「갱신이 실패했다」고 결론 내리면 틀린다 —
|
||
3-1 에서 성공을 이미 봤다.
|
||
|
||
## 4-2. nginx 워커가 그대로다
|
||
|
||
**확인** — 1-6 과 **똑같은 명령**
|
||
```bash
|
||
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||
```
|
||
**실측**
|
||
```
|
||
585 1 80529 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
|
||
586 585 80529 Thu Sep 3 19:00:39 2026 nginx: worker process
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **워커 PID 586 이 그대로다.** `etimes` 도 계속 늘고
|
||
있을 뿐 리셋되지 않았다.
|
||
|
||
**이 결과가 의미하는 것** — **reload 가 없었다.** 그리고 1-6 에서 정한 판정
|
||
기준이 여기서 답을 낸다 — 로그를 뒤질 필요가 없다.
|
||
|
||
## 4-3. 개념 — 왜 파일이 바뀌어도 nginx 는 모르는가
|
||
|
||
**무엇인가.** nginx 는 `ssl_certificate` 가 가리키는 파일을 **기동 시점에 한 번
|
||
읽어 메모리에 들고 있다.** 요청마다 디스크를 다시 보지 않는다.
|
||
|
||
**확인** — nginx 가 무엇을 물고 있는지 본다(sudo 불필요)
|
||
```bash
|
||
ssh test-server 'grep -rn ssl_certificate /etc/nginx/'
|
||
```
|
||
**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt)
|
||
```
|
||
/etc/nginx/sites-available/keycloak-lab:18: ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
|
||
/etc/nginx/sites-available/keycloak-lab:19: ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
|
||
```
|
||
|
||
**왜 여기 나오나.** `live/` 는 **심볼릭 링크**다. certbot 은 갱신하면 이 링크가
|
||
새 `archive/` 파일을 가리키도록 바꾼다.
|
||
|
||
```
|
||
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem
|
||
└─▶ (전) ../../archive/auth.hyeonworks.com/fullchain1.pem
|
||
└─▶ (후) ../../archive/auth.hyeonworks.com/fullchain2.pem
|
||
```
|
||
|
||
**경로는 그대로인데 내용만 바뀐다.** 그래서 nginx 설정을 고칠 필요가 없고,
|
||
**바로 그 때문에 「설정이 그대로니 괜찮다」고 착각하기 쉽다.**
|
||
필요한 것은 설정 변경이 아니라 **reload** 다.
|
||
|
||
**없거나 틀리면.** 인증서가 만료되어 브라우저가 `NET::ERR_CERT_DATE_INVALID`
|
||
를 띄운다. **그 시점에 디스크에는 멀쩡한 인증서가 들어 있고 갱신 로그도
|
||
`SUCCESS`** 다 — 그래서 원인을 찾는 데 오래 걸린다.
|
||
|
||
## 4-4. 원인은 하나가 아니라 셋이 겹쳤다
|
||
|
||
1-5 에서 이미 본 표가 여기서 판정이 된다.
|
||
|
||
| # | 경로 | 상태 |
|
||
|---|---|---|
|
||
| 1 | `certbot-renew.service` 의 `ExecStartPost` | **없다** |
|
||
| 2 | `renewal-hooks/{deploy,post,pre}/` | **셋 다 비었다** |
|
||
| 3 | certbot 의 nginx 플러그인 | **없다** |
|
||
|
||
**세 경로 전부가 비어 있다. 하나라도 있었으면 자동으로 반영됐다.**
|
||
|
||
## 4-5. ★ 공백을 계산한다 — 여기가 이 실험이 한 번 틀린 자리다
|
||
|
||
**하기** — 감시에서 언제 바뀌었는지 찾는다(5절에서 사람이 reload 한 뒤)
|
||
```bash
|
||
grep -v '0520BB' /tmp/d4-serial.txt | head
|
||
```
|
||
**실측** — [`09-serial-timeline.txt`](../../evidence/d4-certificate-renewal/09-serial-timeline.txt) ·
|
||
[`13-verdict.txt`](../../evidence/d4-certificate-renewal/13-verdict.txt)
|
||
```
|
||
08:10:51 ~ 08:58:47 serial=0520BB...B853 notAfter=Dec 2 ← 옛 것
|
||
08:58:52 serial=06C7CB...EA1D notAfter=Dec 3 ← 바뀐 순간
|
||
|
||
08:22:13 ~ 08:58:52 구간에서 옛 인증서로 관측된 횟수: 428회
|
||
```
|
||
|
||
**두 시각을 나란히 놓는다. 그런데 시계가 다르다.**
|
||
|
||
| | 시각 | 어느 시계 |
|
||
|---|---|---|
|
||
| 새 인증서 디스크 기록 | `17:22:13 KST` → `08:22:13 UTC` | **(ts)** — 106초 빠르다 |
|
||
| 실제 서빙 시작 | `08:58:52` | **(dev)** — 정확 |
|
||
|
||
**틀린 계산** — 그대로 빼면
|
||
```
|
||
08:58:52 − 08:22:13 = 2199초 (36분 39초) ✘
|
||
```
|
||
|
||
**맞는 계산** — 디스크 기록 시각을 실제 시각으로 보정한 뒤 뺀다
|
||
```
|
||
디스크 기록 : 08:22:13 (ts) − 106초 = 08:20:27 (실제)
|
||
서빙 시작 : 08:58:52 (dev) = 08:58:52 (실제)
|
||
────────────────────────────────────────────
|
||
공백 : 2305초 = 38분 25초 ✔
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **106초는 두 값의 차이(2199)에 비하면 5% 도 안 된다.**
|
||
그래서 D-4 에서는 결론이 안 바뀌었다. **하지만 [D-4a](d4a-deploy-hook.md) 는
|
||
1~2초를 재는 실험이고, 거기서는 같은 106초가 결과를 완전히 뒤집는다** —
|
||
보정하지 않으면 훅이 인증서 발급보다 104초 **먼저** 실행된 것이 되어 물리적으로
|
||
불가능해진다.
|
||
|
||
> **두 시계에서 온 값을 빼면서 그 사실을 적지 않으면, 자릿수가 아니라 방향까지
|
||
> 틀릴 수 있다.** 음수 지연이 나오면 계산이 아니라 시계를 의심한다.
|
||
|
||
**그리고 이 38분은 우연히 짧았을 뿐이다.** reload 를 시킨 것은 **사람**이지
|
||
자동화가 아니다. 아무도 안 했다면 **다음 nginx 재시작까지 — 즉 무기한 —**
|
||
옛 인증서를 서빙했을 것이다.
|
||
|
||
## 4-6. 왜 88일 동안 안 보이나
|
||
|
||
```
|
||
오늘 타이머 두 번 SUCCESS (갱신할 것이 없으므로 아무 일도 안 한다)
|
||
+58일쯤 만료 30일 전 → 실제 갱신 ← 여기서 처음으로 절차가 시험된다
|
||
+88일 만료 ← 증상이 나타나는 날
|
||
```
|
||
|
||
**발현하는 날의 증상은 「인증서 만료」이고, 그날에도 로그는 `SUCCESS` 다.**
|
||
그래서 이 결함은 로그 감시로는 못 잡는다. **잡으려면 밖에서 `notAfter` 를
|
||
재야 한다.**
|
||
|
||
**확인** — 감시로 쓸 만한 한 줄. **미검증**
|
||
```bash
|
||
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
|
||
| openssl x509 -noout -checkend 2592000
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `Certificate will not expire` 인가
|
||
`Certificate will expire` 인가. `2592000` 은 30일(초)이다. **서버에 로그인하지
|
||
않고, 밖에서, 실제로 서빙 중인 것을 본다** — 이 세 가지가 이 실험의 교훈이다.
|
||
|
||
## 4-7. 답 ② — reload 는 무중단인가 (근거)
|
||
|
||
**5절에서 사람이 reload 한 뒤에 판정한다.** 감시 세 개를 멈추고 센다.
|
||
|
||
**하기**
|
||
```bash
|
||
touch /tmp/d4-stop
|
||
grep -vE ' 200 ' /tmp/d4-poll.txt | head
|
||
grep -v '코드=200' /tmp/d4-inflight.txt | head
|
||
```
|
||
|
||
**실측** — [`13-verdict.txt`](../../evidence/d4-certificate-renewal/13-verdict.txt)
|
||
|
||
**새 연결** — 0.2초 폴링, 08:10:51 ~ 09:02
|
||
```
|
||
전체 표본 8856건 / 비200 **0건**
|
||
|
||
응답시간 n 중앙 p95 최대
|
||
─────────────────────────────────────────────────────────
|
||
장기 평시 08:20~08:50 5398 98.0ms 205.7ms 1942.9ms
|
||
reload 직전 2분56초 489 116.0ms 200.8ms 387.7ms
|
||
reload 직후 2분08초 342 132.5ms 204.3ms 475.0ms
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **p95 가 205.7 → 204.3 으로 사실상 동일**하고 최대값은
|
||
오히려 낮다. 10초 구간 중앙값은 reload 전후 모두 80~190ms 사이를 오간다 —
|
||
**WiFi 잡음이지 reload 의 흔적이 아니다.**
|
||
|
||
**진행 중이던 요청** — 계획서가 정확히 물은 지점
|
||
```
|
||
08:58:40 요청 시작 (845KB @ 20k/s)
|
||
08:58:52 ← nginx -s reload. 요청 시작 12초 뒤, 전송 한가운데
|
||
08:59:21 종료: 코드=200 바이트=845361(전량) 연결수=1 curl종료=0
|
||
```
|
||
|
||
| 관측 | 읽는 법 |
|
||
|---|---|
|
||
| 바이트가 전량이다 | 잘리지 않았다 |
|
||
| **연결수가 1이다** | 중간에 끊겨 재연결한 게 아니다 |
|
||
| 코드 200 | **옛 워커가 이 요청을 끝까지 책임졌다** |
|
||
|
||
**이 결과가 의미하는 것** — **reload 는 무중단이다.** 옛 인증서로 시작한 연결이
|
||
새 워커 전환을 **관통해** 끝까지 갔다. in-flight 전체 50건 중 종료코드 ≠ 0 은
|
||
0건이다.
|
||
|
||
### ★ 측정 장치가 거짓말할 뻔했다
|
||
|
||
in-flight 감시에서 **76건이 실패했다.** 그대로 적었으면 「갱신 중 대규모 요청
|
||
실패」라는 오보가 됐을 것이다. **서버 탓이 아니었다.**
|
||
|
||
**실측** — [`08-inflight-artifact.txt`](../../evidence/d4-certificate-renewal/08-inflight-artifact.txt)
|
||
```
|
||
08:15:04 코드=000 바이트=0 시간=0.001148 연결수=0 ← 여기부터
|
||
... (76건, 전부 08:15:04)
|
||
08:15:04 코드=200 바이트=845361 시간=42.236496 연결수=1 ← 곧바로 복귀
|
||
```
|
||
|
||
| 근거 | 값 |
|
||
|---|---|
|
||
| 같은 순간 폴링 | 49건 **전부 200** |
|
||
| 연결수 | **0** — TCP 연결 시도조차 못 했다 |
|
||
| 소요 시간 | **50µs** — DNS 조회보다도 짧다 |
|
||
| 재현 | **0/100** |
|
||
| nginx | 그 시각에 아무 일도 안 했다(워커 22.4시간째) |
|
||
|
||
**대조군이 오보를 막았다.** 그리고 **원인은 특정하지 못했다** — `curl` 을 `-s`
|
||
로 돌려 오류 메시지를 버렸고 종료 코드도 안 남겼기 때문이다. 1-8 의
|
||
`d4-inflight.sh` 에 `curl종료=$E` 가 들어 있는 것이 그 수정이다.
|
||
|
||
> **측정 장치가 실패했을 때 왜 실패했는지 남기지 않으면, 그 실패를 대상 탓으로
|
||
> 돌릴지 장치 탓으로 돌릴지 판단할 근거가 없다.**
|
||
|
||
---
|
||
|
||
# 5. 복구 — 사람이 reload 한다
|
||
|
||
**이 절이 곧 4-1 의 공백을 닫는 사건이다.** 순서상 관찰을 다 끝낸 뒤에 친다.
|
||
|
||
## 5-1. reload
|
||
|
||
**하기** — 사람이 친다
|
||
```bash
|
||
date -u '+%H:%M:%S reload (dev)'
|
||
ssh -t test-server 'sudo nginx -t && sudo nginx -s reload'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `test is successful` 두 줄이 먼저 나오고, 그다음
|
||
아무 말 없이 끝난다(`-s reload` 는 조용하다).
|
||
|
||
### 개념 — 왜 `reload` 이고 `restart` 가 아닌가
|
||
|
||
**실측(호스트)** — 이 호스트의 `nginx.service` 유효 설정
|
||
```
|
||
Type=forking Restart=on-failure RestartUSec=100ms
|
||
StartLimitBurst=5 StartLimitIntervalUSec=10s
|
||
KillMode=mixed KillSignal=SIGQUIT PrivateTmp=true
|
||
```
|
||
|
||
**확인** — 이 값들은 유닛 파일이 아니라 **실제 적용값**이라 `show` 로 본다
|
||
```bash
|
||
ssh test-server 'systemctl show nginx -p Type -p Restart -p RestartUSec \
|
||
-p StartLimitBurst -p StartLimitIntervalUSec -p KillMode -p KillSignal -p PrivateTmp'
|
||
```
|
||
|
||
**이 값들이 왜 중요한가**
|
||
|
||
| 설정 | 읽는 법 |
|
||
|---|---|
|
||
| `KillSignal=SIGQUIT` | 정지 신호가 nginx 의 **graceful shutdown** 신호다 — `stop` 도 연결을 끊지 않고 빠진다 |
|
||
| `Restart=on-failure` + `RestartUSec=100ms` | 죽으면 0.1초 뒤 다시 띄운다 |
|
||
| `StartLimitBurst=5` / `StartLimitIntervalUSec=10s` | **10초 안에 5번 실패하면 systemd 가 포기한다.** 설정이 깨진 채 `restart` 를 반복하면 **nginx 가 내려간 채로 멈춘다** |
|
||
| `PrivateTmp=true` | 이 서비스의 `/tmp` 은 **자기만의 것**이다. 여기 뭔가를 쓰면 밖에서 안 보인다 |
|
||
|
||
**그래서 `nginx -t` 를 먼저 친다.** 설정이 깨진 상태에서 reload 를 보내면
|
||
마스터가 새 워커를 못 띄우지만 **옛 워커는 그대로 서비스를 계속한다** —
|
||
인증서는 안 바뀌어도 서비스는 안 죽는다. `restart` 는 그 안전장치가 없다.
|
||
|
||
> `systemctl reload nginx` 도 같은 일을 한다(유닛에 `ExecReload` 가 있을 때).
|
||
> 이 실험대에서 실제로 친 것은 `nginx -s reload` 이고, D-4a 의 훅도 그것을
|
||
> 쓴다 — **미검증**인 쪽은 `systemctl reload` 다.
|
||
|
||
## 5-2. 바뀌었나 — 두 곳을 본다
|
||
|
||
**확인 ①** 워커 PID
|
||
```bash
|
||
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
|
||
```
|
||
**실측** — [`d4a-deploy-hook/01-hook-verified.txt`](../../evidence/d4a-deploy-hook/01-hook-verified.txt)
|
||
의 기준선. **D-4 에서 사람이 reload 한 결과가 이 워커다**
|
||
```
|
||
585 1 ... Thu Sep 3 19:00:39 nginx: master process
|
||
28829 585 ... Fri Sep 4 18:00:35 nginx: worker process ← D-4 에서 사람이 reload 한 것
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **마스터 585 는 그대로, 워커는 586 → 28829.**
|
||
1-6 에서 세운 판정 기준 그대로다.
|
||
|
||
**확인 ②** 밖에서 본 일련번호
|
||
```bash
|
||
tail -3 /tmp/d4-serial.txt
|
||
```
|
||
**실측** — [`09-serial-timeline.txt`](../../evidence/d4-certificate-renewal/09-serial-timeline.txt)
|
||
```
|
||
08:58:52 serial=06C7CB...EA1D notAfter=Dec 3 ← 바뀐 순간
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 일련번호가 **3-1 에서 본 디스크의 값과 같아졌는가.**
|
||
|
||
**이 결과가 의미하는 것** — 디스크와 네트워크가 다시 일치한다. **그 사이의
|
||
2305초가 이 실험의 답이다.**
|
||
|
||
## 5-3. 원상복구 확인표
|
||
|
||
| 항목 | 명령 | 돌아왔을 때 |
|
||
|---|---|---|
|
||
| 서빙 인증서 | 1-1 의 `openssl … -serial` | **3-1 의 새 일련번호와 같다** |
|
||
| 체인 | 1-2 | 4단계, `Verify return code: 0` |
|
||
| 이름 셋 | 1-3 | 세 일련번호가 서로 같다 |
|
||
| nginx | `ps -eo pid,ppid,etimes,lstart,args \| grep nginx:` | 마스터 그대로, 워커 **새것** |
|
||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master` | `200` |
|
||
| 감시 | `ls /tmp/d4-stop` | 있어야 한다(멈춘 상태). 없으면 `touch` |
|
||
| 남은 프로세스 | `ps -ef \| grep d4-` | 없어야 한다 |
|
||
| 임시 파일 | `ls -l /tmp/d4-*.txt /tmp/inflight.bin` | 근거로 남기거나 지운다 |
|
||
|
||
**인증서는 원상복구되지 않는다.** 새것이 정상이고, 옛것으로 돌아갈 이유도 없다.
|
||
|
||
## 5-4. 진짜 고치는 법
|
||
|
||
**이 절차는 사람이 reload 를 쳤기 때문에 38분에서 끝났다.** 자동으로 되게
|
||
하려면 훅이 필요하다.
|
||
|
||
```bash
|
||
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
||
#!/bin/sh
|
||
nginx -t && nginx -s reload
|
||
```
|
||
|
||
`deploy/` 는 **실제로 갱신된 인증서가 있을 때만** 실행된다. `post/` 는 갱신
|
||
여부와 무관하게 매번 돌므로, 하루 두 번 쓸데없이 워커를 갈아치우게 된다.
|
||
|
||
> **★ 이 처방은 검증됐다 — [D-4a](d4a-deploy-hook.md).**
|
||
> 훅 파일 하나로 **발급 → 서빙이 38분 25초에서 1~2초**가 됐다(약 1150배).
|
||
> **처방을 적고 시험하지 않는 것**이야말로 이 실험대가 계속 경계해 온 실수라서,
|
||
> 별도 실험으로 분리했다. **D-4 를 여기까지 했으면 D-4a 를 이어서 한다.**
|
||
|
||
---
|
||
|
||
# 막히면
|
||
|
||
전부 이 실험대가 **실제로 겪은** 증상이다.
|
||
|
||
| 증상 | 원인 | 확인 |
|
||
|---|---|---|
|
||
| 호스트에서 아무 명령이나 빈 결과 | **sudo 가 조용히 실패했다** | `sudo -n -l` → `a password is required`. `ssh -t` 로 다시 |
|
||
| `ssh test-server 'sudo …'` 가 멈춰 있다 | tty 가 없어 비밀번호를 못 묻는다 | **`ssh -t`** |
|
||
| 갱신했는데 일련번호가 그대로 | **그게 이 실험의 결과다** | 워커 PID 를 본다 — 4-2 |
|
||
| 워커 PID 로 판정이 안 선다 | 마스터까지 바뀌었다 | reload 가 아니라 **재시작**이다. `lstart` 를 본다 |
|
||
| 훅 디렉터리가 `Permission denied` | root 전용 | 「비었다」로 읽지 않는다 — 1-5 |
|
||
| 감시가 셸을 닫으면 죽는다 | `&` 만 붙였다 | **`setsid`** — 1-8 |
|
||
| in-flight 에 실패가 무더기로 | **로컬 아티팩트일 수 있다** | 같은 시각 폴링·`연결수`·소요 시간·재현 — 4-7 |
|
||
| 공백이 음수로 나온다 | **두 시계를 그대로 뺐다** | 1-7 로 돌아간다 |
|
||
| `notBefore` 로 발급 시각을 계산했다 | **LE 는 정확히 한 시간 백데이트한다** | SCT 를 본다 — 1-1 |
|
||
| crt.sh 에 인증서가 안 나온다 | **색인이 진실의 부분집합이다** | SCT 는 인증서 안에 있다. `-ext ct_precert_scts` |
|
||
| nginx 에러 로그가 중간에 잘린다 | **한 항목이 2048바이트에서 잘린다**(`NGX_MAX_ERROR_STR`) | 저널 포맷을 바꿔도 안 늘어난다. **access 로그**를 본다 |
|
||
| 체인이 1단계 | `cert.pem` 을 썼다 | 03 의 `ssl_certificate` 한 줄 — 1-2 |
|
||
| 발급 한도에 걸렸다 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 — 2-2 |
|
||
|
||
### crt.sh 에 관한 곁다리 — 실측
|
||
|
||
발급 사실은 Certificate Transparency 에 남으므로 sudo 없이 확인할 수 있을 것
|
||
같았다. 실제로 서빙 중인 인증서에는 SCT 가 2개 박혀 있다. **그런데**
|
||
|
||
**실측** — [`07-renewal-hook-missing.txt`](../../evidence/d4-certificate-renewal/07-renewal-hook-missing.txt)
|
||
```
|
||
$ curl -s 'https://crt.sh/?q=auth.hyeonworks.com&output=json'
|
||
[] ← 0건
|
||
$ curl -s 'https://crt.sh/?q=hyeonworks.com&output=json'
|
||
13건, 최신 not_before=2026-08-11 ← auth 는 없다
|
||
```
|
||
|
||
**인증서에 SCT 가 박혀 있다는 것과 crt.sh 가 그것을 색인했다는 것은 다르다.**
|
||
관측 도구가 진실의 부분집합만 본다는, A-2 의 `up` 지표와 같은 종류의 함정이다.
|
||
|
||
---
|
||
|
||
# 다음
|
||
|
||
| 실험 | D-4 가 남긴 것 |
|
||
|---|---|
|
||
| [D-4a](d4a-deploy-hook.md) deploy 훅 | **처방이 듣는지 시험한다.** 38분 25초 → 1~2초 |
|
||
| [D-3](d3-secret-management.md) 비밀 관리 | **`privkey.pem` 도 비밀이다.** 파일 하나가 전부다 |
|
||
| [B-7](../../experiment-b7-cookie-secret-rotation.md) | SAN 이 세 개뿐이라 **네 번째 호스트명을 못 썼다** — 1-3 |
|
||
| 운영 | **감시는 로그가 아니라 밖에서 본 `notAfter` 로 한다** — 4-6 |
|
||
| 전부 | **두 기계의 시각을 나란히 놓기 전에 시계부터 잰다** — 1-7 |
|
||
| 전부 | **처방을 적었으면 시험한다.** 이 문서는 처방만 적고 끝냈다가 D-4a 를 따로 해야 했다 |
|