# 04 — TLS ## 이 단계가 끝나면 `https://` 가 열리고 체인이 완전하며, 갱신이 자동으로 **서빙까지** 닿는다. ## 전제 [03](../03-nginx/) 이 끝나 nginx 가 Traefik 으로 프록시한다. 그리고 **공개 DNS 에 이름 세 개가 이 호스트를 가리키고 있어야 한다** — Let's Encrypt 가 HTTP-01 로 검증하러 오기 때문이다. --- ## 1. certbot 을 깐다 **하기** ```bash sudo pacman -S certbot certbot-nginx # Arch sudo apt install certbot python3-certbot-nginx # Debian/Ubuntu ``` ## 2. 인증서를 받는다 이름 세 개를 **한 인증서**에 넣는다. **하기** ```bash sudo certbot certonly --webroot -w /var/www/html \ -d auth.hyeonworks.com -d app1.hyeonworks.com -d app2.hyeonworks.com ``` **확인** ```bash sudo certbot certificates ``` > **이 실험대는 와일드카드를 쓰지 않았고, 그 비용이 나중에 청구됐다.** > B-7 에서 oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 `app2` 를 > 빌려야 했다. 와일드카드는 DNS-01 검증이 필요하고 그건 DNS 공급자 API 를 > 붙여야 한다 — 그 절충을 안 한 결과다. ## 3. nginx 가 `fullchain` 을 보게 한다 [03](../03-nginx/) 의 설정에 이미 있다. ``` ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem; ``` **`cert.pem` 이 아니라 `fullchain.pem`.** 서버 인증서만 보내면 중간 인증서가 빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 **정상으로 보이고**, 캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다. **하기** ```bash sudo nginx -t && sudo systemctl reload nginx ``` ## 4. 확인 — 열리는가, 체인이 완전한가 **확인 ①** 열리나 ```bash curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master ``` ``` 200 ``` **확인 ②** 체인 단계와 검증 ```bash 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' ``` **실측** ``` 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) ``` **단계가 1개면 `cert.pem` 을 쓴 것이다.** **확인 ③** 이름 세 개가 한 인증서인가 ```bash 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 하나에 들어 있는 것이다. ## 5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다 **타이머가 도는 것만으로는 부족하다.** **확인** — 타이머 ```bash systemctl list-timers certbot-renew.timer ``` 이것이 `active` 여도 갱신된 인증서가 **서빙되지는 않는다.** nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고, certbot 은 `live/` 심볼릭 링크만 갈아 끼운다. **경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.** 배포판 기본 유닛에는 reload 를 부르는 것이 없다. ```bash systemctl cat certbot-renew.service ``` ``` [Service] Type=oneshot ExecStart=/usr/bin/certbot -q renew PrivateTmp=true ``` `ExecStartPost` 도 `--deploy-hook` 도 없다. **하기** — 훅 하나를 넣는다 ```bash sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF' #!/bin/sh nginx -t && nginx -s reload EOF sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh ``` `deploy/` 에 넣는다. `post/` 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다. `deploy/` 는 **실제로 갱신됐을 때만** 실행된다. **확인** — 실제로 도는지 ```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 ``` **판정은 로그 문구가 아니라 워커 PID 로 한다.** certbot 이 `Hook 'deploy-hook' ran with error output` 이라고 찍는데 **실패가 아니다** — nginx 의 `types_hash` 경고가 stderr 로 나갔을 뿐이고 내용은 `test is successful` · `signal process started` 다. > **로그에서 `error` 를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.** **실측** — 이 실험대에서 잰 차이 | | 훅 없음 | 훅 있음 | |---|---|---| | 갱신 → 서빙 | **2305초 (38분 25초)** | **1~2초** | | 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 | | 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — | **88일 동안 이 결함이 보이지 않는다.** 타이머는 정상이고 매번 `SUCCESS` 로 끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다. 발현하는 날의 증상은 **인증서 만료**이고, 그날에도 로그에는 `SUCCESS` 라고 적혀 있다. 원문: [D-4](../../experiment-d4-certificate-renewal.md) · [D-4a](../../experiment-d4a-deploy-hook.md) · 증거 [`evidence/d4-certificate-renewal/`](../../evidence/d4-certificate-renewal/) ## 6. reload 는 무중단인가 — 쟀다 궁금할 것이므로 결과만 적는다. **무중단이다.** 새 연결 8856건 전부 200, p95 는 205.7ms 대 204.3ms 로 변화 없음. 그리고 845KB 를 20k/s 로 받는 중이던 요청이 **전송 12초째에 reload 를 맞고도** 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다. --- ## 막히면 | 증상 | 원인 | 확인 | |---|---|---| | certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | `dig +short ` · 밖에서 `curl http://` | | 체인 단계가 1개 | `cert.pem` 을 씀 | 위 확인 ② | | 갱신은 됐는데 옛 인증서가 나감 | **deploy 훅 없음** | 워커 PID · `sudo ls /etc/letsencrypt/renewal-hooks/deploy/` | | 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 **워커 PID** | | 발급 한도 | 주당 중복 인증서 5장 | `--dry-run` 으로 먼저 시험 | --- ## 근거를 재려면 (선택) 평소에는 필요 없다. **문서에 남길 근거가 필요할 때만** 이렇게까지 한다. 갱신 중 가용성을 재려면 **주입 전에 대조군부터** 잡는다. 평시 오류율을 모르면 갱신 중에 나온 실패 한 건을 해석할 수 없다. ```bash # 대조군 — 0.2초 × 900회 = 180초 i=0; while [ $i -lt 900 ]; do curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \ https://auth.hyeonworks.com/realms/master i=$((i+1)); sleep 0.2 done > /tmp/control.txt awk '{print $1}' /tmp/control.txt | sort | uniq -c ``` 이 실험대의 대조군은 **900건 전부 200, 오류 0** 이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수 있었다. **그리고 시각을 비교할 때 시계를 확인한다.** 이 실험대는 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:' ```