はじめに
旅行記録アプリ(Rails の API + Nuxt の SPA)を、PaaS の Render の無料プラン(Free)で公開し直すことになりました。個人開発のデモとして公開するための環境です。
Render の無料プランの Web サービスは、CPU 0.1・メモリ 512MB です。
ところが、このアプリを以前 AWS(ECS Fargate)に載せたときの設定ファイルには、こんなメモを残していました。
Rails + bootsnap + jemalloc + image_processing で 512MB は起動失敗リスクあり、1024MB に拡大
「512MB だと起動できないかもしれない」と、過去の自分が書いていたわけです。でも、これは実際に測った結果ではなく、念のための判断でした。
いきなりデプロイして試すより先に、Render の無料プランと同じ CPU・メモリの数値を上限にして、手元の Docker で起動してみることにしました。
先に結論:起動直後と、軽いアクセスを 30 回続けたあとは、メモリが 512MB の 4 分の 1 ほど(113〜129MiB)に収まっていました。一方で、CPU を 0.1 に制限した今回の環境では、ヘルスチェックに応答するまで 89 秒かかりました。メモリより起動時間の方が、気にすべき点だと分かりました。
Render の公式ドキュメントでは、無料プランのインスタンスは本番用途(production)には使わず、趣味のプロジェクトや技術の試用などに使うよう案内されています。今回は、個人開発のデモを公開する環境として使っています。
環境(測定時)
- Mac(Apple M2)+ Docker Desktop(Docker Engine 29.4.1、linux/arm64)
- Ruby 3.4.9 / Ruby on Rails 8.1.3(測定時。現在は 8.1.4 に更新済み)
- Puma(シングルモード・3 スレッド)+ Thruster
- イメージ:Rails が生成した本番用の Dockerfile をベースにしたもの(jemalloc・bootsnap の事前コンパイル入り)
- DB:手元の Docker の MySQL 8.0(開発用のデータ入り)
やったこと:Render の無料プランの数値を上限にして起動する
1. 本番用のイメージを作る
cd backend
docker build -t myapp-backend:memtest .
2. メモリと CPU を制限して起動する
ポイントは次の 3 つのオプションです。
| オプション | 意味 |
|---|---|
--memory=512m |
コンテナが使えるメモリの上限 |
--memory-swap=512m |
メモリとスワップの合計の上限。--memory と同じ値にすると、スワップを使えなくなる
|
--cpus=0.1 |
使える CPU の上限(CPU 0.1 個分) |
--memory だけを指定すると、環境によっては足りない分をスワップで補って動き続けてしまいます。512MB の環境に近づけるため、--memory-swap も同じ値にしてスワップを封じました。
docker run -d --name myapp-memtest \
--memory=512m --memory-swap=512m --cpus=0.1 \
-p 3099:3000 \
-e RAILS_MASTER_KEY=... \
-e DB_HOST=host.docker.internal -e MYSQL_PORT=3316 \
-e MYSQL_USER=... -e MYSQL_PASSWORD=... -e MYSQL_DATABASE=... \
myapp-backend:memtest
(秘密の値は ... にしています。DB は手元の MySQL につないでいます)
3. 起動するまでの時間を測る
ヘルスチェックの API が応答するまで、2 秒おきに確かめました。
start=$(date +%s)
until curl -sf -m 3 http://localhost:3099/api/v1/health > /dev/null; do sleep 2; done
echo "boot: $(( $(date +%s) - start ))s"
boot: 89s
4. メモリの使用量を見る
docker stats --no-stream --format "mem={{.MemUsage}}" myapp-memtest
mem=113.2MiB / 512MiB
5. 少しアクセスしてから、もう一度見る
旅行一覧の API を 30 回続けて(1 回ずつ順番に)叩き、ステータスと応答時間を集計しました。
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code} %{time_total}\n" "http://localhost:3099/api/v1/trips?sort=recent"
done | awk '{c[$1]++; s+=$2; if($2>m)m=$2} END {for(k in c) printf "status %s x%d ", k, c[k]; printf "avg %.2fs max %.2fs\n", s/NR, m}'
status 200 x30 avg 0.58s max 2.08s
docker stats --no-stream --format "mem={{.MemUsage}}" myapp-memtest
docker inspect -f 'OOMKilled={{.State.OOMKilled}} running={{.State.Running}}' myapp-memtest
mem=128.5MiB / 512MiB
OOMKilled=false running=true
結果
| 項目 | 結果 |
|---|---|
| ヘルスチェックに応答するまで | 89 秒(うち約 15 秒は認証情報の探索待ち。後述) |
| メモリ(起動直後) | 113.2MiB / 512MiB |
| メモリ(30 回のアクセス後) | 128.5MiB / 512MiB |
| メモリ不足による強制終了 | なし(OOMKilled=false) |
| 応答 | 30 回すべて 200、平均 0.58 秒、最大 2.08 秒 |
起動直後と 30 回の順番どおりのアクセスのあとでは、メモリは 512MB の約 4 分の 1 に収まっていました。少なくとも、起動と軽いアクセスの範囲では、512MB で動かせました。「512MB では起動できないかも」というメモは、この構成では心配しすぎだったことになります。
ただし、docker stats --no-stream はその瞬間の値です。同時にたくさんアクセスされたときや、画像を加工している最中のようなピーク時のメモリを確かめたものではありません。
なお、docker stats のメモリ使用量は、Linux ではキャッシュの分を差し引いた値です。
気にすべきだったのは起動時間
メモリより気になったのは、起動の 89 秒でした。コンテナのログを時刻つきで見ると、内訳はおおよそ次のとおりです。
docker logs -t myapp-memtest
| ログの時刻(UTC) | 出来事 | かかった時間(目安) |
|---|---|---|
| 〜23:56:20 | 起動スクリプトで bin/rails db:prepare(DB の作成・マイグレーションの確認)→ Thruster が起動 |
約 40 秒 |
| 23:56:38 |
=> Booting Puma(Rails 本体の起動開始) |
約 18 秒 |
| 23:56:53 | Error retrieving instance profile credentials ... (execution expired) |
約 15 秒 |
| 23:57:06 | Listening on http://0.0.0.0:3000 |
約 13 秒 |
(docker run を実行した時刻はログに残らないため、最初の約 40 秒は全体の 89 秒からの逆算です)
気づいた点は 2 つです。
-
起動のたびに
db:prepareが走ります。このプロジェクトのbin/docker-entrypoint(Rails が生成したもの)は、rails serverの前にdb:prepareを実行する作りでした。この処理でも Rails を一度読み込むので、CPU を 0.1 に制限した環境では、それだけで 40 秒ほどかかっていました - 途中で 15 秒ほど待たされています途中で、認証情報を探しに行ってタイムアウトしています**。画像の保存先(S3)の認証情報を渡していなかったため、AWS の SDK が認証情報を順番に探し、最後に EC2 のインスタンスプロファイルを確かめに行って、タイムアウトしていました。あとで測り直すと、この探索で増えていた時間は CPU 0.1 で 5 秒ほどでした(ログの間隔の 15 秒には、Rails 本体の読み込みも含まれていました)。AWS SDK for Ruby は有効な認証情報が見つかった時点で探索を止めるので、認証情報を渡せばこの待ち時間は無くなります(この件は別の記事で詳しく書きます)
Render の無料プランは、15 分間アクセスが無いと停止し、次のアクセスで起動し直します。公式には、その復帰に「約 1 分」かかると書かれています。
手元の 89 秒が、そのまま Render での起動時間になるわけではありません。それでも、CPU が小さい環境では起動が遅くなりやすいことを確認できたので、デプロイしたあとに実際の起動時間を測る必要があると分かりました。手元の測定でリスクを見つけ、本番の環境で確かめる、という順番です。
注意:手元の測定はあくまで目安
- Mac の Docker Desktop は、Linux の仮想マシンの上でコンテナを動かしています。CPU の性能やアーキテクチャ(今回は arm64)も、実際の PaaS とは違います
-
--cpus=0.1で揃えられるのは「使える CPU の上限の数値」までです。元の CPU の速さや、PaaS 側の割り当て方までは再現できません
それでも、「メモリは足りそうか」「起動に何が時間を使っているか」の見当を、デプロイ前に、お金をかけずに付けられるのは大きかったです。
教訓
- 「512MB では足りないかも」のような過去の推測は、測ってから判断します。今回の条件では、4 分の 1 ほどしか使っていませんでした
- ただし、測った範囲より広いことまで結論にしないようにします。今回分かったのは「起動と軽いアクセスでは収まる」までです
- メモリを制限するときは
--memory-swapも同じ値にして、スワップを封じると、より近い条件で試せます - 起動が遅いときは、ログの時刻で区切って、何に時間を使っているかを分けて見ると、CPU のせいなのか、別の待ち時間なのかを切り分けられます
おわりに
過去の自分のメモを信じていたら、「512MB は無理だから有料プランにしよう」と判断していたかもしれません😅 docker run のオプションを 3 つ付けるだけで、デプロイ前に答え合わせの見当を付けられるのはとても便利でした。
小さな無料プランに載せられるか迷っている方の、判断材料になれば嬉しいです🙌
参考
- Docker Docs: Resource constraints(Limit a container's access to memory)
https://docs.docker.com/engine/containers/resource_constraints/#limit-a-containers-access-to-memory - Docker Docs: Resource constraints(Prevent a container from using swap)
https://docs.docker.com/engine/containers/resource_constraints/#prevent-a-container-from-using-swap - Docker Docs: Resource constraints(Configure the default CFS scheduler)
https://docs.docker.com/engine/containers/resource_constraints/#configure-the-default-cfs-scheduler - Docker Docs: docker container stats
https://docs.docker.com/reference/cli/docker/container/stats/ - Render Docs: Compute Plans
https://render.com/docs/compute-plans - Render Docs: Deploy for Free
https://render.com/docs/free - AWS SDK for Ruby Developer Guide: Using AWS SDK for Ruby credential providers
https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/credential-providers.html