--- id: 975a6d61-4e34-4034-a0d2-01fea3b498a3 kind: SETUP slug: wildcard-certificate-with-dns-01-and-a-deploy-hook title: DNS-01 으로 와일드카드 인증서를 받고 갱신이 서빙까지 닿게 한다 topic: lab-environment-build topicName: 실험대 환경 구성 project: virtualization status: 게시 전 studio: "https://hyeonworks.com/studio/documents/975a6d61-4e34-4034-a0d2-01fea3b498a3/edit" pinnedVersions: - name: nginx (엣지 게스트) version: 1.22.1 - name: nginx (물리 호스트) version: 1.30.4 - name: Debian GNU/Linux version: 12 (bookworm) sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6 source: - 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 이 없어 자기 자신을 밖에서 들어오는 경로로 부를 수 없다. 엣지 안에서 치면 이렇게 막힌다. ```text * 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 에서 들어간다. ```bash label="[lab host] 엣지 게스트에 들어간다" ssh kc-lab-edge ``` ### 확인 ① 이 도메인이 무엇으로 풀리는가 ```bash label="[kc-lab-edge] 도메인이 어느 주소로 풀리는지 본다" dig +short auth.hyeonworks.com ``` **실측**(observed) ```text 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 판 번호는 몇인가 ```bash label="[kc-lab-edge] 이 게스트의 nginx 판 번호" nginx -v ``` **실측**(observed) — 같은 설정인데 배포판에서 갈린다. ```text 엣지 (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 양쪽에서 다 돈다. 지시어 형태로 쓰면 이렇게 막힌다. ```text [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 검증을 할 수 있는 상태로 만든다. ```bash label="[kc-lab-edge] ① 두 패키지를 깐다" sudo apt install -y certbot python3-certbot-dns-cloudflare ``` ```bash label="[kc-lab-edge] ② 쓸 수 있는 검증 방식이 무엇인지 본다" certbot plugins 2>/dev/null | grep -E '^\*' ``` **예상 결과**(observed) ```text * 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. **Permissions** 는 `Zone` · `DNS` · `Edit` — 템플릿이 채워 준 그대로 4. **Zone Resources** 는 `Include` · `Specific zone` · **`hyeonworks.com`** 5. **Continue to summary** 다음 **Create Token** **예상 결과** — 토큰 값이 화면에 한 번 나온다. 창을 닫으면 복구가 없으므로 바로 3번으로 넘어간다. **왜 필요한가** — `All zones` 로 두면 계정의 모든 도메인에 대한 DNS 수정 권한이 엣지 게스트 안 평문 파일에 놓인다. Global API Key 는 더 나쁘다 — 계정 전체 만능 키라 폐기하면 그 키를 쓰던 다른 것들이 전부 같이 죽는다. 권한을 어디까지 좁히고 무엇을 감수했는지는 이 절차를 부른 결정이 갖고 있다. **문제가 생기면** — 값을 놓쳤으면 다시 발급받는다. 이 문서에도 터미널에도 값을 적지 않는다. ### 3. 자격증명 파일을 만들고 권한부터 좁힌다 **목적** — certbot 이 읽을 자격증명을 root 만 읽을 수 있는 파일에 둔다. ```bash label="[kc-lab-edge] ① 빈 파일을 600 으로 먼저 만든다" sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini ``` ```bash label="[kc-lab-edge] ② 그다음에 토큰을 쓴다" sudo nano /etc/letsencrypt/cloudflare.ini ``` ③ 아래의 `{{CLOUDFLARE_API_TOKEN}}` 을 2번에서 화면에 한 번 나온 토큰 값으로 바꿔 쓴다. 발급받을 때마다 값이 달라서 여기 적을 수 없다. 저장은 `Ctrl+O` 다음 `Enter`, 나가기는 `Ctrl+X`. ```ini label="③ cloudflare.ini 에 쓸 내용" # file: /etc/letsencrypt/cloudflare.ini dns_cloudflare_api_token = {{CLOUDFLARE_API_TOKEN}} ``` ```bash label="[kc-lab-edge] ④ 권한과 크기만 본다" ls -l /etc/letsencrypt/cloudflare.ini sudo wc -c /etc/letsencrypt/cloudflare.ini ``` ```bash label="[kc-lab-edge] ⑤ 토큰이 살아 있고 권한 범위가 맞는지 묻는다" 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 을 먼저 돌리고 그다음에 발급한다 **목적** — 와일드카드를 포함한 인증서를 받는다. 실패를 시험용 한도에서 먼저 만난다. ```bash label="[kc-lab-edge] ① 먼저 시험한다" sudo certbot certonly --dns-cloudflare \ --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \ -d hyeonworks.com -d '*.hyeonworks.com' --dry-run ``` ```bash label="[kc-lab-edge] ② 실제로 받는다" sudo certbot certonly --dns-cloudflare \ --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \ -d hyeonworks.com -d '*.hyeonworks.com' ``` ```bash label="[kc-lab-edge] ③ 무엇을 받았는지 본다" sudo certbot certificates ``` ```bash label="[kc-lab-edge] ④ 인증서가 실제로 어떤 이름에 유효한지 본다" 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 은 리다이렉트로 바꾼다. ```bash label="[kc-lab-edge] ① 앞 단계에서 쓴 그 파일을 연다" sudo nano /etc/nginx/sites-available/keycloak-lab ``` ```nginx label="② 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; } } ``` ```bash label="[kc-lab-edge] ③ 앞 단계에서 쓴 그 두 줄로 다시 읽힌다" 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. 갱신이 서빙까지 닿게 한다 **목적** — 타이머가 도는 것만으로는 부족하다. 갱신된 인증서를 누가 읽게 만드는 일까지 세운다. 여기가 이 단계의 알맹이다. ```bash label="[kc-lab-edge] ① 타이머가 있는지 본다" systemctl list-timers certbot-renew.timer ``` ```bash label="[kc-lab-edge] ② 표가 비어 나오면 이름이 다른지 찾는다" systemctl list-timers --all | grep -i certbot ``` ```bash label="[kc-lab-edge] ③ 배포판 기본 유닛이 reload 를 부르는지 본다" systemctl cat certbot-renew.service ``` ```text [Service] Type=oneshot ExecStart=/usr/bin/certbot -q renew PrivateTmp=true ``` ```bash label="[kc-lab-edge] ④ 훅 스크립트를 연다" sudo nano /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` ```sh label="⑤ reload-nginx.sh 에 쓸 내용" # file: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh #!/bin/sh nginx -t && nginx -s reload ``` ```bash label="[kc-lab-edge] ⑥ 실행 권한을 준다" sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` ```bash label="[kc-lab-edge] ⑦ 실행 파일이 됐는지 본다" 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 가 붙었는지. 응답 코드가 아니다. ```bash label="[워크스테이션] 협상 과정을 그대로 읽는다" curl -v https://auth.hyeonworks.com/ -o /dev/null ``` 경로는 `/` 다. Keycloak 은 다음 단계에서 올리므로 아직 Ingress 가 없고 `404` 가 정상이다. `/realms/master` 같은 Keycloak 경로를 여기서 쓰면 TLS 가 안 된 건지 Keycloak 이 없는 건지가 섞인다. 읽어야 할 줄만 옮긴다. 전체 출력은 이 실험대에서 캡처해 두지 않았다(unknown). ```text * 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번의 인증서 파일 이름이라 확인 ② 로 간다. 같은 것을 반복해서 재거나 앞뒤 단계의 값과 나란히 비교할 때만 값만 뽑는 형태로 줄인다. ```bash label="[워크스테이션] 코드와 검증 결과만 뽑는다" curl -s -o /dev/null -w '%{http_code} tls=%{ssl_verify_result}\n' https://auth.hyeonworks.com/ ``` **실측**(observed) — 2026-09-11, tailnet 클라이언트에서 ```text 404 tls=0 ``` `tls=0` 이 인증서 검증 통과다. 앞의 `404` 는 다음 단계 이후에 `200` 으로 바뀐다. ### 확인 ② 체인 단계와 검증 **무엇을 확인하는가** — 서버가 중간 인증서까지 보내는지. ```bash label="[워크스테이션] 체인 단계를 센다" 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` 이 되고, 봐야 할 구조는 똑같다. ```text 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` 은 첫 인증서를 검증 못 함. ### 확인 ③ 이름 세 개가 한 인증서인가 **무엇을 확인하는가** — 이름마다 다른 인증서가 붙어 있지는 않은지. ```bash label="[워크스테이션] 세 이름의 일련번호를 나란히 뽑는다" 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 을 직접 편다. ```bash label="[워크스테이션] 지금 서빙되는 인증서의 SAN 을 편다" echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \ | openssl x509 -noout -ext subjectAltName ``` ### 확인 ④ 갱신이 서빙까지 닿는가 — 워커 PID 로 본다 **무엇을 확인하는가** — 훅이 호출되는지, 그리고 호출된 훅이 nginx 를 정말 갈아 끼웠는지. ```bash label="[kc-lab-edge] 훅이 불리는지 먼저 본다. 상태를 바꾸지 않는다" sudo certbot renew --dry-run ``` 출력 끝의 `Running deploy-hook command` 줄과 `simulated renewals` 요약을 본다. 훅 줄이 아예 안 나오면 파일이 `deploy/` 가 아닌 곳에 있거나 실행 권한이 없다. dry-run 은 훅이 호출되는지까지만 말해 준다. ```bash label="[kc-lab-edge] 강제 갱신 전후로 워커를 비교한다. 이 확인은 상태를 바꾼다" # 강제 갱신 전에 워커 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 는 설정 파일 셋을 전부 편집기로 쓴다.