📚

Cloudflare Workersプロキシパターン

2023/05/22に公開1

CloudflareのDeveloper WeekではAI基盤のConstellationや、HTTP以外のTCP接続が可能になるconnect()、PlanetScaleやSupabaseなどのデータベース統合など魅力的な製品の紹介やアップデートがありました。これらを活用することでよりフルスタックなアプリケーションをエッジ上で構築する事が可能になります。

https://www.cloudflare.com/ja-jp/developer-week-2023/updates/

また、Cloudflareに限らず、FastlyでもCompute@Edge上でNext.jsが動いたり先日KVストアが導入されたDeno Deployではエッジ上でリッチなアプリケーションをつくるこができます。

一方、CDNのエッジで実行されることの醍醐味のひとつに「オリジンを活かす」ことがあります。オリジンに対しての「リバースプロキシ」です。「フルスタック」に目が行きがちですが、エッジコンピュートはリバースプロキシに新しい風を吹き込みます。"Code over Configuration"とでも言うのでしょうか。従来の「設定」、例えばVarnishやFastlyにおけるVCL、そして管理画面を置き換えます。より多くのことをより柔軟に、そしてテスタブルで再利用可能なものにしてくれます。

そこで今回はCloudflare Workersを例に、リバースプロキシのユースケースとその実装例をパターンとして12個、紹介します。オリジンありきで紹介しますが、エッジ上のアプリケーションにも適応可能なテクニックもあります。また、Cloudflare以外でも例えば、FastlyのCompute@Edgeでもたいてい同じことができるので参考にしてください。

プロキシの実装

最初にCloudflare Workersにおけるプロキシの実装方法について紹介します。

プロキシのWorkersとオリジンの関係と流れは以下のようになります。

1. リクエストが来る
     |
     v
2. Workersがリクエストを処理する
     |
     v
3. オリジンをfetchする
     |
     v
4. Workersがレスポンスを処理する
     |
     v
5. レスポンスを返す

WorkersはfetchとWeb Standard APIsで構成されます。最低限のプロキシは以下の5行です。

export default {
  async fetch(request: Request) {
     return fetch(request)
  },
}

これだけではよくわからないと思うので、responseを変数に切り出し、コメントを追加してみましょう。

export default {
  async fetch(request: Request) { // 1. リクエストが来る
    // 2. Workersがリクエストを処理する(省略)
    const response = fetch(request) // 3. オリジンをfetchする
    // 4. Workersがレスポンスを処理する(省略)
    return response // 5. レスポンスを返す
  },
}

流れとコードの対応がよくわかったと思います。ポイントは「3」のfetch()です。requestにはオリジンへのリクエストそのものが入っています。今回はそのままそれをfetch()の引数に入れて、オリジンからのレスポンスを受け取っています。典型的なリバースプロキシのパターンです。

fetch()に渡せるのは上記で言うrequestのみではありません。fetch()の第一引数はRequestInfo、つまりRequestオブジェクトかURLの文字列です。例えば、http://example.comのような文字列を渡してhttp://example.comからのResponseを扱うこともできます。ですので、以下は動きます。

export default {
  fetch: () => fetch('http://example.com'),
}

これが面白い点で、応用するとオリジン以外のリソースを扱うことができます。

以上、最低限ReqeustfetchResponseの3つを使ってWorkersを実装していくことになります。

ルーター・フレームワークを使うかどうか

エンドポイントがひとつなら上記のように「スクラッチ」で書いていいと思います。ただ、パスやメソッドによる分岐が必要になったらルーターやフレームワークを使うことを検討した方がいいでしょう。具体的にはitty-routerHonoです。

https://github.com/kwhitley/itty-router

https://hono.dev/

たとえば、あるパスにあるメソッドでやってきたら強制的に404を返すといった記述をスクラッチで書くとこうなります。

export default {
  async fetch(request: Request) {
    const url = new URL(request.url)
    if (request.method === 'GET' && url.pathname === '/abc') {
      return new Response('Not Found', {
        status: 404,
        headers: {
          'Content-Type': 'text/plain',
        },
      })
    }
    return fetch(request)
  },
}

URLオブジェクトを作って条件分岐があります。一方Honoを使うと以下のように書けます。

const app = new Hono()

app.get('/abc', (c) => {
  return c.text('Not Found', 404)
})

app.all('*', (c) => fetch(c.req.raw))

export default app

当然、自分がHonoの作者であるというバイアスはあるものの、フレームワークを使うのは理にかなっています。

  • 記述が短いので、ミスが減る
  • パスに正規表現やワイルドカードを使える
  • パスパラメータ /posts/:id を簡単に取れる
  • URLオブジェクトを生成するのはコストがかかる

また、Honoだとクエリやヘッダのパースが速い、よくテストされたミドルウェアを使えるなどの利点があります。今回はHonoを使った実装を紹介していきます。

プロジェクトの作成

Honoの場合、以下のコマンドで最低限のプロジェクトを作成できます。

npm create hono@latest my-app

// OR

yarn create hono my-app

1.レスポンスヘッダの追加

まずはレスポンスヘッダの追加です。これは「4. Workersがレスポンスを処理する」にあたります。愚直にやると以下のようになります。

app.all('*', async (c) => {
  const res = await fetch(c.req.raw)
  res.headers.set('X-Custom', 'Foo')
  return res
})

ただ、これだと「Can't modify immutable headers.」と怒られるので、Responseオブジェクトを複製してから使います。全体のコードは以下です。

import { Hono } from 'hono'

const app = new Hono()

app.all('*', async (c) => {
  const res = await fetch(c.req.raw)
  const newResponse = new Response(res.body, res)
  newResponse.headers.set('X-Custom', 'Foo')
  return newResponse
})

export default app

ヘッダを見るとしっかり追加されています。

SS

2.CORS

CORSのヘッダの追加もWorkersのプロキシでできます。一から書くとPreflightの処理とか面倒なのでHonoのミドルウェアを使います。

app.use('/api/*', cors())

もしくはオプションを指定できます。

app.use(
  '/api2/*',
  cors({
    origin: 'http://example.com',
    allowHeaders: ['X-Custom-Header', 'Upgrade-Insecure-Requests'],
    allowMethods: ['POST', 'GET', 'OPTIONS'],
    exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
    maxAge: 600,
    credentials: true,
  })
)

3.Basic認証

ApacheやNginxでは簡単に扱えるBasic認証でもエッジ上で実装するのは少々面倒です。一方、Honoのミドルウェアには認証系が揃っていて便利です。

  • Basic認証
  • Bearer認証
  • JWT認証

Basic認証を使うには以下のようにします。

app.use(
  '/auth/*',
  basicAuth({
    username: 'yourname',
    password: 'yoursecret',
  })
)

4.ETagの追加

同様にETagの追加もミドルウェアを使えば簡単です。

app.use('/pages/*', etag())

5.リダイレクト

あるパス以下に対して一括でリダイレクトかけたり、あるパスを置換してリダイレクトしたり、よくあるやつです。これはfetch()が必要ありませんね。

app.get('/old/:id', async (c) => {
  const id = c.req.param('id')
  return c.redirect(`/new/${id}`)
})

6.オリジンの振り分け

アセットをメインのオリジンとは別のストレージに保存している、かつ画像とフォントでホストが違う。そしてそれらを同じドメインのパスで表示したい。例えばこのようなケースです。

http://example.com/assets/images/foo.png => http://imageshost/foo.png
http://example.com/assets/fonts/bar.woff => http://imageshost/bar.woff

以下のコードのみでいけます。パスに正規表現を使ってimagesfontsに限定し、それをキャプチャ。文字列をみて、配信するホスト決定し、URLを生成。fetch()してレスポンスを返しています。

const imageHost = 'http://imagehost'
const fontHost = 'http://fonthost'

app.get('/assets/:type{(?:images|fonts)}/:filename', async (c) => {
  const { type, filename } = c.req.param()
  const hostName = type === 'images' ? imageHost : fontHost
  const url = new URL(`/${filename}`, hostName)
  return fetch(url)
})

7.キャッシュ

Cloudflareでは管理画面での設定も含め、キャッシュをつける方法がいくつかあるのですが、今回はCache APIを使ってみましょう。といってもこれもHonoのミドルウェアを使えば一瞬です。

app.get(
  '/assets/*',
  cache({
    cacheName: 'my-app',
  })
)

課題はパージです。Enterpriseプランでないとカスタムキャッシュキーが使えないし、TagやPrefixでのパージの指定はできません。できるのはURLの指定もしくは「全体を飛ばす = Purge everything」です。例えば「/pages以下のHTMLのキャッシュを飛ばしたい」ということが簡単にはできません。ですのでやり方としては以下の通りです。

  • あきらめて全て飛ばす
  • URLをKVに保存しておきタグをつけておく
  • TTLに頼る
  • KVでキャッシュする(後述)

キャッシュのパージタイミングがそこまでシビアでなければ、TTLでコントロールしつつ、どうしょもないときには全て飛ばす、というやり方でいいと思います。

URLをKVに保存する、というのは自分ではやったことがないのですが、わりといけると思います。どなたかやったことある人いたら教えてください。

8.デバイス別のキャッシュ

僕が初めて書いたWorkersのスクリプトがこの目的でした。PCとモバイルでページを出し分けしてるアプリで愚直にキャッシュをすると、ページにつきキャッシュキーが一つなので、スマホでアクセスしてるにも関わらずPC用のページがでてしまったりします。

それをどうにかしたいのですが、Cloudflareにあるマネージドのデバイス判定の機能はEnterpriseのプランでしか使えません。そこで、工夫した結果、Workersでできました。Cache APIではキャッシュキーを指定できるので、URLのsuffixにデバイス名をつけたものをキーにするのです。

app.get('/pages/*', async (c) => {
  let isMobile = false
  const userAgent = c.req.header('User-Agent') || ''

  if (userAgent.match(/(iPhone|iPod|Android|Mobile)/)) {
    isMobile = true
  }

  const cache = caches.default

  const device = isMobile ? 'Mobile' : 'Desktop'
  const cacheKey = c.req.url + '-' + device

  let response = await cache.match(cacheKey)

  if (!response) {
    response = await fetch(c.req.raw)
    response = new Response(response.body, response)
    response.headers.append('Cache-Control', 's-maxage=3600')
    c.executionCtx.waitUntil(cache.put(cacheKey, response.clone()))
  }

  return response
})

9.HTMLタグの置換

Cloudflare WorkersにはHTMLRewriterという面白いAPIが生えています。ちなみにBunにもあります。またWasmの実装もあります。

https://developers.cloudflare.com/workers/runtime-apis/html-rewriter/

これを使うとjQueryライクなセレクタ指定で、HTMLのタグの要素や属性値を変更できます。Cloudflare DocsのExampleを参考に以下のような実装をしてみます。oldhostからnewhostへ引っ越した際のリンクの書き換えをオリジンではなくエッジでやってしまうのです。

app.get('/pages/*', async (c) => {
  const OLD_URL = 'oldhost'
  const NEW_URL = 'newhost'

  class AttributeRewriter {
    constructor(attributeName) {
      this.attributeName = attributeName
    }
    element(element) {
      const attribute = element.getAttribute(this.attributeName)
      if (attribute) {
        element.setAttribute(this.attributeName,
	  attribute.replace(OLD_URL, NEW_URL))
      }
    }
  }

  const rewriter = new HTMLRewriter().on('a', new AttributeRewriter('href'))

  const res = await fetch(c.req.raw)
  const contentType = res.headers.get('Content-Type')

  if (contentType.startsWith('text/html')) {
    return rewriter.transform(res)
  } else {
    return res
  }
})

10.ホットリンク禁止 - リファラ編

オリジナルの画像を扱っているようなサイトで、いわゆる「直アクセス = ホットリンク」を禁止したいことがあると思います。以下は一般的なリファラを使った実装です。リファラが自サイト以外なら403を返します。

app.get('/posts/:filename{.+.png$}', async (c) => {
  const referer = c.req.header('Referer')
  if (referer && /^https:\/\/dev.yusukebe.com/.test(referer)) {
    return fetch(c.req)
  }
  return c.text('Forbidden', 403)
})

ただ、これには問題があって、ChromeやFireFoxなどユーザーのブラウザでリファラの送出をオフにしてる場合に見れなくなってしまいます。そこで次の「SignedRequest編」の実装があります。

11.ホットリンク禁止 - SignedRequest編

これは、有効期限付きのURLを生成、検証することでホットリンクを禁止する作戦です。実装のフローは以下のようになります。

生成側

  1. 生成と検証でシークレットキーを共有する。
  2. URLパスと有効期限をデータとし、シークレットキーでキーを生成する。
  3. URLにキーと有効期限をクエリパラメータとして追加する。

検証側

  1. URLクエリパラメータからキーと有効期限を取り出す。
  2. キーをシークレットキーを元に検証する。
  3. 有効期限を検証する。
  4. 有効であれば、プロキシする。

これをHonoのミドルウェアとして実装してみてます。以下はまだWIPです。もしかしてHono本体のビルトインミドルウェアに入れるかもしません。

https://github.com/yusukebe/signed-request-middleware

このSignedRequestミドルウェアとHTMLRewriterを組み合わせます。守りたい画像をHTMLRewriterで探して、見つかったらimgタグのurl属性の値をgenerateSignedURLで生成したURLに置換してあげます。また、画像を配信するパスには検証用のハンドラverifySignedRequestを通します。これで検証に失敗したら403を返します。

import { generateSignedURL, verifySignedRequest } from '../../../src'

// ...

app.get('/static/images/*', verifySignedRequest({ secretKey }))

app.get('/', async (c, next) => {
  await next()
  class AttributeRewriter {
    private attributeName: string
    constructor(attributeName: string) {
      this.attributeName = attributeName
    }
    async element(element: Element) {
      const attribute = element.getAttribute(this.attributeName)
      if (attribute) {
        const url = new URL(attribute, c.req.url)
        const generatedURL = await generateSignedURL(url, {
          secretKey,
          expirationMs: 1000 * expirationSec
        })
        element.setAttribute(this.attributeName, generatedURL.toString())
      }
    }
  }

  const rewriter = new HTMLRewriter().on('img', new AttributeRewriter('src'))
  c.res = rewriter.transform(c.res)
})

実際に動いている様子です。

SS

実装はCloudflare、Fastly両方の開発者ページに載っている方法を応用してみました。

https://developers.cloudflare.com/workers/examples/signing-requests/

https://developer.fastly.com/solutions/examples/time-limited-url-tokens

たぶん、この方法は悪くはないと思いますが、他にもっといいやり方があればコメントなどで教えてもらえると嬉しいです。

12.動的コンテンツのキャッシュ

最後は大技です。以前書いたブログ記事が元になっています。

アセットや更新の少ないHTMLは上記したTTLとPurge everythingを使う運用でなんとかなると思います。しかし更新頻度の高いHTMLや、Web APIのレスポンスはキャッシュしにくいです。通常ですとCDNを通していたとしてもpassするのが常套手段だと思います。ただ、Workersを使いこなせば動的コンテンツをキャッシュし、極力オリジンへのアクセスを減らすことができます。

動的コンテンツのキャッシュで問題になるのは生成コストが高いアプリケーションです。キャッシュ制御にTTL採用した場合、TTLを過ぎてキャッシュがなくなった時に、再度データを生成することになります。これはだいたいの場合、機能します。ただし、アクセスが多いアプリだったり、ページ生成に時間が長くかかったりするとThundering Herd問題が発生します。キャッシュがない状態でアクセスが来て、大量のページ生成が走ってしまうのです。その結果、アプリのリソースを圧迫します。

それが解決するのがStale-While-Revalidate = SWRというキャッシュ戦略です。SWRとは簡単に説明すると以下の通りです。

キャッシュが切れたら、バックグラウンドでキャッシュを問い合わせつつ(validate)、その間は古いキャッシュ(stale)を返して、コンテンツが返ってきたらキャッシュを更新する。

昨今のフロントエンドの文脈だとIncremental Static Regeneration = ISRに近いです。

今回はCloudflare KVでこれを実現します。

  • 初回アクセス時のみ直接レスポンスを返す。
  • レスポンスはKVに保存される。
  • 以降、クライアントからのアクセス時にはKVに保存されたレスポンスを返す。
  • トリガーをフックにして、コンテンツの生成、つまりオリジンへのアクセスが走り、返却されたレスポンスをKVへ保存し、既存のものと入れ替える。

SS

コードの断片は以下のようになります。

console.log(`try to get ${originURL} from kv`);
let { value, metadata } =
  await c.env.CONTENT_STORE.getWithMetadata<Metadata>(
    `fresh: ${originURL}`
  );
let response: Response;

if (value && metadata) {
  console.log(`${originURL} already is stored`);
  response = new Response(value, { headers: metadata.headers });
} else {
  let { value, metadata } =
    await c.env.CONTENT_STORE.getWithMetadata<Metadata>(
      `stale: ${originURL}`
    );
  if (value && metadata) {
    console.log(`${originURL} is expired but the stale is found`);
    response = new Response(value, { headers: metadata.headers });
    c.executionCtx.waitUntil(createCache(c, originURL));
  } else {
    console.log(`fetch from ${originURL}`);
    response = await fetch(originURL);
    c.executionCtx.waitUntil(createCache(c, originURL, response));
  }
}

https://github.com/yusukebe/dynamic-content-storing

キャッシュ更新のトリガーはTTLによる有効期限だけではありません。 REST APIにおける「CRUD」で考えてみます。 Read、つまりGETリクエストの場合は常にストアされたコンテンツを返却する。 Create/Update/Deleteという更新系が走ったら新しくコンテンツを生成する、というのはいかがでしょうか。 以下の例はDELETEメソッドでキャッシュを更新しています。

app.delete("/posts/", async (c) => {
  const originURL = `https://${c.env.ORIGIN_HOST}/posts/`;
  c.executionCtx.waitUntil(createCache(c, originURL));
  return c.redirect("/posts/");
});

この、CRUDの更新系でキャッシュをパージして更新するという手法は「Web Speed Hackathon 2022」で500点を出した時に、最後の奥の手で使ったものでもあります。

https://yusukebe.com/posts/2022/wsh/

その他

上記の12個の他にもWorkersは活用できる場面はたくさんあるでしょう。

  • URL正規化
  • ボットプロテクション
  • i18n
  • ログの収集
  • OGPの作成、配信
  • 103 EarlyHints
  • などなど

103 EarlyHintsの実装は面白くて、これもまたHTMLRewriterを使います。以下のページを見るといいでしょう。

https://kian.org.uk/implementing-103-early-hints-with-cloudflare-workers-htmlrewriter/

まとめ

以上、Cloudflare Workersによるリバースプロキシのユースケースとその実装を見てきました。元々Workersは「やれることが少ない」のが逆に面白いところだと思っていて、それが活きるのがオリジンと組み合わせた時なんだと思います。オリジンでやれるけど、エッジでやった方が楽だし、速い。するとオリジンは自分のアプリケーションドメインの実装だけに集中できます。

また、エッジでアプリケーションが動いたとしても今回紹介したパターンがアプリケーションのフロントにレイヤリングされるのだと思います。

Cloudflare WorkersでWebのアプリケーションを作るのも楽しいですが、リバースプロキシとしても使ってみると楽しいかもしれませんね!

Discussion

Kyohei FukudaKyohei Fukuda

めちゃためになりました。ありがとうございます。

間違いかなーと思ったところが、下記の部分です。

http://example.com/assets/images/foo.png => http://imageshost/foo.png
http://example.com/assets/fonts/bar.woff => http://imageshost/bar.woff

http://example.com/assets/fonts/bar.woff => http://imageshost/bar.woff

http://example.com/assets/fonts/bar.woff => http://fonthost/bar.woff
の間違いでしょうか?