移行機能の画面に「データコピーが完了しました」と表示されたのは、日付が変わったあとでした。案内には、あとは切り替えのボタンを押すだけだと書かれています。私はマグカップを片付けながら、押す前に一度だけ新しい側の中身を見ておこうと思いました。
結果としては、その一度が効きました。新サーバーには、その晩に公開したばかりの記事がありませんでした。
最初にお伝えしたいのは、「コピー完了」という言葉が「いまこの瞬間の写し」を意味していなかったことです。写しはコピーを始めた時点のもので、私がその後に書いて公開した分は、当然ながら入っていません。落ち着いて考えれば当たり前でしたが、通知の文面はそこまで言ってくれません。
コピー完了は、コピーを始めた時点の写しでした
共有サーバーの移行機能は、旧サーバーのファイル一式とデータベースを新サーバーへ写します。サイトの規模によりますが、写し終わるまでには時間がかかります。そして完了通知は「写し終わった」という合図であって、「写している間の更新も追いついた」という意味ではありません。
私は最初のうち、通知が届いた時点で新旧が同じ状態になっていると思い込んでいました。結果は芳しくありませんでした。通知の時刻と自分の最後の投稿時刻を並べるだけで気づけたはずのことを、通知の言葉に預けてしまっていたのです。
いま思えば、確認の手間を惜しんだというより、確認の仕方を思いついていなかったのだと感じています。新しいサーバーは DNS の向こう側にあって、touch できないものだと決めてかかっていました。
いまは、通知が届いても切り替えは押しません。押す前に、新しい側だけを覗きにいきます。
切り替える前に、新サーバーの中身だけを覗く
ここで手が止まりました。DNS をまだ替えていないのに、どうやって新サーバーの WordPress に話しかければよいのでしょうか。
答えは curl の --resolve でした。ホスト名はドメインのまま固定し、接続先の IP だけを新サーバーへ差し替えます。SNI も証明書の検証もドメイン名のまま進むので、--insecure に逃げる必要がありません。
# 新サーバーの WordPress に、ドメイン名のまま直接話しかけます。
# NEW_IP は移行先サーバーの IP アドレスです。
curl -sS -o /dev/null -D - \
--resolve "example.com:443:NEW_IP" \
"https://example.com/wp-json/wp/v2/posts?per_page=1" \
| tr -d '\r' | grep -i '^x-wp-total'
--insecure を付けたくなったときは、いったん手を止めていただければと思います。証明書の検証を切ると、意図した新サーバーではない別の場所に繋がっていても気づけません。移行先の証明書がまだ発行されていない段階なら、証明書が用意されるまで待つほうが安全です。
移行先の証明書がまだ発行されていない場合は、移行機能の側で発行が済むまで待ちます。この場合は、待ってから覗きにいくほうが結局は早く終わります。
繋いだ先が本当に新サーバーかどうかは、切り替え前に新サーバーの公開領域へ一時ファイルを置き、それが返るかどうかで確かめられます。旧サーバーには置いていないファイルなので、返ってきたら新しい側に届いています。
数え直しは三段階にしました
最初は件数だけを見るつもりでした。旧が 1,000 件、新も 1,000 件。これで足りると考えていたのです。
足りませんでした。件数が同じでも、その中身が同じとはかぎりません。
| 段階 | 見つけられること | 見つけられないこと |
件数(X-WP-Total) | コピー後に増えた投稿・丸ごと欠けた投稿 | ID が入れ替わった場合/本文だけが欠けた場合 |
| ID の集合 | どの投稿が欠けたか、どれが余分か | 本文が空になった投稿 |
| 本文の長さ | 本文が渡っていない投稿・途中で切れた投稿 | 文字数が同じまま内容が入れ替わった場合(実務ではまず起きません) |
三段階にした理由は、二段階で通ってしまう事故を実際に作ってみたからです。それは次の節に書きます。
以下が、私が実際に走らせている突き合わせのスクリプトです。旧と新の両方から、ID・更新時刻・本文のバイト長を 1 行 1 投稿で書き出し、比べます。
#!/usr/bin/env bash
# 旧サーバーと新サーバーの WordPress 投稿を、切り替え前に全数で突き合わせます。
# 本番: ./wp-migration-diff.sh example.com 203.0.113.10 198.51.100.20
# 検証: BASE_OLD=http://127.0.0.1:8801 BASE_NEW=http://127.0.0.1:8802 ./wp-migration-diff.sh example.com
set -uo pipefail
DOMAIN="${1:?domain required}"
OLD_IP="${2:-}"
NEW_IP="${3:-}"
PER_PAGE=100
STATUSES="publish,future,draft,pending,private"
# 下書きや予約投稿まで数えるには認証が要ります。WP_AUTH に "user:application_password" を入れてください。
AUTH_ARGS=()
[ -n "${WP_AUTH:-}" ] && AUTH_ARGS=(--user "$WP_AUTH")
fetch() { # $1=old|new $2=path
local side="$1" path="$2"
if [ "$side" = old ]; then
if [ -n "${BASE_OLD:-}" ]; then
curl -sS --max-time 30 "${AUTH_ARGS[@]}" "${BASE_OLD}${path}"
else
curl -sS --max-time 30 "${AUTH_ARGS[@]}" --resolve "${DOMAIN}:443:${OLD_IP}" "https://${DOMAIN}${path}"
fi
else
if [ -n "${BASE_NEW:-}" ]; then
curl -sS --max-time 30 "${AUTH_ARGS[@]}" "${BASE_NEW}${path}"
else
curl -sS --max-time 30 "${AUTH_ARGS[@]}" --resolve "${DOMAIN}:443:${NEW_IP}" "https://${DOMAIN}${path}"
fi
fi
}
# 件数だけを合わせても、本文が空のまま渡った投稿は見つかりません。長さまで採ります。
dump() {
local side="$1" page=1 out n
: > "/tmp/wpdiff-${side}.tsv"
while : ; do
out="$(fetch "$side" "/wp-json/wp/v2/posts?per_page=${PER_PAGE}&page=${page}&status=${STATUSES}&_fields=id,modified_gmt,content&orderby=id&order=asc")" || return 1
n="$(printf '%s' "$out" | python3 -c '
import sys, json
rows = json.load(sys.stdin)
if isinstance(rows, dict): # WordPress はページ超過時にエラーオブジェクトを返します
rows = []
for r in rows:
body = (r.get("content") or {}).get("rendered", "")
print("%s\t%s\t%d" % (r["id"], r.get("modified_gmt", ""), len(body.encode("utf-8"))))
print("__COUNT__%d" % len(rows), file=sys.stderr)
' 2>/tmp/wpdiff-n)" || return 1
printf '%s' "$n" | sed '/^$/d' >> "/tmp/wpdiff-${side}.tsv"
grep -q '__COUNT__0' /tmp/wpdiff-n && break
grep -q "__COUNT__${PER_PAGE}" /tmp/wpdiff-n || break
page=$((page + 1))
done
sort -n -o "/tmp/wpdiff-${side}.tsv" "/tmp/wpdiff-${side}.tsv"
}
dump old || { echo "旧サーバーの取得に失敗しました"; exit 2; }
dump new || { echo "新サーバーの取得に失敗しました"; exit 2; }
OLD_N=$(wc -l < /tmp/wpdiff-old.tsv | tr -d ' ')
NEW_N=$(wc -l < /tmp/wpdiff-new.tsv | tr -d ' ')
echo "件数: 旧 ${OLD_N} / 新 ${NEW_N}"
cut -f1 /tmp/wpdiff-old.tsv | sort > /tmp/wpdiff-old.ids
cut -f1 /tmp/wpdiff-new.tsv | sort > /tmp/wpdiff-new.ids
MISSING="$(comm -23 /tmp/wpdiff-old.ids /tmp/wpdiff-new.ids)"
EXTRA="$(comm -13 /tmp/wpdiff-old.ids /tmp/wpdiff-new.ids)"
SHRUNK="$(join -t$'\t' -j1 /tmp/wpdiff-old.tsv /tmp/wpdiff-new.tsv \
| awk -F'\t' '$5 != $3 || $4 != $2 { printf "%s 旧 %s bytes / 新 %s bytes\n", $1, $3, $5 }')"
FAIL=0
[ -n "$MISSING" ] && { echo "新サーバーに無い投稿:"; echo "$MISSING" | sed 's/^/ id /'; FAIL=1; }
[ -n "$EXTRA" ] && { echo "旧サーバーに無い投稿:"; echo "$EXTRA" | sed 's/^/ id /'; FAIL=1; }
[ -n "$SHRUNK" ] && { echo "本文または更新時刻が一致しない投稿:"; echo "$SHRUNK" | sed 's/^/ id /'; FAIL=1; }
if [ "$FAIL" -eq 0 ]; then
echo "差分はありません。切り替えて差し支えありません。"
else
echo "差分があります。切り替えは保留してください。"
fi
exit "$FAIL"
status に下書きや予約投稿まで含めているのは、公開済みだけを数えていると、予約投稿が抜けたことに公開時刻まで気づけないからです。認証が要るぶん手間は増えますが、切り替えの晩に一度だけ走らせるものなので、私は含める側に倒しています。
手元で壊してから、本番に向けました
個人開発では、本番運用のデータでいきなり試すしかない場面が多くなりがちです。ただ、この手のスクリプトは失敗の仕方を先に見ておかないと、エラーが出なかったことをそのまま合格と読み違えます。私自身、何も表示されない出力を見て安心しかけました。
そこで先に手元で偽の WordPress REST API を二つ立てました。片方を旧サーバー、もう片方を新サーバーに見立てて、意図的に二種類の欠損を作ります。
- 旧にあって新に無い投稿を 1 件(コピー完了の通知後に公開した記事のつもり)
- ID も更新時刻も同じなのに、本文だけが空になった投稿を 1 件
そのうえで走らせた結果が、こちらです。
件数: 旧 6 / 新 5
新サーバーに無い投稿:
id 6
本文または更新時刻が一致しない投稿:
id 3 旧 203 bytes / 新 0 bytes
差分があります。切り替えは保留してください。
同じデータどうしを比べたときは、次のように黙って通ります。
件数: 旧 6 / 新 6
差分はありません。切り替えて差し支えありません。
ここが、私にとっていちばん予想と違ったところでした。件数の照合だけなら、本文が空になった id 3 は「一致」として通り抜けます。ID の照合を足しても同じです。長さまで採って、初めて画面に出ます。
言い換えれば、件数の一致は「渡った」ことの証明ではなく、「数が合った」ことの証明にすぎません。この一文を書けるようになるまでに、私は偽サーバーを二回作り直しました。
A レコードだけ替えると、メールが旧サーバーに残ります
投稿が揃ったことを確かめても、まだ半分です。切り替えは DNS の書き換えを伴いますが、書き換える対象は A レコードだけではありません。
| レコード | 忘れたときに起きること |
| A / AAAA | サイトが旧サーバーを向いたまま。いちばん気づきやすい |
| MX | 問い合わせフォームの控えや配信の受信が旧サーバーへ。旧を解約した瞬間に消えます |
| TXT(SPF) | 送信元が新サーバーになっても許可されず、迷惑メール扱いが増えます |
| CNAME(配信・認証用) | 証明書の更新や外部サービスの検証が通らなくなります |
書き換え忘れに気づいたときの対処は、旧サーバーを残しているうちなら難しくありません。消してしまったあとでは、回避のしようがなくなります。移行の順番として、旧サーバーの解約をいちばん最後に置いている理由がここにあります。
とくに SPF は見落としました。旧サーバーのホスト名が a: で直接書かれていて、切り替え後もそのまま残っていたのです。次の小さなスクリプトで、SPF に何が書かれているかを分解して並べています。
# 現在の SPF を取り出して、参照先を一覧にします。
dig +short TXT example.com | tr -d '"' | grep '^v=spf1' | python3 -c '
import sys, re
txt = sys.stdin.read()
for tok in txt.split():
m = re.match(r"^(?:\+|~|-|\?)?(a|mx|ip4|ip6|include|redirect)[:=](.+)$", tok)
if m:
print("%-8s -> %s" % (m.group(1), m.group(2)))
elif tok in ("a", "mx"):
print("%-8s -> (自ドメイン)" % tok)
'
出力に旧サーバーのホスト名が並んでいたら、それが切り替え後に残る宿題です。私は移行の前日にこれを走らせ、書き換える行を紙に書き出してから当日を迎えるようにしました。
エージェントに渡したもの、渡さなかったもの
ここからが、この移行でいちばん考えたところです。
エージェントに渡したのは、量が多くて判断の要らない作業でした。全数の列挙と突き合わせ、DNS レコードの棚卸し、切り替え後のリンク検査。どれも答えが一意に決まり、私が目視で追うには多すぎます。手順を渡せば、同じ結果が何度でも返ってきます。
渡さなかったのは、後戻りできない一手です。移行機能の切り替えボタン、DNS の実際の書き換え、そして旧サーバーの削除。ここだけは自分の指で押しました。押した時刻を手元に控えられることにも意味があります。
数え直しはエージェントに、後戻りできない一手は自分の指で。 この線引きは、眠い夜ほど守るようにしています。
実際の進め方としては、埋め込みターミナルでスクリプトを走らせて出力をその場で読み、差分が出たら理由を追う、という往復にしました。この検証ループの組み方そのものは Antigravity 2.10.0 の埋め込みターミナルで、エージェントの変更を検証するループを組む に書いています。読み取りをどこまで自動承認にしておくかは事前に決めておくと迷いません。その範囲の考え方は 読み取りが自動承認になったレビューモードで、ワークスペースの範囲を決め直す が近い話です。
切り替えたあと、最初の三十分ですること
切り替えを押したあとにも、やることが残っています。順番まで含めて書き残しておきます。
- DNS の TTL を短くしていた場合は、戻す前に反映を確認します。
dig +short example.com @8.8.8.8 と @1.1.1.1 の両方で、新しい IP が返るまで待ちます
- 前段にキャッシュを置いている場合はパージします。パージ前に古い応答を見て「移行が失敗した」と判断してしまうのが、いちばんもったいない誤診です
- 突き合わせスクリプトをもう一度走らせます。今度は旧サーバーを IP 固定で、新サーバーを DNS 経由で見にいきます。これで「読者が見ている側」が正しいことを確かめられます
- 問い合わせフォームから自分宛に一通送り、届くことを確認します。MX と SPF の答え合わせは、実際に一通送るのがいちばん早い方法です
- 旧サーバーはすぐに消しません。私は最低でも一週間は残しています
順番のなかで一つだけ選ぶとすれば、三番目です。切り替え前に一度、切り替え後にもう一度。同じスクリプトを二回走らせるだけで、「移った」ことと「読者に届いている」ことを別々に確かめられます。
今夜これから移行を予定されている方は、まず X-WP-Total を旧と新の両方から一度だけ取ってみていただければと思います。数字が違っていたら、それは切り替えを止める理由です。数字が同じでも、本文の長さまで採るのは、そのあとでも間に合います。
最後まで読んでくださり、ありがとうございました。同じ夜を過ごす方の手もとで、この手順が少しでも役に立てば嬉しく思います。