Remix+CloudflareでWebサイトを作る 42(1から作りなおしてCloudflare PagesのTTFBが遅い原因を探る)
【2024-11-13】TTFBが3.8sかかる理由がわからないので1から作り直す
偶然にも有給をとったのでざっくり以下の各ステップに分けてLighthouseで測定していく。
- Remixのテンプレートを作ってCloudflareにデプロイ
- ライブラリを全部インストールする
- スキーマファイル作ってDBと接続できるようにする
- トップページでなにかのテーブルの内容を一覧取得する
- ページ数を増やす
- 機能を増やす
【2024-11-13】1. Remixのテンプレートを作ってCloudflareにデプロイ
アプリの作成
Cloudflareのドキュメントに書いてあるコマンドを使ってアプリ作成。
pnpm create cloudflare@latest my-app --framework=remix --experimental
...
╭ Deploy with Cloudflare Step 3 of 3
│
╰ Do you want to deploy your application?
Yes / No # Github Actionsを使ってデプロイしたいのでNoを選択した
ローカルからデプロイ
とりあえずmainブランチにpushして以下のコマンドでデプロイ。
pnpm run deploy
Cloudflareの「Workers & Pages」にアプリが作成された。
なんかタブの構成から色々と変わってる。
既存のアプリにも対応させて欲しいな。。
GitHub Actionsでデプロイ
GitHub Secretsの値を元のリポジトリからコピー。
本番環境へデプロイするGithub Actionsをmainにpushしてデプロイしようとしたら以下のエラーが発生。
アプリがないとのこと。
pnpm run deploy
を実行したら勝手に作成してくれたのでは...?
✘ [ERROR] A request to the Cloudflare API (/accounts/***/pages/projects/my-app) failed.
Project not found. The specified project name does not match any of your existing projects. [code: 8000007]
「Workers & Pages」を見ると元のはCloudflare Pages(アイコン:⚡️)だけど、新たに作成したものはCloudflare Workers(アイコン:💠)になっている。
...
- name: 🚀 Deploy
id: cloudflare-wrangler
uses: cloudflare/wrangler-action@v3
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- command: pages deploy ./build/client --project-name=my-app
+ # command: pages deploy ./build/client --project-name=my-app
また、今まではCloudflare Pages用のAPI Tokenを使用していたが、新たにCloudflare Worker用のものを作成してGitHub Secretsに登録しておく。
結果
120msだった。
【2024-11-13】2. ライブラリを全部インストールする
元のアプリの pacakge.json
に書いてあるものを全部コピペしてデプロイする。
このタイミングで package.json
のengines
も元のアプリに合わせて修正。
},
"engines": {
- "node": ">=20.0.0"
+ "node": ">=18.0.0"
}
}
結果
110ms。
$ npx wrangler deploy './server.ts' --outdir bundled/ --dry-run
⛅️ wrangler 3.86.1
-------------------
Total Upload: 2071.89 KiB / gzip: 351.08 KiB
--dry-run: exiting now.
【2024-11-13】3. スキーマファイル作ってDBと接続できるようにする
Tursoをつなげる
Cloudflareの「Workers & Pages」のインテグレーションタブからTursoと接続。
Prismaのスキーマを作成してDBへ接続する
以下コマンドで prisma/schema.prisma
を作成
npx prisma init
スキーマファイルを丸まるコピペ。
以下コマンドで型定義ファイルを作成。
npx prisma generate
マイグレーションファイルを作成。
npx prisma migrate dev --name init
結果
サイズは一気に跳ね上がった。
TTFBは240ms。
$ npx wrangler deploy './server.ts' --outdir bundled/ --dry-run
⛅️ wrangler 3.86.1
-------------------
Total Upload: 4717.43 KiB / gzip: 1261.67 KiB
【2024-11-13】Cloudflare PagesにデプロイしなおしたらTTFB遅いの再現できた
今気づいたけどCloudflare WorkerはCloudflare Pagesと違ってプロダクションとプレビュー環境が分かれていなのか。
同じ環境にしないと検証できないしCloudflare Pagesで作り直す。
修正
Github Actionsは以下のようにPagesにデプロイするようにもどす。
secrets.CLOUDFLARE_API_TOKEN
もCloudflare Pages用のものに戻す。
- name: 🚀 Deploy
id: cloudflare-wrangler
uses: cloudflare/wrangler-action@v3
with:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: pages deploy ./build/client --project-name=my-app # ここ追加
functions/[[path]].ts
を新たに作成。
import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
// @ts-ignore - the server build file is generated by `remix vite:build`
import * as build from "../build/server";
import { getLoadContext } from "../load-context";
export const onRequest = createPagesFunctionHandler({ build, getLoadContext });
wrangler.toml
のmainを更新する。
compatibility_date = "2024-05-10"
name = "my-app"
account_id = "xxx"
main = "functions/[[path]].ts"
結果
遅くなった!!!
一気に240msから3,120msになってしまった。
最初間違ってCloudflare Workersにデプロイして良かった(?)
主なコード自体は同じで差分は以下。
- CLIで作られた
vite.config.ts
ではなく、もとのアプリで使っていたものを使用した - CLIで作られた
server.ts
からfunctions/[[path]].ts
にした(それにともないwrangler.toml
を修正)- そういや初めのほうでみた記事を真似してこう書いてあるけどPagesには
/build/client
をデプロイするわけだしここは関係無い気がする
- そういや初めのほうでみた記事を真似してこう書いてあるけどPagesには
- Cloudflare WorkersではなくCloudflare Pagesにデプロイした
次はこれらを順番に元に戻していって原因を特定する。
vite.config.ts
をCLIで作成したときの状態に戻す
【2024-11-13】戻す
CLIでRemixアプリを作った際にあったオプションをもどしてみる
import {
+ cloudflareDevProxyVitePlugin,
vitePlugin as remix,
cloudflareDevProxyVitePlugin as remixCloudflareDevProxy,
} from "@remix-run/dev";
import { visualizer } from "rollup-plugin-visualizer";
import { defineConfig } from "vite";
import { envOnlyMacros } from "vite-env-only";
import tsconfigPaths from "vite-tsconfig-paths";
import { getLoadContext } from "./load-context";
declare module "@remix-run/cloudflare" {
interface Future {
v3_singleFetch: true;
}
}
export default defineConfig(({ mode }) => {
return {
plugins: [
+ cloudflareDevProxyVitePlugin({
+ getLoadContext,
+ }),
envOnlyMacros(),
remixCloudflareDevProxy({ getLoadContext }),
remix({
future: {
v3_singleFetch: true,
+ v3_fetcherPersist: true,
+ v3_relativeSplatPath: true,
+ v3_throwAbortReason: true,
+ v3_lazyRouteDiscovery: true,
},
}),
tsconfigPaths(),
],
build: {
+ minify: true,
rollupOptions: {
plugins: [
mode === "analyze" &&
visualizer({
open: true,
filename: "stats.html",
gzipSize: true,
brotliSize: true,
}),
],
},
},
server: {
port: 5173,
},
+ resolve: {
+ mainFields: ["browser", "module", "main"],
+ },
ssr: {
+ resolve: {
+ conditions: ["workerd", "worker", "browser"],
+ },
noExternal: [
"remix-i18next",
],
},
};
});
結果
3710msかかった。
ここは関係なし。
wrangler.toml
のmain
を戻してCloudflare Pagesにデプロイ
【2024-11-13】main
は実行される Worker のエントリーポイントへのパスだから関係ないと思うけど一応...。
compatibility_date = "2024-05-10"
name = "my-app"
account_id = "xxx"
- main = "functions/[[path]].ts"
+ main = "./server.ts"
結果
4,210ms
【2024-11-13】ここまでのまとめ、ネクストアクション
まとめ
- Cloudflare Workers
-
./server.ts
をエントリーポイントとしてデプロイすると240ms
-
- Cloudflare Pages
-
pages deploy ./build/client --project-name=my-app
でデプロイすると 3500ms
-
-
vite.config.ts
は無関係
Cloudflare Pagesにデプロイしたいけどこれじゃない方法がわからない。
あるとしたらGithub ActionsではなくてCloudflare PagesとGitHubを連携させてpush時に自動デプロイする方法だけど多分何も変わらないと思うんだよな。
ネクストアクション
- もう1回初めから作り直して、CLIで作ったばかりのコードをCloudflare PagesにGithub Actionsでデプロイしてみる
- 遅い場合
- こうなったらもうどうすればいいかわからない
- 早い場合
- 元のコードの
vite.config.ts
を使う - その後はまた少しずつ元のアプリに近づくようにコードを足していきながら遅くなるポイントを探る
- 元のコードの
- 遅い場合
もしかしたらPrisma入れてサイズが一気に大きくなったところで遅くなっているのかもしれないしな〜。
【2024-11-14】RemixをCloudflare Pagesにデプロイ
アプリ作成
ここと同じことをやる。
pnpm create cloudflare@latest my-app --framework=remix --experimental
Cloudflare Pages用の最低限の修正
パッケージのインストール
pnpm add @remix-run/cloudflare-pages
functions/[[path]].ts
の作成
Pages Functions でリクエストを受けてそれを Remix に流すような形になるので、functions/[[path]].ts にアダプターを設置する。Pages Functions では onRequest 関数を named-export するルールとなっている。[[path]].ts は catch-all のファイル名ルールで、すべてのリクエストが Remix に流されることになる(HTML はもちろん JS などの静的アセットも含む)。
Remix Build によって build/server/index.js ファイルが生成されるため、それを Pages Functions につなぐイメージ。ビルド前には存在しないファイルを import するので型エラーを潰す必要がある。ビルド後は存在するファイルになって型エラーが出なくなるため、@ts-expect-error ではダメ。eslint を黙らせてでも @ts-ignore を使用する。
import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - the server build file is generated by `remix vite:build`
// eslint-disable-next-line import/no-unresolved
import * as build from "../build/server";
export const onRequest = createPagesFunctionHandler({ build });
entry.server.ts
の修正
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.server
*/
import type { AppLoadContext, EntryContext } from "@remix-run/cloudflare";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToReadableStream } from "react-dom/server";
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext,
) {
const body = await renderToReadableStream(
<RemixServer context={remixContext} url={request.url} />,
{
signal: request.signal,
onError(error: unknown) {
// Log streaming rendering errors from inside the shell
console.error(error);
responseStatusCode = 500;
},
},
);
if (isbot(request.headers.get("user-agent") || "")) {
await body.allReady;
}
responseHeaders.set("Content-Type", "text/html");
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
}
ローカルで立ち上げてみる
npx wrangler pages dev ./build/client
これで http://localhost:8788 でRemixの画面が表示されるようになっているはず。
デプロイ
以前はCloudflare Workersにデプロイしたが、今回はCloudflare Pagesにデプロイする。
# 以前はCloudflare Workersにデプロイした
# pnpm run deploy
# 今回はCloudflare Pagesにデプロイ
pnpm run build
npx wrangler pages deploy ./build/client
結果
110ms。
【2024-11-14】Prismaの導入をしてファイルサイズが増えたらTTFBは遅くなるのか?
パッケージ全部追加
Prismaを入れると一気にファイルサイズが大きくなるがこのときのTTFBの変化を見てみる。
package.json
に元のアプリで使っているパッケージをすべて入れる。
schema.prisma
を元のアプリに合わせて更新。
pnpm i
npx prisma init
npx prisma generate
デプロイコマンドを追加してデプロイ。
{
"scripts": {
"build": "remix vite:build",
+ "deploy": "pnpm run build && wrangler pages deploy ./build/client --project-name=APP_NAME",
DBに接続
トップページでloaderを呼び出しTursoのDBの1つのテーブルから適当な値をとってくる。
結果
サイズ(gzip) | TTFB | |
---|---|---|
アプリ作成時 | 354K | 110ms |
パッケージ全部追加 | 354K | 130ms |
DBに接続 | 354K | 670ms |
【2024-11-16】Tursoに課金してCold Startをなくすのは効果があるのか?
背景
Xで呟いたら @coji さんからこんな返信が。
Turso Database Pricing がこちら。
ということで、とりあえず年間ではなく1ヶ月9ドルの方のプランに入ってみる。
課金して測定
3つ目に作成したアプリ
130msに下がったっぽい?
と思いきやDB接続の箇所でエラーが発生しているので以下のようにするとエラーが出なくなる。
ローカルで開発しているときは問題なかったんだけどな。
import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - the server build file is generated by `remix vite:build`
// eslint-disable-next-line import/no-unresolved
import * as build from "../build/server";
+ import { getLoadContext } from "../load-context";
- export const onRequest = createPagesFunctionHandler({ build });
+ export const onRequest = createPagesFunctionHandler({ build, getLoadContext });
こうするとエラーが出なくなるが1,190msかかった。
2つ目に作成したアプリ
このときに3120msを叩き出した方のサイトで試してみる。
5070msかかった。2回目試すと3530ms。
そもそもサイトに「最初にアクセスする」だけが遅いのではなく「アクセス後に別ページに遷移する」ときにも遅くなるのでTursoのコールドスタートは関係ないのかも?
元のアプリ
有料にしたけど3840ms。
loader()
を削除する
この時Tursoは無料プラン。
230msになった。
loader()
はあるがDBから値は取ってきていない
ここではURLのクエリパラメータをloader()
から取ってきて画面に表示するだけしてみた。
Tursoは無料プラン。
210ms。
loader()
で1行だけデータがあるテーブルの値を取得する
Tursoは無料プラン。
1220ms。
有料プランにする。
1240ms。
う〜ん、やっぱTursoのClod startは関係なさそう。
まとめ
デプロイ先 | TTFB | サイズ | |
---|---|---|---|
Cloudflare Workers | Remixアプリ作成時 | 120 ms | 350 KB |
〃 | Prismaを導入してDBに接続する処理を追加 | 240 ms | 1260 KB |
Cloudflare Pages | Remixアプリ作成時 | 110 ms | 350 KB |
〃 | Prismaを導入してDBに接続する処理を追加 | 670 ~ 1240 ms | 1260 KB |
Tursoを無料版から有料版にすることによるTTFBの変化は見られない。
何回も試してたからすでにあったまっているんだろうか。
【2024-11-16】DBへの接続方法が問題なのか?
この記事を見ながら思ったけどDBに接続している方法が問題なのか、loader周りのなにかがおかしいのかわからない。
以下のように適当に200件のデータを取得して表示してみる。
これでTTFBが早ければDBへ接続する処理周りが原因かも?
コード書き換え
async function getTodos() {
// 20件取得
// const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=20');
const todos = await response.json();
return todos;
// 200件取得(デフォルト)
const response = await fetch('https://jsonplaceholder.typicode.com/todos');
const todos = await response.json();
return todos;
// 400件取得
const response1 = await fetch('https://jsonplaceholder.typicode.com/todos');
const response2 = await fetch('https://jsonplaceholder.typicode.com/todos');
const allTodo = await Promise.all([response1.json(), response2.json()]);
return allTodo.flat();
}
export async function loader({ request, context }: LoaderFunctionArgs) {
const todos = getTodos()
return { todos }
}
export default function IndexPage() {
const { todos } = useLoaderData<typeof loader>();
return (
<Suspense
fallback={
<div>
<p>loading...</p>
</div>
}
>
<Await resolve={todos} errorElement={<p>error</p>}>
{(todos) => {
console.dir(todos);
if (!todos) {
return <p>no data</p>
}
return <ul>
{todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
</ul>
}}
</Await>
</Suspense>
)
}
Cloudflare Pagesで測定
時間を空けて何回か計測。
20件取得
810ms(最初だからかかっただけ?)、400ms、210ms、230ms。
200件取得
1000ms、270ms、420ms。
400件取得
310ms、440ms、470ms。
ネットワークで見たらDNS lookupにかなり時間かかってる
Cloudflare Workerで測定
400件取得
890ms(最初だからかかっただけ?)、220ms、390ms
まとめ
今までのまとめ
Prismaを使ってTursoからデータを取得
デプロイ先 | 処理 | TTFB | サイズ |
---|---|---|---|
Cloudflare Workers | Remixアプリ作成時 | 120 ms | 350 KB |
〃 | Prismaを導入してDBに接続する処理を追加 | 240 ms | 1260 KB |
Cloudflare Pages | Remixアプリ作成時 | 110 ms | 350 KB |
〃 | Prismaを導入してDBに接続する処理を追加 | 670 ~ 1240 ms | 1260 KB |
Tursoを無料版から有料版にすることによるTTFBの変化は見られない。
JSONPlaceholderからデータを取得
デプロイ先 | 処理 | TTFB | サイズ |
---|---|---|---|
Cloudflare Workers | 400件取得 | 220 ~ 890ms | 1260 KB |
Cloudflare Pages | 20件取得 | 210 ~ 810ms | 1260 KB |
〃 | 200件取得 | 270 ~ 1000ms | 1260 KB |
〃 | 400件取得 | 310 ~ 470ms | 1260 KB |
【2024-11-17】再現用のリポジトリ作る
明後日Meetupあるし再現できるようにパブリックなリポジトリ作る。
Cloudflare Worker
# 0. アプリ作成
pnpm create cloudflare@latest remix-on-cf-worker --framework=remix --experimental
# 1. 準備
pnpm add prisma @libsql/client @prisma/adapter-libsql concurrently @remix-run/cloudflare-pages
npx prisma init
# 2. schema.prismaを更新
# 3. モデルファイルを作成
npx prisma generate
# 4. マイグレーションファイルを作成
npx prisma migrate dev --name init
# 5. ローカルで適用
turso db shell http://127.0.0.1:8888 < prisma/migrations/20241117081513_init/migration.sql
Cloudflare Pages
結果
-
/json-placeholder-200
: JSON Placeholderから200件取得して表示 -
/json-placeholder-1000
: JSON Placeholderから1000件取得して表示 -
/db-connect
: Turso(DB)に接続してUser
テーブルから5件、Company
テーブルから5件取得して表示
デプロイ対象 | URL | TTFB |
---|---|---|
Cloudflare Worker | /json-placeholder-200 |
390, 400ms |
〃 | /json-placeholder-1000 |
510, 520ms |
〃 | /db-connect |
1800, 2120, 2200, 2330ms |
Cloudflare Pages | /json-placeholder-200 |
240, 430ms |
〃 | /json-placeholder-1000 |
500, 600msms |
〃 | /db-connect |
1970, 2250,2310ms |
Cloudflare Worker
/json-placeholder-200
/json-placeholder-1000
/db-connect
Cloudflare Pages
/json-placeholder-200
/json-placeholder-1000
/db-connect
Remix + Cloudflare PagesのPublicレポジトリのREADMEは日本語で書いたいたけどPrismaの中の人から以下のようなリプライをいただいたので英語にした。
こういうのはいつかどこかの誰かの助けになるかもだからなるべく英語で書いておくほうがいいな。