一部の人には気付かれていたと思うんですが、diary.sorah.jp と blog.sorah.jp が長い間ダウンしていました。復旧させるのも腰が重く 2 年くらい放置していて (その間にも一瞬手を入れたりはしていたけど持続せず)、11 月に入っていろいろな家事をしていてその一環でようやく復活に至ったので報告します。
先に結果として書くと、sorah.jp 含めて Next.js 13 (SSG) になりました。裏に https://github.com/sorah/kozeki というちょっとしたソフトウェアがいて、Markdown ファイル群を処理させています。
なんで復旧の腰が重かったのか
ブログはこれまで sorah/days - Rack アプリをデプロイしていました。サーバーフル!
まずこのアプリ自体が ActiveRecord + Sinatra という構成になっていて Rails アップグレードより手間という問題があり、メンテに難があった。その関係で Ruby のバージョンもなかなか上げられず、上げられないということはインフラも気楽にアップグレードできないという問題に繋がります。
インフラについても 2013 年当初からしばらく VPS の上に素朴 Capistrano デプロイを確かしていたはずで、どこかのタイミングで VPS がレガシーになってきたり様々な理由であんまり運用も安定していませんでした。この頃は素朴なので気付いたら Puma を手で再起動するとかをしていたのでほのぼのとした時代という感じ。
レガシーになった VPS を解体した後は自宅サーバーの Kubernetes や EC2 上の Kubernetes を転々してた。Kubernetes, 結構各種 Controller を含めるとサポート期間が短くどんどんアップグレードを迫られるのと、やはり結構な生き物なので放置してるとすぐに腐りがちで、結果として長期間 Envoy の no healthy upstream エラーが掲出されていたという次第。
自宅サーバーの方が腐りがちで EC2 に移したけど、個人の予算で EKS とか使える訳がないので自前で control plane を運用しており、これもまぁまぁ壊れるわけです。たとえば eks-pod-identity-webhook が不意に落ちていて load-balancer-controller が仕事できなくなり、node の spot instance が入れ替わった後 target が更新されなかったり。ちゃんと優先順位やエラーになるように設定すれば良かったんだろうけど… 不安定なのは CPU や RAM をケチって運用せざるを得ないのも関係してるはず。今日も EC2 インスタンスが急に OOM Killer で破滅をする様子をなだめた。
k8s もそうだし、リバースプロキシ間の mutual TLS とか, Hashicorp Vault で MySQL パスワードのローテーションしたりとか, AS59128 の番号資源を使ったりとか諸々ヘンに丁寧・オーバーエンジニアリングなことをしていたので、それでちゃんとユーザートラフィックを受けたいという意味でもブログを載せていた…という目論見ではあった。けど仕事はどんどん忙しくなるしメンテし続けるのは厳しいですね。ここはもうちょっと落ちててもまぁいいかというサービスを個人で何か持っておきたい。ブログは落ちてるとさすがに困った。
代替としてサクッと ECS に移しても良かったものの、EC2 container instance の面倒を見るという点はサーバーフルにかわりないし、Fargate Spot もそんなに安くはないし、ELB も安くないし… そもそも勤務先が ECS なので k8s 触ってみるみたいな点で選んだりしていた…他の PaaS に移すにしてもソフトウェアの問題も残るしなぁ…というのであんまり考えず、ぜんぶ面倒だなと放置に繋がりました。仕事はどんどん忙しくなるし…。
まとめると、多忙な人間がサーバーフルな代物をメンテするのはそこそこに大変という結論に (もちろんサーバーレスがメンテ不要という訳ではない)。仕事でも最近はメンテナンスコストを低く保つような技術選定をしてるので、つまり仕事ですらダルいと思っていることが個人で出来るわけない。
必要なところでは趣味として続けるけど、とりあえずさすがにブログは安定させたいなと考えていた。趣味個人インフラの整理については別途なにか書くつもり。
どうオーバーホールするか
ということで良くない状況というのは理解していたのだけどなかなか着手できず。2023/9 くらいから真面目に考えはじめて、実際に着手できたのはインフラ整理という家事もガッとやりはじめた 2023/11 頭くらいから。以下の方針:
- サーバーレスに寄せたい
- 前述のとおり。サーバーフルのメンテは今の自分にはだいぶ余暇がなくて厳しいことが分かった
- JavaScript エコシステムの恩恵を受けたい
- JavaScript/CSS でなんかするなら確実に現代の JavaScript エコシステムのお世話になったほうがいい。普段仕事しててももう Sprockets とか使わないし…。
- Ruby は残したい、というか自分のコードはある程度ある状態にしたい
- 意地みたいなところだけど、正直 100% 巨人の肩に乗るのも面白くないから。そうだったらこれまでのブログも自作の Sinatra アプリじゃないし、というかはてなブログで良い………。
- Web ブラウザから直接記事を編集できる必要はない (諦める)
- これまでこのブログ(日記)をそこそこちまちま書いていた頃は手軽に publish したいというのが理由で Web ブラウザから投稿できると良かった。というのもあってこれまでも Jekyll 等への移行をしてこなかった…つもり。現状だとたぶん Web ブラウザから編集したい需要は自分にないし、もし必要になっても現代の GitHub.com の Web インターフェースで何とかできるでしょ、と割り切ることにした。
結果としては Next.js の Static Site Generation (SSG), S3 + CloudFront を選定。その上でバックエンドに Markdown ファイルを JSON ファイル群にビルドする自作ソフトウェアとなった。
(1) の時点でまず static site generator で良いので、Jekyll や Middleman でも良かったのだけど、正直あのドキュメントを読んでも挙動がわかりづらいことが多くて (手を動かしたりコード読めばいいんだけど)、これもだるいなぁ思っていて候補に入れたくなかった。しかし static site generator 自作もそんなに面白くはないしやる気はない。という所で、(2) を踏まえて Next.js の SSG に頼ることにした。
つぎに考えたのは記事のソースをどう管理して Next.js まで運ぶか。(4) があるので Git リポジトリに置いて Next.js で Markdown のレンダリングまでしても良かったのだけど、Headless CMS (Ghost とか) を一応検討してみた… けど既存の Markdown の資産や、今後も Markdown で書きたいなーというのを考えると別に Git 管理でいいな…と落ち着いた。結局、データソースは分離して fetch API とかで済ませられるようにしたいな、というのと (3) を理由に別途考えることに。
この選定にあたっては https://miyagawa.co/blog/miyagawa-co や https://secon.dev/entry/2022/12/11/130000/ を参考にした。Jekyll 頑張るのでも別にいいなぁ (復旧優先) と思ってたけど、secon.dev 爆速で Next.js の SSG でも全然困らなさそうだし快適でいいな…と気持ちが揺らいだ。そして最終的に、確かに慣れてる React + TypeScript の方が他でも使ってアップデートされる知識に頼れるしそれでいいや、と思うに至った。 https://sorah.jp/ は Middleman だ(った)けど Middleman 全然触らないからこれのアップデートもそこそこ面倒だったし…。そして、ここ最近業務で同様に Next.js SSG するようなサイトをデプロイしたりしていて理解していた、というのもある。
Markdown レンダリングして S3 に置くやつ
という訳で https://github.com/sorah/kozeki を作った。
md ファイルを読んできて、そこから HTML にして JSON オブジェクトを生成するのと、Front matter に書いたメタデータを含むリストとなる JSON オブジェクトを生成して保存してくれるソフトウェアになった。この 2 つ以外の生成は一切サポートしない。
1 つのファイルをどのリストに属させるかは front matter や Ruby DSL の設定ファイルで記述すればいいので、 categories: [tech, text]
とかを設定ファイル通して {"collections": ["categories:tech", "categories:text"]}
にして、それを Kozeki が collections/categories:tech.json
にリストとして含めて置いてくれる、というような感じ。URL となる Slug, Permalink についても、中身が 1 つしかないリストとして collections/permalink:2023!11!20!overhaul.json
を生成させてなんとかすれば良いという思想。
無駄にやりたくなって頑張った点としてはインクリメンタルビルド。inotify や fstat の mtime, 外部から stdin で差分を渡すと必要なファイルだけ再レンダリングされる。これを実現するためにローカルファイルシステムに state ファイルとして sqlite3 でメタデータを全部持たせた。結果としては削除の検知やリストの再生成にあたり必要ないソースファイルを読みにいかないし、ビルド時は保存先にはアクセスしにいかないようにした。そうすると結局さまざまなクエリを元にメタデータを呼び出したりチェックしたりしたくなるので SQL でやった方が速いなとか、そういう理由もある。
また、S3 直サポートについても CI でビルドするときに成果物をキャッシュから引っぱってきて aws s3 sync するのも速くはないし微妙じゃない? 直接 PutObject/DeleteObject してくれた方が良いが、そしたらもうちょっと真面目に state 管理したくなるじゃんね…じゃあ state を sqlite3 にして真面目に実装するかとなった。試してないけど S3 に置いたファイルをレンダリングするのも問題なくできると思う。source, destination ともに最低限しかアクセスしないのでパフォーマンスも落ちないはず。
…実際には Next.js の SSG は現状だと毎回全ページビルドしなおしてるので割と無駄な実装になった…かも? オーバーエンジニアリングか? 実際にみてると aws-sdk-s3 のオーバーヘッドが大きくてフルビルドだとそこそこ時間かかる (そのためわざわざ multi Thread でアップロードできるようにしたレベル) ので、実際のところはわからない (オーバーヘッドが SDK 自体なのかネットワークレイテンシのせいなのかは未調査)。
デプロイ
GitHub Actions に iam:AssumeRole させる定番?の構成。Kozeki の出力先は S3 になっているし, next build
した成果物も S3 に置いて CloudFront から配信している。
前述したように Web で編集できる必要はないので全部 Git 管理。 Kozeki で記事はレンダリングするし、毎度 Next.js のサイト側を変更することはないため、リポジトリはなんとなく Next.js (app repo) と Kozeki を動かす部分 (data repo)で分けてみた。
ブログは https://diary.sorah.jp/ と https://blog.sorah.jp/ の 2 つがあるけど、メンテコストを極限までサボるために next build
時に環境変数で中身を切替え。さらに data repo とその出力先も 1 つにして、マルチテナント的な利用をしてる。
app repo で npm ci
した Docker イメージを push して、data repo で実際の next build
を動かしている。AWS ECR を通していてそんなに高速じゃないし面倒なんだけど、GitHub Actions の現状 Personal Access Token や GitHub Apps を通さないと他 repository に絡めない仕様のため仕方ない。Docker 越しにしているので ghcr.io を上手く使えば出来そうな気がする。現状だと app repo を更新した時にビルドするために git commit --allow-empty
でコミットを data repo に積んでいて、そこそこだるいので何とかしたい…かもしれない。
output: "export"
にして next build
すると各ページの後ろに拡張子が付与されてしまう(/nanika
→ out/nanika.html
)。trailingSlash オプションで out/nanika/index.html
にすることも出来るけど、この場合ブラウザで見た時の URL に trailing slash がついてしまう。これまで動いていたブログは trailing slash 無しだったので、できれば避けたい。
こういう時にありがちなのは CloudFront Functions とかで request path を書き換えること…だけど、メンテしないといけない物増やしたくないよね…。考えた結果、正しい Content-Type であれば s3://.../nanika
で html を配信しても問題ないわけで、aws s3 sync
を使わず out/nanika.html
を nanika
としてアップロードするようにした。このブログで使われているスクリプトじゃないけど具体的には下記。ついでに Cache-Control も設定できてお得。
一応 ETag (S3 は普通に使うと md5 で勝手に生成してくれる) を見て更新されてないファイルは再アップロードしないし必要なファイルだけ CloudFront から invalidate する実装も入っている。けど、buildID を固定してもなぜか next build
の成果物が deterministic な感じじゃなくて毎回微妙に変わってしまう…。そのため /*
で invalidate するようにしてる。Next.js の成果物が deterministic じゃないの何でなんだ??? /_next
以下の JavaScript とかが微妙に変わって、word diff を見ると minify された結果の変数名がちょっと入れ替わってるだけみたいな雰囲気を感じている。そしてこれはもっとシンプルな https://github.com/sorah/sorah.jp でも再現する…。
あとは、S3 Static Website Hosting (Plaintext HTTP) を使わない関係で 404 だけちゃんと返せないので、CloudFront Functions はもしかしたら結局不可避かも。
そして問題なく動いてるけど、next build
が毎回フルビルドで、S3 まで読みにいっているので GitHub Actions だと 2 分くらいかかって遅いのがちょっと難点だなぁ。一応 us-west-2 に置いているけど CodeBuild とか併用したほうが早かったりするかな?
記事の執筆ワークフロー
ここ最近自然言語はだいたい Google Docs に draft 書いて Markdown にして…というのを勤務先のブログや、気合いの入った記事を書くときにやっている。Google Docs の方が自然言語のレビューを受けやすいというのが理由だけど、エディタも。
普段は Wezterm + Neovim で暮らしているけど、これで日本語や英語を書くのはまあまあつらい。Web ブラウザで編集するのを放棄した関係でちょっと考える必要がある。しかしここに時間費してもなあ、というところ。任意の他のエディタ使えばいいじゃんと思われがちだけど、問題は自分は今は Windows, macOS から基本 Linux マシンに ssh して全てを済ませているため (個人でも業務でも)、たとえば Kozeki の inotify による継続ビルドとかを踏まえると楽ではない。
SKK での日本語入力についてはマシという理由で、一旦は VS Code Remote で Markdown を書いてみている。VS Code Remote であれば iPad でも動かなくはないし外でもなんとかできそう。 Markdown preview があるのと .vscode/settings.json
でワークスペース設定に CSS を設定できて、実際の Web サイトの見た目に寄せられるのも悪くなさそう。
いちおう最終的なプレビュー自体は Client-side component で JSON ファイルを指定して確認できるようにしている。
おまけ: sorah.jp
別件で https://sorah.jp/ も更新する機会があった。Middleman 3 で固定したまま運用していたんだけど Ruby 3.2 とかで動かない状態で、しかし Middleman 4 にするにも情報がほとんどなくてやってられなくてこっちも突貫工事で Next.js に置き換えた。デザインもブログとちょっと違う状態だったんだけど、app repo からいくつかファイルをコピーして揃えたつもり。
あわせて About ページの内容をここ最近の仕事を含むようにメンテしたりはしたけど、いちおう直近で転職するつもりはまだそんなにありません…。
おわり
これまでブログが不安定という理由でブログを書くのをサボっていて、勤務先のブログでちょっと書くだけ、みたいになっていたので今後はちまちま書いていきたい。Twitter も滅びつつあるし。