--- id: 4f32b469-185a-4dea-8eba-599864a3b476 kind: SETUP slug: reproduce-d4a-deploy-hook title: deploy 훅 파일 하나를 넣고 nginx 워커가 저절로 갈리는지 확인한다 topic: operations-that-report-success topicName: 운영 절차의 완료 판정 — 백업 · 판올림 · Secret · 인증서 갱신 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/4f32b469-185a-4dea-8eba-599864a3b476/edit" pinnedVersions: - name: certbot version: 5.7.0 source: - final/document.md#d층-재현-절차-다섯-편을-직접-치는-순서-d-4a sourceRevision: 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` 가 아니라 `kc-lab-edge` 다.** 기반 가이드 03 이 엣지 nginx 를 호스트에서 게스트로 옮겼고 04 가 거기 certbot 을 깔았다. 2026-09-17 에 양쪽에서 확인했다(observed). ```text [test-server] ps -ef | grep '[n]ginx' → nginx 프로세스가 없다 [kc-lab-edge] root 1065 nginx: master process /usr/sbin/nginx … www-data 1106 nginx: worker process ``` 그래서 아래 `[test-server]` 라벨이 붙은 블록은 전부 `ssh kc-lab-edge` 로 간다. 그 게스트의 `sudo` 는 무암호라 사람이 비밀번호를 칠 일도 없다. | 무엇 | 값 | |---|---| | 관찰하는 기계 | 개발 머신 `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일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다. ```bash label="[test-server] 훅을 지운다 — 지우면 D-4 의 상태로 돌아간다" ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh' ``` ## 주입 전에 같은 명령으로 먼저 본다 네 칸이고, 마지막 칸이 이 편의 답을 지킨다. D-4 의 여덟 칸을 다시 밟지 않는다 — 체인·이름 셋·타이머는 그쪽에서 이미 봤다. ```text 워커 PID → 서빙 인증서와 SCT → 훅 디렉터리가 비었나 → ★ 시계 왜곡 ``` ### 1. 출발점 워커 PID 를 적어 둔다 **무엇을 보는가** — 마스터 PID(Process ID, 프로세스 번호)와 워커 PID 두 숫자, 그리고 워커의 `lstart`. ```bash label="[test-server] nginx 프로세스 두 줄을 본다" ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep" ``` **어디를 보나** — 실측은 이렇다(observed, `01-hook-verified.txt`). ```text 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 를 적어 둔다 **무엇을 보는가** — 지금 밖으로 나가는 인증서의 일련번호와, 발급 시각의 외부 기준. ```bash label="[dev] ① 일련번호와 유효기간을 본다" echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ | openssl x509 -noout -serial -dates ``` ```bash label="[dev] ② 인증서 안의 SCT 를 본다" 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` 가 필요하다. ```bash label="[test-server] deploy 디렉터리를 본다" ssh -t test-server 'sudo ls -la /etc/letsencrypt/renewal-hooks/deploy/' ``` **어디를 보나** — 2026-09-17 에 엣지에서 친 것이다(observed). 디렉터리는 있고 안은 비었다. ```text total 8 drwxr-xr-x 2 root root 4096 Sep 17 04:33 . drwxr-xr-x 5 root root 4096 Sep 17 04:33 .. ``` 원래 실측은 이렇다(observed, `d4-certificate-renewal/12-certbot-state.txt`). ```text /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, 원격 셸 접속) 왕복 직전·직후의 시각과 저쪽 시각을 세 번 찍는다. ```bash label="[dev] ① 세 번 잰다" for i in 1 2 3; do A=$(date -u +%s.%N); B=$(ssh kc-lab-edge 'date -u +%s.%N'); C=$(date -u +%s.%N) echo "A=$A B=$B C=$C" done ``` **재는 대상이 엣지 게스트다.** 이 절차의 판정은 엣지 nginx 가 언제 reload 됐나이므로, 어긋난 시계를 재야 할 짝은 「보는 기계 ↔ 엣지」다. 2026-09-17 에 lab host 와 엣지를 나란히 찍어 보니 **엣지가 94초 느렸다**(observed). ```text lab host 2026-09-17T06:51:28.799Z kc-lab-edge 2026-09-17T06:49:54.782Z ``` 문서가 적은 `106초` 는 그때 그 짝의 값이고, **수를 옮겨 쓰지 말고 지금 자기 실험대에서 다시 잰다.** 게스트는 호스트와 따로 시계를 맞추므로 새로 만들 때마다 값이 다르다. 세 줄 각각에서 `B` 와 `(A+C)/2` 의 차이를 눈으로 뺀다. 그리고 세 번의 값이 서로 비슷한가를 본다 — 흔들리면 네트워크 지연이 섞였고, 안정적이면 진짜 왜곡이다. **2.** 어느 쪽이 맞는지는 외부 기준으로 가른다. ```bash label="[dev] ② 이쪽 시각" date -u ``` ```bash label="[dev] ③ 외부 기준 둘" curl -sI https://www.google.com | grep -i '^date:' curl -sI https://acme-v02.api.letsencrypt.org/directory | grep -i '^date:' ``` ```bash label="[test-server] ④ 저쪽 시각과 NTP 동기 여부" ssh test-server 'date -u; timedatectl show -p NTP -p NTPSynchronized' ``` **예상 결과** — 실측은 이렇다(observed, `01-hook-verified.txt`). ```text 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` 로 흔들리지 않았다는 것도 같이 본다. ```text 실제 시각 = test-server 시계 − 106초 ``` **왜 필요한가** — Let's Encrypt 의 `Date:` 까지 보는 까닭은, 이 절차가 재는 사건의 한쪽 끝이 그쪽의 발급이기 때문이다. 그 기준과 dev 가 일치한다는 것을 확인해 두면 관찰 절의 비교가 같은 시간축 위에서 성립한다. **문제가 생기면** — 세 번의 값이 흔들리면 회선이 조용할 때 다시 잰다. ## 주입 바꾸는 것은 파일 하나, 두 줄이다. 어느 디렉터리에 넣는가가 먼저 정해져야 한다. | 디렉터리 | 언제 실행되나 | |---|---| | `pre/` | 갱신 시도 전 | | `deploy/` | 실제로 갱신된 인증서가 있을 때만 | | `post/` | 갱신 여부와 무관하게 매번 | **왜 `deploy/` 인가.** 타이머는 하루 두 번 돈다. `post/` 에 넣으면 갱신이 없는 날에도 하루 두 번 nginx 를 reload 하게 된다 — 아무 이득 없이 워커만 갈아치운다. `deploy/` 는 certbot 이 `RENEWED_LINEAGE` 를 넘겨줄 때, 즉 실제로 갱신했을 때만 돈다. 없거나 틀리면 D-4 가 측정한 그대로 갱신은 성공하고 서빙은 안 바뀌며, 그 상태로 타이머는 `SUCCESS` 를 찍는다. **`nginx -t &&` 를 앞에 두는 까닭**도 같은 종류의 안전장치다. ```sh 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.** 호스트에 붙는다. ```bash label="[test-server] ① 호스트 셸로 들어간다" ssh test-server ``` **2.** 호스트의 셸에서 편집기로 연다. ```bash label="[test-server] ② 편집기로 연다" nano /tmp/reload-nginx.sh ``` ```sh #!/bin/sh nginx -t && nginx -s reload ``` **예상 결과** — 두 줄이 맞게 들어갔는가를 본다. `#!/bin/sh` 가 첫 줄이어야 한다. ```text #!/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 로 또 붙게 된다.** ```bash label="[test-server] ③ 호스트 셸에서 나온다" exit ``` **이 실험대는 셸로 파일을 만들었다**(observed). 편집기로 여는 형태는 이 형태로 실행하지 않았다(unknown). **아래 두 줄은 호스트 셸이 아니라 dev 머신에서 친 것이다** — 위 ①로 들어갔다면 이 형태는 쓰지 않는다. ```bash label="[dev 머신] 이 실험대가 실제로 친 형태 (observed)" 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 를 붙여 호스트에 붙는다. ```bash label="[test-server] ① tty 를 붙여 들어간다" ssh -t test-server ``` **2.** 호스트의 셸에서 설치한다. ```bash label="[test-server] ② 실행 권한과 함께 설치한다" sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/ ``` **예상 결과** — 아무것도 안 나오면 성공이다. **왜 필요한가** — `install -m755` 가 복사와 권한 설정을 한 번에 한다. `x` 비트가 없으면 certbot 이 훅을 그냥 건너뛴다. **3.** 호스트 셸에서 나온다. 바로 아래 「이 실험대가 실제로 친 형태」가 dev 머신에서 치는 줄이라 한 번 나와야 한다. 그다음 §7 은 다시 호스트 셸 안에서 친다. ```bash label="[test-server] ③ 호스트 셸에서 나온다" exit ``` **이 실험대는 설치와 강제 갱신을 한 줄로 쳤다**(observed). 사람이 비밀번호를 한 번만 치게 하려고 그렇게 쳤다. **아래는 호스트 셸이 아니라 dev 머신에서 친 것이다** — 위 ①로 들어갔다면 이 형태는 쓰지 않는다. ```bash label="[dev 머신] 이 실험대가 실제로 친 형태 (observed)" 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. 훅이 제자리에 있는지 본다 **무엇을 보는가** — 파일의 권한·위치·소유자. ```bash label="[test-server] deploy 디렉터리를 다시 본다" sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ ``` **어디를 보나** — 형태는 이렇다(모양은 observed). ```text 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.** 훅을 직접 실행한다. ```bash label="[test-server] 훅을 손으로 실행한다" sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` **예상 결과** — 형태는 이렇다(모양은 observed). ```text 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` 를 고친 뒤 다시 친다. **5~8 절은 2026-09-17 에 실제로 밟았다. 다만 기계가 `test-server` 가 아니다**(observed). 기반 가이드 03 이 nginx 를, 04 가 certbot 을 `kc-lab-edge` 로 옮겼으므로 `/etc/letsencrypt/renewal-hooks/deploy/` 도 `nginx` 도 그 게스트 안에 있다. 1 절의 `ps` 줄을 랩 호스트에서 치면 **한 줄도 안 나온다.** 아래는 엣지에서 친 것이다 — **따라 하는 순서가 아니라 그날의 기록이다.** 첫 줄의 훅 파일은 5 절 ②의 `nano /tmp/reload-nginx.sh` 로 연다. `printf` 로 찍으면 `\n` 과 `>` 를 먼저 해독한 뒤에야 정작 읽어야 할 두 줄에 닿는데, 그 두 줄은 나중에 열어서 고칠 파일이다. ```bash label="[kc-lab-edge] 5~8 절을 엣지에서 이어 친 형태 (observed)" printf '#!/bin/sh\nnginx -t && nginx -s reload\n' > /tmp/reload-nginx.sh sudo install -m755 /tmp/reload-nginx.sh /etc/letsencrypt/renewal-hooks/deploy/ sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ sudo /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` ```text label="7 절의 실측 — 파일 크기가 40 이 아니라 38 이다" total 4 -rwxr-xr-x 1 root root 38 Sep 17 07:34 reload-nginx.sh ``` ```text label="8 절의 실측 — 위 예상 결과에 없는 셋째 줄이 나온다" nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful 2026/09/17 07:34:08 [notice] 2361#2361: signal process started ``` 셋째 줄은 `nginx -s reload` 가 신호를 보내는 프로세스를 띄웠다고 알리는 것이고, 실패가 아니다. 위 예상 결과는 `nginx -t` 의 두 줄만 실어서 이 줄을 보면 잠깐 멈추게 된다. **그리고 이 절이 세우려는 판정 기준이 여기서 그대로 작동했다**(observed). 훅을 손으로 돌리기 전과 뒤의 두 줄이 이렇다. ```text label="훅 실행 전후" 전 1065 1 11186 Thu Sep 17 04:27:39 2026 nginx: master process 1106 1065 11177 Thu Sep 17 04:27:49 2026 nginx: worker process 뒤 1065 1 11189 Thu Sep 17 04:27:39 2026 nginx: master process 2362 1065 0 Thu Sep 17 07:34:08 2026 nginx: worker process ``` 마스터는 `1065` 로 그대로이고 워커가 `1106` 에서 `2362` 로 갈렸으며 새 워커의 `etimes` 가 `0` 이다. 판정표의 「마스터 그대로 · 워커 바뀜 = reload 됐다」 그 칸이다. 10 절이 이 방법으로 훅의 효과를 판정하는데, **인증서가 없어도 이 판정 기준 자체는 여기서 검증된다.** certbot 이 훅을 부르는지 먼저 보는 형태도 있는데, 이 실험대는 곧바로 강제 갱신을 했다(observed). 아래 줄은 오래 미검증이었는데 **2026-09-17 에 쳤다**(observed). 인증서가 한 장도 없는 상태에서도 돌고, 종료 코드는 `0` 이다. ```bash label="[kc-lab-edge] dry-run 으로 훅 호출만 본다" sudo certbot renew --dry-run ``` ```text label="인증서가 없을 때의 출력" Saving debug log to /var/log/letsencrypt/letsencrypt.log No simulated renewals were attempted. ``` **갱신할 것이 없으면 훅도 안 불린다.** `Running deploy-hook command` 줄은 안 나오고 `No simulated renewals were attempted.` 한 줄로 끝나며, 그래도 종료 코드는 `0` 이다. 그래서 **이 명령의 성공은 훅이 도는지에 대해 아무 말도 안 한다**. **★ 그런데 인증서를 세우고 훅까지 놓아도 dry-run 은 훅을 안 부른다**(2026-09-17, observed). 오래 미검증으로 남겨 둔 줄이라 이번에 재 봤다. `/etc/letsencrypt/renewal-hooks/deploy/` 에 실행 권한까지 준 훅을 놓고, 시뮬레이션이 **성공한** 판에서도 `Running deploy-hook command` 는 안 나온다. ```bash label="[kc-lab-edge] 훅을 놓고, 성공한 dry-run 출력에서 그 낱말을 센다" sudo ls -l /etc/letsencrypt/renewal-hooks/deploy/ sudo grep -c deploy-hook /tmp/dr.txt certbot --version ``` ```text label="이 실험대의 값" -rwxr-xr-x 1 root root 38 Sep 17 09:23 reload-nginx.sh 0 certbot 2.1.0 ``` `certbot --help all` 에 `--run-deploy-hooks` 도 없다(2.1.0 기준, 센 값 `0`). **그러므로 이 판의 certbot 에서는 dry-run 으로 훅을 확인할 방법이 없다.** 이 절의 사전 점검은 「갱신 절차가 도는가」까지만 말한다. 훅이 도는 것은 다음 절의 진짜 갱신에서만 보인다. **★ 그리고 같은 명령이 성공하기도 실패하기도 한다**(2026-09-17, observed). 세 번을 연달아 쳐는데 성공 · 실패 · 성공이었다. 실패한 판은 앞의 둘과 또 다른 사유다. ```text label="세 번째 실패 사유 — DNS 전파" Certbot failed to authenticate some domains (authenticator: dns-cloudflare). The Certificate Authority reported these problems: Domain: auth.hyeonworks.com Type: dns Detail: During secondary validation: DNS problem: NXDOMAIN looking up TXT for _acme-challenge.auth.hyeonworks.com - check that a DNS record exists for this domain Hint: The Certificate Authority failed to verify the DNS TXT records created by --dns-cloudflare. Ensure the above domains are hosted by this DNS provider, or try increasing --dns-cloudflare-propagation-seconds (currently 30 seconds). certbot exit=1 ``` 기본값 30초가 이 도메인에서는 아슬아슬하다. **한 번 실패했다고 설정이 틀린 것이 아니다** — 다시 쳐 보고, 계속 실패하면 `--dns-cloudflare-propagation-seconds` 를 올린다. 이 판의 종료 코드는 `1` 이었다 — 앞에서 본 대로 종료 코드는 사유마다 다르다. **★ 더 나쁜 것은, 종료 코드가 실패를 일관되게 알려 주지 않는다는 점이다**(2026-09-17, observed). 같은 「전부 실패」 본문을 두 번 받았는데 한 번은 `0`, 한 번은 `1` 로 끝났다. ```bash label="[kc-lab-edge] 종료 코드를 따로 잡아서 본다" sudo certbot renew --dry-run >/tmp/dr.txt 2>&1; echo "certbot exit=$?" ``` ```text label="첫 번째 — 본문은 실패, 종료 코드는 0" Failed to renew certificate auth.hyeonworks.com with error: You should register before running non-interactively, … All simulated renewals failed. The following certificates could not be renewed: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure) 1 renew failure(s), 0 parse failure(s) certbot exit=0 ``` 조금 뒤에 같은 명령을 다시 치니 실패 사유가 바뀜었고, 종료 코드도 같이 바뀜었다. ```text label="두 번째 — 본문은 같은 실패, 종료 코드는 1" Failed to renew certificate auth.hyeonworks.com with error: Missing command line flag or config entry for this setting: Please choose an account Choices: ['test-server@2026-09-03T01:50:44Z (66d5)', 'kc-lab-edge@2026-09-17T08:16:31Z (5df4)'] All simulated renewals failed. The following certificates could not be renewed: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (failure) 1 renew failure(s), 0 parse failure(s) certbot exit=1 ``` **두 번 다 `All simulated renewals failed` 이고 `1 renew failure(s)` 인데 종료 코드만 갈렸다.** `0` 을 성공으로 읽으면 첫 번째를 놓치고, 그렇다고 `1` 을 기다려도 두 번째에서만 맞는다. 이 명령에 대해 종료 코드는 판정 근거가 못 된다. **그러므로 `&&` 로 뒤를 잇거나 `$?` 로 갈라선 안 된다.** 판정은 본문의 `renew failure(s)` 수와 `All simulated renewals failed` 줄로 한다. 이 편이 재려는 것이 「성공했다고 보고하는데 실제로는 안 된 일」인데, 사전 점검 명령 자체가 그 성질을 갖고 있다. ```bash label="[kc-lab-edge] 실패를 실패로 읽는 형태" sudo certbot renew --dry-run >/tmp/dr.txt 2>&1 grep -E 'Failed to renew|renew failure' /tmp/dr.txt ``` 두 줄이다. 먼저 파일로 받아 두고, 그 파일에서 판정에 쓰는 줄만 골라 눈으로 읽는다. `renew failure(s)` 앞의 숫자가 `0` 이고 `Failed to renew` 줄이 없으면 통과고, 위 실측처럼 `1 renew failure(s)` 와 `Failed to renew` 가 같이 나오면 실패다. 갱신할 인증서가 한 장도 없으면 `grep` 은 아무것도 안 찍는다 — 그건 통과가 아니라 시뮬레이션할 것이 한 장도 없었다는 뜻이라 `/tmp/dr.txt` 를 그대로 열어 본다. 판정을 `&&` 나 `||` 로 이어 붙이지 않는 까닭은 바로 위 문단과 같다. 종료 코드가 거짓말하는 명령을 살피는 절에서 분기를 셸에 맡기면 같은 함정을 다시 판다. **★ 그리고 인증서를 옮겨도 갱신 능력은 따라오지 않는다**(observed). 다른 기계에서 `live/` · `archive/` · `renewal/` 만 가져오면 위 오류가 난다. `renewal/*.conf` 가 가리키는 **ACME(Automatic Certificate Management Environment, 인증서 발급을 주고받는 프로토콜) 계정**(`/etc/letsencrypt/accounts/`)과 **DNS 자격증명 파일**이 없기 때문이다. 오류 문구가 「등록부터 하라」여서 계정을 새로 만들라는 말로 읽히는데, 실제로 빠진 것은 옮겨 오지 않은 디렉터리다. ```text label="/etc/letsencrypt/ 아래에서 함께 와야 하는 것" live/ 인증서 심볼릭 링크 archive/ 실제 파일 renewal/ 갱신 설정 — authenticator 와 자격증명 경로를 적는다 accounts/ ★ ACME 계정 키. 없으면 "You should register…" cloudflare.ini ★ renewal 이 가리키는 자격증명. 없으면 DNS-01 이 못 돈다 ``` 출력에 `Running deploy-hook command` 계열의 줄이 나오는가, 그리고 `simulated renewals` 요약을 본다. dry-run 은 인증서를 발급하지 않고 한도도 안 깎는다. 훅이 호출되는지까지만 말해 주고, 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다. 그래서 관찰 절이 필요하다. **★ 그리고 `--dry-run` 이 보는 계정은 `renewal/*.conf` 가 가리키는 계정이 아니다**(2026-09-17, observed). `--dry-run` 은 Let's Encrypt **staging 서버**로 붙는데, `renewal/*.conf` 의 `account =` 은 **운영 계정**의 id 를 적어 둔다. 그래서 staging 쪽 계정이 둘 이상이면 certbot 이 고르지 못하고 앞서 본 `Please choose an account` 로 멈는다. ```bash label="[kc-lab-edge] 두 서버의 계정을 각각 센다" sudo find /etc/letsencrypt/accounts -mindepth 3 -maxdepth 3 -type d ``` ```bash label="[kc-lab-edge] renewal 이 어느 계정을 박아 뇌는가" sudo grep -E '^(account|server) ' /etc/letsencrypt/renewal/auth.hyeonworks.com.conf ``` ```text label="이 실험대의 값" /etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/66d5d86599378a0b07936733587017a6 /etc/letsencrypt/accounts/acme-v02.api.letsencrypt.org/directory/8d53f9312e2a4c9cdd13620122cd8272 account = 8d53f9312e2a4c9cdd13620122cd8272 server = https://acme-v02.api.letsencrypt.org/directory ``` `account =` 이 가리키는 `8d53…` 은 `acme-v02`(운영) 아래에만 있다. `--dry-run` 은 `acme-staging-v02` 아래를 보고, 거기 계정이 하나면 그것을 쓰고 둘이면 묻는다. 앞서 고르라고 나온 `66d5` 와 `5df4` 는 **둘 다 staging 계정**이었다 — `5df4` 는 실패한 dry-run 이 그때 새로 만든 것이라, **한 번 실패하고 나면 다음 dry-run 이 계속 실패한다.** 고르라고 나오는 이름은 계정 폴더의 `meta.json` 에서 온다. ```bash label="[kc-lab-edge] staging 계정을 누가 언제 만들었나" sudo sh -c "cat /etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/*/meta.json" ``` ```text label="이 실험대의 값" {"creation_dt": "2026-09-03T01:50:44Z", "creation_host": "test-server"} ``` `Choices:` 에 찍힌 `test-server@2026-09-03T01:50:44Z (66d5)` 가 바로 이 두 칸과 폴더 이름 앞 네 글자다. 둘 이상 나오면 나중에 생긴 쪽의 폴더를 `sudo rm -rf` 로 지워 하나만 남긴다. 이 실험대에서는 `kc-lab-edge` 가 만든 쪽을 지워 `test-server` 가 만든 하나만 남겨 두었다(observed). **지우는 것은 staging 계정이라 운영 인증서에는 영향이 없다.** 위 명령에 `sudo sh -c` 가 붙은 까닭은 경로에 `*` 가 들어 있기 때문이다. `sudo cat …/*/meta.json` 은 셀이 먼저 `*` 를 푸는데, 그 셀은 root 가 아니라 `accounts/` 안을 못 읽고 `No such file or directory` 로 끝난다. **그래서 `--dry-run` 의 실패와 진짜 갱신의 실패는 같은 것이 아니다.** 다만 이 실험대에서 진짜 갱신을 다시 치는 데까지 가지는 않았다(unknown). 확인한 것은 운영 계정이 하나뿐이고 그것이 `account =` 이 가리키는 바로 그 id 라는 것까지다. **중복 계정을 지우고 다시 치니 dry-run 이 통과했다**(2026-09-17, observed). ```text label="staging 계정을 하나만 남긴 뒤" certbot exit=0 Saving debug log to /var/log/letsencrypt/letsencrypt.log Processing /etc/letsencrypt/renewal/auth.hyeonworks.com.conf Simulating renewal of an existing certificate for auth.hyeonworks.com and 2 more domains Waiting 30 seconds for DNS changes to propagate Congratulations, all simulated renewals succeeded: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success) ``` **★ 2026-09-17 에 엣지에서 다시 치고 갱신이 서빙까지 닿는 것을 봤다**(observed). 이번엔 `[test-server]` 가 아니라 `[kc-lab-edge]` 에서 쳤다 — 인증서와 nginx 가 거기 있기 때문이다. ```text label="[kc-lab-edge] sudo certbot renew --force-renewal" Renewing an existing certificate for auth.hyeonworks.com and 2 more domains Hook 'deploy-hook' ran with error output: nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful 2026/09/17 10:36:31 [notice] 4744#4744: signal process started Congratulations, all renewals succeeded: /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem (success) certbot exit=0 ``` **판정은 이 출력이 아니라 서빙하는 인증서로 한다.** 같은 소켓을 갱신 전후로 두 번 열어 일련번호와 날짜를 견준다. ```bash label="[kc-lab-edge] 서빙하는 인증서가 바뀜는가" echo | openssl s_client -connect 127.0.0.1:443 -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -dates -serial ``` ```text label="갱신 전" notBefore=Sep 4 11:29:18 2026 GMT notAfter=Dec 3 11:29:17 2026 GMT serial=06F3E0EF4D1BB03DE58130EAAD1176101373 ``` ```text label="갱신 후" notBefore=Sep 17 09:37:58 2026 GMT notAfter=Dec 16 09:37:57 2026 GMT serial=065547991777D11A408CEA90D945DDA03DF1 ``` 일련번호가 바뀌었으므로 **훅이 돌았고 nginx 가 새 파일을 집었다.** 같은 순간 worker 프로세스도 바뀜다 — 갱신 전 `2629 4712`, 뒤 `4745 4754`. 디스크에는 `privkey4.pem` 이 `Sep 17 10:36` 으로 생겼다. **원래 실행과 다른 데가 둘 있다**(observed). 첫째, 훅 출력에 `types_hash` 경고 줄이 없다 — 엣지의 nginx 설정이 호스트의 것과 달라서다. 둘째, 원래는 worker 하나가 그대로 남았는데 이번에는 둘 다 교체됐다. **`ran with error output` 이 실패가 아니라는 것은 그대로다** — stderr 로 나간 세 줄이 전부 성공 메시지다. 새 인증서의 `notBefore` 가 훅이 도는 시각(`10:36:31`)보다 약 한 시간 앞이다. 왜 그런지는 이 실험대에서 가르지 않았다(unknown) — 발급자가 앞당긴 것인지 시계 차인지는 안 재 봤다. 세 이름 모두 갱신 뒤에도 검증을 통과한다. ```text label="[lab host] 갱신 뒤 세 이름" auth 302 verify=0 app1 200 verify=0 app2 302 verify=0 ``` **그리고 이것이 종료 코드 이야기를 닫는다.** 성공도 `0` 이고 앞의 첫 번째 실패도 `0` 이었다. 같은 명령이 돼을 때와 안 돼을 때 같은 값을 내므로, `$?` 로는 둔 경우를 가를 수 없다. 본문을 읽는 수밖에 없다. ## 관찰 ### 9. 강제 갱신을 친다 **목적** — 인증서 한 장을 실제로 발급하고, 훅이 거기에 붙어 도는지 본다. 되돌릴 수 없다. **1.** 시작 시각을 남긴다. 호스트에서 찍은 것은 `(ts)` 이고 106초 빠르다. ```bash label="[test-server] ① 시작 시각을 ts 시계로 남긴다" date -u '+%H:%M:%S 갱신 시작 (ts 시계)' ``` **2.** 강제 갱신을 건다. ```bash label="[test-server] ② 강제 갱신" sudo certbot renew --force-renewal ``` **예상 결과** — 실측은 이렇다(observed, `02-certbot-with-hook.txt`). ```text 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번에서 적어 둔 값과 지금의 값. ```bash label="[test-server] nginx 프로세스 두 줄을 본다" ssh test-server "ps -eo pid,ppid,etimes,lstart,args | grep 'nginx:' | grep -v grep" ``` **어디를 보나** — 실측은 이렇다(observed, `03-after-state.txt`). ```text 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, 한 인증서가 담는 이름 목록). ```bash label="[dev] 일련번호와 이름 셋을 본다" 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`). ```text 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) | ```bash label="[dev] 새 인증서의 SCT 를 본다" 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`). ```text Signed Certificate Timestamp: Sep 4 12:27:49.054 2026 GMT Signed Certificate Timestamp: Sep 4 12:27:49.048 2026 GMT ``` `(ts)` 값에서 106초를 뺀다. ```text 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일 뒤)에, 증상은 그 뒤 인증서 만료로 나타난다. 정말 지워야 한다면 두 줄이다. ```bash label="[test-server] ① 훅을 지운다" ssh -t test-server 'sudo rm /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh' ``` ```bash label="[test-server] ② 다시 비었는지 본다" 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`). 그날이 오면 두 줄이면 된다. ```bash label="[test-server] ① 워커가 갱신 시각 근처에 떴는가" ssh test-server "ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep" ``` ```bash label="[dev] ② 서빙 인증서의 만료가 밀렸는가" 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 는 원인을 정확히 셋으로 특정하고 고치는 법까지 적었고, 그 처방이 듣는지 확인하는 데 든 비용은 파일 하나와 명령 두 줄이었다. 확인하지 않은 채로 문서에 남았다면 「고치는 법」 항목은 다음 갱신일까지 아무도 시험하지 않은 문장으로 남았을 텐데, 그날이 바로 시험할 수 없는 날이다. ## 이 실험대에서 아직 못 밟은 단계 2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.** - **남은 것** — 강제 갱신부터(9~13 절). 인증서가 있어야 갱신할 것이 생긴다. 지금까지 밟은 것 — 훅 디렉터리가 빈 것(3 절), 두 기계 시계 차 94초(4 절), **훅 파일 작성·설치·확인·손으로 실행(5~8 절)과 그 실행이 워커 PID 를 갈아 끼우는 것(10 절의 판정 기준)**. 기계는 전부 `kc-lab-edge` 로 바꿔서 쳤다. - **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다. - **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다. ## 무엇이 관측이고 무엇이 아닌가 - (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초` 의 공백은 훅이 없어서 생긴 것이고, 그 훅은 인증서를 처음 세울 때 같이 놓였어야 했다.