レガシーからモダンへ:エックスサーバーから Cloudflare/AWS への無停止移行プロセス
エックスサーバーから Cloudflare Pages / AWS へのWordPress・メール・SSL無停止移行の完全手順。DNS切り替え・ロールバック計画・移行後検証まで一気通貫で解説。
はじめに:「切り替え日にサービスが止まる」移行の何が問題か
レガシー環境からモダン環境への移行で最もよくある失敗は「切り替え日に止まる」だ。原因の大半は次の3つに集約される。
移行失敗の典型パターン:
① DNS 切り替え直後の「伝播待ち」を甘く見た
→ DNS の TTL を事前に短縮していなかった
→ 世界中のリゾルバが古い IP をキャッシュしたまま
→ 一部のユーザーには旧環境、一部には新環境が見える「中間状態」が
最大 48 時間続いた
② メール送信設定を後回しにした
→ Web サーバーは新環境に移行済み
→ コンタクトフォームの送信先が旧サーバーのメール設定のまま
→ 移行後 2 日間、問い合わせメールがどこにも届いていなかった
③ ロールバック手順を用意していなかった
→ 新環境で予期しない問題が発生
→ 旧環境に戻そうとしたが、DNS を元に戻せば良いのか
SSL 証明書を再発行すれば良いのか、判断できなかった
→ 障害対応中に判断を迷い、停止時間が延びた
この記事では、エックスサーバーで運用している WordPress サイト + メール + SSL を、Cloudflare Pages + AWS SES + Cloudflare DNS へ移行した実際のプロセスを解説する。「ゼロダウンタイム」の実現には技術より先に 「並走設計」という考え方が必要だ。
なぜ今この移行をするのか — 技術選定の背景
「なぜ Xserver のままではダメなのか」という問いに答えることが、移行設計の出発点だ。
技術選定の判断軸:
旧環境(Xserver 共有サーバー)の限界:
・PHP/MySQL 依存 → スケールできない、コールドスタートの概念がない
・サーバー共有 → 他テナントの影響を受ける(ノイジーネイバー問題)
・デプロイが FTP/手動 → CI/CD が組みにくい
・監視の粒度が荒い → エラーが起きても原因追跡が難しい
・メール送信の信頼性が低い → DMARC 対応が不十分
新環境(Cloudflare + AWS)の優位性:
・Cloudflare Pages → エッジキャッシュ、コールドスタートゼロ
・AWS SES → DKIM/SPF/DMARC 完全対応、バウンス管理
・Cloudflare DNS → DNSSEC、DDoS 軽減、ルーティング制御
・全構成を Terraform でコード化 → 変更履歴が git log に残る
コスト面でも、Xserver の月額料金(約1,000〜3,000円)と比較して、Cloudflare Pages 無料枠 + AWS SES の従量課金(1,000通で0.10 USD)は小〜中規模サイトであれば大幅なコスト削減になる。
Part 1:移行対象の整理と「並走設計」の考え方
移行対象の4層
移行対象の全体像:
旧環境(エックスサーバー) 新環境
────────────────────── ──────────────────────────
WordPress(PHP + MySQL) → 静的 HTML(Cloudflare Pages)
メール(Xserver SMTP) → AWS SES + Route53
SSL(Let's Encrypt / 自動)→ Cloudflare SSL(自動)
DNS(独自ドメイン管理) → Cloudflare DNS
※ WordPress を静的化できない場合は
Xserver → Cloudflare Tunnel 経由の AWS EC2/Fargate も選択肢
「並走設計」とは何か
移行の本質は「スイッチを切り替える」ことではなく、**「新旧両環境を同時に動かしながら、段階的にトラフィックを移す」**ことだ。
並走設計のフロー:
フェーズ1: 新環境の構築(旧環境は触らない)
・新環境をゼロから構築
・DNS はまだ旧環境を向いている
・新環境の URL(pages.dev 等)で動作確認
フェーズ2: DNS の並走準備
・旧環境の DNS レコードの TTL を短縮(3600s → 300s)
・300 秒 = TTL 伝播待ちが最大 5 分になる
・この状態を TTL 変更後 24 時間維持する
フェーズ3: DNS 切り替え
・ネームサーバーを Cloudflare に変更
・既存 TTL(300s)が切れるまでの 5 分で旧→新へ移行
・この間は「旧環境が生きたまま」が重要
フェーズ4: 切り替え後の確認(24 時間)
・新環境の死活確認
・メール送受信の確認
・旧環境は停止しない(ロールバック用に 24〜48 時間は維持)
フェーズ5: 旧環境の停止
・全ユーザーが新環境に移行したことを確認後
・旧環境(Xserver)の契約を終了
リスク評価マトリクス
移行を計画する際、何が失敗すると何が起きるかを事前に書き出すことが重要だ。「なぜその手順が必要か」は、このリスク評価から逆算される。
| リスク項目 | 影響度 | 発生確率 | 対応策 |
|---|---|---|---|
| DNS 伝播の遅延 | 高(ユーザーが見えない) | 高 | TTL 事前短縮(24h前) |
| メール不達 | 高(問い合わせ消失) | 中 | SES 並走期間中の確認 |
| SSL 証明書エラー | 高(HTTPS 不可) | 低 | Cloudflare 自動発行確認 |
| 旧 WordPress URL の404 | 中(SEO・UX低下) | 中 | Cloudflare Redirect Rules 事前設定 |
| ロールバック不能 | 最高(停止が長期化) | 低 | TTL 300s維持・旧環境 48h並走 |
Part 2:フェーズ 1 — 新環境の構築
WordPress の静的化
WordPress のコンテンツを静的 HTML に変換する。プラグイン「Simply Static」または「WP2Static」を使う。
# Simply Static の出力ディレクトリ構成(例)
output/
├── index.html
├── about/
│ └── index.html
├── contact/
│ └── index.html
├── wp-content/
│ ├── uploads/
│ └── themes/your-theme/
│ ├── style.css
│ └── *.js
└── sitemap.xml
静的化の前に確認すべきチェックリスト:
静的化の事前確認:
✅ お問い合わせフォーム: 静的 HTML では PHP が動かない
→ Cloudflare Pages + Hono の API エンドポイントに置き換える
→ AWS SES でメール送信
✅ 会員機能・ログイン機能: 静的化不可
→ 移行スコープ外にするか、別途 SPA + API 構成に再設計
✅ WordPress 検索: 静的 HTML では機能しない
→ Algolia/Pagefind 等の静的サイト向け検索に置き換える
✅ 画像の URL: WordPress の /wp-content/uploads/ を参照している
→ Simply Static が自動的に相対パスに変換するが確認が必要
Cloudflare Pages へのデプロイ
# 静的ファイルを Cloudflare Pages にデプロイ
npx wrangler pages deploy output/ --project-name your-project
# デプロイ後の確認 URL(本番ドメインを向ける前)
# https://abc123.your-project.pages.dev
この時点では *.pages.dev の URL で新環境が動いている。旧環境には影響がない。
コンタクトフォームの置き換え(Hono + SES)
WordPress のコンタクトフォームは、静的サイト化後に動かなくなる。Hono で API エンドポイントを作り、SES でメールを送信する構成に置き換える。
// src/index.ts(Cloudflare Pages Functions として動作)
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { AwsClient } from 'aws4fetch'
type Bindings = {
AWS_ACCESS_KEY_ID: string
AWS_SECRET_ACCESS_KEY: string
ADMIN_EMAIL: string
FROM_EMAIL: string
}
const app = new Hono<{ Bindings: Bindings }>()
app.use('/api/*', cors())
const contactSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
message: z.string().min(10).max(2000),
})
app.post('/api/contact', zValidator('json', contactSchema), async (c) => {
const { name, email, message } = c.req.valid('json')
const aws = new AwsClient({
accessKeyId: c.env.AWS_ACCESS_KEY_ID,
secretAccessKey: c.env.AWS_SECRET_ACCESS_KEY,
region: 'ap-northeast-1',
service: 'ses',
})
const payload = {
Content: {
Simple: {
Subject: { Data: `【お問い合わせ】${name}様より`, Charset: 'UTF-8' },
Body: {
Text: {
Data: `名前: ${name}\nメール: ${email}\n\n${message}`,
Charset: 'UTF-8',
},
},
},
},
Destination: { ToAddresses: [c.env.ADMIN_EMAIL] },
FromEmailAddress: c.env.FROM_EMAIL,
ReplyToAddresses: [email],
}
const response = await aws.fetch(
'https://email.ap-northeast-1.amazonaws.com/v2/email/outbound-emails',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}
)
if (!response.ok) {
return c.json({ error: 'メール送信に失敗しました' }, 500)
}
return c.json({ ok: true })
})
export default app
Part 3:Terraform による DNS・メール設定のコード化
手動で Cloudflare や AWS のコンソールを操作すると「何をいつ変更したか」が記録に残らない。移行手順を Terraform でコード化することで、変更履歴が git log に残り、ロールバックも git revert で対応できる。
ディレクトリ構成
terraform/
├── main.tf
├── variables.tf
├── cloudflare_dns.tf # DNS レコード
├── cloudflare_pages.tf # Cloudflare Pages 設定
├── aws_ses.tf # SES + DKIM/SPF/DMARC
├── aws_iam.tf # SES 送信用 IAM
└── terraform.tfvars # 変数(git に含めない)
Cloudflare DNS レコードの Terraform 化
# cloudflare_dns.tf
terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "~> 4.0"
}
}
}
variable "cloudflare_zone_id" {}
variable "domain" {}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# A / CNAME レコード(Cloudflare Pages へのルーティング)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Cloudflare Pages へのカスタムドメイン接続
# apex ドメインは CNAME が使えないため Cloudflare の CNAME flattening を活用
resource "cloudflare_record" "apex" {
zone_id = var.cloudflare_zone_id
name = var.domain
type = "CNAME"
content = "your-project.pages.dev"
proxied = true # Cloudflare プロキシを通す(CDN + DDoS 保護)
ttl = 1 # proxied = true のとき TTL は自動(1 = Auto)
comment = "Cloudflare Pages - apex domain"
}
resource "cloudflare_record" "www" {
zone_id = var.cloudflare_zone_id
name = "www"
type = "CNAME"
content = var.domain
proxied = true
ttl = 1
comment = "www → apex redirect via Cloudflare"
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# MX レコード(AWS SES インバウンド)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
resource "cloudflare_record" "mx" {
zone_id = var.cloudflare_zone_id
name = var.domain
type = "MX"
content = "inbound-smtp.ap-northeast-1.amazonaws.com"
priority = 10
proxied = false # MX は必ず proxied = false(プロキシ不可)
ttl = 300 # 移行期間中は 300s を維持
comment = "AWS SES inbound MX"
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# SPF レコード
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
resource "cloudflare_record" "spf" {
zone_id = var.cloudflare_zone_id
name = var.domain
type = "TXT"
content = "\"v=spf1 include:amazonses.com ~all\""
proxied = false
ttl = 300
comment = "SPF for AWS SES"
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# DMARC レコード(段階的適用)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
variable "dmarc_policy" {
type = string
default = "none" # none → quarantine → reject の順で強化
description = "DMARC policy: none | quarantine | reject"
}
resource "cloudflare_record" "dmarc" {
zone_id = var.cloudflare_zone_id
name = "_dmarc"
type = "TXT"
content = "\"v=DMARC1; p=${var.dmarc_policy}; rua=mailto:dmarc-reports@${var.domain}; pct=100\""
proxied = false
ttl = 300
comment = "DMARC policy (current: ${var.dmarc_policy})"
}
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# URL リダイレクト(旧 WordPress URL → 新 URL)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
resource "cloudflare_ruleset" "redirect_old_urls" {
zone_id = var.cloudflare_zone_id
name = "WordPress URL redirect"
kind = "zone"
phase = "http_request_dynamic_redirect"
rules {
action = "redirect"
action_parameters {
from_value {
status_code = 301
target_url {
# /2024/01/article-title/ → /blog/article-title/(静的サイトの URL 構造に合わせる)
expression = "regex_replace(http.request.uri.path, \"^/[0-9]{4}/[0-9]{2}/(.+)/$\", \"/blog/${1}/\")"
}
preserve_query_string = false
}
}
expression = "http.request.uri.path matches \"^/[0-9]{4}/[0-9]{2}/\""
description = "Redirect WordPress date-based URLs to new structure"
enabled = true
}
}
AWS SES + DKIM の Terraform 化
# aws_ses.tf
variable "aws_region" { default = "ap-northeast-1" }
variable "ses_from_email" {}
# SES ドメイン ID の作成
resource "aws_sesv2_email_identity" "domain" {
email_identity = var.domain
dkim_signing_attributes {
next_signing_key_length = "RSA_2048_BIT"
}
}
# DKIM レコードを Cloudflare DNS に自動追加
# SES が生成する3つの CNAME レコードを Terraform で自動登録
resource "cloudflare_record" "dkim" {
count = 3
zone_id = var.cloudflare_zone_id
name = "${aws_sesv2_email_identity.domain.dkim_signing_attributes[0].tokens[count.index]}._domainkey"
type = "CNAME"
content = "${aws_sesv2_email_identity.domain.dkim_signing_attributes[0].tokens[count.index]}.dkim.amazonses.com"
proxied = false
ttl = 300
comment = "SES DKIM record ${count.index + 1}/3"
depends_on = [aws_sesv2_email_identity.domain]
}
# MAIL FROM ドメイン(バウンス処理の信頼性向上)
resource "aws_sesv2_email_identity_mail_from_attributes" "domain" {
email_identity = aws_sesv2_email_identity.domain.email_identity
mail_from_domain = "mail.${var.domain}"
behavior_on_mx_failure = "USE_DEFAULT_VALUE"
}
resource "cloudflare_record" "mail_from_mx" {
zone_id = var.cloudflare_zone_id
name = "mail"
type = "MX"
content = "feedback-smtp.ap-northeast-1.amazonses.com"
priority = 10
proxied = false
ttl = 300
comment = "SES MAIL FROM MX"
}
resource "cloudflare_record" "mail_from_spf" {
zone_id = var.cloudflare_zone_id
name = "mail"
type = "TXT"
content = "\"v=spf1 include:amazonses.com ~all\""
proxied = false
ttl = 300
comment = "SES MAIL FROM SPF"
}
# SNS バウンス・苦情通知(SES アカウント停止を防ぐ)
resource "aws_sns_topic" "ses_notifications" {
name = "ses-bounce-complaint-notifications"
}
resource "aws_sesv2_configuration_set" "main" {
configuration_set_name = "main"
}
resource "aws_sesv2_configuration_set_event_destination" "bounce" {
configuration_set_name = aws_sesv2_configuration_set.main.configuration_set_name
event_destination_name = "bounce-and-complaint"
event_destination {
sns_destination {
topic_arn = aws_sns_topic.ses_notifications.arn
}
enabled = true
matching_event_types = [
"BOUNCE",
"COMPLAINT",
]
}
}
Terraform apply の実行順序
移行で Terraform を使う際、適用順序を間違えると DNS が壊れる。正しい順序を示す。
# ① 変数ファイルの準備(git に含めない)
cat > terraform.tfvars << 'EOF'
cloudflare_zone_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
domain = "your-domain.com"
ses_from_email = "noreply@your-domain.com"
dmarc_policy = "none" # 最初は監視モードから開始
EOF
# ② まず SES のみ apply(DNS レコードはまだ変更しない)
terraform apply -target=aws_sesv2_email_identity.domain
# SES が生成した DKIM トークンを確認
terraform output ses_dkim_tokens
# ③ DKIM レコードを DNS に追加(この時点では A/MX は変更しない)
terraform apply -target=cloudflare_record.dkim
# ④ DKIM 検証を確認(SES コンソールで "Verified" になるまで待つ)
aws sesv2 get-email-identity \
--email-identity your-domain.com \
--query 'DkimAttributes.Status' \
--output text
# → SUCCESS
# ⑤ SPF + DMARC を追加
terraform apply -target=cloudflare_record.spf
terraform apply -target=cloudflare_record.dmarc
# ⑥ 全リソースを apply(A/CNAME、MX 切り替えを含む)
# ← この時点でトラフィックが新環境に切り替わる
terraform apply
Part 4:フェーズ 2 — DNS の TTL 短縮(切り替え 24 時間前)
最も重要なフェーズだ。ここを怠ると DNS 伝播に 48 時間かかる。
現在の TTL 確認
# 現在の DNS レコードと TTL を確認
dig A your-domain.com +short
# → 203.0.113.1(Xserver のIP)
dig A your-domain.com
# → ;; ANSWER SECTION:
# → your-domain.com. 3600 IN A 203.0.113.1
# ↑ TTL が 3600秒(1時間)= 変更後 1 時間は古いキャッシュが残る
# MX レコード(メール)の確認
dig MX your-domain.com +short
# → 10 sv12345.xserver.jp.
TTL の短縮(切り替え 24 時間前に実施)
# Xserver のドメイン管理画面、または使用中の DNS 管理画面で変更
# 変更前(Xserver 管理画面での設定値):
# A レコード: 3600 秒(1時間)
# MX レコード: 3600 秒(1時間)
# 変更後:
# A レコード: 300 秒(5分)
# MX レコード: 300 秒(5分)
# 変更後、300秒(5分)待ってから再確認
sleep 300
dig A your-domain.com
# → your-domain.com. 300 IN A 203.0.113.1
# ↑ TTL が 300 秒になっていることを確認
なぜ 24 時間前に実施するか:TTL を 3600 秒から 300 秒に変更しても、既存のキャッシュが期限切れになるまでは古い値(3600 秒 TTL)が使われる。最大 1 時間待てばキャッシュが更新されるが、安全マージンとして 24 時間前に実施する。
TTL を下げすぎないこと — よくある落とし穴
TTL 設定の注意点:
× TTL = 60 秒(1分)に設定する誘惑
→ リゾルバへの DNS クエリが 60 倍に増加
→ Cloudflare の DNS レート制限に引っかかる可能性
→ 300 秒(5分)で十分: ロールバックが 5 分以内に効く
× TTL = 0 秒に設定しようとする
→ RFC 2308 では TTL=0 は「キャッシュしない」だが
多くのリゾルバが最低 5〜60 秒はキャッシュする
→ 実質的な効果が読めない
✅ 推奨: 300 秒(5分)
→ 切り替え後 5 分でほぼ全リゾルバが新 IP を学習
→ DNS クエリ量は現実的な範囲内
→ ロールバック判断猶予: 5 分
Part 5:フェーズ 3 — ネームサーバーの切り替え(本番)
切り替え前の最終チェックリスト
# ① 新環境の動作確認(pages.dev URL で確認)
curl -I https://abc123.your-project.pages.dev
# → HTTP/2 200
# ② コンタクトフォームの送信テスト(新環境の API エンドポイントで)
curl -X POST https://abc123.your-project.pages.dev/api/contact \
-H "Content-Type: application/json" \
-d '{"name":"テスト","email":"test@example.com","message":"テスト送信です(10文字以上)"}'
# → {"ok":true}
# ③ 送信先メールの受信確認(管理者メールボックスを確認)
# ④ SES が本番モードか確認(サンドボックスでないか)
aws sesv2 get-account --query 'ProductionAccessEnabled' --output text
# → true
# ⑤ DKIM の検証状態を確認
aws sesv2 get-email-identity \
--email-identity your-domain.com \
--query 'DkimAttributes' \
--output table
# → Status: SUCCESS, SigningEnabled: true
# ⑥ 旧環境 DNS の TTL が 300 秒になっていることを再確認
dig A your-domain.com | grep -E "IN\s+A"
# → your-domain.com. 298 IN A 203.0.113.1(カウントダウン中)
# ⑦ ロールバック手順を手元に用意する(後述)
ネームサーバーの切り替え手順
Cloudflare への切り替え手順:
1. Cloudflare ダッシュボードにドメインを追加
→ "Add a Site" でドメインを入力
→ Free プランを選択
→ Cloudflare が現在の DNS レコードを自動スキャンして取り込む
2. スキャンされた DNS レコードを確認・修正
A レコード: your-domain.com → 新しいIPまたはCNAME(Cloudflare Pages)
MX レコード: SES の MX レコードに変更
TXT レコード: SPF、DKIM、DMARC を追加
3. Cloudflare が指定するネームサーバーを確認
→ ns1.cloudflare.com
→ ns2.cloudflare.com(実際の値は割り当てによって異なる)
4. ドメインレジストラの管理画面でネームサーバーを変更
(Xserver のドメイン管理 or お名前.com、Google Domains 等)
旧: sv12345.xserver.jp / sv12345.xserver.jp
新: ns1.cloudflare.com / ns2.cloudflare.com
5. 伝播確認(5〜30 分待つ)
切り替え後の即時確認コマンド
# ネームサーバーが Cloudflare に切り替わったか確認
dig NS your-domain.com +short
# → ns1.cloudflare.com.
# → ns2.cloudflare.com.
# A レコードが Cloudflare の IP を向いているか確認
dig A your-domain.com +short
# → 104.21.xxx.xxx(Cloudflare のエッジ IP)
# HTTPS の確認
curl -I https://your-domain.com
# → HTTP/2 200
# → server: cloudflare
# SSL 証明書の確認
echo | openssl s_client -connect your-domain.com:443 2>/dev/null | openssl x509 -noout -issuer -dates
# → issuer=C=US, O=Let's Encrypt または Cloudflare
# → notAfter=...(有効期限)
# メール設定(MX レコード)の確認
dig MX your-domain.com +short
# → 10 inbound-smtp.ap-northeast-1.amazonaws.com.(SES)
# DMARC レコードの確認
dig TXT _dmarc.your-domain.com +short
# → "v=DMARC1; p=none; rua=mailto:dmarc-reports@your-domain.com; pct=100"
Part 6:フェーズ 4 — 切り替え後 24 時間の監視
確認すべき項目と確認コマンド
DNS 切り替え後の 24 時間は「世界中のリゾルバが新しい IP を学習する期間」だ。この間、一部のユーザーには旧環境が見えている可能性がある。
# 複数のグローバルな DNS サーバーから名前解決されているか確認
# (Google DNS, Cloudflare DNS, OpenDNS それぞれで確認)
dig A your-domain.com @8.8.8.8 +short # Google DNS
dig A your-domain.com @1.1.1.1 +short # Cloudflare DNS
dig A your-domain.com @208.67.222.222 +short # OpenDNS
# 全て Cloudflare の IP(104.21.xxx.xxx / 172.67.xxx.xxx)になっていれば OK
# まだ旧 IP が残っているリゾルバには旧環境が見えている
# 海外からの名前解決の確認(無料ツール)
# https://dnschecker.org/ でグローバルの伝播状況を確認
リアルタイムトラブルシューティング — 「メールが届かない」
切り替え後に最も多い問題が「コンタクトフォームから送ったはずのメールが届かない」だ。原因ごとの調査手順を示す。
# ━━━ ステップ1: SES の送信ログを確認 ━━━
# CloudWatch Logs で SES の送信イベントを確認
aws logs filter-log-events \
--log-group-name /aws/ses/emails \
--start-time $(date -d '1 hour ago' +%s000) \
--filter-pattern "BOUNCE OR COMPLAINT OR DELIVERY" \
--output table
# ━━━ ステップ2: API エンドポイントが正しく動作しているか ━━━
# Cloudflare Workers のログを確認(Cloudflare Dashboard → Workers → Logs)
# または直接テスト
curl -v -X POST https://your-domain.com/api/contact \
-H "Content-Type: application/json" \
-d '{"name":"debug","email":"debug@example.com","message":"debug message here"}' \
2>&1 | grep -E "< HTTP|{\"ok\"|error"
# ━━━ ステップ3: SES の送信制限・バウンス率を確認 ━━━
aws sesv2 get-account --output json | jq '{
ProductionAccess: .ProductionAccessEnabled,
SendingEnabled: .SendingEnabled,
BounceRate: .SuppressionAttributes,
DailyQuota: .SendQuota
}'
# ━━━ ステップ4: 特定のメールアドレスが抑制リストに入っていないか ━━━
aws sesv2 list-suppressed-destinations \
--filter Reasons=BOUNCE \
--output table
# 抑制リストから削除する場合
aws sesv2 delete-suppressed-destination \
--email-address problematic@example.com
Cloudflare Analytics でのトラフィック確認
# Cloudflare Analytics で確認するポイント:
# ・リクエスト数が切り替え前と同程度か
# ・エラー率(4xx/5xx)が急増していないか
# ・オリジンエラー率が 0% 付近か
# Cloudflare API でリクエスト統計を取得
curl -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/analytics/dashboard?since=-60" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" | jq '.result.totals | {requests, threats, pageviews}'
旧環境(Xserver)を「生かしたまま」にする理由
切り替え後 24〜48 時間は旧環境を停止しない理由:
理由①: DNS 伝播が完了していないリゾルバへの対応
→ 旧 IP をキャッシュしているリゾルバからは旧環境にアクセスが来る
→ 旧環境を停止すると、そのユーザーは 503 を見ることになる
理由②: ロールバックの選択肢を残す
→ 新環境で予期しない問題が発覚したとき
→ DNS を元に戻せば 5 分以内(TTL 300秒)で旧環境に戻せる
理由③: メール送受信の並走確認
→ MX レコードの切り替えも DNS 伝播待ちが発生する
→ 切り替え期間中は旧環境のメールサーバーも受信できる状態にしておく
Part 7:ロールバック手順
問題が発覚したときのロールバックは 「DNS を旧 IP に戻す」 だけでよい。Cloudflare のダッシュボードで 1 分以内に操作できる。
Terraform でロールバックする場合(推奨)
# git の履歴からロールバック前の状態に戻す
git log --oneline terraform/
# → a1b2c3d feat: switch DNS to Cloudflare Pages
# → e4f5g6h feat: add DKIM and SPF records
# → i7j8k9l feat: initial Xserver DNS configuration ← ここに戻す
# 特定のコミットのファイルに戻す
git checkout i7j8k9l -- terraform/cloudflare_dns.tf
# ロールバック用の設定を apply
terraform plan # 変更内容を確認
terraform apply # 旧 IP に戻す
# ロールバック後の確認
dig A your-domain.com @8.8.8.8 +short
# → 203.0.113.1(旧 Xserver の IP)
手動でロールバックする場合
# ロールバック手順(Cloudflare DNS の管理画面で実施)
# 1. Cloudflare DNS の A レコードを旧 IP に変更
# your-domain.com A 203.0.113.1(旧 Xserver の IP)
# 2. TTL が 300 秒なので、5 分で伝播する
# 3. 確認
dig A your-domain.com @8.8.8.8 +short
# → 203.0.113.1(旧 IP に戻っていること)
curl -I https://your-domain.com
# → server: Apache(Xserver のサーバーヘッダー)
# 4. 旧環境のコンタクトフォームが動作するか確認
# (旧環境の WordPress + PHP のフォームに戻る)
ロールバック後は、新環境で発覚した問題を修正してから再度切り替えを実施する。
Part 8:移行後の「後処理」チェックリスト
移行が完了したら旧環境を停止する前に確認する。
移行後の後処理:
DNS / SSL:
✅ HSTS(HTTP Strict Transport Security)が有効になっているか
curl -I https://your-domain.com | grep -i strict
→ strict-transport-security: max-age=...
✅ www リダイレクトが機能しているか
curl -I http://www.your-domain.com
→ 301 Moved Permanently → https://your-domain.com
✅ HTTP → HTTPS リダイレクトが機能しているか
curl -I http://your-domain.com
→ 301 → https://...
WordPress / SEO:
✅ Google Search Console にサイトマップを再送信
→ https://your-domain.com/sitemap.xml
✅ 旧 WordPress の permalink 構造が維持されているか
→ 旧: /2024/01/article-title/
→ 新: /2024/01/article-title/(同じ URL で応答するか確認)
→ リダイレクトが必要な場合は Cloudflare の Redirect Rules で設定
メール:
✅ info@your-domain.com に送信して受信確認
✅ 旧サーバーのメールアドレスで受信していたメールが SES 経由になっているか
✅ バウンス / 苦情通知の SNS が設定されているか
セキュリティ:
✅ DMARC レポートを受信しているか(移行後 1 週間で確認)
dig TXT _dmarc.your-domain.com +short
✅ DMARC ポリシーを none → quarantine → reject へ段階的に強化
# terraform.tfvars の dmarc_policy を変更して apply するだけ
dmarc_policy = "quarantine"
terraform apply -target=cloudflare_record.dmarc
旧環境の停止:
✅ Xserver のアクセスログで新しいリクエストが来ていないことを確認(48 時間後)
✅ Xserver の MySQL データベースをエクスポートしてバックアップを保存
✅ WordPress のファイル一式を tar.gz でバックアップ
✅ Xserver の契約を停止(または WordPress 停止のみにして契約は維持)
Conclusion:「並走設計」こそが無停止移行の本質
ゼロダウンタイム移行の核心は、技術の高度さではなく**「切り替えを一瞬で行うための準備を丁寧に積み重ねること」**だ。そしてその準備を Terraform でコード化することで、変更履歴が証跡として残り、ロールバックも git revert + terraform apply の 2 コマンドで完結する。
この移行で実現したリスク低減:
TTL 短縮(24 時間前):
伝播待ち 48 時間 → 5 分へ短縮
並走期間(24〜48 時間):
問題発覚時のロールバック所要時間: 5 分以内
フォーム → API 化:
PHP 依存の排除、SES による信頼性向上
Cloudflare DNS への集約:
SSL 自動更新・DDoS 軽減・DNS 管理の一元化
Terraform による IaC 化:
変更履歴が git log に残る
ロールバックが git checkout + terraform apply で完結
DMARC ポリシー強化が terraform.tfvars 1 行の変更で完結
移行プロジェクトにおいて「ゼロダウンタイム」は目標ではなく設計の制約条件として扱うべきだ。「止めない前提」で設計すると、TTL 操作・並走期間・ロールバック手順が自然に要件定義に入ってくる。それが、移行後に「なぜか止まった」「なぜか届かなかった」という後出しの問題を根本から排除する設計思想だ。
| 移行フェーズ | 所要時間 | リスク低減の仕組み | Terraform 対応 |
|---|---|---|---|
| 新環境構築・テスト | 数日〜1週間 | 本番 DNS は旧環境のまま | pages.dev URL で確認 |
| TTL 短縮 | 作業 5 分 + 待ち 1〜24 時間 | 切り替え後の伝播待ちを最大 5 分に限定 | ttl = 300 に変更して apply |
| ネームサーバー切り替え | 作業 5 分 | Cloudflare に全 DNS を移管 | terraform apply |
| 切り替え後確認 | 24〜48 時間 | 旧環境は生かしたまま。問題あれば即ロールバック | git revert + terraform apply |
| DMARC 強化 | 段階的(週次) | none → quarantine → reject | dmarc_policy 変数 1 行変更 |
| 旧環境停止 | 作業 5 分 | 全リゾルバの伝播確認後 | — |
この記事をシェア