Files
document-haness/docs/virtualization/tech-log-studio/lab-environment-build/setup/setup-wildcard-certificate-with-dns-01-and-a-deploy-hook.md
T
DongHyeonkaandClaude Opus 5 2109f726fe feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 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>
2026-09-17 11:01:55 +09:00

34 KiB

id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, sourceRevision, source
id kind slug title topic topicName project status studio pinnedVersions sourceRevision source
975a6d61-4e34-4034-a0d2-01fea3b498a3 SETUP wildcard-certificate-with-dns-01-and-a-deploy-hook DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다 lab-environment-build 실험대 환경 구성 virtualization 게시 전 https://hyeonworks.com/studio/documents/975a6d61-4e34-4034-a0d2-01fea3b498a3/edit
name version
nginx (엣지 게스트) 1.22.1
name version
nginx (물리 호스트) 1.30.4
name version
Debian GNU/Linux 12 (bookworm)
9465582b5d1630eb4ae7c4e078021486919bf6b6
final/document.md#190-단계-04-let's-encrypt-와-인증서-갱신
final/document.md#184-이-부의-출처와-범위

DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다

엣지 게스트에 certbot 과 Cloudflare 플러그인을 깔고 DNS-01 로 와일드카드 인증서를 받아 nginx 에 443 을 얹는 절차다. 갱신이 서빙하는 인증서까지 닿게 하는 deploy 훅도 넣는다. 물리 호스트에는 아무것도 두지 않는다.

관계

  • 인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다 왜 이 방식이어야 했는지와 토큰이 평문으로 놓이는 비용을 그 결정이 갖고, 여기는 그것을 실행하는 명령만 갖는다.
  • 갱신은 매번 SUCCESS 였고 옛 인증서가 계속 나갔다 — 2305초와 1~2초 훅이 없을 때 갱신과 서빙이 얼마나 벌어지는지를 그 기록이 쟀다. 여기서 훅을 넣는 다섯째 단계가 그 측정에서 나왔다.
  • 엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다 이 절차가 고쳐 쓰는 nginx 파일과 80 서버 블록을 그 단계가 먼저 세운다.
  • 도구가 낸 출력은 대상의 상태가 아니다 — 그 명령이 무엇을 세는지부터 가른다 갱신 로그의 SUCCESS 도 훅 로그의 error output 도 서버가 지금 무엇을 내보내는지를 말하지 않는다. 그래서 판정을 워커 PID 와 체인 단계 수로 한다.
  • 가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다 확인 명령을 엣지 안에서 치면 층의 답이 아니라 친 곳의 답이 돌아온다. 어느 기계에서 치는지가 이 단계의 판정을 가른다.
  • Keycloak 2노드와 PostgreSQL 을 k3s 에 올리고 클러스터가 묶였는지 확인한다 다음 단계다. 여기서 잰 404 tls=0 이 거기서 200 으로 바뀌는 것이 그 단계가 만든 변화다.

본문

읽기 전에 — 어디서 치는가

이 단계도 셸이 갈린다.

번호 무엇 어디서
1 certbot 설치 [kc-lab-edge]
2 Cloudflare 토큰 발급 브라우저
3 토큰 파일 · 토큰 검증 [kc-lab-edge]
4 시험 발급 · 실제 발급 · 확인 [kc-lab-edge]
5 nginx 에 443 을 얹는다 [kc-lab-edge]
6 갱신이 서빙까지 닿게 한다 [kc-lab-edge]
판정 열리는가 · 체인이 완전한가 tailnet 에 붙은 다른 머신 — 여기만 엣지가 아니다

판정만 엣지에서 치지 않는 까닭을 가이드가 한 줄로 적었다 — 엣지에는 Tailscale 이 없어 자기 자신을 밖에서 들어오는 경로로 부를 수 없다. 엣지 안에서 치면 이렇게 막힌다.

* connect to 100.83.212.4 port 443 failed: Connection refused

auth.hyeonworks.com 은 호스트의 tailnet 주소로 풀리는데 엣지 게스트에는 Tailscale 이 없다. 엣지에서 나간 패킷은 호스트의 virbr0 으로 들어가고 호스트의 DNAT 규칙은 iifname "tailscale0" 만 매칭하므로 그 패킷은 규칙에 안 걸리고, 호스트 443 에 리스너가 없어 거절된다. 설정이 틀린 것이 아니라 친 곳이 틀렸다. 가이드는 그 기계를 「tailnet 에 붙은 다른 머신」이라고만 부르고 다섯 셸 이름 중 어느 것인지 짚지 않았다(unknown).

편집기로 여는 파일은 셋이다 — Cloudflare 자격증명, 443 블록을 넣은 nginx 설정, deploy 훅 스크립트. 설치와 발급과 갱신은 certbot 명령을 그대로 쓴다. 비밀은 값을 화면에 찍지 않는다. 토큰이 제대로 들어갔는지는 파일 권한과 크기로 보고, 값이 맞는지는 Cloudflare 에 물어 응답의 상태 문자열로 가른다.

이 단계가 세우는 것

가이드 04 의 「이 단계가 끝나면」은 한 줄이다.

https:// 가 열리고 체인이 완전하며, 갱신이 자동으로 서빙까지 닿는다.

세 마디가 각각 다른 확인을 요구한다. 「열린다」는 curl -v, 「체인이 완전하다」는 openssl s_client, 「서빙까지 닿는다」는 워커 PID 로 판정한다. 앞의 둘까지만 보고 끝내는 문서가 많고, 이 실험대가 재 둔 결함은 정확히 세 번째에 있다.

무엇
패키지 certbot · python3-certbot-dns-cloudflare
자격증명 /etc/letsencrypt/cloudflare.ini600, root 만 읽기
발급 대상 -d hyeonworks.com -d '*.hyeonworks.com'
lineage 디렉터리 /etc/letsencrypt/live/hyeonworks.com/ — 첫 번째 -d 에서 따온 라벨
nginx 가 읽는 것 fullchain.pem · privkey.pem
유효기간 오늘 + 90일
deploy 훅 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh (chmod +x)
타이머 certbot-renew.timercertbot-renew.service

제약이 검증 방식을 정했다(observed). 같은 Let's Encrypt 인증서인데 「이 도메인이 네 것이냐」를 증명하는 방법만 다르고, 이 실험대에는 선택의 여지가 없다.

무엇 HTTP-01 DNS-01
검증 방향 Let's Encrypt 가 우리 서버로 (인바운드) certbot 이 DNS 공급자 API 로 (아웃바운드)
공개 인터넷에서 보여야 하나 그렇다 아니다
와일드카드 불가 가능
필요한 것 80 포트 · 공개 A 레코드 DNS 공급자 API 토큰

가이드는 이 선택을 우열로 적지 않는다. 공개 서버라면 HTTP-01 이 맞고, 토큰도 DNS 연동도 없어 관리할 것이 적다.

아래 절차는 DNS-01 로 받는 형태다. 다만 지금 돌고 있는 이 실험대의 certbot 이 어느 쪽으로 등록되어 있는지는 아직 읽지 못했다(unknown) — 호스트의 sudo 가 비밀번호를 요구해 /etc/letsencrypt/renewal/*.confauthenticator 를 비대화식으로 읽을 수 없었다. 이 실험대의 문서 두 개도 이 대목에서 서로 어긋나, 한쪽은 DNS-01 로 결론냈고 다른 한쪽은 같은 04 단계를 certbot certonly --webroot 로 적어 두었다. 어느 쪽이 실제로 등록되어 있는지는 그 한 줄을 읽어야 갈린다.

전제와 되돌리기

전제는 한 줄이다 — 앞 단계가 끝나 엣지 nginx 가 Traefik 으로 프록시한다.

되돌리는 절차는 원본 가이드 04 에 없다(unknown). 되돌리기에 가장 가깝게 적힌 문장은 배치의 까닭 한 줄뿐이다.

인증서·certbot·갱신 타이머·deploy 훅이 전부 엣지 게스트에 산다. 물리 호스트에는 아무것도 두지 않는다 — 그래야 virsh undefine kc-lab-edge 한 줄로 이 계층을 통째로 되돌릴 수 있다.

이 문장은 「게스트를 통째로 지우면 같이 없어진다」이고 걷어내는 절차가 아니다. 순서도, 사전 조건도, 지운 뒤 무엇을 확인하는지도 없고 이 실험대가 그렇게 지워 본 기록도 없다. 이 단계가 엣지에 남기는 것은 넷이다.

남는 것 어디에
패키지 둘 게스트 패키지 데이터베이스
cloudflare.ini · 인증서 묶음 · deploy 훅 /etc/letsencrypt/ 아래
certbot-renew.timer 활성화 systemd
443 블록 /etc/nginx/sites-available/keycloak-lab

Cloudflare 쪽 토큰은 엣지를 지워도 계정에 남는다(external). 토큰은 그쪽 서버가 들고 있고 엣지에 있던 것은 사본이다. 가이드는 그 폐기를 적지 않았다(unknown).

세우기 전에 먼저 본다

두 확인은 아무것도 바꾸지 않는다. 하나는 검증 방식을 정하고, 하나는 5번에서 쓸 설정 문법을 가른다.

여기부터 6번까지가 엣지 게스트 셸이다. lab host 에서 들어간다.

ssh kc-lab-edge

확인 ① 이 도메인이 무엇으로 풀리는가

dig +short auth.hyeonworks.com

실측(observed)

100.83.212.4

어디를 봐야 하는가 — 주소 한 줄. 그 주소가 공개 인터넷 대역인가, 100.64.0.0/10 안인가.

이 결과가 의미하는 것100.64.0.0/10 은 CGNAT 용으로 예약된 대역이라 공개 인터넷에서 라우팅 자체가 되지 않는다(external). 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지 않고, Let's Encrypt 를 tailnet 에 초대할 방법도 없다. 그래서 HTTP-01 은 쓸 수 없고 DNS-01 을 쓴다. 여기서 공개 주소가 나오는 환경이라면 위 표의 왼쪽을 고르는 편이 낫다.

확인 ② nginx 판 번호는 몇인가

nginx -v

실측(observed) — 같은 설정인데 배포판에서 갈린다.

엣지 (Debian 12):   nginx version: nginx/1.22.1
물리 호스트 (Arch): nginx version: nginx/1.30.4

어디를 봐야 하는가1.25.1 이 경계다. 지금 셸의 번호가 그 위인가 아래인가.

이 결과가 의미하는 것 — 1.25.1 미만이면 http2 on; 지시어가 없다. 5번의 설정처럼 listen 의 파라미터로 쓰면 1.22 와 1.30 양쪽에서 다 돈다. 지시어 형태로 쓰면 이렇게 막힌다.

[emerg] 1287#1287: unknown directive "http2" in /etc/nginx/sites-enabled/keycloak-lab:14
nginx: configuration file /etc/nginx/nginx.conf test failed

실행 절차

1. certbot 과 Cloudflare 플러그인을 깐다

목적 — DNS-01 검증을 할 수 있는 상태로 만든다.

sudo apt install -y certbot python3-certbot-dns-cloudflare
certbot plugins 2>/dev/null | grep -E '^\*'

예상 결과(observed)

* dns-cloudflare
* standalone
* webroot

^\* 로 거른 것은 certbot 이 쓸 수 있다고 표시한 플러그인 앞에 별표를 붙이기 때문이다.

왜 필요한가dns-cloudflare 가 이 목록에 없으면 4번의 발급이 unrecognized arguments: --dns-cloudflare 로 끝난다. 플러그인은 certbot 본체와 별개 패키지라 한쪽만 깔려 있어도 certbot 명령 자체는 돈다.

문제가 생기면 — 목록에 없으면 ① 의 뒤엣것이 깔렸는지 본다. cloud-init 이 이미 깔았다면 이 단계를 건너뛴다.

2. Cloudflare 토큰을 발급받는다

목적 — certbot 이 인증용 TXT 레코드를 직접 만들었다 지운다. 그래서 DNS 쓰기 권한이 필요하다.

브라우저에서 다섯 단계다.

  1. https://dash.cloudflare.com/profile/api-tokens 에서 Create Token
  2. Edit zone DNS 템플릿을 고르고 Use template
  3. PermissionsZone · DNS · Edit — 템플릿이 채워 준 그대로
  4. Zone ResourcesInclude · Specific zone · hyeonworks.com
  5. Continue to summary 다음 Create Token

예상 결과 — 토큰 값이 화면에 한 번 나온다. 창을 닫으면 복구가 없으므로 바로 3번으로 넘어간다.

왜 필요한가All zones 로 두면 계정의 모든 도메인에 대한 DNS 수정 권한이 엣지 게스트 안 평문 파일에 놓인다. Global API Key 는 더 나쁘다 — 계정 전체 만능 키라 폐기하면 그 키를 쓰던 다른 것들이 전부 같이 죽는다. 권한을 어디까지 좁히고 무엇을 감수했는지는 이 절차를 부른 결정이 갖고 있다.

문제가 생기면 — 값을 놓쳤으면 다시 발급받는다. 이 문서에도 터미널에도 값을 적지 않는다.

3. 자격증명 파일을 만들고 권한부터 좁힌다

목적 — certbot 이 읽을 자격증명을 root 만 읽을 수 있는 파일에 둔다.

sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
sudo nano /etc/letsencrypt/cloudflare.ini

③ 아래의 {{CLOUDFLARE_API_TOKEN}} 을 2번에서 화면에 한 번 나온 토큰 값으로 바꿔 쓴다. 발급받을 때마다 값이 달라서 여기 적을 수 없다. 저장은 Ctrl+O 다음 Enter, 나가기는 Ctrl+X.

# file: /etc/letsencrypt/cloudflare.ini
dns_cloudflare_api_token = {{CLOUDFLARE_API_TOKEN}}
ls -l /etc/letsencrypt/cloudflare.ini
sudo wc -c /etc/letsencrypt/cloudflare.ini
CF=$(sudo awk -F'= *' '/api_token/{print $2}' /etc/letsencrypt/cloudflare.ini)
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify -H "Authorization: Bearer $CF"

예상 결과 — ④ 는 -rw------- 이고 바이트 수가 0 이 아니다. sudo 없이 wc 를 치면 Permission denied 가 나는데 그것이 정상이다. ⑤ 의 응답에는 "status":"active""success":true 가 들어 있다. 값 자체는 셸 변수에만 담기고 화면에 찍히지 않는다.

왜 필요한가 — 두 명령의 순서가 그 자체로 보안 조치다. install -m 600 /dev/null 은 빈 파일을 600 으로 만드는 명령이고, 토큰을 먼저 쓰고 나서 권한을 고치면 그사이에 파일이 열려 있다. ⑤ 를 여기서 해 두면 뒤에서 실패했을 때 DNS 문제인지 토큰 문제인지를 헷갈리지 않는다.

문제가 생기면 — 권한이 -rw-r--r-- 면 같은 게스트의 다른 사용자도 토큰을 읽는다. 바이트 수가 0 이면 편집기에서 저장을 안 했거나 다른 경로에 썼다. ⑤ 의 응답에 "code":6003 이 보이면 토큰 값이 틀렸거나 잘렸고, "code":9109 면 권한 범위가 모자라다.

4. dry-run 을 먼저 돌리고 그다음에 발급한다

목적 — 와일드카드를 포함한 인증서를 받는다. 실패를 시험용 한도에서 먼저 만난다.

sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d hyeonworks.com -d '*.hyeonworks.com' --dry-run
sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d hyeonworks.com -d '*.hyeonworks.com'
sudo certbot certificates
sudo openssl x509 -noout -ext subjectAltName -in /etc/letsencrypt/live/hyeonworks.com/fullchain.pem

예상 결과 — ① 의 마지막 줄이 The dry run was successful. 이고, ② 는 Successfully received certificate. 와 저장 경로를 찍는다. ③ 에서 볼 것은 네 줄이다.

Domains: hyeonworks.com *.hyeonworks.com — 한 줄에 둘 다
Expiry Date: 오늘 + 90일, VALID
Certificate Path: /etc/letsencrypt/live/hyeonworks.com/fullchain.pem
Private Key Path: /etc/letsencrypt/live/hyeonworks.com/privkey.pem

④ 는 DNS:hyeonworks.com, DNS:*.hyeonworks.com 두 개를 내놓고 auth.hyeonworks.com 은 두 번째에 걸린다.

왜 필요한가 — dry-run 을 먼저 도는 것은 Let's Encrypt 의 주당 중복 인증서 5장 한도를 dry-run 이 쓰지 않기 때문이다(external). dry-run 은 스테이징 서버에 대고 시험만 하므로 /etc/letsencrypt/live/ 에는 아무것도 안 생기고, 그 직후 certbot certificatesNo certificates found 를 내는 것이 정상이다. -d 를 둘 주는 것은 와일드카드가 한 단계만 덮기 때문이고, a.b.hyeonworks.com 도 apex 인 hyeonworks.com 자신도 *.hyeonworks.com 에 들어가지 않는다.

디렉터리 이름과 인증서가 덮는 이름은 별개다(observed). 여기서 가장 많이 헷갈린다.

무엇 정해지는 방식
디렉터리 이름 live/hyeonworks.com/ certbot 이 이 묶음을 관리하려고 붙인 라벨. 첫 번째 -d 에서 따오고 서빙과 무관하다
SAN 목록 브라우저가 보는 실제 유효 호스트명. -d 로 준 이름 전부

문제가 생기면 — 최초 실행이면 계정 등록 대화가 먼저 뜬다. 이메일을 비우면 Invalid email address: . 로 되묻고, 약관은 Y, 뉴스레터는 발급과 무관하므로 N 이다. 프롬프트 없이 돌리려면 -m <이메일> --agree-tos --no-eff-email 을 붙인다. --register-unsafely-without-email 은 쓰지 않는다 — 갱신 실패를 알려 줄 통로가 사라지는데, 6번이 재는 것이 바로 그 갱신이다. DNS-01 은 TXT 레코드가 퍼질 때까지 기다리느라 수십 초 걸리므로 중간에 끊지 않는다.

5. nginx 에 443 을 얹는다

목적 — 앞 단계에서는 80 만 세웠다. 인증서가 생겼으니 443 블록을 더하고 80 은 리다이렉트로 바꾼다.

sudo nano /etc/nginx/sites-available/keycloak-lab
# file: /etc/nginx/sites-available/keycloak-lab
upstream k3s_traefik {
	server 192.168.122.11:80;
	server 192.168.122.12:80;
}

server {
	listen 80 default_server;
	server_name _;
	return 301 https://$host$request_uri;
}

server {
	listen 443 ssl http2 default_server;
	server_name _;

	ssl_certificate     /etc/letsencrypt/live/hyeonworks.com/fullchain.pem;
	ssl_certificate_key /etc/letsencrypt/live/hyeonworks.com/privkey.pem;
	ssl_protocols       TLSv1.2 TLSv1.3;

	location / {
		proxy_pass http://k3s_traefik;
		proxy_http_version 1.1;
		proxy_set_header Host              $host;
		proxy_set_header X-Forwarded-Host  $host;
		proxy_set_header X-Forwarded-Proto https;
		proxy_set_header X-Forwarded-Port  443;
		proxy_set_header X-Forwarded-For   $remote_addr;
		proxy_set_header X-Real-IP         $remote_addr;
		proxy_read_timeout 3600s;
		proxy_send_timeout 3600s;
	}
}
sudo nginx -t && sudo systemctl reload nginx

예상 결과syntax is oktest is successful 두 마디가 나오고 reload 가 돈다. 앞에 붙는 types_hash 경고는 통과를 막지 않는다.

왜 필요한가 — 앞 단계의 파일에서 바뀐 곳이 셋이다.

앞 단계에서는 지금
80 블록 location / { proxy_pass … } 리다이렉트만
443 블록 없었다 인증서와 함께 새로
X-Forwarded-Proto http https

ssl_certificate 에 적는 것은 cert.pem 이 아니라 fullchain.pem 이다. 서버 인증서만 보내면 중간 인증서가 빠져 체인이 끊기는데, 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이고 캐시가 없는 클라이언트에서만 깨지므로 발견이 늦다. 4번 ③ 에서 certbot 이 찍어 준 경로와 한 글자도 다르면 안 된다.

문제가 생기면cannot load certificate 로 막히면 경로를 본다. live/auth.hyeonworks.com/ 이라고 적으면 그 디렉터리가 없다. 확인 ② 에서 본 판 번호가 1.25.1 미만인데 http2 를 지시어로 썼다면 여기서 unknown directive "http2" 가 나온다.

6. 갱신이 서빙까지 닿게 한다

목적 — 타이머가 도는 것만으로는 부족하다. 갱신된 인증서를 누가 읽게 만드는 일까지 세운다. 여기가 이 단계의 알맹이다.

systemctl list-timers certbot-renew.timer
systemctl list-timers --all | grep -i certbot
systemctl cat certbot-renew.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
# file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/sh
nginx -t && nginx -s reload
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
ls -l /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

예상 결과 — ① 은 NEXTLEFT 가 채워져 있고 ACTIVATEScertbot-renew.service 를 가리킨다. ③ 에는 ExecStartPost--deploy-hook 도 없고, 여기 없는 것을 보는 것이 그 명령의 목적이다. ⑦ 의 권한 문자열에는 x 가 세 번 보인다.

왜 필요한가 — 타이머가 active 여도 갱신된 인증서가 서빙되지는 않는다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고 certbot 은 live/ 심볼릭 링크만 갈아 끼우므로, 설정에 적힌 경로는 그대로이고 그 경로가 가리키는 파일만 바뀌어 nginx 에는 다시 읽을 계기가 생기지 않는다. 배포판 기본 유닛은 인증서를 새로 받는 데까지만 책임지고 받은 것을 누가 읽게 만드는 일은 아무도 하지 않는다. 훅이 그 둘을 잇는다. 훅을 post/ 가 아니라 deploy/ 에 넣는 것은 post/ 가 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치우기 때문이고, deploy/ 는 실제로 갱신됐을 때만 실행된다. 실행 권한이 없으면 certbot 이 이 훅을 조용히 건너뛴다.

문제가 생기면 — ③ 에 무엇인가 적혀 있는 배포판이라면 이 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만 확인한다. 갱신은 됐는데 옛 인증서가 나가면 ⑦ 부터 다시 본다.

구성 값

이 훅이 있고 없고의 차이를 이 실험대가 쟀다(observed).

무엇 훅 없음 훅 있음
갱신에서 서빙까지 2305초 (38분 25초) 1~2초
무엇이 reload 했나 사람이 직접 certbot deploy 훅
아무도 안 했다면 다음 nginx 재시작까지 = 사실상 무기한

88일 동안 이 결함이 보이지 않는다. 타이머는 정상이고 매번 SUCCESS 로 끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다. 발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고 적혀 있다.

reload 자체는 무중단이었다(observed). 새 연결 8856건 전부 200 이었고 p95 는 205.7ms 대 204.3ms 로 변화가 없었으며, 845KB 를 받는 중이던 요청이 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다. 옛 워커가 그 요청을 끝까지 책임진다.

끝났는지 판정한다

세 확인은 tailnet 에 붙은 다른 머신에서 친다. 1번부터 6번까지는 전부 엣지에서 쳤지만 판정은 밖에서 들어와야 의미가 있다.

확인 ① 열리나 — 처음 한 번은 협상 과정을 읽는다

무엇을 확인하는가 — TLS 가 붙었는지. 응답 코드가 아니다.

curl -v https://auth.hyeonworks.com/ -o /dev/null

경로는 / 다. Keycloak 은 다음 단계에서 올리므로 아직 Ingress 가 없고 404 가 정상이다. /realms/master 같은 Keycloak 경로를 여기서 쓰면 TLS 가 안 된 건지 Keycloak 이 없는 건지가 섞인다.

읽어야 할 줄만 옮긴다. 전체 출력은 이 실험대에서 캡처해 두지 않았다(unknown).

*  SSL connection using TLSv1.3 / ...
*  subject: CN=hyeonworks.com
*  issuer: C=US; O=Let's Encrypt; CN=...
*  SSL certificate verify ok.
< HTTP/1.1 404 Not Found

어디를 봐야 하는가 — 별표로 시작하는 줄 넷이다. 어떤 TLS 판으로 협상했는가, subject 의 CN 이 무엇인가, issuer 가 Let's Encrypt 인가, 그리고 SSL certificate verify ok. 가 있는가. 그 아래 < 로 시작하는 첫 줄이 응답 상태다. subjecthyeonworks.com 인 것이 맞다 — 와일드카드 인증서라 CN 은 apex 이름이고 auth.hyeonworks.com 은 SAN 의 *.hyeonworks.com 에 걸린다.

이 결과가 의미하는 것 — 네 줄이 다 나오면 인증서가 붙었고 체인이 클라이언트 기준으로 검증됐다. 인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을 본다. verify 줄 대신 unable to get local issuer certificate 가 나오면 중간 인증서가 빠진 것이고, 원인은 5번의 인증서 파일 이름이라 확인 ② 로 간다.

같은 것을 반복해서 재거나 앞뒤 단계의 값과 나란히 비교할 때만 값만 뽑는 형태로 줄인다.

curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/

실측(observed) — 2026-09-11, tailnet 클라이언트에서

404 tls=0

tls=0 이 인증서 검증 통과다. 앞의 404 는 다음 단계 이후에 200 으로 바뀐다.

확인 ② 체인 단계와 검증

무엇을 확인하는가 — 서버가 중간 인증서까지 보내는지.

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'

실측(observed) — 이름을 따로 받던 시절의 기록이라 0번 s:auth.hyeonworks.com 이다. 와일드카드로 받으면 CN = hyeonworks.com 이 되고, 봐야 할 구조는 똑같다.

 0 s:CN = auth.hyeonworks.com
   i:C = US, O = Let's Encrypt, CN = YE2
 1 s:C = US, O = Let's Encrypt, CN = YE2
   i:C = US, O = ISRG, CN = Root YE
 2 s:C = US, O = ISRG, CN = Root YE
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X2
 3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
Verify return code: 0 (ok)

어디를 봐야 하는가 — 왼쪽의 번호가 몇까지 가는가, 그리고 각 단계의 발급자 줄이 다음 단계의 주체 줄과 같은가. 마지막이 Verify return code: 0 (ok) 인가.

이 결과가 의미하는 것 — 단계가 1개면 cert.pem 을 쓴 것이다. 서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태인데, 이때 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이므로 이 명령이 유일하게 믿을 수 있는 판정이 된다. 고치는 곳은 5번의 ssl_certificate 한 줄이고, 고친 뒤 문법 검사와 reload 를 하고 여기서 다시 잰다. Verify return code 가 0 이 아니면 숫자마다 뜻이 다르다 — 10 은 만료, 20 은 발급자를 못 찾음, 21 은 첫 인증서를 검증 못 함.

확인 ③ 이름 세 개가 한 인증서인가

무엇을 확인하는가 — 이름마다 다른 인증서가 붙어 있지는 않은지.

for H in auth app1 app2; do
  echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
    | openssl x509 -noout -serial
done

어디를 봐야 하는가 — 찍히는 세 줄의 일련번호가 서로 같은가. 값 자체에는 뜻이 없고 셋이 일치하는지만 본다.

이 결과가 의미하는 것 — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는 인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 갱신 훅도 장마다 따로 돌고, 한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.

echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | openssl x509 -noout -ext subjectAltName

확인 ④ 갱신이 서빙까지 닿는가 — 워커 PID 로 본다

무엇을 확인하는가 — 훅이 호출되는지, 그리고 호출된 훅이 nginx 를 정말 갈아 끼웠는지.

sudo certbot renew --dry-run

출력 끝의 Running deploy-hook command 줄과 simulated renewals 요약을 본다. 훅 줄이 아예 안 나오면 파일이 deploy/ 가 아닌 곳에 있거나 실행 권한이 없다. dry-run 은 훅이 호출되는지까지만 말해 준다.

# 강제 갱신 전에 워커 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

--force-renewal 은 인증서를 실제로 새로 받으므로 발급 한도를 깎는다. 진짜 판정이 필요할 때만 한 번 쓴다.

어디를 봐야 하는가 — 두 출력의 첫 열과 둘째 열 묶음이다. 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. lstart 를 같이 뽑는 것은 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터 프로세스는 그대로이고 워커만 갈리는 쪽이 정상이다.

이 결과가 의미하는 것 — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를 읽었다. 안 바뀌었으면 인증서는 갱신됐는데 서빙되는 것은 옛것이다. 판정은 로그 문구가 아니라 워커 PID 로 한다 — certbot 이 Hook 'deploy-hook' ran with error output 이라고 찍는데 실패가 아니다. nginx 의 types_hash 경고가 stderr 로 나갔을 뿐이고 내용은 test is successfulsignal process started 다. 로그에서 error 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.

통과 조건을 한 번에 다시 본다

무엇 명령 통과
열리는가 curl -v https://auth.hyeonworks.com/ -o /dev/null SSL certificate verify ok.
검증값 curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' … 404 tls=0
체인 openssl s_client … | grep … 번호가 3까지 · Verify return code: 0 (ok)
이름 셋 for H in auth app1 app2; … 일련번호 세 줄이 같다
서빙까지 강제 갱신 앞뒤의 ps … 'nginx: worker' 워커 PID 집합이 바뀐다

막히면

증상 원인 확인
certbot 검증 실패 DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 dig +short auth.hyeonworks.com · 밖에서 curl -I http://auth.hyeonworks.com
체인 단계가 1개 cert.pem 을 씀 확인 ②
갱신은 됐는데 옛 인증서가 나감 deploy 훅 없음 워커 PID · sudo ls /etc/letsencrypt/renewal-hooks/deploy/
훅이 실패한 것처럼 보임 stderr 경고를 error 로 표시 문구 말고 워커 PID
발급 한도 주당 중복 인증서 5장 --dry-run 으로 먼저 시험
unrecognized arguments: --dns-cloudflare 플러그인 미설치 certbot plugins | grep '^\*'
토큰 응답이 "code":6003 · "code":9109 토큰 값이 잘렸거나 권한 범위가 좁다 Cloudflare 에서 토큰을 다시 발급한다
DNS-01 이 오래 걸림 TXT 전파 대기 정상이다. 끊지 않는다
재구축 뒤 인증서가 없음 발급하지 말고 백업을 되돌린다 한도를 아끼는 길이다
cannot load certificate 설정에 lineage 디렉터리 이름을 잘못 적었다 sudo certbot certificatesCertificate Path: 와 대조
엣지에서 친 curlConnection refused 친 곳이 틀렸다 tailnet 에 붙은 다른 머신에서 다시 친다

무엇이 관측이고 무엇이 아닌가

  • (observed) dig 가 낸 100.83.212.4, certbot plugins 세 줄, nginx -v 두 줄, 체인 4단계와 Verify return code: 0 (ok), 404 tls=0, 배포판 기본 유닛에 훅이 없다는 것, 갱신에서 서빙까지 2305초 대 1~2초, reload 무중단 측정값.
  • (external) 100.64.0.0/10 이 CGNAT 예약 대역이라는 것과 Let's Encrypt 의 발급 한도와 유효기간 90일은 규격이고 이 실험대가 잰 값이 아니다.
  • (external) Cloudflare 계정에 남는 토큰은 엣지를 지운다고 없어지지 않는다. 가이드는 폐기를 적지 않았다.
  • (inferred) 2305초 측정은 훅이 물리 호스트에만 있던 시절의 기록이라, 엣지 게스트에서 다시 잰 값이 아니다. 지금 배치에서 같은 수가 나오는지는 재지 않았다.
  • (unknown) 체인 실측은 이름을 따로 받던 시절 것이고, 와일드카드로 받은 지금의 출력은 싣지 않았다.
  • (unknown) curl -v 의 전체 출력, certbot certificates 의 실제 화면, 타이머 목록의 출력, 확인 ③ 의 일련번호 세 줄은 가이드가 봐야 할 줄만 적었고 값이 남아 있지 않다.
  • (unknown) 원본 가이드 04 에 되돌리는 절차가 없다. 패키지와 자격증명과 인증서와 훅과 타이머와 443 블록을 걷어내 본 적이 없다.
  • (unknown) 이 단계에는 「이 실험대는 이렇게 했다」와 「따라 하는 사람은」 두 갈래가 없다. 04 는 설정 파일 셋을 전부 편집기로 쓴다.