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 でも対応させてみた。