기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
40 KiB
id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
| id | kind | slug | title | topic | topicName | project | status | studio | pinnedVersions | source | sourceRevision | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 4f32b469-185a-4dea-8eba-599864a3b476 | SETUP | reproduce-d4a-deploy-hook | deploy 훅 파일 하나를 넣고 nginx 워커가 저절로 갈리는지 확인한다 | operations-that-report-success | 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 | keycloak-session-store | 게시 전 | https://hyeonworks.com/studio/documents/4f32b469-185a-4dea-8eba-599864a3b476/edit |
|
|
cdac9b8178391311d8eca1ebc6cac15bb62d79af |
deploy 훅 파일 하나를 넣고 nginx 워커가 저절로 갈리는지 확인한다
certbot 의 deploy/ 훅에 두 줄짜리 파일 하나를 넣고, 갱신 뒤 nginx 워커가 사람 손 없이 갈리는지 확인하는 절차다. D-4 가 끝나 있어야 성립하고 인증서를 한 장 더 쓴다.
관계
- deploy 훅 하나가 그 공백을 1~2초로 줄였다 이 절차가 만드는 워커 교체와 1~2초를 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
- 새 인증서가 디스크에 있고 38분 25초 동안 옛 인증서가 나갔다 이 절차가 고치는 결함을 그 기록이 잰다. 훅이 없을 때의 값이 거기 있다.
- reload 를 사람이 아니라 deploy 훅이 부르게 한다 이 절차가 넣는 파일이 그 결정의 내용이고, 여기서 나온 두 값이 그 결정의 근거다.
- 적용됐는지는 로그 문구가 아니라 상태로 판정한다
certbot 이 성공한 훅에도
ran with error output을 찍는 것을 여기서 만난다. 판정은 워커 PID 로 한다. - 두 시계에서 온 값을 빼지 않는다 여기서는 106초를 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나고, 보정을 반대로 걸면 음수 지연이 나온다.
- 갱신 타이머가 실제 갱신에서도 도는가 이 절차는 강제 갱신으로만 훅을 시험한다. 타이머가 스스로 갱신하는 경로는 약 59일 뒤에야 조건이 성립한다.
- 인증서를 강제로 갱신하고 밖에서 보이는 일련번호가 언제 바뀌는지 잰다 먼저 해 둬야 하는 편이다. 판정 기준과 시계 왜곡 값을 거기서 재 두고, 이 절차는 그 값을 그대로 쓴다. 주입 방향도 되돌리기도 반대라 절차를 겹쳐 적지 않았다.
본문
읽기 전에 — 어디서 치는가
기계가 둘이다. 밖에서 보는 openssl 은 [dev] 에서 치고, 주입은 전부 [test-server] 쪽이라 사람이 비밀번호를 친다.
| 무엇 | 값 |
|---|---|
| 관찰하는 기계 | 개발 머신 dev. 시계가 외부 기준과 맞는다 |
| 주입하는 기계 | 호스트 test-server. 시계가 106초 빠르다 |
| 바꾸는 것 | 파일 하나, 두 줄. /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh |
| certbot | 5.7.0. nginx 플러그인은 없다 |
| 인증서 | --force-renewal 을 또 한 번 친다. 주당 중복 인증서 5장 한도를 두 장 쓴 셈이 된다 |
| 원래 실행 | 2026-09-04 12:27 UTC(실제) |
시각 표기 규약은 D-4 와 같고 여기서는 훨씬 무겁다. 이 절차는 1~2초를 재고, 106초 어긋난 시계를 섞으면 결과가 뒤집힌다.
| 표기 | 뜻 |
|---|---|
12:27:49 (실제) |
보정한 값. 외부 기준과 일치 |
21:29:36 KST (ts) |
test-server 시계. 106초 빠르다 |
12:29:05 (dev) |
개발 머신 시계. 보정 불필요 |
이 실험이 가르는 것
D-4 는 결함을 찾고 처방을 적어 두고 검증하지 않았다.
| D-4 가 남긴 항목 | 상태 |
|---|---|
| deploy 훅을 넣으면 자동 반영되는가 | 미측정. 훅은 아직 넣지 않았다 |
처방이 듣는지 모르는 채 「이렇게 고치면 된다」고 쓰는 것은 이 실험대가 스물세 번 경계해 온 실수라고 가이드는 적는다. 그래서 별도 실험으로 분리했다.
판정할 것은 셋이다.
| # | 질문 | 무엇으로 가르나 |
|---|---|---|
| ① | 훅이 실행되는가 | certbot 출력 |
| ② | nginx 가 정말 reload 되는가 | 워커 PID (문구가 아니라) |
| ③ | 얼마나 빠른가 | SCT ↔ 보정한 훅 시각 |
②가 이 편의 방법이고 ③이 이 편에서 가장 까다롭다. 판정을 문구로 하면 certbot 이 찍는 ran with error output 에 걸려 성공을 실패로 읽고, 시각을 보정하지 않으면 훅이 발급보다 먼저 돈 것이 되어 물리적으로 불가능한 값이 나온다.
절차를 끝까지 밟으면 훅 디렉터리가 비어 있는 데서 파일 하나를 넣는 것, certbot 이 ran with error output 이라고 찍는데 실패가 아닌 것, 마스터는 그대로고 워커만 자동으로 갈리는 것, 서빙 인증서가 곧바로 바뀌는 것, 발급에서 서빙까지 1~2초인 것, 보정하지 않으면 훅이 발급보다 107초 뒤에 돈 것으로 나오는 것, notBefore 가 발급 시각이 아닌 것을 자기 화면에서 보게 된다.
무중단인지는 이 편이 재지 않는다. 폴링과 전송 중 요청 감시는 D-4 에 있고, 거기서 잰 8856건 과 845361 바이트는 사람이 친 nginx -s reload 를 잰 값이다. 이 편이 재는 것은 훅이 거는 reload 가 실제로 일어나는가와 그 속도다.
전제와 되돌리기
- D-4 를 먼저 한다. 특히 두 가지가 없으면 이 절차는 성립하지 않는다 — 「reload 판정은 워커 PID 로 한다」는 기준, 그리고 두 기계 시계의 왜곡을 미리 재 둔 값.
- 관찰은 dev 에서, 주입은
test-server에서 사람이 친다. - 호스트의
sudo는 비밀번호를 요구한다. 이 절차의 주입은 전부 그쪽이다. - 이 호스트의 certbot 은
5.7.0이고 nginx 플러그인은 없다.
★ 인증서를 한 장 더 쓴다. certbot renew --force-renewal 을 또 한 번 치므로, D-4 에서 한 번 썼다면 이번이 두 번째이고 Let's Encrypt 의 주당 중복 인증서 5장 한도를 두 장 쓴 셈이 된다. 세어 두고, 절차만 확인하려면 --dry-run 을 먼저 쓴다.
되돌리기는 한 줄인데, 되돌리지 않는 편이 낫다. 훅은 결함을 고치는 파일이라 지우면 D-4 의 상태로 돌아가고, 그 결함은 다음 실제 갱신(약 59일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다.
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
주입 전에 같은 명령으로 먼저 본다
네 칸이고, 마지막 칸이 이 편의 답을 지킨다. D-4 의 여덟 칸을 다시 밟지 않는다 — 체인·이름 셋·타이머는 그쪽에서 이미 봤다.
워커 PID → 서빙 인증서와 SCT → 훅 디렉터리가 비었나 → ★ 시계 왜곡
1. 출발점 워커 PID 를 적어 둔다
무엇을 보는가 — 마스터 PID(Process ID, 프로세스 번호)와 워커 PID 두 숫자, 그리고 워커의 lstart.
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
어디를 보나 — 실측은 이렇다(observed, 01-hook-verified.txt).
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 한 것
이 값이 뜻하는 것 — 이 세 값을 적어 둔다. 관찰 절의 판정이 이 값과의 비교다.
워커 28829 는 D-4 에서 사람이 nginx -s reload 를 쳐서 생겼다. 마스터는 여전히 585, 어제 19:00:39 에 뜬 그대로다. 마스터가 유지되고 워커만 바뀌는 것이 reload 의 서명이라는 것을 D-4 에서 확인했고, 이 절차는 그 기준을 그대로 쓴다. 그러니까 출발점 자체가 사람이 건 reload 의 결과이고, 이 절차가 재려는 것은 훅이 거는 reload 다.
2. 서빙 인증서와 SCT 를 적어 둔다
무엇을 보는가 — 지금 밖으로 나가는 인증서의 일련번호와, 발급 시각의 외부 기준.
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -dates
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
어디를 보나 — serial 을 적어 둔다. 관찰 절에서 이 값이 바뀐다. Timestamp 두 줄은 CT(Certificate Transparency, 발급 사실을 공개 로그에 남기는 구조) 로그가 자기 시계로 서명한 시각이다.
이 값이 뜻하는 것 — SCT(Signed Certificate Timestamp, CT 로그가 인증서에 박아 주는 서명된 시각)는 이 실험대의 두 기계와 무관한 제3의 기준이라, 관찰 절에서 시계 보정의 심판이 된다.
3. 훅 디렉터리가 비어 있는지 본다
무엇을 보는가 — deploy/ 안에 무엇이 있는지. root 전용이라 sudo 가 필요하다.
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
어디를 보나 — 실측은 이렇다(observed, 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 ..
이 값이 뜻하는 것 — total 8 과 . .. 만 나온다. sudo 없이 치면 Permission denied 이고, 그 빈 출력을 「비어 있다」로 읽는 것이 D-4 에서 실제로 걸렸던 함정이다.
4. 시계 왜곡을 지금 잰다
목적 — 이 절차의 답은 1~2초인데 시계가 106초 어긋나 있으면 그 답이 통째로 사라진다. 왜곡은 사후에 되짚을 수 없다.
1. SSH(Secure Shell, 원격 셸 접속) 왕복 직전·직후의 시각과 저쪽 시각을 세 번 찍는다.
for i in 1 2 3; do
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
echo "A=$A B=$B C=$C"
done
세 줄 각각에서 B 와 (A+C)/2 의 차이를 눈으로 뺀다. 그리고 세 번의 값이 서로 비슷한가를 본다 — 흔들리면 네트워크 지연이 섞였고, 안정적이면 진짜 왜곡이다.
2. 어느 쪽이 맞는지는 외부 기준으로 가른다.
date -u
curl -sI https://www.google.com | grep -i '^date:'
curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:'
ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized'
예상 결과 — 실측은 이렇다(observed, 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 다. 세 번 다 +106.1 로 흔들리지 않았다는 것도 같이 본다.
실제 시각 = test-server 시계 − 106초
왜 필요한가 — Let's Encrypt 의 Date: 까지 보는 까닭은, 이 절차가 재는 사건의 한쪽 끝이 그쪽의 발급이기 때문이다. 그 기준과 dev 가 일치한다는 것을 확인해 두면 관찰 절의 비교가 같은 시간축 위에서 성립한다.
문제가 생기면 — 세 번의 값이 흔들리면 회선이 조용할 때 다시 잰다.
주입
바꾸는 것은 파일 하나, 두 줄이다. 어느 디렉터리에 넣는가가 먼저 정해져야 한다.
| 디렉터리 | 언제 실행되나 |
|---|---|
pre/ |
갱신 시도 전 |
deploy/ |
실제로 갱신된 인증서가 있을 때만 |
post/ |
갱신 여부와 무관하게 매번 |
왜 deploy/ 인가. 타이머는 하루 두 번 돈다. post/ 에 넣으면 갱신이 없는 날에도 하루 두 번 nginx 를 reload 하게 된다 — 아무 이득 없이 워커만 갈아치운다. deploy/ 는 certbot 이 RENEWED_LINEAGE 를 넘겨줄 때, 즉 실제로 갱신했을 때만 돈다. 없거나 틀리면 D-4 가 측정한 그대로 갱신은 성공하고 서빙은 안 바뀌며, 그 상태로 타이머는 SUCCESS 를 찍는다.
nginx -t && 를 앞에 두는 까닭도 같은 종류의 안전장치다.
nginx -t && nginx -s reload
설정이 깨진 상태에서 nginx -s reload 를 보내면 마스터가 새 워커를 못 띄운다. -t 로 먼저 검사하고 통과할 때만 reload 한다. 실패하면 옛 워커가 서비스를 계속하므로 인증서는 안 바뀌지만 서비스는 죽지 않는다. 이 순서 하나가 「인증서가 안 바뀐다」와 「사이트가 내려간다」를 가른다.
restart 를 쓰지 않는 까닭도 같다. 실측(호스트)로 확인한 nginx.service 의 유효 설정은 Restart=on-failure · RestartUSec=100ms · StartLimitBurst=5 · StartLimitIntervalUSec=10s 다(observed). 설정이 깨진 채 restart 를 걸면 10초 안에 5번 실패하고 systemd 가 포기한다 — nginx 가 내려간 채로 멈춘다.
5. 훅 파일을 만든다
목적 — 사람이 비밀번호를 치며 실행할 명령을 짧게 만들려고, 파일 내용은 sudo 가 필요 없는 곳에서 미리 만들어 둔다.
1. 호스트에 붙는다.
ssh test-server
2. 호스트의 셸에서 편집기로 연다.
nano /tmp/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
예상 결과 — 두 줄이 맞게 들어갔는가를 본다. #!/bin/sh 가 첫 줄이어야 한다.
#!/bin/sh
nginx -t && nginx -s reload
왜 필요한가 — 훅은 읽고 고칠 파일이지 한 번 찍고 마는 출력이 아니다. 파일을 열면 이미 무엇이 있는지 보이고, 같은 절차를 두 번 밟았을 때 >> 로 잘못 쳐서 줄이 두 번 들어가는 사고도 안 난다.
/tmp 를 여기서 쓰는 것은 괜찮은데, 이건 당신의 대화형 셸이 쓰는 /tmp 이기 때문이다. 다만 certbot-renew.service 는 PrivateTmp=true(실측(호스트), observed)라 그 서비스가 보는 /tmp 은 다른 곳이다. 훅이 나중에 /tmp 에 로그를 남기도록 만들면 타이머가 돌렸을 때 그 파일을 밖에서 찾을 수 없다(unknown — 이 실험은 훅에 로그를 넣지 않았다). 훅의 로그는 logger 로 저널에 보내거나 /var/log 아래에 쓴다.
3. 호스트 셸에서 나온다. 다음 절이 다시 ssh 로 들어가므로 여기서 나오지 않으면 test-server 안에서 test-server 로 또 붙게 된다.
exit
이 실험대는 셸로 파일을 만들었다(observed). 편집기로 여는 형태는 이 형태로 실행하지 않았다(unknown). 아래 두 줄은 호스트 셸이 아니라 dev 머신에서 친 것이다 — 위 ①로 들어갔다면 이 형태는 쓰지 않는다.
ssh test-server "printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh"
ssh test-server 'cat /tmp/reload-nginx.sh'
문제가 생기면 — 첫 줄이 #!/bin/sh 가 아니면 certbot 이 훅을 실행하지 못한다. 파일을 다시 연다.
6. 훅을 설치한다
목적 — deploy/ 에 실행 권한과 함께 넣는다. 여기부터 사람이 비밀번호를 친다.
1. tty 를 붙여 호스트에 붙는다.
ssh -t test-server
2. 호스트의 셸에서 설치한다.
sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/
예상 결과 — 아무것도 안 나오면 성공이다.
왜 필요한가 — install -m755 가 복사와 권한 설정을 한 번에 한다. x 비트가 없으면 certbot 이 훅을 그냥 건너뛴다.
3. 호스트 셸에서 나온다. 바로 아래 「이 실험대가 실제로 친 형태」가 dev 머신에서 치는 줄이라 한 번 나와야 한다. 그다음 §7 은 다시 호스트 셸 안에서 친다.
exit
이 실험대는 설치와 강제 갱신을 한 줄로 쳤다(observed). 사람이 비밀번호를 한 번만 치게 하려고 그렇게 쳤다. 아래는 호스트 셸이 아니라 dev 머신에서 친 것이다 — 위 ①로 들어갔다면 이 형태는 쓰지 않는다.
ssh -t test-server 'sudo sh -c "install -m755 /tmp/reload-nginx.sh \
/etc/letsencrypt/renewal-hooks/deploy/ && certbot renew --force-renewal \
> /tmp/d4a-renew.txt 2>&1; chmod 644 /tmp/d4a-renew.txt; tail -25 /tmp/d4a-renew.txt"'
읽기는 어렵다고 가이드가 스스로 적는다. 처음 할 때는 한 줄씩 치고 익숙해지면 합친다. 한 줄로 합치면 설치와 강제 갱신이 한 명령 안에 들어가서, 중간에서 멈췄을 때 훅이 깔린 상태인지 아닌지를 따로 봐야 한다.
문제가 생기면 — sudo 가 조용히 빈 결과를 주면 tty 가 붙지 않았다. ssh -t 로 다시 붙는다.
주입 검증
갱신을 걸기 전에 훅이 제자리에, 실행 가능한 상태로 있는지 본다. 한 번뿐인 강제 갱신을 오타 때문에 날리지 않기 위해서다.
§7 부터 §9 까지는 호스트 셸 안에서 친다. §6 ③에서 나왔으므로 §6 ①의 ssh -t test-server 로 다시 들어간 뒤 아래를 친다 — sudo 가 비밀번호를 물으니 -t 가 붙은 쪽으로 들어간다. 이 세 절의 명령에는 앞에 ssh 가 없는데, 호스트 셸 안에 있다는 전제이기 때문이다. dev 머신에서 그대로 치면 /etc/letsencrypt/ 가 없어 엉뚱한 결과를 보게 된다.
7. 훅이 제자리에 있는지 본다
무엇을 보는가 — 파일의 권한·위치·소유자.
sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/
어디를 보나 — 형태는 이렇다(모양은 observed).
total 4
-rwxr-xr-x 1 root root 40 Sep 4 21:2x reload-nginx.sh
세 가지를 본다. x 비트(-rwxr-xr-x)가 있는가 — 없으면 certbot 이 그냥 건너뛴다. 디렉터리가 deploy/ 인가 — post/ 에 들어가면 매번 돈다. 소유자가 root 인가.
8. 훅을 손으로 한 번 돌린다
목적 — 가장 확실한 사전 점검이다. 훅 스크립트가 실제로 도는지 본다.
1. 훅을 직접 실행한다.
sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
예상 결과 — 형태는 이렇다(모양은 observed).
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
왜 필요한가 — test is successful 을 본다. 이때 워커 PID 도 바뀌는데, 이 스크립트는 실제로 reload 하기 때문이다. 그러므로 §1 의 ps 줄을 여기서 한 번 더 쳐서 새 워커 PID 로 바꿔 적는다 — 지금은 호스트 셸 안이므로 그 줄에서 ssh test-server 를 떼고 큰따옴표 안쪽만 친다. 가이드는 이 재측정을 지시만 하고 명령을 다시 싣지 않았다. 건너뛰면 §10 의 「워커가 바뀌었다」가 훅이 한 것인지 여기서 손으로 돌린 것이 한 것인지 갈리지 않는다.
문제가 생기면 — nginx -t 가 실패하면 && 뒤가 안 돌고 워커도 안 바뀐다. nginx.conf 를 고친 뒤 다시 친다.
certbot 이 훅을 부르는지 먼저 보는 형태도 있는데, 이 실험대는 곧바로 강제 갱신을 했다(observed). 아래는 가이드가 미검증으로 표시한 줄이다(unknown).
sudo certbot renew --dry-run
출력에 Running deploy-hook command 계열의 줄이 나오는가, 그리고 simulated renewals 요약을 본다. dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 훅이 호출되는지까지만 말해 주고, 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다. 그래서 관찰 절이 필요하다.
관찰
9. 강제 갱신을 친다
목적 — 인증서 한 장을 실제로 발급하고, 훅이 거기에 붙어 도는지 본다. 되돌릴 수 없다.
1. 시작 시각을 남긴다. 호스트에서 찍은 것은 (ts) 이고 106초 빠르다.
date -u '+%H:%M:%S 갱신 시작 (ts 시계)'
2. 강제 갱신을 건다.
sudo certbot renew --force-renewal
예상 결과 — 실측은 이렇다(observed, 02-certbot-with-hook.txt).
Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Renewing an existing certificate for auth.hyeonworks.com and 2 more
Hook 'deploy-hook' ran with error output:
2026/09/04 21:29:36 [warn] 37250#37250: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
2026/09/04 21:29:37 [warn] 37251#37251: could not build optimal types_hash, you should increase either types_hash_max_size: 1024 or types_hash_bucket_size: 64; ignoring types_hash_bucket_size
2026/09/04 21:29:37 [notice] 37251#37251: signal process started
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Congratulations, all renewals succeeded:
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success)
왜 필요한가 — 다섯 줄을 하나씩 읽는다.
| 줄 | 실제 의미 |
|---|---|
Hook 'deploy-hook' ran with error output: |
훅이 실행됐고, stderr 에 뭔가 있었다 |
[warn] could not build optimal types_hash |
nginx 의 일반 경고. 갱신과 무관 |
nginx: … test is successful |
nginx -t 통과 |
[notice] … signal process started |
nginx -s reload 가 신호를 보냈다 |
Congratulations, all renewals succeeded |
갱신 성공 |
ran with error output 은 실패가 아니다. certbot 은 훅이 stderr 에 무엇이라도 쓰면 이 문구를 붙이는데, 종료 코드를 말하지 않는다. 여기서 stderr 로 나간 것은 nginx 의 types_hash 경고뿐이고 내용은 전부 성공이다.
로그에서 error 를 grep 하는 감시를 걸어두면 성공한 훅을 실패로 오독한다. 반대 방향도 위험한데, 이 실험은 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지 재지 않았다(unknown). 그래서 판정은 문구가 아니라 워커 PID 로 한다.
문제가 생기면 — 발급 한도에 걸렸으면 이번 주에 중복 인증서 5장을 이미 썼다는 뜻이다. 다음 주까지 기다린다.
여기서 호스트 셸을 나온다. §10 부터의 명령은 앞에 ssh test-server 가 붙어 있거나 [dev] 라벨이 달려 있고, 둘 다 dev 머신에서 친다. [dev] 가 붙은 openssl s_client 세 줄은 호스트 안에서 쳐도 그대로 돌아가므로 잘못 친 것이 화면에 드러나지 않는다. 그런데 §12 의 보정은 「dev 에서 본 시각은 그대로 쓰고 호스트에서 본 시각에서만 106초를 뺀다」 위에 서 있어서, 어느 기계에서 쟀는지를 섞으면 뺄 값이 어긋나고 1~2초짜리 답이 통째로 없어진다.
10. 워커 PID 로 판정한다
무엇을 보는가 — 1번과 8번에서 적어 둔 값과 지금의 값.
ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep"
어디를 보나 — 실측은 이렇다(observed, 03-after-state.txt).
585 1 95412 Thu Sep 3 19:00:39 2026 nginx: master process /usr/bin/nginx
37252 585 74 Fri Sep 4 21:29:36 2026 nginx: worker process
| 무엇 | 전 | 후 | 판정 |
|---|---|---|---|
| 마스터 | 585 |
585 |
그대로 |
| 워커 | 28829 |
37252 |
바뀌었다 |
워커 lstart |
Fri Sep 4 18:00:35 (ts) |
Fri Sep 4 21:29:36 (ts) |
방금 떴다 |
워커 etimes |
— | 74 |
74초 전 |
이 값이 뜻하는 것 — 마스터 PID 는 유지되고 워커만 바뀌었다. D-4 에서 「reload 되었는가」를 판정하려고 세운 방법이 그대로 작동한다. 그리고 이번에는 사람이 아니라 훅이 했다 — 왼쪽 칸의 워커 28829 는 D-4 에서 사람이 친 nginx -s reload 가 만들었고, 오른쪽 칸의 37252 는 deploy/ 훅이 만들었다.
etimes 74 를 같이 보는 까닭은 PID 가 우연히 재사용될 수 있기 때문이다. lstart 와 etimes 가 「방금」을 가리켜야 진짜 새 워커다.
11. 서빙 인증서가 바뀌었는지 본다
무엇을 보는가 — 밖으로 나가는 인증서의 일련번호와 SAN(Subject Alternative Name, 한 인증서가 담는 이름 목록).
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -dates -ext subjectAltName
어디를 보나 — 실측은 이렇다(observed, 03-after-state.txt).
serial=06F3E0EF4D1BB03DE58130EAAD1176101373
notBefore=Sep 4 11:29:18 2026 GMT
notAfter=Dec 3 11:29:17 2026 GMT
X509v3 Subject Alternative Name:
DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com
이 값이 뜻하는 것 — serial 이 2번에서 적어 둔 값과 다르다. D-4 의 인증서(06C7CB…EA1D)에서 바뀌었고 SAN 은 세 이름 그대로다. 훅 하나로 ①②가 끝났고 남은 것은 「얼마나 빨랐나」다.
12. 시계를 보정해 발급과 서빙 사이를 잰다
무엇을 보는가 — 가진 시각은 셋이고 두 개는 다른 시계에서 왔다.
| 사건 | 원래 값 | 어느 시계 |
|---|---|---|
| 인증서 발급 | SCT Sep 4 12:27:49.054 GMT |
CT 로그 (독립) |
훅의 nginx -t |
로그 2026/09/04 21:29:36 |
(ts) |
| 새 워커 기동 | lstart Fri Sep 4 21:29:36 |
(ts) |
훅의 nginx -s reload |
로그 2026/09/04 21:29:37 |
(ts) |
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
어디를 보나 — 실측은 이렇다(observed, 01-hook-verified.txt).
Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT
Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT
(ts) 값에서 106초를 뺀다.
12:27:49.05 인증서 발급 ← SCT (외부 권위 기준)
12:27:50 훅 nginx -t ← 로그 21:29:36 KST(ts) − 106초
12:27:50 새 워커 37252 기동 ← lstart 21:29:36 KST(ts) − 106초
12:27:51 훅 nginx -s reload ← 로그 21:29:37 KST(ts) − 106초
이 값이 뜻하는 것 — 발급에서 서빙까지 1~2초다.
독립 시계인 SCT 가 보정한 훅 시각의 1초 앞에 놓이므로, 보정이 자기 검증된다.
보정하지 않으면 어떻게 되는지는 두 갈래다. 원본 가이드는 이 대목을 한 문장에 붙여 놓았으므로 갈라 적는다.
| 어떻게 계산하나 | 나오는 값 | 무엇이 틀렸나 |
|---|---|---|
그냥 뺀다 (12:29:36 − 12:27:49) |
+107초 |
훅이 발급보다 107초 뒤로 보인다. 참값 1~2초보다 약 106초 크다 |
| 106초를 반대쪽에 건다 | 훅이 발급보다 앞 | 음수 지연이다. 훅은 갱신이 끝나야 도니 성립하지 않는다 |
원본은 앞 칸의 수치(+107초)에 뒷 칸의 결론(「104초 먼저」)을 이어 붙였다. +107초 는 「뒤」이므로 거기서 「먼저」가 나오지 않고, 104 라는 수가 어느 계산에서 나왔는지도 그 문서에 남아 있지 않다(unknown). 고쳐 쓰지 않고 어긋난 채로 적어 둔다. 어느 계산으로 가든 두 시계에서 온 값을 그대로 빼면 안 된다는 것은 같기 때문이다.
음수 지연이 나오면 계산이 아니라 시계를 의심한다. 그 의심을 가르는 것은 제3의 시계다 — 여기서는 CT 로그의 SCT 였다.
13. notBefore 를 발급 시각으로 쓰지 않는다
무엇을 보는가 — 인증서에 적힌 notBefore 와 SCT 의 차이.
인증서에는 notBefore=Sep 4 11:29:18 이라고 적혀 있지만 이건 발급 시각이 아니다. Let's Encrypt 는 notBefore 를 정확히 한 시간 백데이트한다 — 클라이언트 시계가 조금 빨라도 「아직 유효하지 않은 인증서」가 되지 않게 하려고 그렇게 적는다. 그리고 한 시간을 더한 값(12:29:18)을 발급 시각으로 그대로 쓰지도 않는다. 이 실험대의 두 인증서에서 SCT 는 그보다 일관되게 약 89초 앞섰다.
| 인증서 | notBefore |
notBefore + 1시간 |
SCT | 차이 |
|---|---|---|---|---|
| D-4 이전 것 | Sep 3 00:47:23 |
01:47:23 |
01:45:53.18 |
약 89.8초 |
| D-4a 새것 | Sep 4 11:29:18 |
12:29:18 |
12:27:49.05 |
약 88.9초 |
이 값이 뜻하는 것 — 이 차이의 원인은 이 실험이 규명하지 않았다(unknown). 다만 시각의 기준으로는 SCT 를 쓴다. 그것이 보정을 자기 검증한 값이기 때문이다. notBefore 를 그대로 발급 시각으로 쓰면 한 시간을 잃는다.
14. D-4 와 나란히 놓는다
| 무엇 | 훅 없음 (D-4) | 훅 있음 (D-4a) |
|---|---|---|
| 갱신 → 서빙 | 2305초 = 38분 25초 |
1~2초 |
| 무엇이 reload 했나 | 사람이 친 nginx -s reload |
certbot deploy 훅 |
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | 해당 없음 |
| 차이 | 약 1150배 |
바뀐 것은 파일 하나, 두 줄이다.
부수 정정이 하나 딸려 나왔다 — D-4 의 2199초 는 틀렸다. 이 실험이 시계를 재는 바람에 앞 실험의 숫자가 정정됐다. D-4 에서 적은 2199초(36분 39초)는 archive/cert2.pem 의 mtime(test-server 시계)과 일련번호 관측(dev 시계)을 그대로 뺀 값이었다.
| 사건 | 시각 (실제 UTC) |
|---|---|
| 새 인증서 디스크 기록 | 08:20:27 ← mtime 17:22:13 KST (ts) − 106초 |
| 실제 서빙 시작 | 08:58:52 ← dev 관측, 보정 불필요 |
| 갱신과 서빙 사이 | 2305초 = 38분 25초 |
두 시계에서 온 값을 빼면서 그 사실을 적지 않으면 자릿수가 아니라 방향까지 틀릴 수 있다. D-4 에서는 오차가 106초여서 결론이 안 바뀌었지만 1~2초를 재는 여기서는 결과를 완전히 뒤집었다.
복구와 원상복구 확인표
이 주입은 고장이 아니라 고침이라 남긴다. 지우면 D-4 의 상태로 돌아가고, 그 결함은 다음 실제 갱신(약 59일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다. 정말 지워야 한다면 두 줄이다.
ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/'
다시 total 8 인가를 본다.
시간이 지나야 시험할 수 있는 항목이 하나 있다.
| 항목 | 상태 |
|---|---|
certbot-renew.timer 가 실제 갱신을 하는가 |
미측정. 만료 30일 전에야 조건이 성립한다 — 증거의 VALID: 89 days 는 만료까지이므로 갱신은 약 59일 뒤다 |
훅은 --force-renewal 로 검증했다. 타이머가 스스로 갱신하는 경로도 같은 certbot renew 를 부르고 같은 deploy/ 훅을 실행하므로 미지수는 「타이머가 뜨는가」 하나이고, 그것은 D-4 에서 이미 확인했다(오늘 두 번 status=0/SUCCESS).
그날이 오면 두 줄이면 된다.
ssh test-server "ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep"
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial -enddate
워커 lstart 가 갱신 시각 근처인가, 그리고 notAfter 가 밀렸는가를 본다. 문구가 아니라 이 둘로 판정한다.
| 항목 | 명령 | 이렇게 되어 있어야 한다 |
|---|---|---|
| 훅 | sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ |
-rwxr-xr-x … reload-nginx.sh (남긴다) |
| nginx | ps -eo pid,ppid,etimes,lstart,args | grep nginx: |
마스터 그대로, 워커 새것 |
| 서빙 인증서 | openssl … -serial -dates |
관찰 절의 새 일련번호 |
| 체인 | D-4 의 체인 확인 한 줄 | 4단계, Verify return code: 0 |
| 밖 | curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master |
200 |
| 임시 파일 | ssh test-server 'ls -l /tmp/reload-nginx.sh /tmp/d4a-renew.txt' |
지워도 된다. 훅은 /etc 에 설치됐다 |
| 발급 한도 | — | 이번 주에 몇 장 썼는지 세어 둔다 |
막히면
| 증상 | 원인 | 확인 |
|---|---|---|
ran with error output 을 보고 실패로 판단했다 |
stderr 에 뭔가 있으면 무조건 붙는 문구다 | 워커 PID |
| 훅이 아예 안 불렸다 | x 비트가 없거나 deploy/ 가 아니다 |
sudo ls -l …/deploy/ |
| 훅은 돌았는데 워커가 안 바뀐다 | nginx -t 가 실패해 && 뒤가 안 돌았다 |
훅을 손으로 실행 |
| 워커도 마스터도 바뀌었다 | reload 가 아니라 재시작됐다 | lstart 두 줄을 본다 |
| 지연이 음수로 나온다 | 두 시계를 그대로 뺐다 | 시계 재는 절차로 돌아간다 |
| 발급 시각이 한 시간 어긋난다 | notBefore 를 발급 시각으로 읽었다 |
SCT 를 본다 |
| 시계 왜곡을 지금 재려는데 값이 흔들린다 | 네트워크 지연이 섞였다 | 3회 이상 재서 안정적인지 본다 |
| 호스트 명령이 조용히 빈 결과 | sudo 가 비밀번호를 못 물었다 | ssh -t 로 다시 |
훅 로그를 /tmp 에 썼는데 안 보인다 |
certbot-renew.service 는 PrivateTmp=true |
logger 로 저널에 보내거나 /var/log 아래에 쓴다(unknown) |
| nginx 경고가 계속 거슬린다 | types_hash_max_size 기본값 |
갱신과 무관하다. 고치려면 nginx.conf 를 손본다 |
이 편이 남기는 한 문장은 「처방을 적었으면 시험한다」이다. D-4 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었고, 그 처방이 듣는지 확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다. 확인하지 않은 채로 문서에 남았다면 「고치는 법」 항목은 다음 갱신일까지 아무도 시험하지 않은 문장으로 남았을 텐데, 그날이 바로 시험할 수 없는 날이다.
무엇이 관측이고 무엇이 아닌가
- (observed) 주입 전 워커 두 줄(
585와28829,lstart Fri Sep 4 18:00:35), 훅 디렉터리의total 8, 시계 측정 네 줄과+106.1세 번, certbot 출력 전문(ran with error output·types_hash경고 두 줄 ·test is successful·signal process started·Congratulations, all renewals succeeded·fullchain.pem (success)), 주입 뒤 워커 두 줄(585·37252·etimes 74·lstart Fri Sep 4 21:29:36 2026), 새 인증서의serial=06F3E0EF4D1BB03DE58130EAAD1176101373·notBefore=Sep 4 11:29:18 2026 GMT·notAfter=Dec 3 11:29:17 2026 GMT와 SAN 세 이름, SCT 두 줄(Sep 4 12:27:49.054·Sep 4 12:27:49.048),notBefore↔ SCT 표의약 89.8초·약 88.9초. - (observed, 호스트 확인)
nginx.service의Restart=on-failure·RestartUSec=100ms·StartLimitBurst=5·StartLimitIntervalUSec=10s,certbot-renew.service의PrivateTmp=true. 증거 파일이 아니라 이 호스트에서 확인한 값이라 가이드가 실측(호스트)으로 따로 표시했다. - 이 편이 잰 reload 는 훅이 걸었다 — 워커
37252를 만든 것은deploy/훅이고, 주입 전 워커28829는 D-4 에서 사람이 친nginx -s reload가 만들었다. 10번 표의 「전 / 후」 두 칸이 사람과 훅이다. 1~2초는 훅이 건 reload 를 잰 값이고, D-4 의2305초는 사람이 건 reload 까지의 간격이다. 무중단 판정의8856건과845361바이트는 이 편의 값이 아니다 — D-4 가 사람이 건 reload 에서 쟀고, 이 편은 폴링도 전송 중 요청 감시도 돌리지 않았다. 2199→2305는 이 실험이 앞 실험을 정정했다 — D-4 가archive/cert2.pem의 mtime(test-server 시계)과 일련번호 관측(dev 시계)을 그대로 빼서2199초로 적었고, 여기서 시계 왜곡 106초를 재고 나서2305초로 고쳤다. D-4 에서는 106초가 결론을 안 바꿨지만 여기서는 보정하지 않으면 뺀 값이 참값보다 약 106초 어긋나고, 보정을 반대로 걸면 음수 지연이 나와 성립하지 않는다. 정정한 값과 정정 전 값을 둘 다 남겨 둔 까닭이 그것이다.- (unknown)
certbot renew --dry-run에서Running deploy-hook command줄이 나오는지 — 이 실험대는 곧바로 강제 갱신을 했다. 훅이 진짜로 실패했을 때 certbot 이 무엇을 찍는지, 훅이/tmp에 남긴 로그가PrivateTmp때문에 안 보이는지,notBefore+1시간과 SCT 사이 약 89초 차이의 원인. 가이드가 전부 미검증으로 표시했다. - 두 형태로 적은 곳이 둘이다 — 훅 파일을 만드는 것과 설치·갱신을 한 줄로 합치는 것. 이 실험대는
printf … > /tmp/reload-nginx.sh로 만들고 설치와 강제 갱신을 한 줄로 쳤다(observed). 편집기로 여는 형태와 한 줄씩 치는 형태는 이 형태로 실행하지 않았다(unknown). 실제로 친 줄을 지우지 않고 나란히 적었다. - 비밀은 이 편에 나오지 않는다 — 다루는 값이 훅 파일 두 줄과 PID 와 시각이라 옮길 비밀이 없다. 일련번호·PID·호스트명은 식별자라 그대로 적었다. 새
privkey2.pem도 D-3 의 문제를 그대로 안고 있지만 이 절차는 그 파일을 열지 않는다. - 이 실험이 확인하지 않은 것 — 타이머가 스스로 갱신하는 경로. 만료 30일 전에야 조건이 성립하고(증거의
VALID: 89 days는 만료까지이므로 갱신은 약 59일 뒤다), 그때 볼 두 줄만 적어 두었다. - 가이드가 「다음」에 적은 한 줄이 이 편의 결론이기도 하다 — 「이 훅은 구축 절차에 들어가야 한다. 사후에 붙이는 것이 아니다」. D-4 가 잰
38분 25초의 공백은 훅이 없어서 생긴 것이고, 그 훅은 인증서를 처음 세울 때 같이 놓였어야 했다.