2026年 01月 10日

配信画像どうするか悩んでる

既存のをまずは最適化した。トータルで JPEG 以外も含めて 5.9GB ある。

$ jpegoptim -p --all-progressive --total *.jpg
...
Average compression (2467 files): 4.79% (292586k)
jpegoptim -p --all-progressive --total *.jpg  980.72s user 45.35s system 98% cpu 17:24.23 total

約 292MB削減。ロスレス最適化だけで結構減ったなあ。

全体を AVIF 化してしまうのをやったほうがいい気もする。

まず新規アップロードについて JPEG でアップロードしたら自動で AVIF に変換かけることにした。ただ、これが非常に重かったのでいろいろ妥協した以下のようにした。

avifenc --jobs 3 --speed 8 --yuv 420 -q 60

Lightroom からは AVIF で出すので、写真を JPEG でアップロードするのはあんまりこだわらないときに限られるのでいいだろう……

あと PNG に関しても oxipng か optipng が入っていたら自動で通すようにした。スクショとか上げることが多いのでこれは効くはず

全JPEGのAVIF化で問題になるのは、あとはOGP画像。OGP画像は今まではエントリの最初の img の src をそのままブチこむということをしてたけど、これができなくなる。ウーン。まあ諦めて普通のOGP画像を出せばいいか。大して共有されるわけではないから

Ubuntu 20.04 に avifenc を入れる

README.md に書いてある通りだけど、ext/ 以下で libaom をビルドしてそれを static link するのが良いようだ

git clone -b v1.3.0 --depth 1 https://github.com/AOMediaCodec/libavif.git
cd libavif
cd ext
sh aom.cmd
cd ..
cmake -DAVIF_BUILD_APPS=ON -DBUILD_SHARED_LIBS=OFF -DAVIF_CODEC_AOM=LOCAL -DAVIF_LIBYUV=LOCAL . 

v1.3.0 より新しいと要求する cmake があたらしすぎてダメだった

Ubuntu 20 から 24 に do-release-upgrade

LLM にリリースノートと ps aux の結果とかを食わせながら注意点を適当にあらいだしてやった。こういうときペアでやってる感じがあってだいぶ気が楽だ。

そこそこすんなりいったのでよかった。

h2o

openssl のアップグレードがあるので h2o を一時的に static link に。前回アップグレード時もビルドしなおしになってハマった記憶

h2o 用の openssl を配置しとく

 wget https://www.openssl.org/source/openssl-3.0.12.tar.gz
 tar xzvf openssl-3.0.12.tar.gz 
 cd openssl*
./config --prefix=/opt/h2o-ssl no-shared
make
sudo make install

ビルドしなおし

$ git clean -fdx
$ export PKG_CONFIG_PATH=/opt/h2o-ssl/lib64/pkgconfig  
$ cmake -DOPENSSL_ROOT_DIR=/opt/h2o-ssl -DOPENSSL_USE_STATIC_LIBS=TRUE  -DWITH_FUSION=ON -DCMAKE_C_FLAGS="-march=native" .
$ make
$ make install

$ ./h2o -v
h2o version 2.3.0-DEV@afe696c72
OpenSSL: OpenSSL 3.0.12 24 Oct 2023
mruby: YES
capabilities: YES
fusion: YES
ssl-zerocopy: YES
ktls: YES
key-exchanges: secp521r1, secp384r1, x25519, secp256r1

$ ldd ./h2o
        linux-vdso.so.1 (0x00007ffef3ffb000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f411d0e2000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f411d0dc000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f411cf8d000)
        libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f411cf71000)
        libcap.so.2 => /lib/x86_64-linux-gnu/libcap.so.2 (0x00007f411cf68000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f411cd76000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f411d951000)

バックアップ

念のため

sudo tar czvf /data/vps_config_backup_20260110_v2.tar.gz \
  /home \         
  /etc \
  /usr/local \
  /var/lib \
  /var/spool/cron \
  /root \
  /opt \
  /srv \
  /var/www \
  --exclude=/var/lib/docker \
  --exclude="/home/*/tmp" \
  --exclude="/home/*/swp"

sudo do-release-upgrade

20 → 22 → 24 とあげていった。普通に起動してくれてよかった

rabbitmq

mqtt server としてしか使ってない割に do-release-upgrade のたびに障壁になってたので、使うのをやめた。

mochi-mqtt/server + すこしの実装で、自分の要求には完全な代替を Gemini が書いてくれたので、それをsystemdでデーモン化した。

mackerel-agent

インストールスクリプト流しなおした

certbot

動かなくなっていたので snap 版をつかうようにした。

 sudo apt install snapd
 sudo snap install core; sudo snap refresh core
 sudo snap install --classic certbot
 sudo ln -s /snap/bin/certbot /usr/bin/certbot

systemd と Unix domain socket

2021年にh2oをsystemd管理にしてから、UNIXドメインソケットでの接続 (unix:/tmp/backend とかにしてた) がうまくいかず、ずっと TCP を無駄に通してた。

これがいまさらながら解決した。つまり /tmp は systemd が PrivateTmp で隔離しているので共有できないというだけだった。

なので以下のように RuntimeDirectory の設定を入れる。

RuntimeDirectory=hanrangon
RuntimeDirectoryMode=0755

これで /run/hanrangon が systemd によって作られるので、ここの下に /run/hanrangon/socket として UNIXドメインソケットを作るといいらしい。

フロント(h2o)からは http://[unix:/run/hanrangon/socket] を指定する。

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 ぐらい。

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