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 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과
keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지
밟았고 밟은 범위를 편마다 적었다.

명령이 못 도는 것을 고쳤다.

- kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다.
  라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다
- `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표
- busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다
- `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을
  못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다
- 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v`
- `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어
  그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다
- B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다
- D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는
  `kc-lab-edge` 의 `certbot.service` 다
- `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로
- `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다

결과가 조건부인 것을 갈랐다.

- readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다
- 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다
- A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다
- A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다

증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은
길이만 적었고 화면에 찍힌 토큰은 가렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:59:42 +09:00

591 lines
36 KiB
Markdown

---
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` 으로 바뀌는 것이 그 단계가 만든 변화다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
이 단계도 셸이 갈린다.
| 번호 | 무엇 | 어디서 |
|---|---|---|
| 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] 도메인이 어느 주소로 풀리는지 본다"
getent hosts auth.hyeonworks.com
```
**실측**(observed)
```text
100.83.212.4 auth.hyeonworks.com
```
**여기에 `dig` 를 바로 쓰지 않는다.** 엣지 게스트에는 `dig` 가 없다 — 2026-09-17 에 cloud-init 으로 갓 만든 `kc-lab-edge` 에서 쳐서 확인했다(observed).
```text
dig: command not found
```
cloud-init 이 까는 것은 `curl` 과 `nftables` 뿐이라 `dnsutils` 가 안 들어간다. `getent hosts` 는 `libc` 가 주는 것이라 어디서나 있고 주소와 이름을 한 줄로 낸다. `dig` 의 형태가 꼭 필요하면 먼저 깐다 — 둘 다 같은 `100.83.212.4` 를 냈다(observed).
```bash label="[kc-lab-edge] dig 를 쓰려면 먼저 깐다"
sudo apt install -y dnsutils && dig +short auth.hyeonworks.com
```
**어디를 봐야 하는가** — 주소 한 줄. 그 주소가 공개 인터넷 대역인가, `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 판 번호"
sudo nginx -v
```
**`sudo` 가 붙는 까닭은 앞 단계와 같다.** 실행 파일이 `/usr/sbin/nginx` 인데 데비안 일반 사용자 `PATH` 에 그 디렉터리가 없다. `sudo` 없이 치면 `nginx: command not found` 가 나오고, 안 깔린 것처럼 보이지만 아니다(2026-09-17, observed).
**실측**(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 이 막힘 | `getent hosts 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) 이름 풀이가 낸 `100.83.212.4`, `certbot plugins` 세 줄, `nginx -v` 두 줄, 체인 4단계와 `Verify return code: 0 (ok)`, `404 tls=0`, 배포판 기본 유닛에 훅이 없다는 것, 갱신에서 서빙까지 2305초 대 1~2초, reload 무중단 측정값.
- (observed) 2026-09-17 에 새로 만든 엣지에서 잰 것 — `dig` 가 없다는 것, `getent hosts` 와 `dnsutils` 설치 뒤의 `dig` 가 같은 주소를 낸다는 것, `nginx -v` 가 `sudo` 없이는 `command not found` 라는 것, `certbot plugins` 세 줄이 문서에 적힌 것과 같다는 것.
- (unknown) 2번부터 4번까지(토큰 발급 · 자격증명 파일 · 시험 발급 · 실제 발급)는 2026-09-17 에 다시 밟지 않았다. Cloudflare 토큰이 필요한 구간이라 토큰 없이는 칠 수 없고, 값을 옮겨 오지도 않았다. 그래서 **5번의 443 블록과 6번의 훅도 재확인하지 못했다** — 인증서 파일이 없으면 그 설정은 `nginx -t` 를 못 지난다.
- (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 는 설정 파일 셋을 전부 편집기로 쓴다.
<!-- body:end -->