Skip to content

Cloudflare Workers のアーキテクチャと env の注入タイミング

Cloudflare Workers のアーキテクチャを読んでいて、腹落ちしなかった点が3つあった。

  • コンテナではなく isolate:そう言われても、コンテナが無いわけがない。
  • env の反映タイミング:他人と同居しているなら、反映のタイミングが難しくなるのではないか。
  • secret manager 経由の値:デプロイ時に焼き込まれるのか、実行時に読まれるのかが分からない。

どれも前提が少しズレていた。 その崩れ方まで含めて記録する。


公式ドキュメント(How Workers works)の要点は3つある。

ランタイムは V8 エンジンを使い、V8 の isolate(軽量な実行コンテキスト、つまりサンドボックス)単位でコードを動かす。 1つのランタイムインスタンスが数百から数千の isolate を切り替えながら動かし、メモリは isolate ごとに完全に分離されている。

関数ごとに VM を立てるのではなく、既存の環境の中に isolate を作る。 VM を起動する必要がないので、VM モデルのコールドスタートがそのまま消える。 isolate 1つの起動は VM やコンテナ上の Node プロセスより約100倍速く、メモリ消費も1桁少ないとされている。

*.workers.dev や自分のドメインへのリクエストが Cloudflare のいずれかのデータセンターに届くと、そのリクエストを引数に fetch() ハンドラが呼ばれ、Response を返して応答する。 常駐プロセスではなく、リクエスト単位で実行され、課金される。

isolate はリクエスト中は基本的に生きているが、公式の limits に達した場合やマシンのリソースが逼迫した場合、イベント解決後に選択的に evict される。 理由はマシンのリソース制約、サンドボックス破りを試みる疑わしいスクリプト、個別のリソース上限などが挙げられている。

加えて、1インスタンスがシングルスレッドのイベントループで複数リクエストを扱う。 await 中に別のリクエストが差し込まれる形も含まれるため、2つのリクエストが同じインスタンスに行く保証はない。


コンテナではない?

コンテナがゼロなのではなく、顧客ごとには作られない、が正しい。

ドキュメントは「Workers は JS ランタイムの起動コストをコンテナ起動時に一度だけ払う」と書いている。 つまりマシン上にランタイムのプロセス(コンテナ)は動いていて、その中に何千人ぶんものコードが isolate として同居している。

違いは分離をどのレイヤーでやるかにあった。

分離のレイヤーテナントごとに必要なもの
コンテナ / VMOS レベル(namespace、cgroups、ハイパーバイザ)OS プロセスと言語ランタイムのコピー
V8 isolateプロセス内部(V8 のヒープとグローバルスコープ)ヒープとグローバルスコープのみ。ランタイム本体は共有
従来型のサーバーレス 顧客ごとにコンテナを起動 コンテナ A 言語ランタイム+コード コンテナ B 言語ランタイム+コード コンテナ C 言語ランタイム+コード Workers 1プロセスに全員が同居 V8 ランタイム(共有) 起動コストは1回だけ isolate isolate isolate isolate isolate isolate isolate isolate isolate ### クラスローダとタブの比喩

1つの JVM の中に複数アプリをクラスローダで分離して載せるイメージが近い。 V8 はそれを言語仕様のレベルで厳密にやっている。

もう一つの見方は Chrome のタブである。 isolate はもともと Chrome が多数のサイトの JS を1プロセス内で安全に走らせるために作った仕組みで、Cloudflare はそれを「複数サイト」から「複数テナント」に読み替えて使っている。

  • 分離が薄い:OS カーネルではなく V8 の言語境界で守っているので、Spectre のようなサイドチャネルが直撃する。
  • 任意のバイナリが動かない:動かせるのは V8 が実行できるもの(JS と WASM)だけで、フルの POSIX プロセスではない。
  • 寿命の保証がない:evict されうるので、グローバルスコープに状態を持てない。

「プロセス丸ごとを1人1つ借りる」から「ランタイムの中の1区画を借りる」への移行だと考えると、起動が速いことと使えるものが制限されることが一本の因果でつながる。


env の読み込みは V8 ランタイムが起動する時だと思うけど、他の人も同居してるなら反映タイミング難しくならない?

env は V8 ランタイム起動時には読まれない。 fetch(request, env, ctx) の第2引数として、呼び出しのたびに渡される。

Node の process.env のようなプロセスグローバルではないので、「ランタイム起動時に環境変数を読み込む」というレイヤー自体が存在しない。 Node や Next.js の癖で process.env.MY_SECRET を書くと、Workers では実行時に undefined になる(nodejs_compat 有効時を除く)。

したがって同居の心配は消える。 isolate はメモリが完全に分離されていて、しかも isolate は Worker のバージョンごとに作られる。 env は同居プロセスの共有環境ではなく、自分の isolate に手渡される引数オブジェクトだった。

バインディング変更時の isolate 再利用

Section titled “バインディング変更時の isolate 再利用”

コードを変えずバインディングだけを変更してデプロイした場合、Cloudflare は既に動いている isolate を再利用することがある。 コードを無駄に再ロードせずに環境変数やバインディングを変えられる、という最適化である。 トラフィックが来続けている isolate は、長時間 hot のまま生き残る可能性がある。

つまり落とし穴はプラットフォーム側ではなく、自分のコード側にある。

// NG: 遅延初期化でも、キャッシュした瞬間に古い値が焼き付く
let client;
export default {
fetch(req, env) {
client ??= new Client(env.API_KEY); // isolate が hot な間ずっと古いキー
// ...
}
};

シークレットをローテーションしたのに、グローバルにキャッシュしたクライアントが古い値を握り続ける、というのが具体的な事故の形になる。 リクエストごとに env から組み立て直すのが安全側の選択になる。


設定画面から入れるだけなのか、.env は使えるのか、という点も違った。 3層あり、それぞれ注入のタイミングが異なる。

wrangler.jsonc や wrangler.toml の vars に書く。 デプロイ時にバージョンへ焼き込まれ、リクエスト時の env にはもう値として入っている。

ダッシュボードで環境変数を変更しても、次に deploy した時に Wrangler が上書きする。 止めたい場合は keep_vars = true を設定ファイルに追加する。

wrangler secret put かダッシュボードで設定する。 wrangler secret put は新しいバージョンを作って即座にデプロイするので、gradual deployments を使っているなら wrangler versions secret put を使う。

.env は使える。 ただし原則としてローカル開発用であり、ローカル用のシークレットは Wrangler 設定ファイルと同じディレクトリの .dev.vars か .env に置く。 両方を置くのは避けたい。 .dev.vars がある場合、.env の値はローカル開発時の env オブジェクトに含まれないからである。

デプロイ時にも使う道がある。

Terminal window
# コードとシークレットを1操作で流す(JSON または .env 形式)
npx wrangler deploy --secrets-file .env.production
npx wrangler versions upload --secrets-file secrets.json

ファイルに含まれないシークレットは前バージョンのものが保持されるので、CI/CD 向きである。

さらに secrets 設定プロパティで必要なシークレット名を宣言しておくと、deploy 時に全部設定済みかを検証し、足りなければ名前を列挙してエラーで落ちる。

{
"secrets": {
"required": ["API_KEY", "DB_PASSWORD"]
}
}

3つめの疑問、secret manager を使ったときの注入タイミングの答えがここにある。

secrets_store_secrets でバインドする。 バインディングは env 上に置かれるが、値を取り出すには非同期呼び出しが必要になる。

const key = await env.API_KEY.get();

注入はデプロイ時ではなくリクエスト処理中に起きる。 実行時に読まれるので、バインドされた Worker は再デプロイなしで現在の値を見る。 ローテーションが即座に伝播する代わりに、毎回 await が必要になる。

注入更新の反映
vars / secretsデプロイ時にバージョンへ焼き込み新バージョンのデプロイ
Secrets Storeリクエスト中に .get()再デプロイ不要

トップレベルスコープの env と、状態の置き場所

Section titled “トップレベルスコープの env と、状態の置き場所”

最後にもう1つ罠がある。 cloudflare:workers から env を import すれば、トップレベルスコープでも環境変数やシークレットにアクセスできる。

import { env } from "cloudflare:workers";

ただしリクエストコンテキスト外での I/O は許されないため、KV への呼び出しなど一部のメソッドは動かない。 Secrets Store の .get() もこの I/O にあたるので、トップレベルで先読みしてキャッシュすることはそもそもできない。 仮にリクエスト内でグローバル変数にキャッシュすれば、hot な isolate が古い値を握り続けて Secrets Store を使う意味が消える。

3つの疑問すべてが、同じ結論に着地した。

状態は関数の引数か env バインディング経由で渡し、モジュールレベルの変数には置かない。

isolate の寿命が保証されないことも、バインディング変更時に isolate が再利用されることも、Secrets Store が実行時読み取りであることも、この一行に収束する。 グローバルに mutable な状態を持たないことが、Workers では推奨ではなくアーキテクチャ上の要請になっている。