このブログで使っているGhostを有料のマネージド版から自分でVPSサーバーを借りてセルフホスト版に移行したので、移行手順等を含めて記録しておきます。
モチベーション
短く言うと、自分に発破をかける意図でマネージドプランをやったけどどうにもうまくいってないので、一旦運営コストを下げるために移行しました。
それまでStarterプラン(年間108ドル)だったのが、APIさえも使えずかなり不便だったため、意を決してCreatorプラン(年間300ドル)に2024/3/25に移行していました。運用コストをかけることでしっかり回収するように自分を誘導したかったのですが、この1年を振り返るとなかなか継続的なアウトプットには繋がらなかったという感じで申し訳ないです。
ありがたいことに支援してくださっている方のおかげで300ドルはペイすることができました。2年目、気を引き締め直して継続するかどうかを悩んだのですが、プランの維持でいっぱいいっぱいよりも別のことに使えた方がベターだと考え、収支バランスを見直すことにしました。
もう1点、マネージドにすることでメンテナンスを省力化することができるというものがあったのですが、昨今はDockerのようなコンテナ技術によってセルフホストも格段に楽になったのも大きな理由の一つです。
VPSサーバー選定
自宅サーバーを色々いじっているので自宅でも良いかと思ったのですが、Stripeなどの決済が絡むので可用性の低い状態で運用するのは望ましくないと考えたのでサーバーを借りることにしました。
AWSやGCPなどのIaaSも検討しましたが、サービス規模的にはVPSで十分です。Ghostの場合はRAM1GBで借りている人が多かった印象なので、TokyoリージョンのあるVPSを中心に検討しましたが、どこも価格的には大きく違いはなく5ドルです。
Linodeはスペック面でも色々と魅力的だったのですが、なんとなくさくらインターネットの田中さんをタイムラインで見る機会が最近多いのでさくらのVPSにしようと思いました。2018年まで使っていたみたいなので7年ぶりです。
移行手順
さくらのVPSにdebian+docker環境でGhostをインストール
自宅サーバーで使っているProxmoxがdebianがデフォ、RasbianもdebianベースなのでVPSもdebianにして統一しました。
OSインストール時にGithubの公開鍵をインポートしてくれる機能があって、細かいところで便利じゃーんって思ったけどログインしたら鍵がなかった。一体どういうことだったんだろう…(いつものようにshellで入れた)

標準的なセットアップなので詳細は割愛。なお、さくらのVPSにはパケットフィルターのような機能が提供されているが、無効にしてOSレベルで設定しておいたほうがよいらしい。Dockerはこれを見ればOK。

あとはDocker HubからGhostの公式イメージを入れる。
https://hub.docker.com/_/ghost
services:
ghost:
image: ghost:latest
restart: always
ports:
- 8080:2368
environment:
# see https://ghost.org/docs/config/#configuration-options
database__client: mysql
database__connection__host: db
database__connection__user: root
database__connection__password: example
database__connection__database: ghost
# this url value is just an example, and is likely wrong for your environment!
url: http://localhost:8080
# contrary to the default mentioned in the linked documentation, this image defaults to NODE_ENV=production (so development mode needs to be explicitly specified if desired)
#NODE_ENV: development
volumes:
- ghost:/var/lib/ghost/content
db:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: example
volumes:
- db:/var/lib/mysql
volumes:
ghost:
db:
imageをlatestにして、credentialsを適当に変えて docker compose up -d
で立ち上がる。楽ちん。
多分手順的にはこの段階でHTTPS化しておいたほうがよい。(後述)
Ghostの環境ができたらmigration作業に入ります。
member以外のマイグレーション
以下のガイドはセルフホストGhostからGhost Pro(マネージド)への移行ですが参考になります。Ghostの管理画面からダウンロードできるものがほとんどですが、images/とmedia/の中の画像ファイルだけはGhostサポート経由じゃないといけないのでghost.ioのサイトアドレスを添えてサポートにメールを出せばOK(全部入りのバックアップzipがもらえます)。

リモート先のdockerの中にファイルを送るのよく理解していなかったので調べながらやりました。
scp images.zip {さくらのVPS}:~/ghost/images.zip
scp media.zip {さくらのVPS}:~/ghost/media.zip
vpsにsshして
cd ghost
# 解凍
unzip '*.zip'
docker ps # コンテナ名確認 ここではghost-ghost-1
# Dockerに転送
docker cp images/ ghost-ghost-1:/var/lib/ghost/content/
docker cp media/ ghost-ghost-1:/var/lib/ghost/content/
# パーミッション変更
docker exec ghost-ghost-1 chown -R node:node /var/lib/ghost/content/images
docker exec ghost-ghost-1 chown -R node:node /var/lib/ghost/content/media
ここまでやると画像がちゃんと表示されるはず。

ドメイン切り替え & HTTPS化
memberのマイグレーションにはStripeの再接続が絡んでくるので、この段階でドメインを移行してHTTPS化しておくことにしました。
Cloudflareのドメインを使っているのですが、Ghost ProのときはProxyを使えなかったのが、セルフホストだと使用できるようです。といっても、モードはフル(Cloudflareとオリジンサーバーの間もTLS保護が必要)が必要なので、どちらにせよVPS上で証明書をいい感じにしないといけません。
一昔前ならCertbotを使ってLet's Encryptの自動更新設定などをしていたわけですが、今はOSSのCaddyを使って短いファイルをかけば終わりです。インストールもdocker-compose.ymlにCaddyを追加するだけ。楽ですね。
Caddyfileにはシンプルにwwwありとなしに設定。こう書いてしまうと証明書が2つ発行されてしまうようなのですが、Cloudflareでwwwからネイキッドドメインの方に転送をかけているからか、Caddyでもリダイレクトかけたらなんか挙動が怪しかったのでまぁいいかという感じです。
hanatane.net, www.hanatane.net {
reverse_proxy localhost:8080
}
memberの移行
さて、memberの移行はStripeと接続しているメンバーがいる場合はちょっと難しいので手順を確認。

まずはmemberをexportしておく。このままimportしようとしてもStripeと接続されているユーザーはimportできないので注意。
まず、旧サイトの方からStripeの接続を切る必要がある。接続を切るには有料メンバーを削除する必要がある。ドキュメントにあるようにstatusがpaidやcomplimentaryのメンバーを削除するとStripeから切断できるようになる。Stripeと切断してもStripeのサブスクリプションは継続される。
旧サイトのwebhookが残っているので忘れずに切断しておく。
旧サイトとStripeが切断できたので、管理UIの方で新サイトの方でStripeに再接続する。接続先は旧サイトと同じものを使う。
新サイトと接続ができたらmemberをimportする正常に有料メンバーもimportできていることを確認。
Mailgunの設定
ニュースレター機能のためにMailgunを設定します。Mailgunは無料で1日100通までなのでこれ以上の規模の場合は有料を検討します。現在の環境で大量送信するメールを到達させるにはMailgunのようなサービスを使うのが必要です。
Add payment info nowのチェックを外してユーザー登録すると無料で登録できます。
ドメインを1つ接続します。Mailgun用の適当のサブドメインを設定。DKIM keyを自動設定するオプションができていたので利用しました。

DNS設定が色々表示されるのでCloudflareの方に設定しておきます。しばらくするとCheckが押せるようになります。
Ghost側の設定は送信用のアドレスをMailgunのSMTPで作成しているものと一致させるのとdocker-compose.ymlに設定を追加しました。

swapを追加
ここにきて動作がかなり不安定でCPU timeもかなり発生していたのでメモリ不足を疑った。調べてみるとGhost, MySQL, Caddyで1GBをいっぱいいっぱいで使用している。

RAM1GBでGhostを動かしているユーザーはほとんどSwapを使っていて、さくらのVPSでは初期設定でSwapが有効化されていないのでこれを有効化した。これはさくらのVPSのマニュアル通りでOKでした。


Swap領域を作ってからは安定した。

Cloudflareのキャッシュ設定
Cloudflareのドメインを使っているので、Cloudflare周りをうまく設定するとパフォーマンスを無料で上げることができる。Ghost Proの方はこの辺のCDN設定も含まれているので、せっかくなのでできることはやっておこう。
CloudflareのDNS設定でProxyをオン、TLSのEncryptionモードはFull(Strict)にすることで、Cloudflareの恩恵をフルに受けつつGhostを正常に表示できる。
加えてキャッシュルールを以下の通りに従って設定する。

サイト変更時のWebhookでキャシュを削除するCloudflare Workersも設定しておく。

うまくエコシステムを活用できるなぁと思った。
今回採用しなかったもの
Pintura
Ghost Proの方ではPinturaという画像エディタを使っていますが、Ghost用のライフタイムプランが用意されています。ライフタイムといっても1年間のアップデートに限られているので何回か購入し直すような形になるでしょう。
iPhoneから直接アップロードして適当に切り抜いてと便利ではあるのですが、今回は一旦見送りました。
Cloudinary
GhostはAdminからメディアをアップロードしたときに自動で圧縮するわけではなく、テーマからの呼び出しに応じて必要なファイルを作成する方式のようです。よくiPhoneからUniversal Clipboard経由で画像をペーストするので、2MB以上のファイルがかなりあって、少ないSSD容量を圧迫していく恐れがあります。
GhostはCloudinaryのAdapterがあって、内部ストレージを使用せずにCloudinaryにアップロードできるようになります。色々な便利機能を差し置いても、単純にストレージ容量の節約になるのは良さそうだと思いました。
ただGhostの公式Dockerイメージをそのまま使えなくなるので更新周りの作業が重くなりそうだなと思って今回は見送りました。Cloudinaryを使う場合はGhost CLIを使うのが良さそうです。

感想
- Dockerでめちゃくちゃ楽になった
- Cloudflare以外を使う理由がないくらいに便利
- サーバーをいじるのはやはり麻薬
支出の改善だけで終わらず、この1年は思ったようなアウトプットができなかったのをしっかり反省して次の1年にもっとうまく運用したいと思います。皆さん支援ありがとうございました。
Discussion