ひとつの TREK から生えた二十個の stack

13 min

まえがき

数日前、GitHub trending を眺めていたら TREK というプロジェクトを見つけた。複数人で地図上にルートを引いたり、宿を予約したり、予算をリスト化したりできて、作ったあとに他の人へ共有してさらに編集してもらえる、というものだ。demo サイトに入って少し触ってみたら、かなり使いやすかった。

npy と出かけるときの旅程は、ずっと Obsidian でつぎはぎして作っていた。いくつかの daily note を相互リンクしながら書いていくのだけれど、書けば書くほどぐちゃぐちゃになり、たいてい出発当日になっても「午前はどこ、午後はどこ」くらいの粗いものしかできていない。TREK を見た瞬間、自分で一台動かしたくなった。作者の demo をそのまま使うこともできるが、旅行計画みたいなものを他人のサーバーに置くのは、どうにも少し気持ち悪い。遅かれ早かれ自分でデプロイすることになる。

どうせ自分でデプロイするなら、ついでに新しい VPS を一台開けてしまおう。

Netcup ARM

VPS 選びは想像以上に悩んだ。DigitalOcean / Linode は同等構成だと普通に倍以上高い。中国国内の小規模業者は安いが、25 番ポートのアウトバウンドと ICP 備案という二つの潜在的な地雷があるので、触らずに済むなら触りたくない。Hetzner はコスパがいいけれど、ARM ノードのロケーション選択が柔軟ではない。結局、回り回って Netcup がいちばん楽だった。

具体的には VPS 2000 ARM G11 を選んだ。10 vCore、16G メモリ、512G NVMe、2.5 Gbps ネットワークで、月額は税込 €13.41。この構成を x86 側で取ろうとすると、どう考えても倍は高くなる。トラフィックは flatrate で、24 時間の移動平均が 2 TB を超えた場合だけ 200 Mbps に制限される。自分のような弱小サイトでは、まず届かない。

注文前に Google で少し探したところ、-50% の coupon を拾えたので、初回支払いは €6.7 ちょっとまで下がった。更新時には通常価格に戻る。この手の「初回半額」商法には特に文句はない。どうせ初年度のこの一発だけでも十分おいしい。

データセンターは Nuremberg、Wien、Amsterdam、米東海岸の Manassas の四つから選べる。最終的には Manassas にした。中国国内からよくある経路で ping を飛ばすと、他の三つの欧州拠点より一段低かったからだ。メール IP のレピュテーションについては、どうせ送信メールはすべて Resend HTTPS ブリッジ経由にする(後で書く)ので、ローカル IP から直接配送することに依存しない。そのためだけに欧州データセンターへこだわる必要はなかった。

ゼロから TREK が動くまで

注文してからログイン情報が届くまで、およそ 5 分。最初にやることは何かをインストールすることではなく、まず SSH を締めることだった。root ログイン禁止、パスワードログイン禁止、ポート変更、ufw はデフォルトで deny in。fail2ban を二日ほど走らせてログを見たところ、22 番ポートを変える前は一日 5000 回以上のブルートフォース試行があり、変更後は一桁まで落ちた。入れたてのマシンなのに、外の人間のほうがこちらより焦っている。

ARM64 のイメージエコシステムは、この二年でかなり良くなった。後から順次入れていった二十個ほどの stack のうち、Caddy、Postgres、Stalwart、SnappyMail、Open WebUI、Vaultwarden、Dagu、Glance、Karakeep、Paperless はすべて公式のマルチアーキテクチャイメージがあり、docker pull するだけでそのまま動いた。唯一少し手間がかかったのは Forgejo runner だ。base イメージ自体は ARM64 で問題ないのだが、runner の中で docker compose を deploy ツールとして使いたかったため、自分で docker-cli と docker-compose-plugin を追加する必要があった。最終的には buildx で ARM64 イメージを自分の Forgejo registry に push し、runner 起動時にそれを直接 pull するようにした。

ようやく TREK 本体の番だ。リポジトリ mauriceboe/TREK には公式の docker-compose.yml があるので、それをほぼそのままコピーして数行だけ変えた。

  • ポートは web という docker ネットワークからだけ見えるようにした
  • ENCRYPTION_KEY は openssl rand -base64 32 で新しく生成した
  • データベース DATABASE_URL は SQLite にした。自分用なら十分
  • web という external network を追加し、Caddy とつないだ

Caddyfile 側は reverse_proxy trek

の一行で締め。docker compose up -d から trek.shinya.click にログインページが出るまで、前後 15 分もかからなかった。がらんとした dashboard を眺めながら最初のアカウントを登録し、反射的に次に考えたのは「旅程はどこから引き始めよう」ではなく、「このマシンに他に何を詰め込めるか」だった。この病気については自分でもわかっている。

入れ始めたら止まらない

後半夜の作業は、だいたいそんな思いつきから始まった。どうせスペックには余裕がある。遊ばせておくのももったいないので、以前からセルフホストしたかったものを全部ここへ運んでくることにした。

翌日起きてから、ついでに Forgejo を入れてコードホスティングを手元に戻し、ブログもこの流れで作り直した。これは下で別に書く。メールは Stalwart + SnappyMail + 自作の Resend bridge の三点セット。AI 系は CLIProxyAPI を置いて OpenAI / Claude / Gemini 各社のサブスクリプションをまとめ、その前に Open WebUI を一枚かぶせて自分用に使う。さらに後ろにはパスワード、クラウドドライブ、画像ホスティング、ノート、あとで読む、RSS、PDF ツール群と、セルフホストできるものはほぼ一通り試した。SSO は Authelia でこの一群のサービスをまとめ、一度ログインすればどこでも使えるようにした。バックアップは Dagu で DAG を一つ起こし、毎日暗号化スナップショットを R2 に送る。

少しずつ入れ終えると、全体構成はだいたいこの図のようになった。

架構図,不過還缺了博客部分
架構図,不過還缺了博客部分

入口側はすべて Cloudflare SaaS のスマート DNS 解析でオリジンへ戻す。Caddy が唯一の外向き TLS 終端で、Host ヘッダーに応じて対応するコンテナへリバースプロキシする。ログインが必要なサービスは一律 Authelia forward_auth を通す。送信メールは Resend HTTPS API ブリッジ経由で Netcup のブロックを回避する。バックアップは毎日フルの暗号化スナップショットを Cloudflare R2 へ送る。

後から Dockge のパネルで二十個ほどの stack が全部緑に光っているのを見ると、なかなか満足感がある。ただ、ここにはデプロイ依存症的な勢いでやった部分がかなりあることも認める。もともとは TREK が一つ欲しかっただけなのに、最後にはセルフホスト基盤一式になっていた。この図の中で TREK は小さな一マスにすぎない。

ついでにブログも移行した

以前のブログは Astro + retypeset で、markdown ファイルを push すると CI がビルドして公開する形だった。一年以上使って大きな問題はなかったが、ずっと引っかかっていた点がいくつかある。誤字修正ひとつでも commit、push、build 待ちが必要。ちょっとした「友達リンク」や「独立ページ」のような軽量ページを追加するにも、ファイルツリーをまたいじる必要がある。旅行記で写真が増えると、ビルドが信じられないほど遅くなる。

ちょうど新しいマシンも整ったので、ブログもまとめて作り直すことにした。

新しいスタックは SvelteKit 2 + adapter-node SSR + UnoCSS。見た目は retypeset を引き継ぎつつ、スタイルは UnoCSS で書き直した。コンテンツのバックエンドは PocketBase に変え、7 つの collection を作って、記事、タグ、旅行記、旅行記の日数、旅行記の写真、独立ページ、友達リンクをすべて SQLite に収めた。i18n は zh / en / ja の三言語併存に変更。元の Astro 側から書き出した markdown は migration スクリプトを書いて PB に一括投入し、123 本の記事、4 本の旅行記、148 件の旅行記写真レコードに加え、pages、friends もすべて DB に入れた。

デプロイはブルーグリーン方式に変えた。blog-blue と blog-green の二つのコンテナを常駐させ、稼働中の色は /opt/app/blog/active で示す。Caddy のリバースプロキシ上流は /opt/app/caddy-blog/upstream.caddy というファイルを import する。CI が新しいイメージを push した後に switch.sh を走らせ、target 側の色を起動し、healthcheck を待ち、import ファイルを書き換え、caddy reload でトラフィックを切り替える。失敗した場合は自動でロールバックする。この仕組みを作り終えたことで、公開フロー全体は「push して三分待つ」から「PB の管理画面で少し直して、保存すれば即反映」になった。

管理画面は SvelteKit 内部の SPA として /admin パスにぶら下げ、Authelia forward_auth を通している。admin → PB の書き込みリクエストは Caddy の同一オリジンリバースプロキシ /api/pb/* を経由し、token は Caddy が注入する。ブラウザにも git リポジトリにもこの key は触れない。PB への書き込みがあると JS フックが blog コンテナ内部のエンドポイントへ POST し、サーバー側キャッシュを失効させたうえで Cloudflare API をもう一度呼び、エッジキャッシュを purge する。公開ページは平均的には相変わらず CDN ヒットだ。

移行全体は前後二晩で終わった。意外なほど順調だった。

おわりに

何日もいじり倒したのに、trek では今のところ計画を一つも作っていない = =