🔥

Cloudflare Pages&Workers を使う(Vercel からの移行)

2021/10/17に公開

はじめに

Vercel にホスティングしているウェブサイトがあるが、商用利用することになったので使えなくなった

以下に記載があるが、無料の Hobby プランは非営利目的の使用に限定されている
https://vercel.com/pricing

Vercel の移行先候補としては、Netlify、Cloudflare、Firebase あたりを考えたが、以下の条件を満たしたい

  • 無料
  • SSG しているので、静的ファイルのホスティングができること
  • サーバーレス関数が使えること
    • Vercel の Serverless Functions を使ってランタイムで実行する POST 処理があるため

Firebase は Spark プラン(無料)だと、Cloud Functions が使えなくなった
よって、Blaze プラン(従量課金)にする必要があるのでパス(今の所、無料枠を超えることは無さそうだが、、)

Netlify は Netlify Functions があるので 1 番移行コストが低そう
しかし、無料枠だと日本国内の Edge サーバーが使えず、国外(シンガポールらしい)のサーバーを経由して配信されてしまうため、レイテンシーが発生してしまう
また、FaaS のパフォーマンス的な話では Vercel の Serverless Functions と同様に Netlify Functions も AWS Lambda なので、コールドスタートの課題もあるかなと思う
一応サイトパフォーマンスに関しては、Vercel と同等を維持したいので Netlify はパス

Lambda と Cloudflare Workers のパフォーマンスの話は以下に書いてある
Edge サーバーで動くスクリプトという点で、Cloudflare Workers は Lambda@Edge と近い機能になるが、パフォーマンス的には Cloudflare が優れているとのこと(Cloudflare のドキュメントということを考慮して見る必要があるが)
https://www.cloudflare.com/learning/serverless/serverless-performance
(でも CloudFront Functions ってのもあったような・・・)

よって、今回の移行プランは以下で進める

  • Cloudflare Pages による静的サイトホスティング
  • Cloudflare Workers を FaaS として使う

現状、Cloudflare Pages は Free プランでも「Unlimited bandwidth」らしい
ただし、Edge サーバーで動くという特性上、課金するしないに関係なく制限はきつい
https://developers.cloudflare.com/workers/platform/limits#account-plan-limits

Cloudflare Pages への移行

公式のマイグレーションガイドがある
https://developers.cloudflare.com/pages/migrations/migrating-from-vercel

以下から Cloudflare のアカウントを作成
https://dash.cloudflare.com/sign-up/pages

メールアドレス認証後、Cloudflare と連携する GitHub リポジトリを選択する画面に遷移するので、ビルドコマンドなどを設定後、ビルド&デプロイが行われる

Production ブランチ(master,main)以外のブランチで GitHub に変更を push した場合、Preview 環境にデプロイされる

環境変数についても、Production 環境と Preview 環境で分けて登録できる
また、Cloudflare Access を使えば、Preview 環境にメールによるワンタイム PIN 認証をかけられる(Cloudflare Pages の設定画面から Access policy を enable にするだけで自動で構築される)

Cloudflare Pages で出たエラー

Nuxt.js を使っているが、以下のエラーが Chrome コンソール上に出て JS が上手く読み込めていないっぽい

Uncaught SyntaxError: Unexpected token '<'

また、以下の Warning も出ていた
preload 属性で読み込んでいる payload.js が window の load イベント後、使用されなかったと
Chrome Dev ツールで見ると、ちゃんと payload.js はダウンロードできるてるっぽいが、、

The resource https://.../payload.js was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally.

調べていると、以下の Issue を見つけて、全然分からないけど Nuxt のバージョンを2.14.1から2.14.8に上げたら解決した
https://github.com/nuxt/nuxt.js/issues/7717

ビルドが遅い

Vercel と比べるとビルドが倍くらい遅い(Vercel は 2 分 30 秒くらい、Cloudflare は 5 分くらい)
Cloudflare のビルドログを見てみると、ビルド自体は 2 分くらいで終わっているが、Initializing build environment という工程に 2 分以上かかってる
これ何やってるんだろう?

Cloudflare Workers への移行

管理画面での設定

Cloudflare の管理画面からHomeタブ -> Workersを選択
サブドメインを設定する
プランを選択する

Wrangler(CLI)をセットアップ

Cloudflare Workers をデプロイする際は Wrangler という CLI を利用する

npm i @cloudflare/wrangler -g
brew install cloudflare-wrangler # Homebrewでも公開されている

Wrangler を直接叩くのは、Worker の動作確認をローカルから行いたい場合になると思う
プロダクション運用する場合は CI を構築した方が良いが、cloudflare/wrangler-actionという GitHub Action がある

次に Wrangler を認証する
以下から API トークンを発行
https://dash.cloudflare.com/profile/api-tokens

Edit Cloudflare Workersというトークンテンプレートを使用
Account ResourcesZone Resourcesは、自アカウントのみ許可
その他の設定は変更せず

wrangler config

発行した API トークンを入力する

次に Example の worker を生成する
今回は以下の Quickstarts から TS のテンプレートを選択
https://developers.cloudflare.com/workers/get-started/quickstarts

wrangler generate my-ts-project https://github.com/cloudflare/worker-typescript-template

以下のコマンドでサーバーが起動し、http://127.0.0.1:8787で確認ができる

cd my-ts-project
wrangler dev --host example.com

wrangler.toml を以下に設定

wrangler.toml
name = "example"
type = "javascript"
workers_dev = true
compatibility_date = "2021-09-28"

[build]
command = "npm install && npm run build"

[build.upload]
format = "service-worker"

以下のコマンドで[tomlのname].[サブドメイン].workers.devにデプロイできる

wrangler publish

この Worker が公開され、外部からのリクエストが可能になる
また、カスタムドメインを設定しないと、workers.devという名前固定なので、検索エンジンのクローラーにインデックスされると、site:workers.devで検索結果に表示される可能性がある(と思う)

Worker の実装

今回は、Worker で Chatwork にメッセージを送る API を試しに実装し、デプロイする

実装例が以下にまとまっていて、POST リクエストを処理する方法や CORS 設定など参考になった
https://developers.cloudflare.com/workers/examples

以下が Service Worker のエントリーポイントとなる TS

index.ts
import { handleChatwork, handleOptions } from '@/handler'

addEventListener('fetch', e => {
  if (e.request.method === 'OPTIONS') {
    return e.respondWith(handleOptions())
  }

  const { pathname } = new URL(e.request.url)
  if (pathname.startsWith('/api/chatwork')) {
    return e.respondWith(handleChatwork(e.request))
  }

  return e.respondWith(new Response('Not Found', { status: 404 }))
})

fetch イベントでリクエストを受け付け、関数を実行する
pathname で分岐しているが、今後たとえばメールの POST も作りたいってなった時にif (pathname.startsWith('/api/mail')) {という分岐を追加するだけで対応できるようにした

handleOptions はこの Worker を利用するアプリケーションが POST で JSON を送っている(Content-Type: application/json)ので、Preflight request に対応した

以下は index.ts から呼ばれる処理のメインとなるハンドラー

handler.ts
/* global CHATWORK_API_URL,CHATWORK_API_TOKEN */

const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://...',
  'Access-Control-Allow-Methods': 'POST,OPTIONS',
  'Access-Control-Max-Age': '86400',
  'Access-Control-Allow-Headers': 'Content-Type'
}

export const handleOptions = (): Response => new Response(null, { headers: corsHeaders })

export const handleChatwork = async (request: Request): Promise<Response> =>
  fetch(CHATWORK_API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'X-ChatWorkToken': CHATWORK_API_TOKEN
    },
    body: `body=${JSON.stringify(await request.json(), null, '\t')}&self_unread=1`
  })
    .then(res => {
      if (res.ok)
        return new Response('/api/chatwork send message success', { status: 200, headers: corsHeaders })
      throw new Error(res.statusText)
    })
    .catch(
      (err: Error) =>
        new Response(`/api/chatwork send message error: ${err.message}`, { status: 500, headers: corsHeaders })
    )

CHATWORK_API_URL,CHATWORK_API_TOKEN は環境変数になる
Cloudflare Workers は Node.js ではないので、process.env.CHATWORK_API_URL的なのが使えない
よって、Cloudflare Workers の構文では、環境変数の変数名を直接コードに記載する

当然、TS や ESLint を使っているとエラーで怒られるので、以下で回避している

  • TS エラーに関しては以下の型定義を追加

    @types/worker.d.ts
    declare const CHATWORK_API_URL: string
    declare const CHATWORK_API_TOKEN: string
    
  • ESLint エラーに関しては、グローバル変数化

    /* global CHATWORK_API_URL,CHATWORK_API_TOKEN */
    

    グローバル変数化するより、単純に ESLint エラーを ignore した方が安全かも

また、上記のような handler をローカルで動作確認する場合、wrangler devだと少々面倒なので、wrangler previewで確認すると良い(watch オプションとか付いてるし)

webpack

Cloudflare Workers を実装する上で、AWS Lambda などと比べると開発体験は非常に良かったが、webpack を自分で書く必要があった
今回は ts-loader を使う程度だったが、この辺のビルド周りが隠蔽されるといいなーって思った(もしかしたら、wrangler.toml にオプションが色々あるのかもしれない)

webpack.config.js
const path = require('path')

module.exports = {
  mode: 'development',
  entry: path.join(__dirname, 'src', 'index.ts'),
  output: {
    filename: 'worker.js',
    path: path.join(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: [
          {
            loader: 'ts-loader',
            options: { transpileOnly: true }
          }
        ],
        exclude: /node_modules/
      }
    ]
  },
  resolve: {
    extensions: ['.js', '.ts'],
    alias: { '@': path.join(__dirname, 'src') }
  },
  devtool: 'cheap-module-source-map'
}

Worker のデプロイを CI で実行

Vercel だと/apiディレクトリに JS か TS を置いておけば、Vercel 側で勝手に Serverless Functions にデプロイしてくれて使えるようにしてくれていたが、Cloudflare Workers だと自分でデプロイする必要がある
デプロイ自体はwrangler publishだけでいけるが、CI にやらせたい

公式のcloudflare/wrangler-actionという GitHub Actions があるのでこれを使う
ただ、wrangler publishするだけなので、使わなくても実装は簡単だと思う

name: Wrangler

on:
  push:
    branches:
      - 'master'

jobs:
  deploy:
    name: Run deploy
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Publish
        uses: cloudflare/wrangler-action@1.3.0
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          workingDirectory: 'dev/worker' # ルートにwrangler.tomlを置いていない場合、サブディレクトリを指定

Discussion