0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

一度失敗したWordPressの移行をAmazon Lightsailでやり直してみた

0
Last updated at Posted at 2026-09-27

概要

この記事は生成AIで作成した記事を人間により加筆修正しています。

「レンタルサーバーの期限まであと1か月、今度はちゃんと移行したい」

数年書いてきた個人ブログを、共用レンタルサーバーで動かしていました。レンタルサーバー代は年間1万円以上かかっていました。

以前Amazon Q Developer時代にAWSへの移行に挑戦してみましたが、記事の画像が抜け落ち、レイアウトが崩れてしまい、結局原因が分からないまま移行を断念しました。
「またあれをやるのか」と気が重かったのですが、レンタルサーバーの更新期限(支払日)が迫っているので今度は原因から解決していくことにしました。

そして、無事に移行できました!
369記事、画像6,378ファイル、広告の配信まで含めて、崩れずに引っ越せました!

前回の失敗の原因は 日本語ファイル名のUnicode正規化 でした。この原因に気づくまでに時間がかかりました💦
他にもパーマリンクが全ページ404になったり、DBを一度壊したり、切替後にメールが完全に死んでいたことに気づいたりしました。同じことをやろうとする人が同じ穴に落ちないように、詰まったところを中心に書いていきます。

Amazon Lightsailって何?
AWSが提供している、月額固定のサーバーサービスです。すごく雑に言うと「AWSが用意してくれた、値段が分かりやすいレンタルサーバー」です。
通常のAWS(EC2など)は使った分だけ課金されるので事前に金額が読みにくいのですが、Lightsailは「月$12でCPU2個・メモリ2GB・ディスク60GB・転送3TB」のように定額です。WordPress入りのイメージが用意されているので、作成した瞬間からブログが動きます。

やりたかったこと

図にするとこれだけです。

やることは記事とデータを移して、見ている先を切り替えるだけです。ただ、このブログには広告を貼っていてわずかにアドセンス収益が出ているので、表示が崩れたり画像が消えたりすると直接損をします。そこが気を遣うところでした。

移行対象はこれくらいの規模です。

項目 数量
投稿記事 369
メディア(画像) 922
画像の実ファイル(サムネイル含む) 6,378
インストール済みプラグイン 35(有効29)
データベース 72MB
アップロード済みファイル 261MB

移行方法を比べる

WordPressの移行方法は大きく3つあります。

方法 手間 失敗しにくさ 向いている人
移行プラグイン(All-in-One WP Migration など) 小 中 サイトが小さい人
DBダンプ + FTPでファイル直取得 中 高 サイトが大きい人、原因を追いたい人
記事のエクスポート機能で移す 大 低 記事だけ移せばよい人

最初はボタン一発で済む移行プラグインを使うつもりでしたが、実際に試してみると、エクスポートしたファイルのサイズが90MBを超えて、ブラウザ経由のアップロードが不安定でした。しかも移行元のサーバーではダウンロードそのものが500エラーになりました。

結局 DBダンプ + FTPでファイルを直接取る方法にしました。手間は増えますが、どのファイルがどこへ行ったかを自分で確認できます。前回失敗したため、分からない原因をできるだけ減らしたかったためです。

前回の失敗の原因は、日本語ファイル名の正規化だった

ここが今回一番の山場でした。

移行元のサイトを調べていて、同じ画像に対して2種類のURLが存在することに気づきました。見た目はまったく同じ文字列なのに、バイト列が違います。

ぺいんとicon.jpg   ← パターンA
ぺいんとicon.jpg   ← パターンB(見た目は同じ)

原因はUnicodeの正規化形の違いでした。

Unicode正規化(NFC / NFD)って何?
「が」のような濁点付きの文字には、コンピュータ上の表現方法が2通りあります。

  • NFC … 「が」を1文字として持つ
  • NFD … 「か」+「濁点」の2文字として持つ

画面に表示すると、どちらも「が」に見えます。人間の目には区別がつきません。
ところがファイルシステムやWebサーバーにとっては、まったく別のファイル名です。
macOSは伝統的にNFDを使い、WindowsやLinuxはNFCを使います。

つまりこういうことが起きていました。

移行元のサイトを実測すると、DBが参照している6,378パスのうち 640ファイルがNFD形でした。前回の移行では、このNFD形のファイルが転送中にNFCへ変換され、DBから見て「存在しないファイル」になっていました。

この問題は転送ツールの設定で起きます。ファイル名を勝手に正規化するツールを使うと、日本語ファイル名の画像だけが静かに消えます。
しかも消えるのは一部のファイルだけなので、トップページを見ただけでは気づきません。古い記事を開いて初めて気づきます。

対策はファイル転送時に文字コードの変換をさせないようにします。今回は lftp を使い、次の2行を指定しました。

# 文字コードの自動変換を無効にする。これがないとファイル名が正規化されてしまう
set ftp:charset ""
set file:charset ""

転送後、DBが参照する6,378パスすべてについて、実ファイルが存在するかをスクリプトで突き合わせました。

正規化形 ファイル数 一致
ASCIIのみ 4,034 全件
NFC形 1,704 全件
NFD形 640 全件

全件バイト一致を確認してから次の工程に進みました。

Lightsailのイメージ選びで1つ罠がある

LightsailでWordPressを作るとき、イメージ(ブループリント)を選びます。ここで名前の似た2つが並んでいます。

イメージ 中身 状態
WordPress Bitnami版 2026年5月に更新終了。2026年11月以降は新規作成できない
WordPress(Lightsail版) AWS製 現行

長年の情報の多くはBitnami版向けに書かれています。ディレクトリ構成もコマンドも違うので、記事を参考にするときは注意が必要です。今回は現行のLightsail版を選びました。

Lightsail版の構成を実際に確認すると以下のようになっていました。

項目 値
OS Debian 12
Webサーバー Apache 2.4 + mod_php
PHP 8.2
データベース MariaDB 10.11
WordPressの場所 /var/www/html
wp-config.php の場所 /var/www/wp-config.php

移行元のPHPは7.2でした。PHP 7.2 から 8.2 への飛躍があるので、古いプラグインが動かない覚悟が必要です。

全体の仕組み

できあがった構成はこうなりました。

ドメインの登録先は変えていません。ネームサーバーをRoute 53に向けただけです。移管はそれ自体がリスクなので、DNSの管理権だけを移しました。

小さいサイトで先に試す

本番のブログは369記事あります。これをいきなり移行するのは怖かったので、同じサーバーで動かしていた記事28本の小さいサイトで先に通しました。

予備検証の段階で、次の問題が新たに出ました。

  • パーマリンクが全ページ404になる
  • プラグインの一斉有効化でサイトが落ちる
  • DBを壊しかける

検証が終わったらインスタンスは削除して、本番用を手順書どおりに作り直しました。設定を試行錯誤で汚した環境をそのまま本番にすると、何が効いているのか分からなくなります。

つまづきポイント(1) パーマリンクが全ページ404になる

トップページは表示されるのに、記事を開くと全部404になりました。

原因はDebianの既定設定でした。/var/www/ に対して .htaccess を読まない設定になっています。

# Debianの既定(/etc/apache2/apache2.conf)
<Directory /var/www/>
    AllowOverride None
</Directory>

WordPressのパーマリンクは .htaccess の書き換えルールで動いているので、これが読まれないと記事URLが解決できません。

設定ファイルを追加して解決しました。

<Directory /var/www/html>
    Options FollowSymLinks
    AllowOverride All
    Require all granted
</Directory>

.htaccess ファイル自体も、移行元からコピーせず新規に作り直しました。移行元の .htaccess には、そのレンタルサーバー固有の記述(独自のキャッシュ制御やアクセス制限)が入っていて、別の環境では意味がないどころか動作を壊します。

つまづきポイント(2) 触ってはいけないファイルを動かしてDBを壊す

Lightsail版には /opt/aws/wordpress/credentials.log というファイルがあります。初期パスワードが書かれているので、読み終わったら消すか移動したくなります。

これを移動したらサイトがDB接続エラーになりました。

wp-config.php を読むと、実行時にこのファイルを読んでDBパスワードを取り出す作りになっていたのです。

/opt/aws/wordpress/credentials.log は削除も移動もしないでください。設定ファイルが実行時に参照しているので、無くなるとサイトが落ちます。
「パスワードが平文で置いてあるのは気持ち悪い」と思っても、触るのは仕組みを確認してからにしてください。

つまづきポイント(3) プラグインを一斉に有効化してサイトが500になる

ファイルを移してDBを入れ、プラグインをまとめて有効化したところ、サイト全体が500エラーになりました。

エラーログを見ると原因は1つのプラグインでした。

PHP Fatal error: Unparenthesized `a ? b : c ? d : e` is not supported.
  in .../vendor/twig/twig/lib/Twig/Node.php on line 42

三項演算子の書き方がPHP 8で禁止されたため、構文エラーで落ちていました。PHP 7.2で動いていた古いプラグインが8.2で死ぬ、という典型例です。

最終的にPHP 8.2で動かなかったのは4つでした。

プラグイン 症状 対応
SNSシェア系 Fatal error 停止。テーマの標準機能で代替
カテゴリ一括変更 Warning 停止。管理用なので表示に影響なし
外部画像取り込み Warning 停止
記事マップ表示 Warning 停止

有効29個のうち23個が動き、Fatal errorもWarningも0件の状態に落ち着きました。

一番心配していたのは、広告を PHPコードを直接書けるウィジェットで表示していた箇所です。2010年代のプラグインなので8.2で動く保証がありません。これは無事に動きました。

プラグインを移すときは、一斉に有効化せず DBの active_plugins を空にした状態から1つずつ有効化してください。まとめて有効化すると、どれが原因で落ちたのか分からなくなります。

DNSの切替は2段階に分ける

ここからが本番切替です。いきなりやると問題の切り分けができないので、2段階に分けました。

フェーズ1では、移行元とまったく同じ内容のレコードをRoute 53に作ってから委任を切り替えます。訪問者から見た挙動は何も変わりません。ここでDNSの移管だけを確実に終わらせます。

このとき見落としやすいのがワイルドカードレコードです。移行元には * のAレコードがあり、これを写し忘れると一部のサブドメインが解決できなくなります。移行前のレコードは1つずつ実測して書き写しました。

フェーズ2でAレコードを新サーバーに向けます。TTLを60秒にしておいたので、反映も巻き戻しも1分で済みます。実測ではこうなりました。

リゾルバ 反映までの時間
権威サーバー 約1分
Google Public DNS 約1分
Quad9 / OpenDNS 約1分
Cloudflare 約4分

HTTPS証明書は切替前に取る

ここは事前に考えておかないと、証明書エラーの時間帯ができます。

Let's Encryptの証明書を取る方法は主に2つあります。

認証方式 仕組み 切替前に取れるか
HTTP-01 Webサーバーにファイルを置いて認証局に読ませる 取れない
DNS-01 DNSにTXTレコードを置いて認証局に読ませる 取れる

HTTP-01が使えないのは、認証局がAレコードを引いて接続してくるためです。切替前はAレコードが古いサーバーを指しているので、認証のリクエストは古いサーバーに届いてしまいます。

つまり切替前に証明書を取るならDNS-01しかありません。

IAMユーザーを作らずにDNS-01を通す

certbotにはRoute 53を自動操作するプラグインがあります。ただしLightsailのインスタンスには IAMロールを割り当てられないので、アクセスキーを持つIAMユーザーが必要になります。

「取得したら鍵を消せば安全」と考えたのですが、うまくいきませんでした。鍵を消した時点で証明書の自動更新も止まります。ということで、最初から鍵を作らない方法を採用しました。

certbotの --manual-auth-hook は「TXTレコードを登録する処理」を書く場所ですが、「登録されるのを待つ処理」を書いても動きます。

#!/bin/bash
# certbot が CERTBOT_DOMAIN と CERTBOT_VALIDATION を環境変数で渡してくる
# このフックはRoute 53を操作しない。要求された値が見えるまで権威サーバーに問い合わせて待つ
set -uo pipefail
D="${CERTBOT_DOMAIN:-}"
V="${CERTBOT_VALIDATION:-}"

echo "REQ $(date -Is) _acme-challenge.${D} ${V}" >> /home/admin/acme/requests.txt

# 最大15分待つ
for i in $(seq 1 90); do
  if dig +short +time=5 +tries=1 TXT "_acme-challenge.${D}" @ns-xxxx.awsdns-xx.org \
      | tr -d '"' | grep -qx "$V"; then
    sleep 10
    exit 0
  fi
  sleep 10
done
exit 1

フックが要求をファイルに書いて待っている間に、別の場所からRoute 53のAPIでTXTレコードを登録します。値が見えたらフックが exit 0 して、certbotが認証に進みます。これでサーバー上にAWSの認証情報を1つも置かずに証明書が取れました。

実測では、TXTレコードを登録してから権威サーバーに見えるまで1〜2分でした。

本番の前に --dry-run で通す
certbotの --dry-run はテスト環境を使うので、本番の発行制限を消費しません。しかも認証フックは実際に実行されるので、待機ロジックをそのままリハーサルできます。
1回試してから本番を叩くと安心です。

更新方式は切替後に変える

初回はDNS-01で取りましたが、この設定のままだと自動更新のたびに人間を待つ状態になります。切替が終わればAレコードが新サーバーを指すのでHTTP-01が使えます。設定ファイルを書き換えて、追加の発行なしで自動更新に移しました。

ここで1つ失敗があったので、注意です。設定ファイルにセクションをファイルの途中に挿入したため、後続の行がそのセクションの中身として読まれて壊れました。

Failed to renew certificate with error: Couldn't create root for server http-01
challenge responses: No such file or directory: 'https:/acme-v02.api.letsencrypt.org'

URLをディレクトリとして開こうとしています。INI形式なので、セクション見出し以降の行は全部そのセクションに属します。結果として server = https://... が「ドメイン名 server の設定」として解釈されていました。

そもそもそのセクションは不要だったので、削除したら一発で通りました。

セクションを持つ設定ファイル(INI、TOMLなど)に項目を追加するときは、必ずファイルの末尾に置くか、セクションの構造を理解してから入れてください。行単位の置換でセクション見出しを挿入すると、後ろの行の意味が変わります。

切替前に「本番と同じ経路」で検証する

証明書が手に入ると、切替前に本番とまったく同じ経路を検証できます。

やり方は curl --resolve と同じです。接続先IPは新サーバーに固定して、SNIとHostヘッダは本番のドメイン名を使います。Pythonなら標準ライブラリの接続処理を差し替えるだけです。

import http.client, socket, ssl

TARGET_IP = "(新サーバーのIP)"

class PinnedHTTPSConnection(http.client.HTTPSConnection):
    """接続先IPを固定しつつ、SNIとHostヘッダには本来のホスト名を使う"""
    def connect(self):
        self.sock = socket.create_connection((TARGET_IP, self.port), self.timeout)
        self.sock = self._context.wrap_socket(self.sock, server_hostname=self.host)

サーバー側から見ると本番アクセスと区別がつきません。これで切替前に全部確認できました。

検証項目 結果
画像6,378ファイル 全件200
投稿・メディア・カテゴリ・タグの件数 移行元と一致
記事内の装飾要素(2,645箇所) 移行元と一致
http:// の混在参照 0件
証明書の検証 正常

さらに同じHostヘッダで移行元のIPにも接続すれば、新旧の応答を1対1で比べられます。

検証中、カテゴリページの10件が404を返していて焦りました。移行元にも同じリクエストを投げたところ、移行元でも同じ10件が404 でした。日本語スラッグのカテゴリが元から404になっていた既存の不具合で、移行で壊したものではないと切り分けられました。

この方法のもう1つの利点は、ファイアウォールを自分のIPだけに絞ったまま検証が終わることです。外部に一度も露出させずに本番相当の確認ができます。

つまづきポイント(4) 切替後、メールが完全に死んでいた

表示は完璧で、画像も広告も検証済みでしたが、数日経ってメール設定を確認したところ、以下のようになっていました。

sh: 1: /usr/sbin/sendmail: not found
wp_mail() の戻り値: false(送信失敗)

LightsailのWordPressイメージにはメール送信の仕組み(MTA)が入っていません。 つまり切替以降、次のメールが全部届いていませんでした。

  • お問い合わせフォームの通知
  • コメント通知
  • パスワードリセットメール

共用レンタルサーバーでは当然送れていたので、これは移行による機能の喪失です。しかも画面には何も出ません。サイトはまったく正常に見えます。

移行後のチェックリストに「メールが送れるか」を必ず入れてください。
表示・画像・広告をどれだけ検証しても、メールは画面に出ないので気づけません。
wp_mail() の戻り値と、エラー内容($phpmailer->ErrorInfo)の両方を確認するのが確実です。

紛らわしいのは、PHPの設定には sendmail_path が入っていることです。設定を眺めただけでは送れそうに見えます。実体が存在しないだけなので、実際に送ってみないと分かりません。

Amazon SESで復旧する

AWS上のサーバーは25番ポートが既定で制限されているので、自前でメールサーバーを立てても外に出せません。Amazon SESを使いました。

Amazon SESって何?
AWSのメール配信サービスです。すごく雑に言うと「メールを送る部分だけを借りる」仕組みです。
自分でメールサーバーを立てると、迷惑メール判定されない設定や、送信の失敗処理まで全部面倒を見る必要があります。SESはその部分を引き受けてくれます。

送信制限の解除は本当に必要か先に確認する

SESは初期状態だと「検証済みのアドレス宛にしか送れない」制限があります(1日200通まで)。解除には申請と待ち時間が必要なので、条件反射で申請する前に必要かどうかを確認しました。

判断基準は「任意のアドレスへ送る必要があるか」だけです。

用途 宛先 制限下で足りるか
お問い合わせの通知 管理者(固定) 足りる
コメント通知 管理者(固定) 足りる
パスワードリセット 管理者自身 足りる
フォーム送信者への自動返信 訪問者(任意) 足りない

お問い合わせフォームの設定を確認すると、通知先は管理者のアドレス固定で、フォーム入力値が入るのは Reply-To だけでした。Reply-To は送信先ではないので、制限のままで足りると判断できました。

送信元は必ず自分のドメインにする

元の設定では送信元が管理者の個人メールアドレス(フリーメール)になっていました。これをそのままSESから送ると、他人のドメインを自分のサーバーから送る形になり、迷惑メール判定されやすくなります。

送信元を wordpress@(自分のドメイン) に変えて、DKIM署名とSPFが整合するようにしました。Reply-To はフォーム入力値のまま残るので、受信後にそのまま返信できます。

つまづきポイント(5) IAMポリシーを絞りすぎて送信が拒否される

SESを使うためのIAMポリシーで、「最小権限にしよう」として送信元だけを対象に指定しました。

{
  "Effect": "Allow",
  "Action": ["ses:SendRawEmail"],
  "Resource": "arn:aws:ses:ap-northeast-1:(アカウントID):identity/(自分のドメイン)"
}

実際に送ると拒否されました。

SMTP Error: data not accepted. SMTP server error: DATA END command failed
Detail: Access denied: User '...' is not authorized to perform 'ses:SendRawEmail'
on resource '...identity/(宛先のアドレス)'

SESは送信元だけでなく宛先も権限評価の対象にします。送信制限がかかっている状態では宛先アドレスも登録済みの識別子として扱われるので、そこへの権限が必要でした。

修正版のポリシーがこちらです。対象を広げる代わりに、条件で送信元アドレスを固定しています。

{
  "Effect": "Allow",
  "Action": ["ses:SendRawEmail", "ses:SendEmail"],
  "Resource": "*",
  "Condition": {
    "StringEquals": { "ses:FromAddress": "wordpress@(自分のドメイン)" }
  }
}

対象が * でも、条件で送信元が固定されているので他のアドレスを騙って送ることはできません。絞り込みは対象リソースではなく条件で行うのがSESの正しい形でした。

IAMポリシーを絞ったら、必ず実際の操作で確認してください。
ポリシーの作成が成功したことは、その権限で目的の操作ができることを意味しません。

認証情報はデータベースに置かない

SESのSMTP認証情報を設定するとき、プラグインの管理画面から入れるとデータベースに平文で保存されます。DBのバックアップを取るたびに秘密が複製されることになります。

wp-config.php に定数で書けばDBに入りません。

define( 'WPMS_ON', true );
define( 'WPMS_MAILER', 'smtp' );
define( 'WPMS_MAIL_FROM', 'wordpress@(自分のドメイン)' );
define( 'WPMS_MAIL_FROM_FORCE', true );
define( 'WPMS_SMTP_HOST', 'email-smtp.ap-northeast-1.amazonaws.com' );
define( 'WPMS_SMTP_PORT', 587 );
define( 'WPMS_SSL', 'tls' );
define( 'WPMS_SMTP_AUTH', true );
define( 'WPMS_SMTP_USER', '(アクセスキーID)' );
define( 'WPMS_SMTP_PASS', '(SMTPパスワード)' );

WPMS_MAIL_FROM_FORCE を有効にするのは必須でした。IAMポリシー側で送信元アドレスを固定しているので、送信元が揃っていないと権限エラーになります。

紛らわしいポイント ファイアウォールの全開放

切替のときにファイアウォールを全開放しようとして、素直に 0.0.0.0/0 を指定したらエラーになりました。

InvalidInputException: You cannot specify a CIDR or a CIDR list alias together
with "0.0.0.0/0". Specifying "0.0.0.0/0" allows all CIDRs and CIDR list aliases.

Lightsailでは「許可するIPを指定しない」ことが全許可という仕様です。既存のルールに特定のIPが入っていると 0.0.0.0/0 との併記になって弾かれます。

一度ルールを削除してから、IPを指定せずに開き直すのが正解でした。

検証で気づいた、共用サーバーが持っていたもの

移行元と移行先のレスポンスヘッダを、同じHostヘッダで接続先IPだけ変えて比べてみました。表示が同じでも中身が違うかもしれないと思ったからです。

ヘッダ 移行元(共用サーバー) 移行先(Lightsail)
server 出していない Apache/2.4.68 (Debian)
x-cache-status あり なし
x-mod-pagespeed あり なし
strict-transport-security なし なし

検証を通じて明らかになったこと2つありました。

1つ目は、Lightsailがサーバーのバージョンまで出していたことです。移行元は隠していたので、これは移行による劣化です。設定を追加して隠しました。

ServerTokens Prod
ServerSignature Off

2つ目は、共用サーバーがサーバー側のページキャッシュと自動最適化を裏で効かせていたことです。移行先にはこれがないので、毎回PHPが動きます。CPU2個・メモリ2GBなので通常のアクセスでは問題ありませんが、共用サーバーが面倒を見てくれていた部分が見えました。

かかる費用

料金は2026年9月時点のものです。

項目 単価
Lightsail(CPU2個・メモリ2GB・ディスク60GB・転送3TB) 月 $12
Amazon Route 53 ホストゾーン 月 $0.50
Amazon SES(月数十通) $0.01 未満
Let's Encrypt 証明書 無料

合計で月 $12.5 ほど、1ドル150円なら年間で約22,500円です。

正直、移行元の共用レンタルサーバーは年13,200円のため、金額だけ見ると、移行して高くなっています。

今回はAWSのクレジットがあるので自己負担は発生していませんが、「安くするための移行」ではありませんでした。共用レンタルサーバーの年1万円台は、かなり安いです。

やってみた感想

一番良かったのは、中で何が起きているか全部見えるようになったことです。

共用サーバーだと、画像が表示されない理由を調べようとしてもログに手が届きません。今回は6,378ファイルのパスを1つずつ突き合わせて、640ファイルがNFD形だと数えられました。前回の移行で諦めた原因も、無事に解決しました。

同時に、共用レンタルサーバーがどれだけ面倒を見てくれていたかも分かりました。メールが送れるのは当たり前ではありませんし、ページキャッシュも勝手に効いていました。移行して初めて、無くなったものに気づきました。

自分で組む価値があるのは、たぶんこういう人だと思います。
中で何が起きているかを把握したい人。AWSの勉強も兼ねたい人。AWSクレジットが余っている人。

逆に「ブログを書くことに集中したい」なら、共用レンタルサーバーのままが幸せです。月1,000円ちょっとで、メールもキャッシュも付いてきます。

もし同じ移行をやるなら、順番だけは守ってほしいです。小さいサイトで試す、切替前に本番と同じ経路で検証する、切替後にメールを確認する。 この3つを飛ばすと、私が踏んだ穴をそのまま踏むことになります。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?