はじめに
このブログは2017年の開設からずっと、AWS の EC2 上に立てた WordPress で運用してきました。ただ、ここ数年は更新頻度が落ちる一方で、サーバー代とメンテナンスの手間だけがかかり続ける状態に。
思い切って WordPress をやめて、Hugo で作る静的サイトに移行しました。この記事はその移行後、最初に書く記事です。先に「移行して何が変わったか」を書いて、そのあとで、なぜ移行したのか、どう移したのか、どこでハマったのかを、これから同じことをする人向けにまとめます。
移行前と移行後のトップページはこんな感じです。


先に結論:移行して何が変わったか
- 記事を書く流れが「エディタで Markdown を書いて push」だけになった。サーバーを起動する儀式が無い
- サーバーの更新・監視・バックアップが不要になった
- ページの表示が軽くなった(PHP も DB も無い、ただの HTML)
- 月額コストは 20 ドル強から、EC2 停止後は数百円程度になる見込み
- 旧記事の URL と画像はそのまま。被リンクも検索流入も失っていない
- サイト内検索が使えるようになった
最後の検索は、静的サイトにしてむしろ増えた機能です。WordPress の検索はサーバー上の PHP が DB に問い合わせて結果ページを作る仕組みなので、StaticPress で HTML に書き出したサイトでは動きません。「入力に応じてその場で作るページ」は書き出しようがないからです。なので旧サイトには検索フォーム自体を置いていませんでした。
Hugo では、ビルド時に全記事のタイトル・本文・URL を 1 つの JSON に書き出し、検索ページがそれをブラウザで読み込んで JavaScript で絞り込みます。サーバー側の処理はなく、S3 に置いた静的ファイルだけで完結します。記事 429 本で索引は 2.5MB ほど。記事を足せば再ビルドで勝手に更新されます。

代わりに、コメント機能や管理画面からの画像アップロードのような「動的なもの」は無くなりました。このブログではどちらも使っていなかったので、失ったものはほぼありません。
ここからは、なぜそうしたのか、どうやったのかの話です。
移行を決めた理由
お金
EC2(t2.micro)、Elastic IP、EBS、Route53 と積み上がって、月に20ドル強。しかも記事を書いていない月も同じだけかかります。静的サイトなら S3 と CloudFront だけで、このブログのアクセス規模だと月に数百円で収まる見込みです。
手間とセキュリティ
WordPress 本体、PHP、プラグイン、OS のアップデート。放置すると脆弱性が溜まり、更新すると何かが壊れる。書きたいときにサーバーの面倒から始まるのが、更新が遠のく原因になっていました。静的サイトには攻撃される「実行中のもの」がないので、この心配がまるごと消えます。
書くたびに EC2 を起動して、終わったら止める
サーバー代を抑えるために、書かないときは EC2 を停止していました。すると記事を書くたびに、AWS のコンソールにログインして EC2 を起動し、立ち上がるのを待ってから管理画面を開く、という手順から始まります。書き終わったら StaticPress で静的化して、EC2 を止めるのを忘れないようにする。忘れると、次に気づくまで課金が続きます。
「思いついたときにサッと書く」とは程遠い儀式で、これが地味に一番効いていました。静的サイトには起動も停止もありません。
書き方を Markdown と git にしたかった
記事は Windows のデスクトップでも Mac のノートでも書きます。WordPress では記事の実体がサーバー上の DB にあるので、どの PC からでも「まずサーバーを起動して管理画面を開く」ことになり、上の儀式が毎回ついて回ります。
そこで、ブログのデータをひとつのリポジトリに持ち、どの PC からでも同じものを開いて続きを書ける形にしたかった。記事は Markdown のテキストファイル、管理は git。clone すれば手元に全記事がそろい、履歴も残る。エディタで書いて git push するだけ、という流れにしたかったのが最後の一押しです。
移行後の構成
| 項目 | 移行前 | 移行後 |
|---|---|---|
| 生成 | WordPress(PHP + MySQL) | Hugo(静的サイトジェネレータ) |
| テーマ | 独自カスタマイズ | Stack(Hugo テーマ) |
| 配信 | EC2 → 静的化プラグイン(StaticPress) → S3 / CloudFront | S3 / CloudFront(そのまま流用) |
| 記事 | 管理画面のエディタ | Markdown + git(GitHub のプライベートリポジトリ) |
| 画像 | サーバーにアップロード | 旧画像は S3 に据え置き、新規は記事フォルダに同梱して git 管理 |
構成図で並べるとこうなります。移行前は「書く場所」と「配信する場所」の間に EC2 上の WordPress と静的化プラグインの StaticPress が挟まっていて、しかも書く前に EC2 を起動し、書き終わったら止める、という手順がセットでした。移行後は EC2 が丸ごと消えて、手元で書いた Markdown を GitHub に push すると、ビルドと S3 への同期が自動で走る形になります(自動化は準備中)。
配信面(S3 + CloudFront)は WordPress 時代から StaticPress で使っていたので、移行後も URL 構造をそのまま維持できました。/2024/03/01/12064/ のような旧 URL も、画像の /wp-content/uploads/... もそのままです。被リンクや検索流入を失わないために、ここは最初から必須条件にしていました。
リポジトリの中身はこうなっています。テーマ本体は触らず、サイト側のフォルダに設定と少しの上書きだけを置く構成です。
blogenist/
config/_default/
hugo.toml 基本設定・URL の形式・コードハイライト
params.toml テーマ(Stack)の表示設定
menu.toml サイドバーのメニューと SNS リンク
content/posts/ 記事(Markdown)。旧記事は <記事ID>.md、新記事は <番号>/index.md
data/ 商品リスト、動画リスト(TOML)
layouts/ テーマの上書きとショートコード(必要最小限)
assets/scss/custom.scss 配色などの調整
static/ ads.txt、favicon など
themes/stack/ テーマ本体(無改変)移行の手順
1. WordPress からエクスポート
管理画面の「ツール → エクスポート」で WXR(XML)を出力。記事本文、カテゴリ、タグ、アイキャッチの紐づけ、添付ファイルの情報がすべて入っています。記事512本で 15MB ほどでした。
2. WXR を Markdown に変換
Python で変換スクリプトを書きました。標準の XML パーサーだと WordPress の出力に含まれる不正な文字で落ちるので、正規表現で <item> を切り出す方式にしています。骨格はこれだけです。
import re, html
raw = open("export.xml", encoding="utf-8").read()
items = re.findall(r"<item>(.*?)</item>", raw, re.DOTALL)
def cdata(block, tag):
m = re.search(rf"<{tag}>\s*<!\[CDATA\[(.*?)\]\]>\s*</{tag}>", block, re.DOTALL)
return m.group(1) if m else ""
for block in items:
if cdata(block, "wp:post_type") != "post":
continue
if cdata(block, "wp:status") != "publish": # 下書き・非公開・ゴミ箱は持ち込まない
continue
post_id = cdata(block, "wp:post_id")
title = html.unescape(cdata(block, "title"))
body = cdata(block, "content:encoded")
# ... 本文の整形、front matter の組み立て、content/posts/<post_id>.md へ保存変換後の記事は、こういう front matter を持った Markdown になります。
---
title: "記事タイトル"
date: 2024-03-01T12:00:00+09:00
url: "/2024/03/01/12064/" # 旧 URL をそのまま固定
image: "/wp-content/uploads/2024/02/eyecatch.jpg" # アイキャッチは S3 の旧画像
categories:
- "ガジェット"
tags:
- "GoPro"
words: 2161 # 文字数・所要時間は変換時に前計算(理由は後述)
minutes: 5
wpPostId: 12064
---変換でやっていることは次のとおりです。
url:に旧 URL(/YYYY/MM/DD/<記事ID>/)を書いて、パーマリンクを固定_thumbnail_id→ 添付ファイルのパス、と辿ってアイキャッチを復元- WordPress 時代の独自ショートコード(Amazon 商品リンク、関連記事リンク)を Hugo のショートコードに置き換え
- 旧テーマ専用の装飾(太字クラスやマーカー)は、Markdown 標準の
<strong>や<mark>に変換 - ハイライトプラグインのコードブロックを Markdown のコードブロックに変換(これも後述)
最後の2つは地味に大事で、旧テーマの CSS クラスを引きずると、テーマを変えるたびに「旧記事だけ崩れる」ことになります。移行時に標準の書き方へ寄せておくと、後が楽です。
3. 画像は動かさない
画像は 4,000 枚以上・700MB あります。これを git に入れるのは現実的ではないので、S3 にあるものをそのまま使うことにしました。パスも変えないので、記事本文の画像リンクは一切書き換えていません。ローカルで画像込みの表示を確認したいときだけ S3 から同じフォルダにコピーし、git からは除外しています。
**/public/
**/resources/_gen/
**/static/wp-content/uploads/ # 旧画像は S3 が正。ローカルにコピーしても git には入れない新しく追加する画像だけ、記事フォルダに同梱して git 管理にしています。
4. ローカルで確認
hugo server で手元に立ち上げ、トップ・記事・カテゴリ・タグ・検索を一通り確認。
cd blogenist
hugo server -D --renderToMemory # -D で下書きも表示、--renderToMemory でディスクに書き出さない全記事のビルドは 6秒です。WordPress の管理画面を開くより早い。
│ JA
─────────────────┼──────
Pages │ 607
Paginator pages │ 425
Static files │ 4195
Aliases │ 120
Built in 6376 msハマったところ
目次が出ない
WordPress の本文は HTML なので、見出しも <h2> のままです。Hugo の目次(.TableOfContents)は Markdown の見出しからしか作られないため、変換時に HTML の見出しタグを Markdown の見出しに変換して解決しました。
<h2>移行を決めた理由</h2>
<h3>お金</h3>## 移行を決めた理由
### お金記事同士を参照するとビルドが止まる
関連記事カードで「リンク先記事の文字数」を出そうとして、テンプレートからリンク先の本文集計を参照したところ、ビルドが数分経っても終わらなくなりました。記事 A が記事 B の本文を要求し、B が A を要求し……と互いに待ち合う形になるためです。
{{ $target := site.GetPage .Params.url }}
<span>{{ $target.WordCount }}文字 / 約{{ $target.ReadingTime }}分</span>対策は、文字数や読了時間を変換時に前計算して front matter に持たせ、カードはそれだけを読むようにすること。リンク先の front matter(タイトル・画像・日付・文字数)だけで完結させれば、一瞬でビルドが終わります。
{{ $target := site.GetPage .Params.url }}
<span>{{ $target.Params.words }}文字 / 約{{ $target.Params.minutes }}分</span>コードブロックの色分けが消えた
WordPress ではハイライト用のプラグインを使っていたので、本文にはプラグイン独自の <pre> が 1,400 個ほど残っていました。Hugo はこれをただの HTML として素通しするので、色分けもコピー用ボタンも無い状態に。

<pre class="lang:sh decode:true" title="command">brew install hugo</pre>これを Markdown のコードブロックに変換したら、Hugo 標準のハイライトがそのまま効きました。ファイル名やコマンド名(title)は、Hugo の render hook でブロックの上に帯として出しています。
```bash {title="command"}
brew install hugo
```
OGP の説明文に \n が混ざる
SNS でシェアしたときの説明文に、改行が \n という文字のまま出ていました。WordPress 由来の本文に Windows 形式の改行や連続空白が含まれていたのが原因で、説明文を作るテンプレートで空白類をまとめて整形するようにして解決。
{{- $d := .Summary | plainify | htmlUnescape -}}
{{- $d = replaceRE "[\\s\\x{3000}]+" " " $d | strings.TrimSpace -}}
{{- return $d -}}移行直後は、実際に投稿したときのカード表示まで確認しておいたほうがよさそうです。
非公開にした記事が公開されたままだった
これは Hugo というより静的化の落とし穴。WordPress で「非公開」や「削除」にした記事が、StaticPress で S3 に書き出したページとしては残っていて、URL を知っていれば普通に見られる状態になっていました。静的化は「追加・更新」はしてくれても「削除」までは面倒を見てくれなかったわけです。
移行後は、ビルド結果を S3 に同期するときに --delete を付けて、ビルドに含まれないページは S3 からも消えるようにしました。記事を下げたいときは Markdown を消せば終わりです。
aws s3 sync public/ s3://<バケット名>/ --delete --exclude "wp-content/uploads/*"「ちょっとカスタマイズ」の誘惑
テーマに無い機能を自前の CSS や JS で足し始めると、テーマを更新するたびに直す羽目になります。今回は「テーマ標準の設定で済むならそれを使う。自前で書くなら最小限」を方針にして、途中でテーマも乗り換えました(最初は別のテーマで進めていましたが、左サイドバーや目次の追従など、欲しいものが標準で揃っていた Stack に切り替え)。
それでも、記事の前に出す商品カードや動画カードのように、自前で組んだ部品はいくつかあります。何を自前で足したかは README に一覧で残して、テーマ更新のときに見比べられるようにしています。

これから
- GitHub Actions で
push→ ビルド → S3 同期 → CloudFront キャッシュ無効化まで自動化 - 旧 EC2 と Elastic IP を削除して、コスト削減を確定させる
- 商品紹介カードや関連記事カードなど、WordPress 時代に便利だった部品を引き続き整備
移行作業そのものは、休日の作業で2日ほど。エクスポートさえ取ってしまえば、あとは手元で何度でもやり直せるので、思っていたより気楽でした。WordPress の維持に疲れている人には、静的サイトへの移行はかなりおすすめです。
