2026年 01月 09日

OGP画像の動的生成

統一OGP画像を作ったところで、そういえば最近のサービスはどこもOGP画像をダイナミック生成させていて、リッチでちょっと羨しいなと思っていたことに気付く。こんな日記には実装・運用コスト的に、見合う価値があるわけないのだけど、バックエンド置き換えの機会ということでやってみることにした。

使ってるのは golang 標準の image と golang.org/x/image/font/opentype

RGBAで処理しつつ、最終的に16色のパレットに割り当ててサイズを減らしている。一応ファイルキャッシュをしたいが、あまり容量の余裕のあるサーバではないのでできる限りのことをする。

以下のような感じで最適化した。パレット化の効果はすさまじい。

  1. 初期実装 (RGBA / BestCompression なし): 66KB
  2. BestCompression 適用後: 64KB
  3. 256色パレット化 (RGBA → Paletted): 25KB
  4. 128色パレット化: 23KB
  5. 64色パレット化: 21KB
  6. 32色パレット化: 19KB
  7. 16色パレット化: 15KB


あと速度的にも手元で 1600req/sec ぐらい (静的ファイル 590000req/secに対し) 出るようにまで調整してある (pprof の結果を Gemini CLI に投げつけると割とよくやってくれた)

ggはダメでした

gg で Noto Sans JP を読むと交差部分が正しく描画されなくてだめだった。Gemini CLI が「OGP作るならこれ!」っていうから、とりあえず採用してみたけどかなしい。あきらかにだいぶ古いライブラリっぽいしなあ。

結果的には images で必要十分かつパフォーマンスも満足なのでよかった。

タイトル長いエントリのとき

短くフェードアウトするようにしてみた。最初は「…」で省略してたけどイマイチだったのでこの形に

パフォーマンスチューニング

pprof でひたすら潰していった。html/template のリフレクションを丁寧に潰すのが地味に効く (funcMap 呼ぶのが本当に遅いみたい)

一通りやったので `hey -z 5s -c 20` (20並列 5秒) でそれぞれのエンドポイントのスループットを測った結果出してみた。マシン性能で数字が変わっちゃうので、静的ファイルに対しての係数も出してみた。

フィードは encoding/xml で、思ったよりすごく早くてびっくり。個別ページとトップページが html/template で、レンダリング後のキャッシュは入れてないので毎回出している。

   基準値: 静的ファイル配信 (/images/github.svg) = 0.3ms (コスト係数 1.0)
 
 
   ┌──────────────────────┬────────────────────────┬───────────────────┬───────────────────────┐
   │ ページ種別           │ スループット (req/sec) │ 平均処理時間 (ms) │ コスト係数 (静的=1.0) │
   ├──────────────────────┼────────────────────────┼───────────────────┼───────────────────────┤
   │ 静的ファイル         │ 59,240                 │ 0.3               │ 1.0                   │
   │ OGP (キャッシュあり) │ 48,695                 │ 0.4               │ 1.3                   │
   │ フィード             │ 7,143                  │ 2.8               │ 9.3                   │
   │ 個別ページ           │ 4,958                  │ 4.0               │ 13.3                  │
   │ トップページ         │ 2,305                  │ 8.7               │ 29.0                  │
   │ OGP (キャッシュなし) │ 1,644                  │ 12.1              │ 40.3                  │
   └──────────────────────┴────────────────────────┴───────────────────┴───────────────────────┘
 
 
   指標の解説
    * 平均処理時間 (ms): hey で計測された平均レイテンシです。
    * コスト係数: (各ページの平均処理時間) / (静的ファイルの平均処理時間) で算出。
        * 例: トップページ(8.7ms)は静的配信(0.3ms)の 29倍 の時間を要しており、そのマシンの性能によらず「静的ファイル29個分の重さがある」という実装の相対的な複雑さを示します。


実際はサーバのネットワークがボトルネックになる。

h3のサーバが不安定なのでGSOをオフにしてみる

VPS 上の h2o サーバの h3 接続が不安定に。手元から curl しても、https://http3check.net/ を使っても繋がったり繋がらなかったり不定。

結局 mtu いじったりなんやかんやいじっているうちになおってしまった。設定は結局元のままだしわけわからん。

GSO が怪しいのでオフにしてみることに。Gemini と壁打ちしたところ、最終的に矛盾がないのは「virtio_net ドライバの TXリングバッファ(送信キュー)がスタックしていた」説らしいですが。

$ sudo ethtool -k ens3 | grep -i segmentation  
tcp-segmentation-offload: off
        tx-tcp-segmentation: off
        tx-tcp-ecn-segmentation: off
        tx-tcp-mangleid-segmentation: off
        tx-tcp6-segmentation: off
generic-segmentation-offload: on
tx-fcoe-segmentation: off [fixed]
tx-gre-segmentation: off [fixed]
tx-gre-csum-segmentation: off [fixed]
tx-ipxip4-segmentation: off [fixed]
tx-ipxip6-segmentation: off [fixed]
tx-udp_tnl-segmentation: off [fixed]
tx-udp_tnl-csum-segmentation: off [fixed]
tx-sctp-segmentation: off [fixed]
tx-esp-segmentation: off [fixed]
tx-udp-segmentation: off [fixed]

$ sudo ethtool -K ens3 gso off

$ sudo ethtool -k ens3 | grep -i segmentation  
tcp-segmentation-offload: off
        tx-tcp-segmentation: off
        tx-tcp-ecn-segmentation: off
        tx-tcp-mangleid-segmentation: off
        tx-tcp6-segmentation: off
generic-segmentation-offload: off
tx-fcoe-segmentation: off [fixed]
tx-gre-segmentation: off [fixed]
tx-gre-csum-segmentation: off [fixed]
tx-ipxip4-segmentation: off [fixed]
tx-ipxip6-segmentation: off [fixed]
tx-udp_tnl-segmentation: off [fixed]
tx-udp_tnl-csum-segmentation: off [fixed]
tx-sctp-segmentation: off [fixed]
tx-esp-segmentation: off [fixed]
tx-udp-segmentation: off [fixed]

Chrome の is_broken=true

Chrome は一回 h3 がうまくいかなくなるとブラックリスト的なものに入れるようで、しばらく h2 でしかアクセスしてくれなくなる。クリア方法がわからん。

chrome://net-export/ して HTTP_STREAM_JOB_CONTROLLER で検索すると is_broken=true が見える。

24497: HTTP_STREAM_JOB_CONTROLLER
https://lowreal.net/
Start Time: 2026-01-09 08:50:46.370

t=83062 [st= 0] +HTTP_STREAM_JOB_CONTROLLER  [dt=50]
                 --> allowed_bad_certs = []
                 --> is_preconnect = true
                 --> privacy_mode = "disabled"
                 --> url = "https://lowreal.net/"
t=83062 [st= 0]   +PROXY_RESOLUTION_SERVICE  [dt=0]
t=83062 [st= 0]      PROXY_RESOLUTION_SERVICE_RESOLVED_PROXY_LIST
                     --> proxy_info = "DIRECT"
t=83062 [st= 0]   -PROXY_RESOLUTION_SERVICE
t=83062 [st= 0]    HTTP_STREAM_JOB_CONTROLLER_PROXY_SERVER_RESOLVED
                   --> proxy_chain = "[direct://]"
t=83062 [st= 0]    HTTP_STREAM_JOB_CONTROLLER_ALT_SVC_FOUND
                   --> alt_svc = "quic lowreal.net:443, expires 2026-01-09 09:00:11"
                   --> is_broken = true
t=83112 [st=50] -HTTP_STREAM_JOB_CONTROLLER

GUI からの操作だとどうあがいてもクリアできないっぽい。待つか、Profile フォルダの "Network Persistent State" の JSON を削除するしかないっぽい。

$  pwd
/Users/cho45/Library/Application Support/Google/Chrome/Default

$ cat "Network Persistent State" | jq '.net.http_server_properties.broken_alternative_services[] | select(.host == "lowreal.net")'
{
  "anonymization": [
    "...",
    false,
    0
  ],
  "broken_count": 3,
  "host": "lowreal.net",
  "port": 443,
  "protocol_str": "quic"
}
{
  "anonymization": [
    "...",
    false,
    0
  ],
  "broken_count": 8,
  "broken_until": "1767933207",
  "host": "lowreal.net",
  "port": 443,
  "protocol_str": "quic"
}

一応バックアップとってからクリア。

$ cp "Network Persistent State" "Network Persistent State.bak"
$ cat "Network Persistent State.bak" | jq '.net.http_server_properties.broken_alternative_services |= map(select(.host != "lowreal.net"))' > "Network Persistent State"

エントリの要約 (OGPとかメタデータにつかう) summary と、最初の画像 image を DB のカラムとして入れることにした。今まで毎回 HTML パーサーでパースさせていたので割と無駄だった。

でさらに summary を LLM で生成できたらいいなあという目論見で、Open AI 準拠のAPI (さくらのAI Engine) でいくつかモデルやプロンプトを試してみたけど、いまいち質が安定せず、一旦お蔵入り。

Web Share API

Web Share API というのがあるんだなあ、ということで付けてみた。ファイルもこれで共有できるみたい。知らなかった。

Firefox は未対応。あとmacOSの共有はあんまり意味ないので、SNSの共有ボタンをまるごと代替できるものではないようだ。

customElements.define('web-share-button', class extends HTMLElement {
	connectedCallback() {
		const title = this.getAttribute('title');
		const url = this.getAttribute('url');

		if (!navigator.share) {
			this.style.display = 'none';
			return;
		}

		// Web Share API 対応: ボタンを表示
		const button = document.createElement('button');
		button.className = 'share-button';
		button.textContent = '共有';
		button.addEventListener('click', async () => {
			try {
				await navigator.share({ title, url });
			} catch (err) {
				if (err.name !== 'AbortError') {
					console.error('共有エラー:', err);
				}
			}
		});
		this.appendChild(button);
	}
});


https://github.com/cho45/ticker-generator でも対応させてみた。

2026年 01月 08日

pprof 入れてバックエンドのプロファイルとって最適化した。プロファイラの解釈はLLMにやらせると眠くてもできて便利。しかしLLMに「推測するな計測せよ」「推測するなドキュメントを見よ」を徹底させることができない。すぐ「たぶんあれだ!」って検討違いに走り出して「ちがった〜」ってなってる。自分みたい。腹立つ。

html/template を便利に使いすぎるとやたらリフレクションがコールツリーに出てくるようになる。変なことするなら、渡すデータ構造変えたほうがいいというの一理あると思いつつ、そこまでじゃないんだよな、ということで複数回リフレクション読んでたところを、単に funcMap で一気にやる1個のリフレクションでいけるようにしたりとかした。

ここ数日夜遅くまでこのブログをいじっているので死ぬほど眠い

表側にも検索機能つけようか (管理画面には単純なLIKE検索がつけてある)と試行錯誤してみてたけどやっぱりあんまりよくない気がしている。Google が見捨てた(ひっかからない)ような古くて恥かしいやつが検索によって浮かびあがってくると辛い。元々表側に直接的な検索つけてない最大の理由だしなあ。

なんかいい方法があればなあ。といっても時間で足切りするぐらいしか思いついてないけど

I'm feeling lucky 的な検索しかできないとかだと良いかもしれない。

「あのとき見たあのエントリ検索したい」というぐらいの価値があるエントリならググればたぶんヒットするだろうし、独自にサイト内検索を実装するなら、本来の検索とは別の意味が必要なんじゃないか。

といっても最近は Google も個人サイトを超冷遇していて、site:lowreal.net してもひっかからないことが増えてしまったのだよなあ。だから検索は検索としてほしい。でも自分用の検索は別に管理画面で十分。寝ろ

ogp の画像が自分の写真で、Slack に流れきたのを見てゾっとしたので作りかえた

HTMLで1200x630指定して作って「ノードのスクリーンショットをキャプチャ」をするのが最速っぽい。

headless chrome でスクショとるのとか試してみたけど、なんか変な帯ができたりして意図したビューポートサイズにならないことに1時間ぐらいハマったので諦めた。

スクロールハイジャッキングがないことの安心感

モバイルでウェブサイト見る上での重要な要素になってしまったな。広告があるサイトだと、変なところをタッチしないための慎重なスクロールを要求される。

スクロールを邪魔してこないという信頼が価値になってしまったか

h2o ssl-offload: zerocopy

デフォルトだと fusion が有効にならなかったが明示的に指定したらいけた。

cmake -DWITH_BUNDLED_SSL=on  -DWITH_FUSION=ON -DCMAKE_C_FLAGS="-march=native" .

ゼロコピー系の設定をいれてみた。ただ効果測定できるほどの情報がない

proxy.zerocopy: ALWAYS  
ssl-offload: zerocopy   

AVIF

Lightroom Classic から AVIF が書けるようになってたことに気付いた。試してみる。画像サイズ取得するコードがたぶん動かんなこれ……

caniuse でも Baseline 2024らしい。

16Mピクセルで書き出したとき、この画像だとファイルサイズが JPEG 80% で 6MB ぐらい。AVIF だと 1.5MB ぐらい。

なんと、画像サイズちゃんととれてた。やったー

2026年 01月 07日

CSS 2026

せっかくバックエンドシステムも変わったことだしと思ってCSSをアてなおした。何年ぶりかわからん (調べたら約10年一緒だった)

以下のように gemini-3-flash-preview に指示して、何度かやりなおしたら割といい感じだったので、さらに指示を加えたり手動で数字いじってなんとかした。

この日記を以下の条件で CSS を書きなおして新しいデザイン(見た目)を適用するとしたらどうしますか。やってみて
- 落ち着いたトーン 
- 「読みやすさ」最重視  
-「コード」は情報密度を高く
- 「写真」はできる限り大きく 
- ブロックの大きさに px 基本的に指定しないこと 
- フルにレスポンシブ (モバイルも同一ページです) 

中央揃えだとなんかモダンに感じる。

コードブロックと写真の幅を画面いっぱいにしたりするのは後からの指示。

HTML の変更はしないつもりだったけど、結局微調整した。

ヘッダのアイコン

ヘッダにアイコンも追加してみた。Nano Banana Pro で何度かイメージを伝えつつ気に入るのをいろいろ出させてブラッシュアップしたあと、Inkscape でトレースして、「パスの簡略化」で点の数を削減した。

なお gemini-3-pro-preview に同様にことをやらせたら、完全なるゴミができたため捨てました。遅いし

JS と WebComponents

CSS とは直接関係ないけどJSまわりも若干モダンにした。ものすごく古い書き方をしていたり、loading="lazy" がない時代の画像のプレースホルダーコードが残ったりしていた。なんか大抵のことはブラウザがやってくれる時代になっていた……

見出しの改行位置を調整するのを独自でやっていたけど、完全にやめて budou-xのWebComponents を導入した。

アーカイブあさり

  • 2014年1月 はこれはこれでよかったな
  • 2015年2月 は今も使ってるタイトルに近い。けどウェブフォントだったようだ
  • 2016年10月 は前のCSSに近い。ここからはほぼマイナーアプデしかしてないはず

バックエンドかえてからメタデータ(ogpとかもろもろ)がちゃんと入っておらず、はてブのタイトルとかがおかしくなっていた。なおしたつもり。

結構いろいろなおしまくっている

  • sitemap.xml / feed.xml は encoding/xml で出すように
    • html/template だと XML の PI が出せなかったり、そもそもXML用じゃないので問題がある。text/template は論外
  • 関連エントリを 2-gram かつより効率良い実装に書き換え。
    • エントリ数が1万弱なので、そんなに困ってはいないのだけどやりたくて

DB の DATE 型にしてあるカラムを TEXT 型にする

go-sqlite3 だと DATE を time.Time にしてくれちゃうが、それだと困るという。DBをそのままで持ってくる前提で、元々 CAST(date AS TEXT) as date とかで頑張っていたけど、これが sqlc と非常に相性が悪い。CAST するとそのクエリ専用の型が作られてしまう。そして共通の型に詰め替えるという手間 (コピー) が発生する。

sqlc の overrides とかでなんとかならないかと思ったが、go-sqlite3 の時点で time.Time になっちゃうのと、特にドライバオプションで切り替えることもできなそうなので、諦めて TEXT にすることにした。

ただ SQLite はカラムの型変更が ALTER TABLE でできない。知らなかった。

ということでめんどいことに……

BEGIN TRANSACTION;

-- 1. Create new table with TEXT type for date
CREATE TABLE entries_new (
    id INTEGER PRIMARY KEY,
    ...
);

-- 2. Copy data from old table
INSERT INTO entries_new (id, title, body, formatted_body, path, format, date, created_at, modified_at, publish_at, status)
SELECT id, title, body, formatted_body, path, format, CAST(date AS TEXT), created_at, modified_at, publish_at, status
FROM entries;

-- 3. Drop old table and rename new table
DROP TABLE entries;
ALTER TABLE entries_new RENAME TO entries;

-- 4. Re-create indexes
CREATE INDEX index_date ON entries (date, path);
...

COMMIT;