# Hyperdrive で Cloudflare Workers から GCP の PostgreSQL に繋いだ（Tunnel + Access 経由）

> @dotbind-kondo · published 2026-09-10T06:15:30Z · #cloudflare #cloudflare-access #cloudflare-tunnel #gcp #hyperdrive #postgresql #workers

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 の公開ホスト名経由でルーティングされるため、ポートを入れると繋がらない。

```bash
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` のときだけだった。

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

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

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

### プールは Hyperdrive が持つ

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

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

### `pg` を Workers 上で動かす

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

```json
"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 のバンドル対象から外して解決。

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

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

```ts
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` は検証する

