기록 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>
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 |
|
9465582b5d1630eb4ae7c4e078021486919bf6b6 |
|
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.ini — 600, 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.timer → certbot-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/*.conf 의 authenticator 를 비대화식으로 읽을 수 없었다. 이 실험대의 문서 두 개도 이 대목에서 서로 어긋나, 한쪽은 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 쓰기 권한이 필요하다.
브라우저에서 다섯 단계다.
https://dash.cloudflare.com/profile/api-tokens에서 Create TokenEdit zone DNS템플릿을 고르고 Use template- Permissions 는
Zone·DNS·Edit— 템플릿이 채워 준 그대로 - Zone Resources 는
Include·Specific zone·hyeonworks.com - 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 certificates 가 No 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 ok 와 test 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
예상 결과 — ① 은 NEXT 와 LEFT 가 채워져 있고 ACTIVATES 가 certbot-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. 가 있는가. 그 아래 < 로 시작하는 첫 줄이 응답 상태다. subject 가 hyeonworks.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 successful 과 signal 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 certificates 의 Certificate Path: 와 대조 |
엣지에서 친 curl 이 Connection 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 는 설정 파일 셋을 전부 편집기로 쓴다.