docs(d4): measure the controls before the injection that needs a password

The forced renewal needs sudo on the host, and the host asks for a password.
That blocks the injection, not the experiment — the part worth doing first
was the control anyway.

Control 1 — new connections, 0.2s x 900 over 180s: 900/900 = 200, zero
failures. The noise floor is 0, so a single non-200 during the renewal can
be attributed to the renewal. Without this the observation would have been
uninterpretable, which is the mistake A-6 made calling a -41% control
"no effect" and A-8 made claiming zero-downtime from 9 samples.

Control 2 — the poll cannot answer the question the plan actually asked.
TLS handshakes were 900/900, meaning every request is a fresh connection,
so it measures "are new connections accepted", not "what happens to a
request already in flight". A separate device: the 845KB admin console
bundle pulled at --limit-rate 20k, holding one request open for 42 seconds.
Baseline 200 / 845361 bytes / 1 connection.

Monitors are running under setsid, and stop three minutes after the
certificate serial changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 17:12:31 +09:00
co-authored by Claude Opus 5
parent 74c9b3cea7
commit 716e62524a
4 changed files with 180 additions and 16 deletions
@@ -0,0 +1,19 @@
D-4 대조군 — 주입 없는 상태의 가용성 잡음 바닥
수집 시각(dev): 2026-09-04T08:03:07Z UTC
대상: https://auth.hyeonworks.com/realms/master · 0.2초 간격 900회 = 180초
형식: http_code time_total time_appconnect(TLS 핸드셰이크까지)
왜: 갱신 중 000 이 한 번 나와도, 평시 오류율을 모르면 그게 갱신 탓인지 알 수 없다.
----------------------------------------------------------------
표본 900 개
[상태코드 분포]
900 200
[응답시간 ms]
최소 67 중앙 98 p95 195 최대 1121 평균 106.9
[TLS 핸드셰이크 ms — 0 이면 연결 재사용, >0 이면 새 핸드셰이크]
핸드셰이크 발생 900회 / 900 평균 83 ms 최대 1100 ms
[비정상 응답 원문 — 있으면 아래에 전부]
비200 총 0
@@ -0,0 +1,22 @@
D-4 대조군 2 — '진행 중이던 요청' 측정 장치의 무주입 동작
수집 시각(dev): 2026-09-04T08:08:45Z UTC
왜 이 장치가 따로 필요한가
05-control 의 0.2초 폴링은 매 요청이 새 TCP 연결이다(핸드셰이크 900/900).
그래서 '새 연결을 받아주는가'는 재지만, D-4 가 묻는 '이미 진행 중이던
요청이 어떻게 되는가'는 재지 못한다. 재하려면 reload 순간에 실제로
전송 중인 요청이 있어야 한다.
장치
845KB 짜리 관리 콘솔 번들을 --limit-rate 20k 로 내려받는다.
응답을 일부러 느리게 읽어 요청을 ~42초 동안 살아 있게 만든다.
대상: https://auth.hyeonworks.com/resources/55yjq/admin/keycloak.v2/assets/main-BbID33M6.js
----------------------------------------------------------------
[대조군: 주입 없이 1회]
코드=200 받은바이트=845361 총시간=41.392198s 연결수=1 실효속도=20423B/s
기대 크기 845361 / 실제 845361 bytes
판정 기준 (주입 시 이 값들과 비교한다)
· 코드 200 + 크기 845361 = 진행 중이던 요청이 끝까지 살아남았다(graceful)
· 코드 000 또는 크기 부족 = reload 가 진행 중이던 연결을 끊었다
· 연결수 2 이상 = 중간에 끊겨 curl 이 다시 붙었다
@@ -6,9 +6,13 @@
| 파일 | 무엇을 보여주는가 | | 파일 | 무엇을 보여주는가 |
|---|---| |---|---|
| `01-certificate-state.txt` | SAN 3개(와일드카드 아님) · **체인 4단계, `Verify return code: 0`** · `certbot-renew.timer` enabled·active, 11시간 전 실행 · 88일 남음 · **`sudo: a password is required` 로 강제 갱신 불가** | | `01-certificate-state.txt` | SAN 3개(와일드카드 아님) · **체인 4단계, `Verify return code: 0`** · `certbot-renew.timer` enabled·active, 11시간 전 실행 · 88일 남음 · **`sudo: a password is required` 로 강제 갱신 불가** |
| `05-control-no-injection.txt` | **대조군 1 — 잡음 바닥.** 0.2초 × 900회 / 180초 동안 **900전부 200, 오류 0**. 중앙 98ms · p95 195ms. **TLS 핸드셰이크 900/900** = 매 요청이 새 연결이다 |
| `06-inflight-control.txt` | **대조군 2 — '진행 중이던 요청' 측정 장치.** 845KB 번들을 `--limit-rate 20k` 로 받아 요청을 **42초간 살려 둔다.** 무주입 시 코드 200 · 845361바이트 · 연결수 1 |
## 핵심 세 줄 ## 핵심 세 줄
1. **인증서가 이름 3개만 담는다.** B-7 에서 oauth2-proxy 를 올릴 호스트가 없어 Grafana 의 `app2` 를 빌려야 했던 실제 비용이 여기서 나왔다. 1. **인증서가 이름 3개만 담는다.** B-7 에서 oauth2-proxy 를 올릴 호스트가 없어 Grafana 의 `app2` 를 빌려야 했던 실제 비용이 여기서 나왔다.
2. **체인이 완전하다** — 단계가 4개이므로 `fullchain.pem` 을 쓰고 있다. 1개면 `cert.pem` 실수이며 캐시 없는 클라이언트에서만 깨진다. 2. **체인이 완전하다** — 단계가 4개이므로 `fullchain.pem` 을 쓰고 있다. 1개면 `cert.pem` 실수이며 캐시 없는 클라이언트에서만 깨진다.
3. **강제 갱신은 못 했다.** 호스트 sudo 가 비밀번호를 요구한다. 타이머가 active 라는 것은 "갱신이 된다"의 확인이 아니다. 3. **강제 갱신은 아직 못 했다.** 호스트 sudo 가 비밀번호를 요구한다. 타이머가 active 라는 것은 "갱신이 된다"의 확인이 아니다.
4. **주입 전에 대조군을 먼저 잡았다.** 평시 실패가 0/900 이므로, 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수 있다. 대조군 없이 주입부터 했다면 그 한 번을 해석할 수 없었다 — A-6 에서 −41% 를 "영향 없음"이라 적었던 실수가 바로 그것이다.
5. **폴링만으로는 D-4 의 질문에 답할 수 없다.** 핸드셰이크 900/900 이 말해주듯 매 요청이 새 연결이라, 재는 것은 "새 연결을 받아주는가" 뿐이다. 계획서가 물은 "진행 중이던 요청"은 42초짜리 in-flight 장치로 따로 잡는다.
+134 -15
View File
@@ -135,14 +135,67 @@ A층에서 `conntrack`·`tc` 를 자유롭게 썼는데, **호스트는 다르
> **이 사실은 B-7 에서 처음 드러났다** — nginx 설정을 읽으려던 시도가 계속 > **이 사실은 B-7 에서 처음 드러났다** — nginx 설정을 읽으려던 시도가 계속
> 빈 결과였고, 그게 **sudo 의 조용한 실패**였다. 여기서 다시 확인된다. > 빈 결과였고, 그게 **sudo 의 조용한 실패**였다. 여기서 다시 확인된다.
### 그래서 답하지 못한 것 ### 아직 답하지 못한 것 — 과 그 앞에서 먼저 한 것
`sudo` 를 못 쓰니 **주입은 못 한다.** 하지만 주입 없이도 할 수 있는 일이
하나 남아 있었고, 그게 사실 더 중요하다 — **대조군**이다.
> 갱신 중에 `000` 이 한 번 나왔다고 하자. 그게 갱신 탓인가?
> **평시 오류율을 모르면 대답할 수 없다.** A-6 에서 41% 를 "영향 없음"이라
> 적었다가 틀린 것도, A-8 에서 표본 9개로 무중단을 주장한 것도 같은 종류의
> 실수였다. 그래서 주입보다 대조군을 먼저 잡는다.
#### 대조군 1 — 새 연결을 받아주는가 (0.2초 × 900회 = 180초)
```
표본 900 개
[상태코드] 900 × 200 ← 오류 0
[응답시간] 최소 67 · 중앙 98 · p95 195 · 최대 1121 ms
[TLS 핸드셰이크] 900회 / 900 ← 매 요청이 새 연결이다
```
**잡음 바닥이 0이다.** 이 뜻은 명확하다 — 갱신 중에 비200 이 단 한 번이라도
나오면, 그건 갱신 탓으로 귀속해도 된다. 대조군이 0이 아니었다면 그렇게 말할 수
없었다.
#### 대조군 2 — 그런데 위 장치는 D-4 의 질문에 답하지 못한다
핸드셰이크가 900/900 이라는 것은 **매 요청이 새 TCP 연결**이라는 뜻이다.
그래서 이 폴링이 재는 것은 "새 연결을 받아주는가" 이지, 계획서가 물은
**"갱신 중 진행 중이던 요청은 어떻게 되는가"** 가 아니다. reload 순간에
실제로 전송 중인 요청이 있어야 잴 수 있다.
장치를 따로 만들었다 — 845KB 짜리 관리 콘솔 번들을 `--limit-rate 20k`
일부러 느리게 받아, 요청 하나를 **42초 동안 살아 있게** 만든다.
```
코드=200 받은바이트=845361 총시간=41.392198s 연결수=1 실효속도=20423B/s
기대 크기 845361 / 실제 845361 bytes
```
주입 시 이 값과 비교해 판정한다.
| 관측 | 뜻 |
|---|---|
| 코드 200 · 크기 845361 · 연결수 1 | 진행 중이던 요청이 끝까지 살았다 = graceful |
| 코드 000 또는 크기 부족 | reload 가 진행 중이던 연결을 끊었다 |
| 연결수 ≥ 2 | 중간에 끊겨 curl 이 다시 붙었다 (사용자에겐 지연으로 보인다) |
#### 남은 항목
| 계획서의 항목 | 상태 | | 계획서의 항목 | 상태 |
|---|---| |---|---|
| nginx reload 타이밍에 무중단인가 | **미측정** | | 갱신 중 새 연결이 끊기는가 | **대조군 확보** (평시 0/900 실패) · 주입 대기 |
| 갱신 중 진행 중이던 요청은 | **미측정** | | 갱신 중 진행 중이던 요청은 | **장치 확보** (42초 in-flight) · 주입 대기 |
| `certbot-renew.timer` 가 실제 갱신을 하는가 | **미측정** (58일 뒤에야 알 수 있다) | | `certbot-renew.timer` 가 실제 갱신을 하는가 | **미측정** (58일 뒤에야 알 수 있다) |
주입 명령은 하나뿐이고, 비밀번호가 있는 사람만 실행할 수 있다.
```bash
ssh test-server
sudo certbot renew --force-renewal
```
### 이론적으로는 무엇을 기대하는가 ### 이론적으로는 무엇을 기대하는가
``` ```
@@ -174,25 +227,91 @@ A-1 의 NetworkPolicy, A-3 의 `--grace-period=0`, B-5 의 AOF 모두
## 5. 재현 절차 (명령어) ## 5. 재현 절차 (명령어)
```bash ```bash
# 1. 인증서 내용 — 밖에서 볼 수 있다 # ─────────────────────────────────────────────────────────────
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ # 1. 인증서 내용 — 밖에서 볼 수 있다(sudo 불필요)
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName # ─────────────────────────────────────────────────────────────
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 -dates -subject -ext subjectAltName
done
# 세 이름의 일련번호가 같으면 → 인증서 1장에 SAN 3개다.
# B-7 이 네 번째 이름을 못 쓴 이유가 여기 있다.
# ─────────────────────────────────────────────────────────────
# 2. 체인 완결성 — 단계가 1개면 cert.pem 을 쓴 것이다 # 2. 체인 완결성 — 단계가 1개면 cert.pem 을 쓴 것이다
# ─────────────────────────────────────────────────────────────
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| grep -E "^ *[0-9] s:|Verify return code" | grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'
# 3. 갱신 자동화 # ─────────────────────────────────────────────────────────────
systemctl list-timers certbot-renew.timer --no-pager # 3. ★ 대조군을 먼저 잡는다 — 이걸 건너뛰면 주입 결과를 해석할 수 없다
systemctl is-enabled certbot-renew.timer # ─────────────────────────────────────────────────────────────
# 3-a. 새 연결 가용성: 0.2초 × 900회 = 180초
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 # 비200 이 몇 개인가
# 4. 강제 갱신 (sudo 필요 — 이 실험대에서는 불가) # 3-b. 진행 중 요청: 845KB 를 20k/s 로 받아 42초 동안 살려 둔다
JS=$(curl -s https://auth.hyeonworks.com/admin/master/console/ \
| grep -oE '/resources/[a-z0-9]+/admin/[^"]+\.js' | head -1)
curl -s --limit-rate 20k -o /tmp/inflight.bin \
-w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}\n' \
"https://auth.hyeonworks.com$JS"
# ─────────────────────────────────────────────────────────────
# 4. 감시를 켠다 — 일련번호가 바뀌면 3분 더 재고 스스로 멈춘다
# ─────────────────────────────────────────────────────────────
BASE=$(echo | openssl s_client -connect auth.hyeonworks.com:443 \
-servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial | cut -d= -f2)
rm -f /tmp/d4-stop
# 감시 0 — 일련번호. 바뀌는 순간이 nginx 가 새 인증서를 집은 순간이다
setsid bash -c '
BASE='"$BASE"'
while [ ! -f /tmp/d4-stop ]; do
S=$(echo | openssl s_client -connect auth.hyeonworks.com:443 \
-servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial -enddate | tr "\n" " ")
echo "$(date -u +%H:%M:%S) $S"
case "$S" in *"$BASE"*) ;; *) sleep 180; touch /tmp/d4-stop;; esac
sleep 5
done' > /tmp/d4-serial.txt 2>&1 < /dev/null &
# 감시 1 — 새 연결
setsid bash -c '
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' > /tmp/d4-poll.txt 2>&1 < /dev/null &
# 감시 2 — 진행 중 요청을 끊김 없이 연달아 (reload 순간에 반드시 하나가 떠 있게)
setsid bash -c '
while [ ! -f /tmp/d4-stop ]; do
echo "$(date -u +%H:%M:%S) $(curl -s --limit-rate 20k -o /dev/null \
-w "코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}" \
https://auth.hyeonworks.com'"$JS"')"
done' > /tmp/d4-inflight.txt 2>&1 < /dev/null &
# ★ setsid 가 필요하다. 그냥 & 로 띄우면 부모 셸이 끝날 때 같이 죽는다
# (A-3 에서 파드 안 & 가 exec 종료와 함께 죽은 것과 같은 함정이다).
# ─────────────────────────────────────────────────────────────
# 5. 주입 — 여기만 sudo 가 필요하다
# ─────────────────────────────────────────────────────────────
ssh test-server
sudo certbot renew --force-renewal sudo certbot renew --force-renewal
# 갱신 중 다른 창에서:
# while true; do curl -s -o /dev/null -w '%{http_code} ' https://auth.hyeonworks.com/realms/master; sleep 1; done
```
--- # ─────────────────────────────────────────────────────────────
# 6. 판정
# ─────────────────────────────────────────────────────────────
grep -vE ' 200 ' /tmp/d4-poll.txt # 새 연결이 끊긴 순간 (대조군은 0건)
grep -v '코드=200' /tmp/d4-inflight.txt # 진행 중 요청이 끊긴 순간
grep -v "$BASE" /tmp/d4-serial.txt | head # 새 인증서가 서빙되기 시작한 시각
```
## 6. 남긴 것 ## 6. 남긴 것