무중단 서버 이전: DNS 컷오버와 Let's Encrypt 타이밍 함정
앞선 글들에서 이미지를 빌드하고, 서버가 그 이미지를 받아 실행하는 데까지 왔습니다. 이제 실제 도메인을 신 서버로 넘길 차례였습니다. 작은 서비스를 구 서버에서 신 서버로 이전하는 일이었고, 도메인 하나만 돌리면 끝이라고 생각했습니다. 그런데 DNS를 바꾼 뒤, HTTPS 인증서가 발급되지 않았습니다.
무중단 이전의 기본 설계
서버를 옮길 때 가장 두려운 건 전환 순간의 다운타임과, 문제가 생겼을 때 되돌릴 수 없는 상황입니다. 그래서 이전은 다음 순서를 따랐습니다.
1. 신 서버에 앱을 먼저 배포 (임시 도메인으로)
2. 임시 도메인으로 정상 동작 검증
3. DNS를 신 서버로 전환 (구 서버는 계속 켜둠)
4. 24~48시간 모니터링
5. 안정 확인 후 구 서버 종료
핵심은 3번에서 구 서버를 끄지 않는다는 점입니다. DNS 전환은 즉시 전파되지 않기 때문에, 한동안은 구 서버로 들어오는 사용자와 신 서버로 들어오는 사용자가 공존합니다. 구 서버를 살려두면 그 사이 누구도 사이트가 끊기지 않고, 문제가 생기면 DNS를 되돌리는 것만으로 즉시 롤백할 수 있습니다.
DNS를 바꾸기 전에 미리 검증하는 법
DNS를 바꾸고 나서야 신 서버가 도메인을 제대로 받는지 확인하는 건 늦습니다. 다행히 curl --resolve를 쓰면, DNS를 건드리지 않고도 특정 서버가 그 도메인 요청을 받아 주는지 미리 확인할 수 있습니다.
# 도메인은 아직 구 서버를 가리키지만,
# 이 요청만 신 서버(198.51.100.20)로 강제 연결해서 확인
curl --resolve example.com:443:198.51.100.20 -k https://example.com/
이 요청이 신 서버에서 200과 함께 우리 앱을 돌려주면, 리버스 프록시의 라우팅은 준비된 것입니다. 인증서는 아직 없으니 -k로 검증만 건너뛰고, 실제 콘텐츠가 올바른지만 봤습니다. 여기까지 통과하면 "DNS만 바꾸면 되는 상태"라는 확신이 생깁니다.
컷오버, 그리고 인증서가 안 나오는 문제
검증이 끝나서 DNS의 A 레코드를 신 서버 IP로 바꿨습니다. 공개 리졸버들(Google, Cloudflare 등)은 곧 새 IP로 전파됐습니다. 그런데 신 서버로 접속하면 브라우저가 인증서 경고를 띄웠습니다. Let's Encrypt 인증서가 발급되지 않은 것입니다.
리버스 프록시(Traefik)의 ACME 로그를 열어 보고서야 원인을 알았습니다.
ERR Unable to obtain ACME certificate for [example.com]:
403 :: unauthorized :: 203.0.113.10: Invalid response from
http://example.com/.well-known/acme-challenge/xxxxx: 404
Let's Encrypt가 챌린지를 검증하러 구 서버 IP(203.0.113.10)로 요청을 보내고 있었습니다. 챌린지는 신 서버가 들고 있으니, 구 서버는 당연히 404를 돌려줬고, 그래서 발급이 실패한 것입니다.
원인: DNS TTL을 미리 낮추지 않았다
공개 리졸버는 전부 새 IP를 가리키는데, 왜 Let's Encrypt만 구 IP로 갔을까요. Let's Encrypt도 자체 DNS 리졸버를 쓰고, 그 리졸버는 레코드의 TTL만큼 이전 값을 캐시합니다. 제가 바꾸기 전 A 레코드의 TTL이 3600초(1시간)였기 때문에, LE의 리졸버가 변경 직전에 캐시해 둔 구 IP를 최대 한 시간 동안 그대로 쓰고 있었던 것입니다.
부끄럽지만, 컷오버 전에 TTL을 미리 낮춰 두는 기본을 건너뛴 대가였습니다. TTL이 3600이면 전환도 롤백도 최대 한 시간이 걸리고, 이렇게 인증서 발급까지 그 시간에 묶입니다.
추가로 만난 두 가지 함정
기다리면 풀릴 문제였지만, 그냥 기다리기만 해서는 풀리지 않는 부분이 두 가지 더 있었습니다.
1. Traefik ACME 백오프
Traefik은 인증서 발급에 몇 번 실패하면 자동 재시도를 멈춥니다. 그래서 LE의 캐시가 만료돼 이제는 성공할 수 있는 상태가 되어도, 프록시가 다시 시도하지 않아 인증서가 계속 없는 채로 남아 있었습니다. 서비스를 한 번 재배포해서 라우터 설정을 갱신하니 그제서야 새 발급 시도가 걸렸습니다. 다만 Let's Encrypt에는 시간당 실패 검증 횟수 제한이 있으니, 캐시가 확실히 만료됐다고 판단될 때만 재시도해야 합니다.
2. :latest 태그 캐싱
이미지를 pull-only로 배포하면서 :latest 태그를 썼는데, 재배포를 해도 새 이미지를 자동으로 내려받지 않는 문제가 있었습니다. 도커가 로컬에 있는 :latest를 그대로 재사용하기 때문입니다. 서버에서 직접 docker pull로 최신 이미지를 당겨 온 뒤 재배포하거나, 관리 UI의 "최신 이미지를 받아 재시작" 동작을 써야 했습니다.
# 재배포가 새 이미지를 안 받을 때, 서버에서 강제로 최신을 당김
sudo docker pull ghcr.io/OWNER/app-frontend:latest
검증: 인증서가 진짜 유효한지 확인하는 법
인증서 상태를 확인할 때 openssl s_client 한 줄짜리는 핸드셰이크 방식 때문에 실패로 잘못 나오는 경우가 잦았습니다. 결국 가장 믿을 만한 건 curl이었습니다. -k 없이 요청해서 200이 돌아오면, 그건 브라우저가 신뢰하는 정식 인증서라는 뜻입니다.
# -k 없이 200이면 = 정식 인증서로 검증 통과
curl --resolve example.com:443:198.51.100.20 -sS -o /dev/null \
-w "%{http_code}\n" https://example.com/
한 가지 더. 문제를 풀겠다고 재배포를 반복하니, 그때마다 컨테이너가 새로 뜨면서 인증서가 잠깐 유효했다가 다시 무효로 보이는 flapping이 생겼습니다. 발급이 확인된 뒤로는 손을 떼고 안정될 때까지 기다리는 편이 나았습니다.
정리하며
결국 LE의 캐시가 만료되고, 한 번의 재배포로 새 발급 시도가 걸리면서 정식 인증서가 나왔습니다. 이전 자체는 무중단으로 끝났지만, 인증서 발급이 한 시간 가까이 묶인 건 온전히 준비 부족이었습니다.
같은 실수를 반복하지 않기 위해 남기는 교훈입니다.
- 컷오버 전 TTL을 먼저 낮춘다: A 레코드 TTL을 300초 정도로 낮추고 기존 TTL이 만료될 시간을 기다린 뒤에 IP를 바꿉니다. 전환·롤백·인증서 발급이 모두 빨라집니다.
- 구 서버는 전환 후에도 켜 둔다: 전파가 끝날 때까지의 fallback이자, 즉시 롤백 수단입니다.
- 바꾸기 전에
--resolve로 미리 검증한다: DNS를 건드리지 않고 신 서버의 라우팅을 확인할 수 있습니다. - 인증서 검증은
curl(-k 없이)의 200을 믿는다: 도구에 따라 상태가 다르게 보일 수 있습니다.
다음 글에서는, 이 이전 과정에서 뜻밖에 발견한 버그 이야기를 하려 합니다. 5개월 동안 배포하지 않은 코드가, 새로 빌드하자마자 첫 화면부터 하얗게 죽어 버린 사건이었습니다.
관련 글
리졸버를 갈아끼웠습니다 — 순단은 3초, 바뀐 것은 인증서 갱신 경로 전부
설계 세 편을 쓰고 3주 뒤에 실제로 발급했습니다. 계획서에는 기존 리졸버를 건드리지 않겠다고 적어뒀는데 실행에서는 갈아끼웠고, 그 대가로 순단 3초와 '기존 인증서의 갱신 경로가 리졸버 단위로 전부 바뀐다'는 사실이 따라왔습니다. 확인한 것과 아직 확인하지 않은 것도 함께 적었습니다.
와일드카드 인증서는 HTTP-01으로 못 받습니다 — DNS-01이 강제하는 IAM 결정
멀티테넌트 서브도메인을 위해 와일드카드 인증서를 발급하려다 알게 된 것은, 문제가 TLS가 아니라 DNS 쓰기 권한이라는 점이었습니다. 프록시에 Route53 권한을 주는 순간 생기는 폭발 반경과 그것을 좁히는 방법을 정리했습니다.
와일드카드가 못 덮는 것 — Traefik DNS-01과 Caddy on-demand TLS를 가른 지점
와일드카드 인증서를 손에 넣고 나서야 그것이 한 단계밖에 못 덮는다는 사실이 문제가 됐습니다. 고객사 소유 도메인을 받으려면 애초에 다른 메커니즘이 필요했고, 그 판단이 프록시 선택 자체를 다시 보게 만들었습니다.