Cloud RunとLitestreamで激安GraphQL/RDBサーバーを動かす
安いRDBといえばPlanetScaleのHobbyプランだったり、D1だったり、古き良きVPSでセルフマネージなんて選択肢もあるが、GCPで完結すると嬉しいだとかホストマシンの面倒を見たく無いだとか、そういう場合もあると思う。
なお今回の構成で本当に激安になるかどうか無料枠で収まるかどうか、などはインスタンス設定やワークロード次第の部分もあるので試算したり予算アラートを設定した方がいい。
また個人でのホビー用途など、データが欠損してもごめんごめんで許される用途での利用に留めておいた方が無難だとは思う。ごめんごめんで許されない場合にはCloud SQLとかへ移行するなり、最初からCloud SQLとかを採用するなりしたほうがいい。
完成品のサンプルコード
これはeslint設定やらローカル環境向けのdocker-compose.ymlやらも一式入った、開発環境スターターキットになっている。
packages
全般的にバンドルサイズが小さいパッケージを選択してる。Cloud Runのコールドスタート時パフォーマンスにバンドルサイズ自体はあんまり関係ないらしい(ビルド時間やデプロイ時間には影響する)が、最近はサーバーサイドのライブラリであってもCloudflare Workers(スクリプトサイズ上限が1MB)みたいな環境でも使いやすいライブラリに将来性を感じている。
graphql-helix
GraphQLサーバーのシェアで言えばおそらくApollo Serverがトップだと思うが、軽量なGraphQL Helixを今回のサンプルでは採用している。Helixは軽量GraphQLサーバーというよりもGraphQLサーバーを実装するためのユーティリティ関数集みたいな感じなので必要な機能だけ実装すれば自然と軽量になる。なおhelixとセットで名前がよく挙がるenvelopは今回のサンプルでは利用していない。
esbuild
TSのトランスパイルおよび、コールドスタート時の実行時requireを減らすためNode.js環境向けにバンドルするのに利用。webpackでも同様にNode.js向けバンドルが出来るみたいだがwebpackなにもわからないむずかしい。
@graphql-tools/schema
Nexusは採用していない。今のNexusは普通に使うとかなりバンドルサイズが大きい。個人差はあると思うが、大きくDXが損なわれるというわけでもないのでスキーマファーストを採用。
Prisma Client
これも現状だとバンドルサイズがそこそこ大きい。その上ネイティブモジュールのクエリエンジンが必要なのでたぶん普通にはCloudflare Workers上で扱えない。workers-D1間接続に使うならPrisma Data Proxyを利用する事になるんじゃないかと思う。Prisma Data, Inc.的にもPDPを使ってほしいだろうから将来的にここら辺がどうにかなる可能性も低い気がする。
と逆風はあるものの無いとDXが大きく損なわれ、これといった有力な代替も現状ないと思うので採用。Cloudflare, Inc.がPrisma Data, Inc.を買っていい感じにしてほしい。
実装
特筆する事がないので省略。サンプルとして単純なスキーマを実装してある。
Dockerfile.local
手元のdocker-composeから使う用。run_dev.shからはesbuildのwatchモードでビルドしてnodemonで起動している。
DATABASE_URL="file:../local.db"
みたいな.envを作成してdocker-compose up
すると起動する。コンテナ内でnpm ci
してるが、ホストマシン側のVSCodeやIDE等からはそれが見えないのでホスト側でもnpm ci
するとよい。
Dockerfile
Cloud Runで使う用。builder側にもSQLiteを入れないとprisma generateが走らないことに気付かずしばらくはまった。基本的にはnode_module配下のライブラリは全てindex.jsへバンドルされているのだがクエリエンジンを含んだ.prismaに関してはbuilderからコピーしている。
なお何らかの形で初回デプロイだけ/prod.dbを本番コンテナへ送り込む必要がある。
run.sh
/prod.dbが存在しなければGCSからのリストアをして、最後にlitestream経由でindex.jsを起動している。
build.ts
NODE_ENVがdevelopmentの場合はwatchモードでビルドされる。またその場合はonRebuildでnpm run gen
を起動してresolver型も再生成するようにしている。
GCPの設定
Google Cloud StorageにLitestream用のバケットを作る
よしなに
Cloud RunのコンテナインスタンスからGoogle Cloud Storageへアクセスできるようにする
Default compute service account
という名前でCloud Runが標準で利用するサービスアカウントが存在するが、これには編集者ロール(roles/editor)
が付いてるので何もしなくてもCloud RunのlistestreamからGCSへアクセスできるはず。
なのだけれど編集者ロールは過剰権限なので本当は新しくサービスアカウントを作った方がいい。コンソールからの操作であれば「IAMと管理」からCloud Run 起動元(roles/run.invoker)
、ストレージ オブジェクト作成者(roles/storage.objectCreator)
、ストレージ オブジェクト閲覧者(roles/storage.objectViewer)
を付ける。
deploy
gcloud beta run deploy hogeservicename --source . --execution-environment gen2 --max-instances 1 --set-env-vars DATABASE_URL="file:/prod.db",REPLICA_URL="gcs://hogebucketname/prod"
手元のGraphiQLとかで動作をとりあえず試すならAllow unauthenticated invocations to [hogeservicename]
はyで公開アクセス可能に。
SQLiteのそもそもの成り立ちからして、複数のアプリーションコンテナから同時にアクセスがあった時の排他制御がちゃんと考慮されているわけではないと思う(そんなこともない?)ので最大インスタンス数は1に設定。なおここではCLIオプションで指定しているがyamlを作成して利用しても良い。
リクエスト処理(mutation)が終わった後にLitestreamによるレプリケーションが走る事を考えると--no-cpu-throttling
を付けた方が良いと思われるが、私の環境では付けなくても概ねレプリケーション成功してそう(とはいえレプリケーションに時間がかかるような更新だと失敗しそう)だった。
仮に--no-cpu-throttling
で常時cpu割当てを有効にした場合でも、リクエストがなくなってから15分後にコンテナ自体が落ちる(最小インスタンス数設定が0なら)のであんまりリクエストが来ないような用途であれば安価に収まりそう。
雑ベンチ
手元からcurlで10回クエリーを投げて雑にベンチをとってみる。
for i in {1..10} ; do curl -w "code: %{http_code}, speed: %{time_total}\n" -o /dev/null -s -X POST -d "{\"query\":\"{statuses{id body createdAt}}\"}" https://example.com/graphql ; done
code: 200, speed: 2.925923
code: 200, speed: 0.124769
code: 200, speed: 0.147736
code: 200, speed: 0.146688
code: 200, speed: 0.130952
code: 200, speed: 0.156702
code: 200, speed: 0.126227
code: 200, speed: 0.142072
code: 200, speed: 0.137412
code: 200, speed: 0.137263
一回めはコールドスタートなので結構かかっている。コールドスタート時には通常の初期化処理に加えて、今回の構成だとGCSからダウンロードしてのDB復元処理も走るので、DBサイズ増大に比例して増大すると思われる。
ただスケールアウトを考慮せず(そもそもできない)1コンテナに限って運用するのであればコールドスタートはあまり問題にはならない。
14分に1回サーバーの生存を確認をしたい場合
や
などを利用するとよい。
--no-cpu-throttling
と組み合わせる場合は24時間ずっとcpu割り当てになってしまうので、それはあれ。
Artifact Registryのcloud-run-source-deployに溜まる古いバージョンのイメージを削除したい場合
Discussion
wal を有効にする実装が見当たらなかったので、 mutation した場合、結果が litestream でレプリケーションされないのでは?という気がしました。
Litestreamが自動的に設定するみたいですね
知らなかった!ありがとうございます 🙌