KAKESU~/@dotbind-kondo/hyperdrive-cloudflare-to-gcp-postgres

Hyperdrive で Cloudflare Workers から GCP の PostgreSQL に繋いだ(Tunnel + Access 経由)

Cloudflare Workers から、GCE の VM 上で動いている PostgreSQL に Hyperdrive + Cloudflare Tunnel 経由で接続できたという記録。DB はインターネットに一切晒していない。

背景

CMS を Workers に移すにあたって、DB をどこに置くかが論点だった。DB ごとマネージドに移す案もあったが、DB を動かさないのが一番安く・速く・リスクが低い。問題は「Workers から VM の PostgreSQL にどう繋ぐか」だった。

制約は 2 つ。

  1. Workers はリクエストごとに isolate が使い捨てられるので、通常のコネクションプールが張れない。毎回接続しに行くと TCP + TLS ハンドシェイクで数百 ms 溶ける
  2. PostgreSQL をインターネットに公開したくない

Hyperdrive が両方を解く

1 に対して: Hyperdrive は Cloudflare 側でコネクションを温め続けるので、isolate 側は毎回ハンドシェイクしなくて済む。Workers 有料プランに含まれ、クエリ課金も egress 課金もない

2 に対して: Hyperdrive は Cloudflare Tunnel 経由で非公開の DB に接続できる。VM ではすでに cloudflared が動いていたので、既存トンネルに TCP ルートを 1 本足すだけで済んだ。認証は Cloudflare Access の service token。

結果の経路:

Workers (CMS)
  → Hyperdrive
    → Cloudflare Access(service token 認証)
      → Cloudflare Tunnel
        → GCE VM の PostgreSQL (TLS)

Cloudflare 側と GCP 側が、公開ポートを一切開けずに繋がっている。

設定でハマった点

Access 経由のときはポートを指定しない

wrangler hyperdrive create で Access 経由の設定を作るとき、--access-client-id--origin-port併用できない。Tunnel の公開ホスト名経由でルーティングされるため、ポートを入れると繋がらない。

wrangler hyperdrive create <name> \
  --host=<tunnel-hostname> \
  --database=<db> --user=<user> \
  --password <認証情報のため省略> \
  --access-client-id='<id>' --access-client-secret='<secret>' \
  --sslmode=require

CLI はフラグレベルで排他にしてくれているので気づけるが、ダッシュボードで作る場合はポート欄を空のままにする必要がある。

作成コマンドの成功 = 経路の検証が終わっている

この作成コマンドが成功すること自体が、Tunnel + Access + TLS が全部正しく繋がった証明になる。 Hyperdrive は設定作成時に実接続を試すため。ここが通れば経路側の切り分けは不要。

sslmode は実装によって意味が違う

Hyperdrive は接続先 DB が TLS を話すことを要求するので、VM の PostgreSQL には自己署名証明書を入れてある。

  • Hyperdrive 側の設定は require のままでよい(Cloudflare の実装は libpq 準拠 = 暗号化するが検証しない)
  • node-postgres から直接 Tunnel 越しに繋ぐ側は require だと弾かれる。node-postgres は libpq と違って require でも検証する
cannot connect to Postgres. Details: self-signed certificate

pg-connection-string の実装を見ると、rejectUnauthorized を外すのは no-verify のときだけだった。

case 'no-verify': {
  config.ssl.rejectUnauthorized = false
  break
}

同じパラメータ名で意味が違うので混乱しやすい。

アプリ側で必要だったこと

プールは Hyperdrive が持つ

アプリ側のコネクションプールは実質無効化して、リクエストを跨いだコネクション共有を止める。

postgresAdapter({ pg, pool: { connectionString, maxUses: 1 } })

pg を Workers 上で動かす

node-postgres は Workers を検出すると pg-cloudflareCloudflareSocket を使う。ところがこのパッケージの exportsworkerd 条件のときだけ実体を返し、それ以外は空スタブexports.default = {})を返す。

"exports": {
  ".": {
    "workerd": { "import": "./esm/index.mjs", "require": "./dist/index.js" },
    "default": "./dist/empty.js"
  }
}

OpenNext の esbuild は workerd 条件を使うが、その前段の Next.js(webpack)は使わない。webpack がバンドルした時点で空スタブが確定し、b2 is not a constructor で落ちていた。webpack のバンドル対象から外して解決。

// next.config.ts
serverExternalPackages: ['pg', 'pg-cloudflare'],

外部化すると今度は esbuild 後の default import の解釈がずれて pg.Poolundefined になったので、モジュール実体を明示的に渡して回避した。

import * as pgNamespace from 'pg';
const pg = (pgNamespace as any).default ?? pgNamespace;

結果

項目
API 応答約 70 ms
管理画面の初回応答約 1.9 秒(コールドスタート込み)
DB の追加費用ゼロ(VM に据え置き)
DB の公開ポートなし(Tunnel の内側のまま)
ダウンタイムゼロ

学び

  • Workers に載せられないのはアプリではなく DB。DB を動かさない構成にすれば、移行リスクとコストの大半が消える
  • Hyperdrive は Tunnel 経由で非公開 DB に繋げる。Cloudflare と GCP を、DB をインターネットに晒さずに繋げられる。Workers 有料プランに含まれ追加課金なし
  • Access 経由の Hyperdrive 設定ではポートを指定しない
  • wrangler hyperdrive create が通れば経路の検証は終わっている
  • sslmode は実装によって意味が違う。libpq / Hyperdrive の require は検証しない、node-postgres の require は検証する