무중단 서버 이전: 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개월 동안 배포하지 않은 코드가, 새로 빌드하자마자 첫 화면부터 하얗게 죽어 버린 사건이었습니다.
관련 글
Vercel 커스텀 도메인 연결하기 — CLI, 가비아 A레코드, HTTPS 검증까지
여름 멘토링용 정적 웹앱을 Vercel에 올린 뒤 개인 블로그 도메인의 서브도메인을 연결한 과정을 정리했습니다. Vercel CLI로 도메인을 추가하고 가비아 DNS에 A레코드를 등록한 다음, dig·curl·openssl로 전파와 HTTPS 인증서 발급까지 계층별로 확인하는 방법을 담았습니다.
Deep Health Check로 Next.js 배포 안정성 높이기: 롤링 업데이트의 함정을 피하는 방법
Coolify 롤링 업데이트에서 Health Check가 성공해도 실제 페이지 렌더링이 실패하는 문제를 경험했습니다. ESM 모듈 호환성 에러와 정적 렌더링 실패를 해결하고, Deep Health Check를 구현하여 배포 안정성을 개선한 과정을 공유합니다.
OCI ARM64 서버 통합기: 빌드 RAM 병목을 GitHub Actions로 분리했습니다
무료 OCI ARM64 서버 2대를 1대로 합치려다, 진짜 병목이 런타임이 아니라 '빌드 RAM 피크'라는 걸 알게 됐습니다. 박스에서 빌드를 걷어내고 GitHub Actions 네이티브 arm64 러너로 빌드해 GHCR에 올리는 '빌드/실행 분리'로 통합을 해낸 회고입니다.