DigitalOceanからHetznerへの移行
(isayeter.com)- 月額 $1,432 規模の本番インフラを月額 $233 の専用サーバーへ移しつつ、OS まで入れ替えながらもダウンタイムなしでサービス継続性を維持
- 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 のジョブキューを使用
- 数十万ユーザー向けのライブモバイルアプリを運用
- 既存サーバーの OS は CentOS 7 で、すでにサポート終了状態
- 新サーバーの OS は AlmaLinux 9.7 で、RHEL 9 互換ディストリビューションであり、CentOS の自然な後継候補
- 今回の移行はコスト削減だけでなく、数年間セキュリティアップデートを受けられなかった OS から脱却する契機でもあった
無停止戦略
- 単純な 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段階: Web ファイルの 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 に到達するリクエストは新サーバーへ静かに転送される構成
- ユーザー視点では中断が見えない方式
- Python スクリプトが 34個の Nginx サイト設定全体の
-
第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.000004Position: 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の欠落やユーザー認証障害が発生
- import 後、
- 最初の修正試行では以下のコマンドを使用
systemctl stop mysqldmysqld --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 で複製が停止
- 原因はダンプが2回に分かれて行われ、その間に一部テーブルへ書き込みが発生し、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 プロジェクト Webhook が依然として既存サーバー IP を指していることを確認
- GitLab API を使って全プロジェクトを走査し、Webhook を一括更新するスクリプトを作成して適用
最終結果
- 月額費用は $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を回避し、スレーブ環境が実際には読み取り専用でなくなる問題
- SUPER 権限があると
- DNS 更新、Nginx 設定変更、Webhook 修正はスクリプト化が重要
- 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- 2台の MySQL サーバー全体のすべてのテーブル row count を比較
final_gitlab_webhook_update.py- すべての GitLab プロジェクト Webhook を新サーバー IP に更新
mydumper- mydumper ライブラリ
- すべてのスクリプトは
DRY_RUN = Trueモードをサポートし、実適用前に安全なプレビューが可能
1件のコメント
Hacker News のコメント
数か月前にサーバー2台をLinodeとDOからHetznerへ移したが、コストをかなり大きく、しかも同程度に削減できた。さらに印象的だったのは、数十のサイトが異なる言語、古いライブラリ、MySQLやRedisまで絡み合ったカオスなスタックだったことだ。ところがClaude Codeがこれを丸ごと移行してくれて、存在しないライブラリは一部コードを書き直しながら対応していた。こうした複雑な移行はいまやずっと簡単になっていて、今後は事業者間の移動性がさらに高まる気がする
AWSからHetznerへ移る計画を立てている。Amazonは競合より時には20倍高い価格を付け、少しまともな価格を得ようとすると長期契約を強要し、データ移行も非常に高くつくようにしていて、あまりに顧客敵対的だと感じる。egress料金で人を囲い込んでいるつもりなのだろうが、実際には一部だけ競合へ移しても全体を移させる圧力として働く。それでも私はAmazon専用サービスの上にプラットフォームを積んでいないので、移行は多少しやすい方だ
こういう記事を見るたびに、みんな冗長化やロードバランサーの話をあまりしないのが不思議だ。サーバー1台が死ねば複数のサービスが一緒に落ち得るのに、本当にそれで問題ないと思っているのか気になる。金は節約できても、保守時間や将来の頭痛の種を余計に抱えるかもしれない
私たちはlithus.euで、さまざまなクラウドからHetznerへ顧客をよく移してきた。通常はマルチサーバー、時にはマルチAZ構成にし、Kubernetesでワークロードを分散してHAを提供している。単一ノードならKubernetesはやり過ぎかもしれないが、ノードが複数あるならずっと筋が通る。バックアップはVeleroとアプリケーションレベルのバックアップを併用し、たとえばPostgresではWALバックアップでPITRまで持っていく。状態データは最低2ノードに置いてHAを保証する。性能面でもベアメタルの方が概して良く、AWS比で応答時間が半分になることが多かった。理由は仮想化そのものというより、NVMe、低いネットワーク遅延、少ないcache contentionといった周辺要因のおかげだと思う。関連内容は以前書いたHN投稿にもさらに書いてある
この記事はかなり読みにくかった。Claudeがマイグレーションをして、その次にClaudeが書いたレポートを読まされている感じだった。LLMのおかげでこれだけ節約できたならそれは素晴らしいが、公開するならせめて推敲して、重複やLLMっぽい語り口は整理してほしかった
Hetznerには注意が必要だと思う。以前は本当に気に入っていたが、最近離れた。うちのCI/CDパイプラインで使っていたVM約30台を、36ドルの請求争議1件で全部落とされた。銀行記録を含めて全額支払い済みの証拠を出しても見ようとせず、緊急で連絡を取っている最中にも結局アクセスをすべて遮断された。今はScalewayへ移した
数か月前、小さなSaaSのサイドプロジェクト向けにAWS代替を探していたとき、コスト削減とEUクラウド支援の観点から最初はHetznerを真剣に検討していた。自分でやることが多くても受け入れるつもりだったが、決定的にIPレピュテーションが足を引っ張った。会社で使っているマネージドAWSファイアウォールのルールの1つがHetznerのIPを多く、もしかすると全部ブロックしていて、私の業務用ノートPCでもHetzner IP上のサイトはITポリシーのせいで開けなかった。Cloudflareのようなものを使えば多少ましになるかもしれないが、DDoS保護が弱いという話も見た。結局私はEUリージョンのDO App Platformを選び、マネージドDBオプションも大きな利点だった
こうしたマイグレーション体験を共有してくれたのはかなり有益でありがたい。私はDOとHetznerの比較を、DoorDashやUberEatsを開くのと自分で夕食を作ることのトレードオフのように見ている。コスト比率も似たような感じだ。私は3大クラウドとオンプレミスの両方を扱うが、細かい作業やPoCテストでは今でもDigitalOceanのコンソールに向かう。ボタンを数回押すだけでサーバーやバケットが用意され、sane defaultがあり、バックアップもチェックボックス1つで付くといった利便性は、時間の価値を考えれば確かに意味がある
DBバックアップをどうしているのか気になった。replicaやstandbyがあるのか、それとも単に時間単位バックアップなのか知りたかった。こうした単一サーバー構成では、SSDのようなハードウェア障害が起きるとアプリがすぐ止まり得るし、特にSSDが死ぬと再構築のあいだ何時間、あるいは何日も停止するかもしれないと思った
ヘッダーに入っていたミーム画像は私が作ったものだった。この記事に載せたもので、こんなふうに2回も使われているのを見るとうれしかった