DigitalOcean에서 Hetzner로 마이그레이션하기
4 hours ago
4
- 월 $1,432 규모의 프로덕션 인프라를 월 $233 전용 서버로 옮기면서, 운영체제까지 교체하고도 다운타임 없이 서비스 연속성 유지
- 30개 MySQL 데이터베이스와 34개 Nginx 가상 호스트, GitLab EE, Neo4J, Supervisor, Gearman을 새 서버에 동일하게 구성한 뒤 실시간 복제와 최종 증분 동기화로 이전 완료
- 데이터베이스 이전의 핵심은 mydumper·myloader 병렬 처리와 MySQL replication 조합이었고, MySQL 5.7에서 8.0으로 올리며 발생한 sys 스키마와 권한 문제도 수정
- 컷오버는 DNS TTL 축소, 기존 서버의 Nginx 리버스 프록시 전환, A 레코드 일괄 변경 순서로 진행돼 DNS 전파 중에도 기존 IP 요청이 새 서버로 전달된 구조
- 결과적으로 월 $1,199 절감, 연 $14,388 절감, CPU·메모리·스토리지 상향과 0분 다운타임을 함께 달성한 사례
마이그레이션 배경
- 터키에서 소프트웨어 회사를 운영하는 환경에서 급격한 인플레이션과 터키 리라 약세로 인해 달러 기준 인프라 비용 부담이 크게 증가한 상황
- 기존 DigitalOcean 서버 비용은 매달 $1,432였으며, 구성은 192GB RAM, 32 vCPU, 600GB SSD, 1TB 블록 볼륨 2개, 백업 포함 형태
- 새 대상은 Hetzner AX162-R 전용 서버였으며, AMD EPYC 9454P 48코어 96스레드, 256GB DDR5, 1.92TB NVMe Gen4 RAID1 구성
- 월 비용은 $233으로 낮아졌고, 월 절감액은 $1,199, 연 절감액은 $14,388 규모
- 기존 서버의 신뢰성이나 개발자 경험에는 불만이 없었지만, steady-state 워크로드에서는 가격 대비 성능이 더 이상 합리적이지 않은 상태
기존 운영 환경
- 운영 스택은 단순 테스트 환경이 아니라 실제 프로덕션 환경 구성
- MySQL 데이터베이스 30개, 총 248GB 데이터 규모
- 여러 도메인에 걸친 Nginx 가상 호스트 34개 운영
- GitLab EE 백업 42GB 포함
- Neo4J Graph DB 30GB 규모 운영
- Supervisor로 수십 개의 백그라운드 워커 관리
- Gearman 작업 큐 사용
- 수십만 사용자를 대상으로 하는 라이브 모바일 앱 운영
- 기존 서버 운영체제는 CentOS 7이었고 이미 지원 종료 상태
- 새 서버 운영체제는 AlmaLinux 9.7이며, RHEL 9 호환 배포판이자 CentOS의 자연스러운 후속 선택지
- 이번 이전은 비용 절감뿐 아니라, 수년간 보안 업데이트를 받지 못한 운영체제에서 벗어나는 계기
무중단 전략
- 단순 DNS 변경과 서비스 재시작 방식은 허용하지 않고, 6단계 마이그레이션 절차로 무중단 이전 진행
-
1단계: 새 서버 전체 스택 설치
- Nginx를 기존과 동일한 플래그로 소스 컴파일 설치
- PHP는 Remi repo를 통해 설치하고, 기존 서버의 동일한 .ini 설정 파일 적용
- MySQL 8.0, Neo4J Graph DB, GitLab EE, Node.js, Supervisor, Gearman 설치 및 기존 동작과 일치하도록 구성
- DNS 레코드를 건드리기 전, 모든 서비스가 기존 서버와 동일하게 동작하도록 맞춘 상태
- SSL 인증서는 기존 서버의 /etc/letsencrypt/ 전체 디렉터리를 rsync로 복사해 처리
- 전체 트래픽이 새 서버로 전환된 뒤 certbot renew --force-renewal로 인증서 일괄 강제 갱신 수행
-
2단계: 웹 파일 rsync 복제
- /var/www/html 전체 디렉터리 약 65GB, 150만 개 파일을 SSH 기반 rsync로 복제
- --checksum 옵션으로 무결성 검증 수행
- 컷오버 직전에 변경 파일 반영을 위한 최종 증분 동기화 추가 수행
-
3단계: MySQL 마스터-슬레이브 복제
- 덤프 후 복원으로 데이터베이스를 내리는 대신 실시간 복제 구성
- 기존 서버를 마스터, 새 서버를 읽기 전용 슬레이브로 설정
- 초기 대용량 적재는 mydumper 사용, 이후 덤프 메타데이터에 기록된 정확한 binlog 위치부터 복제 시작
- 컷오버 시점까지 양쪽 데이터베이스를 실시간 동기 상태로 유지
-
4단계: DNS TTL 축소
- DigitalOcean DNS API를 스크립트로 호출해 모든 A/AAAA 레코드 TTL을 3600초에서 300초로 축소
- MX, TXT 레코드는 변경하지 않음
- 메일 레코드 TTL 변경 시 전달성 문제를 일으킬 수 있어 제외한 상태
- 기존 TTL이 전 세계적으로 만료되도록 1시간 대기 후 5분 이내 컷오버 준비 완료
-
5단계: 기존 서버 Nginx를 리버스 프록시로 전환
- Python 스크립트가 34개 Nginx 사이트 설정 전반의 server {} 블록을 파싱
- 기존 설정은 백업하고, 새 서버를 가리키는 프록시 설정으로 대체
- DNS 전파 중에도 기존 IP로 들어오는 요청은 새 서버로 조용히 전달되는 구조
- 사용자 입장에서는 중단이 보이지 않는 방식
-
6단계: DNS 컷오버와 기존 서버 종료
- Python 스크립트로 DigitalOcean API를 호출해 모든 A 레코드를 새 서버 IP로 수 초 안에 변경
- 기존 서버는 1주일간 cold standby로 유지 후 종료
- 서비스는 전체 과정 동안 직접 응답하거나 프록시를 통해 응답하는 형태로 유지돼, 가용성 공백 구간이 없었던 상태
MySQL 마이그레이션
- 전체 작업 중 가장 복잡한 구간이 MySQL 이전 과정
-
데이터 덤프
- 표준 mysqldump 대신 mydumper 사용
- 새 서버의 48 CPU 코어를 활용한 병렬 export/import로, 단일 스레드 mysqldump 기준 며칠 걸릴 작업을 몇 시간으로 단축
- 사용한 주요 옵션에는 --threads 32, --compress, --trx-consistency-only, --skip-definer, --chunk-filesize 256 포함
- 메인 덤프의 metadata 파일에 스냅샷 시점의 binlog 위치 기록
- File: mysql-bin.000004
- Position: 21834307
- 해당 값이 이후 복제 시작 지점으로 사용된 상태
-
덤프 전송
- 덤프 완료 후 SSH 기반 rsync로 새 서버에 전송
- 총 248GB 압축 청크 전송
- mydumper의 --compress 옵션으로 압축된 청크가 네트워크 전송 속도 향상에 기여
-
데이터 적재
- myloader 사용
- 주요 옵션은 --threads 32, --overwrite-tables, --ignore-errors 1062, --skip-definer 구성
-
MySQL 5.7에서 8.0으로의 전환 문제
- CentOS 7 환경으로 인해 기존 서버는 MySQL 5.7에 머물러 있던 상태
- 이전 전 mysqlcheck --check-upgrade로 데이터가 MySQL 8.0과 호환되는지 확인했고, 결과는 문제 없음
- 새 서버에는 최신 MySQL 8.0 Community 설치
- 프로젝트 전반에서 쿼리 실행 시간이 유의미하게 감소했고, 원문에서는 MySQL 8.0의 개선된 optimizer와 InnoDB 향상을 이유로 언급
- 다만 버전 점프로 인한 문제도 발생
- import 이후 mysql.user 테이블 컬럼 구조가 예상 51개가 아니라 45개 상태
- 그 결과 mysql.infoschema 누락, 사용자 인증 장애 발생
- 첫 번째 수정 시도는 아래 명령 사용
- systemctl stop mysqld
- mysqld --upgrade=FORCE --user=mysql &
- 첫 시도는 ERROR: 'sys.innodb_buffer_stats_by_schema' is not VIEW 오류로 실패
- 원인은 sys 스키마가 뷰가 아닌 일반 테이블로 import된 상태
- 해결은 DROP DATABASE sys; 실행 후 업그레이드 재실행 방식
- 이후 정상 완료
MySQL 복제 구성
- 두 서버 모두 덤프 적재가 끝난 뒤, 새 서버를 기존 서버의 replica로 구성
- CHANGE MASTER TO 구문에 기존 서버 IP, 복제 사용자, 포트 3306, MASTER_LOG_FILE='mysql-bin.000004', MASTER_LOG_POS=21834307 지정
- 이후 START SLAVE; 실행
- 거의 즉시 error 1062 Duplicate Key로 복제가 중단된 상태
- 원인은 덤프가 두 번에 나뉘어 수행되면서 그 사이 일부 테이블에 쓰기가 발생했고, import된 덤프와 binlog 재생이 같은 행을 중복 삽입하려 한 상황
- 해결을 위해 아래 설정 적용
- SET GLOBAL slave_exec_mode = 'IDEMPOTENT';
- START SLAVE;
- IDEMPOTENT 모드는 duplicate key와 missing row 오류를 조용히 건너뛰는 방식
- 모든 핵심 데이터베이스가 오류 없이 동기화됐고, 몇 분 안에 Seconds_Behind_Master 값이 0으로 감소
컷오버 전 검증
- DNS 레코드를 건드리기 전에 새 서버에서 모든 서비스가 올바르게 동작하는지 확인 필요
- 검증 방법은 로컬 머신의 /etc/hosts 파일을 임시 수정해 도메인을 새 서버 IP로 매핑하는 방식
- 브라우저와 Postman은 새 서버로 요청을 보내고, 외부 사용자는 계속 기존 서버로 접속하는 구조
- API 엔드포인트, 관리자 패널, 각 서비스 응답 상태 점검
- 모든 항목 확인 후 실제 컷오버 진행
SUPER 권한 문제
- 마스터-슬레이브 복제가 완전히 동기화된 뒤, 새 서버에서 read_only = 1인데도 INSERT 문이 성공하는 현상 확인
- 원인은 모든 PHP 애플리케이션 사용자에게 SUPER 권한이 부여된 상태였기 때문
- MySQL에서는 SUPER 권한이 read_only를 우회
- SHOW GRANTS FOR 'some_db_user'@'localhost'; 결과에서 SUPER 권한 포함 상태 확인
- 총 24개 애플리케이션 사용자에서 REVOKE SUPER ON *.* FROM 'some_db_user'@'localhost'; 반복 실행
- 이후 FLUSH PRIVILEGES; 수행
- 그 다음부터는 read_only = 1이 애플리케이션 사용자 쓰기를 올바르게 차단하면서 복제는 계속 허용하는 상태
DNS 준비
- 모든 도메인은 DigitalOcean DNS로 관리했고, 네임서버는 GoDaddy에서 연결된 상태
- TTL 감소 작업은 DigitalOcean API를 대상으로 스크립트화
- 변경 대상은 A, AAAA 레코드만 한정
- MX, TXT 레코드는 건드리지 않음
- Google Workspace 전달성 이슈 가능성 때문에 메일 관련 레코드 TTL 변경 제외
- 기존 TTL 만료를 위해 1시간 대기 후 컷오버 준비 완료
기존 서버 Nginx의 리버스 프록시 전환
- 34개 설정 파일을 수작업으로 편집하는 대신 Python 스크립트로 자동 변환 수행
- 스크립트는 모든 설정 파일의 server {} 블록을 파싱하고, 핵심 content block 식별 후 프록시 설정으로 대체
- 원본 설정은 .backup 파일로 백업
- 예시 설정에서는 proxy_pass https://NEW_SERVER_IP;, proxy_set_header Host $host;, proxy_set_header X-Real-IP $remote_addr;, proxy_read_timeout 150; 적용
- 핵심 옵션은 proxy_ssl_verify off
- 새 서버의 SSL 인증서는 도메인에 대해 유효하며 IP 주소에 대해서는 유효하지 않기 때문
- 양 끝단을 모두 제어하는 환경이어서 여기서는 검증 비활성화 허용
컷오버 절차
- 컷오버 직전 조건은 복제 지연이 Seconds_Behind_Master: 0 이고 리버스 프록시 준비 완료 상태
- 실행 순서는 다음과 같음
- 새 서버에서 STOP SLAVE;
- 새 서버에서 SET GLOBAL read_only = 0;
- 새 서버에서 RESET SLAVE ALL;
- 새 서버에서 supervisorctl start all
- 기존 서버에서 nginx -t && systemctl reload nginx 실행으로 프록시 활성화
- 기존 서버에서 supervisorctl stop all
- 로컬 Mac에서 python3 do_cutover.py 실행해 DNS의 모든 A 레코드를 새 서버 IP로 변경
- 약 5분 전파 대기
- 기존 서버에서 모든 crontab 항목 주석 처리
- DNS 컷오버 스크립트는 DigitalOcean API를 호출해 모든 A 레코드를 약 10초 안에 변경
컷오버 후 추가 작업
- 이전 완료 후 다수의 GitLab 프로젝트 웹훅이 여전히 기존 서버 IP를 가리키는 상태 확인
- GitLab API를 통해 모든 프로젝트를 스캔하고, 웹훅을 일괄 업데이트하는 스크립트 작성 및 적용
최종 결과
- 월 비용은 $1,432에서 $233으로 감소
- 연간 절감액은 $14,388
- 성능 측면에서도 더 강한 서버 확보
- CPU는 32 vCPU에서 96 logical CPU로 증가
- RAM은 192GB에서 256GB DDR5로 증가
- 스토리지는 약 2.6TB 혼합 구성이 2TB NVMe RAID1로 전환
- 다운타임은 0분
- 전체 마이그레이션 소요 시간은 대략 24시간
- 사용자 영향은 없었던 상태
핵심 교훈
- MySQL replication은 무중단 마이그레이션의 핵심 수단
- 초기에 설정하고 충분히 따라잡게 한 뒤 컷오버하는 방식
- MySQL 사용자 권한은 이전 전에 반드시 점검 필요
- SUPER 권한이 있으면 read_only를 우회해 슬레이브 환경이 실제 읽기 전용이 아니게 되는 문제
- DNS 업데이트, Nginx 설정 변경, 웹훅 수정은 스크립트화 중요
- 34개 이상 사이트를 수작업으로 처리하면 시간이 오래 걸리고 오류 가능성이 증가
- mydumper + myloader 조합은 대용량 데이터셋에서 mysqldump보다 훨씬 빠른 방식
- 32스레드 병렬 덤프·복원으로 며칠 걸릴 작업을 몇 시간으로 단축
- steady-state 워크로드에서는 클라우드 제공자가 비쌀 수 있으며, 전용 서버가 더 낮은 비용으로 더 높은 성능을 제공할 수 있는 사례
GitHub 스크립트
- 마이그레이션에 사용한 Python 스크립트 전부를 GitHub에 공개
- 포함된 스크립트 목록
- do_list_domains_ttl.py
- 모든 DigitalOcean 도메인의 A 레코드, IP, TTL 조회
- do_ttl_update.py
- 모든 A/AAAA 레코드 TTL을 300초로 일괄 축소
- do_to_hetzner_bulk_dns_records_import.py
- 모든 DNS zone을 DigitalOcean에서 Hetzner DNS로 이전
- do_cutover_to_new_ip.py
- 모든 A 레코드를 기존 서버 IP에서 새 서버 IP로 전환
- nginx_reverse_proxy_update.py
- 모든 nginx 사이트 설정을 리버스 프록시 설정으로 변환
- mysql_compare.py
- 두 MySQL 서버 전반의 모든 테이블 row count 비교
- final_gitlab_webhook_update.py
- 모든 GitLab 프로젝트 웹훅을 새 서버 IP로 갱신
- mydumper
- 모든 스크립트는 DRY_RUN = True 모드를 지원해 실제 적용 전 안전한 미리보기 가능
-
Homepage
-
Tech blog
- DigitalOcean에서 Hetzner로 마이그레이션하기