Files

04 — TLS

이 단계가 끝나면

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

전제

03 이 끝나 엣지 nginx 가 Traefik 으로 프록시한다.

어디서 치는가

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

★ 검증 방식을 먼저 정한다 — HTTP-01 이냐 DNS-01 이냐

같은 Let's Encrypt 인증서인데 「이 도메인이 네 것이냐」를 증명하는 방법만 다르다. 그리고 이 실험대에서는 선택의 여지가 없다.

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

이 실험대는 공개 인터넷을 쓰지 않는다. 도메인 세 개는 tailnet 주소를 가리킨다.

dig +short auth.hyeonworks.com
100.83.212.4

100.64.0.0/10 은 CGNAT 용으로 예약된 대역이라 공개 인터넷에서 라우팅 자체가 되지 않는다. 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지 않는다. Let's Encrypt 를 tailnet 에 초대할 방법도 없다. 그래서 HTTP-01 은 쓸 수 없고 DNS-01 을 쓴다.

공개 서버라면 HTTP-01 이 맞다. 토큰도 DNS 연동도 필요 없어서 관리할 것이 적다. DNS-01 이 더 좋은 방식이어서 고르는 것이 아니라, HTTP-01 이 못 쓰이는 환경이라 고르는 것이다. 개념은 session-lab-concepts.md 의 「DNS-01 은 언제 쓰는가」.


1. certbot 을 깐다

cloud-init 이 이미 깔았다면 건너뛴다 — kc-lab.yaml.examplepackages 에 들어 있다.

하기[kc-lab-edge]

sudo apt install -y certbot python3-certbot-dns-cloudflare

확인 — 쓸 수 있는 검증 방식이 무엇인가

certbot plugins 2>/dev/null | grep -E '^\*'

실측

* dns-cloudflare
* standalone
* webroot

어디를 봐야 하는가dns-cloudflare 한 줄이 있는가. 없으면 플러그인 패키지가 안 깔린 것이고, --dns-cloudflare 를 줘도 unrecognized arguments 로 끝난다.

2. 인증서를 받는다

DNS-01 이면 와일드카드를 받을 수 있다. 이 실험대는 처음에 이름 셋을 따로 받았고, 그 비용이 B-7 에서 청구됐다 — oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 app2 를 빌려야 했다.

하기[kc-lab-edge]. 토큰은 존 하나 + DNS:Edit 으로 좁힌다. 계정 전역 API Key 를 쓰지 않는다.

sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
sudo tee /etc/letsencrypt/cloudflare.ini >/dev/null <<'EOF'
dns_cloudflare_api_token = <Cloudflare API 토큰>
EOF

sudo certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
  -d hyeonworks.com -d '*.hyeonworks.com' --dry-run

--dry-run 을 먼저 붙인다. Let's Encrypt 는 같은 이름 조합에 대해 주당 중복 인증서 5장 제한이 있고, --dry-run 은 그 한도를 쓰지 않는다. 통과하면 --dry-run 만 떼고 다시 친다.

DNS-01 은 느리다. TXT 레코드가 퍼질 때까지 기다려야 해서 발급이 수십 초 걸린다. certbot 이 기본 대기 시간을 두고 있으니 중간에 끊지 않는다.

확인 — 인증서가 실제로 생겼고 이름이 다 들어갔는가

sudo certbot certificates

어디를 봐야 하는가 — 네 줄이다. Domains:이름 세 개가 한 줄에 나열되어 있는가(줄이 두 개로 갈라져 있으면 인증서가 두 장 만들어진 것이다), Expiry Date: 가 오늘로부터 90일 뒤이고 VALID 인가, Certificate Path:/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem 인가, 그리고 Private Key Path: 가 그 옆의 privkey.pem 인가. 경로 두 개가 03 의 nginx 설정에 적은 것과 한 글자도 다르면 안 된다.

이 결과가 의미하는 것 — 여기 나온 경로가 곧 nginx 가 읽을 파일이다. 발급은 성공했는데 No certificates found 가 나오면 sudo 없이 쳐서 다른 사용자의 설정 디렉터리를 본 것이다. 발급 자체가 실패했다면 검증 단계에서 막힌 것이므로 DNS 와 80 포트부터 본다(아래 「막히면」).

이 실험대는 와일드카드를 쓰지 않았고, 그 비용이 나중에 청구됐다. B-7 에서 oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의 app2 를 빌려야 했다. 와일드카드는 DNS-01 검증이 필요하고 그건 DNS 공급자 API 를 붙여야 한다 — 그 절충을 안 한 결과다.

3. nginx 가 fullchain 을 보게 한다

03 의 설정에 이미 있다.

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 로 보완해서 정상으로 보이고, 캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다.

하기

sudo nginx -t && sudo systemctl reload nginx

4. 확인 — 열리는가, 체인이 완전한가

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

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

형태 (이 실험대에서 캡처해 두지 않았다 — 읽어야 할 줄만)

*  SSL connection using TLSv1.3 / ...
*  subject: CN=auth.hyeonworks.com
*  issuer: C=US; O=Let's Encrypt; CN=...
*  SSL certificate verify ok.
> GET /realms/master HTTP/1.1
< HTTP/1.1 200 OK

어디를 봐야 하는가* 로 시작하는 줄 넷이다. 어떤 TLS 판으로 협상했는가, subject 의 CN 이 지금 친 이름과 같은가, issuer 가 Let's Encrypt 인가, 그리고 SSL certificate verify ok. 가 있는가. 그 아래 < 로 시작하는 첫 줄이 응답 상태다. -o /dev/null 은 본문만 버리는 것이라 이 줄들은 그대로 남는다.

이 결과가 의미하는 것 — 이 네 줄이 다 나오면 인증서가 붙었고 체인이 클라이언트 기준으로 검증됐다. TLS 에서 막힐 때 봐야 할 것이 전부 여기 있으므로, 인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을 본다. verify 줄 대신 unable to get local issuer certificate 가 나오면 중간 인증서가 빠진 것이고, 그 원인은 3번의 cert.pem/fullchain.pem 이다 — 확인 ②로 간다.

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

curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
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'

실측

 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)

어디를 봐야 하는가 — 왼쪽의 번호(0·1·2·3)가 몇까지 가는가, 그리고 각 단계의 i:(발급자)가 바로 다음 단계의 s:(주체)와 같은가. 0번이 우리 서버 인증서이고, 위 실측에서 0의 i:CN = YE2 인데 1의 s: 가 같은 CN = YE2 다 — 사슬이 이어져 있다는 뜻이다. 마지막이 Verify return code: 0 (ok).

이 결과가 의미하는 것단계가 1개면 cert.pem 을 쓴 것이다. 서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태다. 이때 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이므로, 이 명령이 유일하게 믿을 수 있는 판정이다. 고치는 곳은 03 의 ssl_certificate 한 줄이고, 고친 뒤 nginx -t && systemctl reload nginx 하고 여기서 다시 잰다. 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

5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다

타이머가 도는 것만으로는 부족하다.

확인 — 타이머

systemctl list-timers certbot-renew.timer

어디를 봐야 하는가 — 네 칸이다. NEXT(다음 실행 시각)와 LEFT(남은 시간)가 채워져 있는가, LAST/PASSED 가 하루 안쪽인가, UNIT 옆의 ACTIVATEScertbot-renew.service 를 가리키는가. 표가 통째로 비어 나오면 타이머가 없는 것이다 — 이름이 배포판마다 다르니 systemctl list-timers --all | grep -i certbot 로 찾는다.

이 결과가 의미하는 것 — 여기까지가 「갱신이 돌기는 하는가」의 답이고, 대부분의 문서가 여기서 끝난다. 그런데 이것이 active 여도 갱신된 인증서가 서빙되지는 않는다. nginx 는 인증서를 기동 시점에 읽어 메모리에 들고 있고, certbot 은 live/ 심볼릭 링크만 갈아 끼운다. 경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.

배포판 기본 유닛에는 reload 를 부르는 것이 없다.

systemctl cat certbot-renew.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true

어디를 봐야 하는가ExecStart= 한 줄과, 그 아래에 ExecStartPost=있는지 없는지. 그리고 ExecStart 의 인자에 --deploy-hook 이 붙어 있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.

이 결과가 의미하는 것ExecStartPost--deploy-hook 도 없다. 즉 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 누가 읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 — 「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는 배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만 확인하면 된다.

하기 — 훅 하나를 넣는다

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/lab/edge/reload-nginx.sh. 여기 손으로 치지 말고 그걸 밀어 넣는 편이 낫다.

cat deploy/lab/edge/reload-nginx.sh \
  | ssh kc-lab-edge 'sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null'
ssh kc-lab-edge 'sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'

이 훅은 한동안 저장소에 없었다. 호스트에만 있어서, 호스트를 초기화하면 아무 오류 없이 사라지고 D-4 가 측정한 상태(갱신 성공 · 서빙 38분 25초 지연 · 타이머는 SUCCESS)로 되돌아갔다. 저장소에 두는 이유가 이것이다.

deploy/ 에 넣는다. post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를 갈아치운다. deploy/실제로 갱신됐을 때만 실행된다.

확인 — 실제로 도는지

이 확인은 상태를 바꾼다. --force-renewal 은 인증서를 실제로 새로 받으므로 발급 한도(주당 중복 5장)를 깎는다. 먼저 --dry-run 으로 훅이 호출되는 것까지만 보고, 진짜 판정이 필요할 때만 강제 갱신을 한 번 쓴다.

sudo certbot renew --dry-run

어디를 봐야 하는가 — 출력 끝의 Running deploy-hook command 줄과 simulated renewals 요약. 훅 줄이 아예 안 나오면 파일이 deploy/ 가 아닌 곳에 있거나 실행 권한이 없는 것이다(ls -lx 를 본다).

이 결과가 의미하는 것 — dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 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

어디를 봐야 하는가 — 두 출력의 첫 열(PID)과 둘째 열 묶음(lstart, 프로세스 시작 시각). 앞뒤로 놓고 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 하는 감시를 걸면 성공한 훅을 실패로 오독한다.

실측 — 이 실험대에서 잰 차이

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

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

원문: D-4 · D-4a · 증거 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 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 '^\*'
DNS-01 이 오래 걸림 TXT 전파 대기 정상이다. 끊지 않는다
재구축 뒤 인증서가 없음 발급하지 말고 백업을 되돌린다 한도를 아끼는 길이다

근거를 재려면 (선택)

평소에는 필요 없다. 문서에 남길 근거가 필요할 때만 이렇게까지 한다.

갱신 중 가용성을 재려면 주입 전에 대조군부터 잡는다. 평시 오류율을 모르면 갱신 중에 나온 실패 한 건을 해석할 수 없다.

# 대조군 — 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번을 재서 코드별로 세는 것이 목적이고, 헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.

어디를 봐야 하는가uniq -c 가 내놓는 줄이 몇 개인가. 한 줄이면 900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다. 두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면 둘째 열을 따로 본다.

awk '{print $2}' /tmp/control.txt | sort -n | tail -1     # 최악값

이 결과가 의미하는 것 — 이 실험대의 대조군은 900건 전부 200, 오류 0 이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수 있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도 증명하지 못하므로, 대조군이 깨끗해질 때까지는 주입을 하지 않는다.

그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.

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:'
timedatectl show -p NTP -p NTPSynchronized

어디를 봐야 하는가 — 세 수 A·B·C 를 눈으로 빼서 초 단위 차이가 몇인가. AC 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그 가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 Date: 헤더의 시각이 둘 중 어느 쪽에 가까운가. 마지막으로 NTPSynchronized=yes 인가.

이 결과가 의미하는 것 — 차이가 수초 이내면 두 기계의 로그를 그대로 나란히 놓아도 된다. 크면 먼저 어느 쪽이 틀렸는지 가른 다음 보정한다. 이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다 — 음수 지연이 나오면 계산이 아니라 시계를 의심한다.