--- kind: CASE slug: renewal-succeeded-while-the-old-certificate-kept-serving title: 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초 topic: build-completion-judgment topicName: 끝났다는 판정 project: virtualization status: 게시 전 sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6 source: - final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신 - final/document.md#189-단계-03-엣지-nginx-라우팅과-호스트-dnat - final/document.md#194-이-부에서-파생될-open-question --- # 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초 갱신은 매번 SUCCESS 로 끝나는데 nginx 는 옛 인증서를 계속 내보냈다. certbot-renew.service 에 훅이 없어 갱신에서 서빙까지 2305초 걸렸고, deploy 훅을 두니 1~2초였다. 만료 30일 전까지는 갱신을 안 해 88일 동안 아무 표시도 나지 않는다. ## 관계 - **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다** 이 인증서를 어떻게 받기로 했는지가 그 결정이고, 여기서는 받은 뒤의 갱신을 다룬다. - **도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다** 갱신 로그의 SUCCESS 와 훅 로그의 error output 이 둘 다 상태를 잘못 말한 사건이다. - **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다** 이 단계의 확인을 어느 기계에서 치느냐가 결과를 바꾸고, 층별 확인이 그 순서를 정한다. - **단계별 구축 문서는 작성 시점이 아니라 실행 순서로 검증한다** 훅을 호스트에 두었다가 잃은 것도, 확인 명령을 엣지 안에서 친 것도 그 규칙이 받는 결함이다. - **가이드대로 다시 쳐서 이 실험대가 같은 상태로 서는가** 2305초를 잰 배치와 지금 배치가 다르므로 다시 세운 실험대에서 재야 할 값이 여기 있다. ## 문제 인증서 갱신은 매번 성공으로 끝나는데 서버가 내보내는 인증서는 옛것이었다. certbot-renew.timer : 정상 certbot-renew.service 의 결과 : SUCCESS 유닛의 ExecStartPost 또는 --deploy-hook : x nginx 가 새 인증서를 읽었나 : x 갱신에서 서빙까지 : 2305초 (38분 25초) 무엇이 reload 했나 : 사람이 직접 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다. ## 결론 certbot-renew.service 는 /usr/bin/certbot -q renew 한 줄이고 인증서를 새로 받는 데까지만 책임진다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 live/ 심볼릭 링크만 갈아 끼우므로, 경로는 그대로이고 내용만 바뀌어 nginx 는 바뀐 줄 모른다. 훅도 사람도 없으면 다음 nginx 재시작까지, 사실상 무기한으로 옛 인증서가 나간다. 해결 : /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh 에 nginx -t && nginx -s reload 를 두고 chmod +x 를 준다 실행 권한이 없으면 : certbot 이 조용히 건너뛴다 post/ 가 아니라 deploy/ 인 까닭 : post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다 훅을 저장소에 두는 까닭 : 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌다 판정 : 로그 문구가 아니라 강제 갱신 전후의 nginx 워커 PID 로 한다 훅을 넣어도 안전했다. reload 는 무중단이었고 새 연결 8856건이 전부 200, p95 는 205.7ms 대 204.3ms 로 변화가 없었다. ## 검증 환경 엣지 게스트 : kc-lab-edge, 192.168.122.10, Debian 12 nginx : nginx/1.22.1 certbot 플러그인 : dns-cloudflare 발급 대상 : -d hyeonworks.com -d '*.hyeonworks.com' lineage 디렉터리 : /etc/letsencrypt/live/hyeonworks.com/ nginx 가 읽는 파일 : fullchain.pem · privkey.pem 타이머 : certbot-renew.timer → certbot-renew.service 인증서 유효기간 : 오늘 + 90일 2305초를 잴 때의 배치 : 훅이 물리 호스트에 있었다 지금의 배치 : certbot · 인증서 · 갱신 타이머 · deploy 훅이 전부 엣지 게스트에 있다 ## 재현 조건 1. 엣지 게스트에 certbot 과 python3-certbot-dns-cloudflare 를 깔고 와일드카드 인증서를 받는다. 2. nginx 443 블록에 fullchain.pem 과 privkey.pem 경로를 적고 reload 한다. 3. systemctl cat certbot-renew.service 로 ExecStartPost 와 --deploy-hook 이 없는지 본다. 4. ps -eo pid,lstart,args 로 nginx 워커 PID 를 적어 둔다. 5. sudo certbot renew --force-renewal 을 친다. 6. 워커 PID 를 다시 본다. 바뀌지 않았으면 옛 인증서가 계속 나가고 있다. 7. deploy/reload-nginx.sh 를 두고 chmod +x 를 준 뒤 4번부터 6번까지를 다시 한다. ## 본문 ## 배포판 유닛이 어디까지 책임지나 Let's Encrypt 인증서는 90일짜리이고 갱신은 `certbot-renew.timer` 가 건다. 타이머가 부르는 유닛을 열어 보면 한 줄이다. ```bash systemctl cat certbot-renew.service ``` ``` [Service] Type=oneshot ExecStart=/usr/bin/certbot -q renew PrivateTmp=true ``` `ExecStartPost` 도 `--deploy-hook` 도 없다. 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 nginx 가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이라, 「기본값이니 괜찮겠지」가 이 결함이 사는 곳이다. 그래서 이 글의 훅이 모든 배포판에 필요하지는 않다. 위 유닛에 `ExecStartPost` 나 `--deploy-hook` 이 이미 적혀 있는 배포판이라면 훅을 새로 넣을 것이 아니라 거기 적힌 명령이 nginx 를 reload 하는지만 본다. ## nginx 는 인증서를 언제 읽나 nginx 는 `ssl_certificate` 에 적힌 파일을 기동과 reload 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 아래의 심볼릭 링크를 새 파일로 갈아 끼운다. 설정에 적힌 경로는 한 글자도 바뀌지 않고 그 경로가 가리키는 파일만 바뀌므로, nginx 쪽에서는 다시 읽을 계기가 생기지 않는다. 갱신이 끝난 뒤에 nginx 로 reload 를 걸어 주는 것이 없으면 옛 인증서가 계속 나간다. ## 88일 동안 이 결함이 보이지 않는다 타이머는 정상이고 갱신은 매번 `SUCCESS` 로 끝난다. 만료 30일 전까지는 갱신 자체를 하지 않으므로 88일 동안 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있다. 훅을 저장소에 두는 까닭도 여기에 있다. 호스트에만 두었을 때는 호스트를 초기화하면 아무 오류 없이 사라졌고, 사라진 것도 갱신이 실제로 일어나는 날까지는 드러나지 않는다. ## 훅 두 줄과 실행 권한 ```sh # file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh #!/bin/sh nginx -t && nginx -s reload ``` ```bash sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` 실행 권한이 없으면 certbot 이 조용히 건너뛴다. `post/` 가 아니라 `deploy/` 에 넣는 것은 `post/` 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이다. 이 실험대에서 잰 차이는 이렇다. | | 훅 없음 | 훅 있음 | |---|---|---| | 갱신 → 서빙 | 2305초 (38분 25초) | 1~2초 | | 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 | 훅도 사람도 없었다면 다음 nginx 재시작까지, 사실상 무기한이다. ## 판정은 로그 문구가 아니라 워커 PID 로 한다 ```bash sudo certbot renew --dry-run ``` dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 따로 본다. 순서가 dry-run 먼저인 것은 뒤엣것이 상태를 바꾸기 때문이다. `--force-renewal` 은 인증서를 실제로 새로 받아 발급 한도(주당 중복 5장)를 깎으므로, 진짜 판정이 필요할 때 한 번만 쓴다. ```bash # 강제 갱신 전에 워커 PID 를 적어 둔다 ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep sudo certbot renew --force-renewal # 워커 PID 가 바뀌었으면 reload 된 것이다 ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep ``` reload 는 마스터를 그대로 두고 워커만 갈아 끼우므로, 전후로 워커 PID 가 바뀌면 새 설정이 적용됐다. `lstart` 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다. certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍는데 실패가 아니다. nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful` 과 `signal process started` 다. 로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다. ## 훅을 넣어도 안전한가 갱신 때마다 reload 가 도는 것이 서비스에 영향을 주는지도 쟀다. 새 연결 8856건이 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었다. 845KB 를 20k/s 로 받는 중이던 요청은 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다. ## 수치를 내기 전에 시계를 쟀다 갱신 시각과 reload 시각이 다른 기계에 찍히므로, 두 시각을 빼기 전에 시계부터 쟀다. test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다. 음수 지연이 나오면 계산이 아니라 시계를 의심한다. ```bash A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N) # 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다 curl -sI https://www.google.com | grep -i '^date:' timedatectl show -p NTP -p NTPSynchronized ``` ## 확인하지 못한 것 2305초는 훅이 물리 호스트에만 있던 시절의 값이다. 지금은 certbot 과 인증서와 갱신 타이머와 deploy 훅이 전부 엣지 게스트에 있고, 그 배치에서 다시 재면 같은 수가 나오는지는 재지 않았다. 그래서 이 글의 2305초는 훅이 없으면 이만큼 벌어진다는 한 번의 측정이고 지금 배치의 값이 아니다. 1~2초 쪽도 같은 시기에 잰 값이다. 측정 출력을 `final/evidence/` 에 남기지 않았다. 2305초도 8856건도 p95 두 값도 845361바이트도 106초도 SSOT 본문에만 있다. 워커 PID 를 전후로 비교한 출력도 저장해 두지 않았다. 88일이라는 잠복 기간은 만료 30일 전에야 갱신을 시작한다는 동작에서 끌어낸 것이지, 한 주기를 실제로 돌려 본 값이 아니다. 훅을 뺀 채 만료일까지 가 본 기록은 없고 그런 기록이 있을 까닭도 없다.