Supercherenkoスマレジアプリと解説メディアの個人開発

Astro の dist が毎回変わる。ASTRO_KEY を固定してデプロイの空打ちを止める

Astro でビルドしたサイトを Cloudflare Workers へ出している人向けの話です。前回と同じものを本番へ出さないようにしたくて、ビルド結果を比べる仕組みを作ろうとすると、最初にここで詰まります。

確認したのは Astro 7.2、@astrojs/cloudflare 14.2、wrangler 4.123 の組み合わせ。ホストは Cloudflare Workers の無料プランで、パッケージマネージャは pnpm です。

同じソースでも dist は一致しない

ソースを1文字も変えずに2回ビルドして、出力の内容ハッシュを取ります。

pnpm exec astro build
find dist -type f -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256

これを2回やると、値が違います。ファイルを触っていないのに出力が変わる。つまり「前回と同じかどうか」をハッシュで判定する仕組みは、この時点では作れません。何を出しても常に「変わった」と答えるからです。

違うのは425チャンクのうち1つ、43文字だけ

全体が揺れているわけではありません。ファイル名の集合と、内容の集合を別々に比べると分かります。

pnpm exec astro build
find dist/server/chunks -type f -exec shasum -a 256 {} + | awk '{print $1}' | sort > /tmp/c1
ls dist/server/chunks | sort > /tmp/n1

pnpm exec astro build
find dist/server/chunks -type f -exec shasum -a 256 {} + | awk '{print $1}' | sort > /tmp/c2
ls dist/server/chunks | sort > /tmp/n2

cmp -s /tmp/n1 /tmp/n2 && echo "ファイル名は一致"
cmp -s /tmp/c1 /tmp/c2 && echo "内容も一致" || diff /tmp/c1 /tmp/c2 | grep -c '^[<>]'

手元では425個のチャンクがあり、ファイル名の集合は完全に一致しました。内容が違うのは1つだけ。_virtual_astro_manifest_*.mjs という、Astro のマニフェストが入ったファイルでした。

そのファイルを文字単位で突き合わせると、違う区間は1つしかありません。長さは43文字。直前に並んでいるのは "key":" という7文字でした。

毎回変わっているのは、マニフェストに埋め込まれた鍵1つだけ、ということになります。

理由は公式ドキュメントに書いてあります。

Astro uses cryptography to encrypt props passed to server islands, protecting sensitive data from accidental exposure. By default, a new, random encryption key is generated with each build and embedded in the server bundle.

server islands に渡す props を暗号化するための鍵です。既定ではビルドごとに新しく生成され、サーバーバンドルに埋め込まれます。鍵が変われば、それを含むファイルのハッシュも変わる。出力全体のハッシュも当然変わります。

注意したいのは、server:defer を1つも使っていなくても鍵が埋まる点です。手元のサイトは server islands を使っていません。それでも毎回変わっていました。使っていない機能のために出力が揺れていたわけです。

ASTRO_KEY を固定する

鍵は環境変数 ASTRO_KEY で固定できます。生成するコマンドが用意されています。

pnpm exec astro create-key

出力された値を ASTRO_KEY として渡せば、その鍵が使われます。ただしこれは暗号鍵なので、画面やシェルの履歴に残さないほうが安全です。値を表示せずに .env へ入れるなら、こう書けます。

pnpm exec astro create-key 2>&1 | grep '^ASTRO_KEY=' > .env

.env は git の管理から外しておきます。

これで2回ビルドしてハッシュを取り直すと、dist 全体が完全に一致します。425個のチャンクだけでなく、prerender した HTML も、CSS も画像も同じ値になりました。

固定は再現性のための裏技ではありません。公式が挙げている場面は3つあります。ローリングデプロイ、複数リージョンでのホスティング、server islands を含むページを CDN がキャッシュする構成です。いずれも「暗号化した側と復号する側で鍵が揃っていないと壊れる」という話で、ビルドが再現的になるのはその副産物にすぎません。

前回と同じものを出さない

鍵が固定できれば、出力のハッシュが判定に使えます。ビルドしてからハッシュを取り、前回出したときの値と比べます。

set -euo pipefail

STATE=".wrangler/last-deploy"

dist_hash() (
	# 括弧はサブシェル。pipefail をこの中だけで切る
	set +o pipefail
	find dist -type f -print0 \
		| sort -z \
		| xargs -0 shasum -a 256 2>/dev/null \
		| shasum -a 256 \
		| cut -d" " -f1
)

astro build

hash="$(dist_hash)"
last=""
[ -f "$STATE" ] && last="$(cut -d" " -f1 < "$STATE")"

if [ "$hash" = "$last" ] && [ -z "${DEPLOY_FORCE:-}" ]; then
	echo "前回出したものと同じ。デプロイしない。"
	exit 0
fi

wrangler deploy

mkdir -p "$(dirname "$STATE")"
printf "%s %s\n" "$hash" "$(date +%Y-%m-%dT%H:%M:%S%z)" > "$STATE"

記録するのはデプロイの後です。手前で失敗すれば set -e が止めるので、出せていない回が「出した」ことになりません。

ハッシュを出力側で取るのがこの作りの要点です。入力側、つまりソースのファイルをまとめてハッシュする手もあります。ただしそれだと、配信物に入らないファイルを直しただけでデプロイが走ります。手元のリポジトリで数えると、git が見ている111ファイルのうち26が配信物に1バイトも入りません。README や手順書、デプロイスクリプト自身、エージェント用の指示書などです。ドキュメントの誤字を直すたびに本番のバージョンが1つ増えるのは、まさに止めたかったことでした。

代わりに、判定するには毎回ビルドが必要になります。入力側なら1秒未満で終わる判定に、十数秒かかる。避けたいのがアップロードとバージョンの発行なら、この交換は釣り合います。

気をつけること

鍵を .env に置いても、wrangler は Worker へ送りません。wrangler deploy --dry-run を通して、bindings の一覧に ASTRO_KEY が出ないことを確かめました。ここは自分で確かめたほうがよい箇所です。Cloudflare のドキュメントが .env について説明しているのは、ローカル開発で env オブジェクトに載るという文脈だけ。本番へ渡るかどうかは書かれていません。

鍵が無いときは判定を飛ばして必ず出します。鍵が設定されていなければ dist は毎回変わるので、判定は意味を持ちません。そこで黙ってスキップするほうへ倒すと、設定の抜けが「デプロイしたのに反映されない」という形で出てきます。逆に倒せば、余分に1回出すだけで済む。倒す向きを間違えると、原因の分かりにくい事故になります。

--dry-run のときは記録しません。実際には出していないので、記録すると次の本番が「変わっていない」と誤判定されて出なくなります。

ダッシュボードや別のマシンから触ったときは気づけません。判定はローカルの記録との比較なので、そこを迂回した変更は見えないままです。DEPLOY_FORCE=1 のような素通しの逃げ道を1つ置いておきます。

shasum や perl をパイプの末尾に置くなら、`set -o pipefail` を入れます。これが無いと、パイプの手前で wrangler が失敗しても末尾のコマンドの終了コードが返ります。失敗が成功として通ってしまい、記録まで進みます。

どこで判定するか

同じ「無駄なデプロイを止める」でも、置き場所で性格が変わります。

  • 出力(dist)のハッシュ: 本当に配信物が変わったときだけ出せる。鍵の固定が前提で、判定には毎回ビルドが必要
  • 入力(ソース)のハッシュ: 判定が速く、ビルドの前に決められる。配信物に関係ないファイルでも出てしまい、.env のような git 管理外の変更は拾えない
  • 判定しない: 出したいときに出す。回数を数える必要がないなら、これで足りる

出力側を選ぶなら、まず鍵を固定するところからです。固定しないまま出力を比べても、判定は常に「変わった」と答え続けます。

記事の一覧へ