コスト削減のために開発環境の DB を夜間だけ止める — Cloud Scheduler + Workflows で Cloud SQL を平日22時に停止する
・ 読了 約15分

現在、WebアプリケーションをGoogle Cloudを用いて開発しています。大規模なアプリケーションではないですが、まだ売り上げを生んでいないものなので、できる限りコストは抑えたいです。
API サーバー、データ処理サービス、常駐workerなどにCloud Runを使用しており、データベースはCloud SQL、非同期処理用にはCloud Tasksを使用しています。
開発者のタイムゾーンは全員日本(JST)であり、開発環境は、夜間や週末にはほとんど使いません。
また、Cloud Run の API サーバー群は min-instances=0 にしているため、リクエストがなければ基本的にインスタンスは起動せず課金されません。一方で、Cloud SQL と min-instances=1 で動かしている worker は、使っていない時間帯も課金が続くためここの費用を抑えたいと考えていました。
そこで、平日の8時から22時までだけ開発環境を動かし、それ以外の時間は自動で停止する仕組みを作りました。
稼働時間は概算ではありますが、週168時間から週70時間になり、単純計算では約6割削減できます。この記事では、実際に構築した仕組みと、運用してから気づいた点を紹介します。
Cloud SQLだけを止めればよいわけではなかった
当初は、これらの中では比較的料金がかかるCloud SQLを夜間だけ停止すれば十分だと考えていました。しかし、実際に停止対象としたのはCloud SQLだけではなく、常駐させているCloud Run、Cloud Tasksも停止させています。
- Cloud SQL
activationPolicyをNEVERにして停止し、ALWAYSに戻して起動する - Cloud Runの常駐worker
min-instancesを0にする - Cloud Tasksのキュー
キューをpauseする
当然と言えば当然ですが、Cloud SQLだけを停止すると、workerやCloud Tasksの再試行はそのまま動き続けます。
その結果、停止中のDBに対して数秒おきに接続を試み、夜通しエラーログを出し続ける状態が起きてしまいました。無駄なログが増えるだけでなく、ログ量に応じた費用も発生してしまいます。
そのため、DBを止める場合は、DBに依存する処理も一緒に止める必要がありました。
停止時と起動時では操作順を逆にする
当たり前の内容かもしれませんが、停止時と起動時の順序は、次のようにしています。
stop : キューをpause → workerのmin-instancesを0 → Cloud SQLを停止
start : Cloud SQLを起動 → workerのmin-instancesを復元 → キューをresume停止時は、DBに依存する側から先に止めます。
反対に、起動時は依存される側であるDBから先に起動します。こうすることで、DBが利用できない状態のままworkerやキューだけが動き始める時間を作らずに済みます。
起動処理では、WorkflowsのコネクタはCloud SQLのlong-running operation(インスタンスの起動のように完了まで時間のかかる操作)の完了を自動で待ってから次に進んでくれるため、DBが使える状態になってから次の処理に進みます。
構成はCloud SchedulerからWorkflowsを実行するだけ
自動化は、Cloud SchedulerとCloud Workflowsの2段構成にしています。
Cloud Scheduler
stop : 平日22:00
start: 平日08:00
↓
Workflow Executions API
↓
Cloud Workflows
├─ Cloud Tasksのpause / resume
├─ Cloud Runのmin-instances変更
└─ Cloud SQLの停止 / 起動
Cloud Schedulerのcronは2本です。

前述のように開発者が全員日本在住のためSchedulerのtime_zoneにはAsia/Tokyoを指定しています。
これにより、UTCへ換算せずJSTのままcronを記載しています。時刻設定は後から見直すことも多いため、UTC換算を挟まないだけでも読み間違いを減らせます。
金曜日の22時に停止した後は、土日にstartジョブが実行されないので、そのまま月曜日の朝まで停止します。
夜間停止・起動をどう動かすか(Workflowsの採用)
さてここまでWorkflowsについてはあまり触れてませんでしたが、Workflowsとは、Google Cloudのマネージドなオーケストレーションサービスです。「どのAPIを、どの順番で呼ぶか」という手順を書いておくと、その通りにサーバーレスで実行してくれます。実行基盤となるVMやコンテナを自分で用意する必要はなく、Cloud SQL・Cloud Run・Cloud Tasksといった各サービスのAPIを、コネクタ経由で定義したYAMLファイルから直接呼び出せます。
今回の夜間停止・起動は、「キューを止める → workerを止める → DBを止める」(起動はその逆順)という、順序のある複数操作です。同じ処理はcronからgcloudコマンドを複数書くことで構築することもできますが、次の理由からWorkflowsを選びました。
- VMやコンテナなどの実行基盤を管理しなくてよい
- Google Cloudの各APIをYAMLから直接呼び出せる
- 処理の順序や、どのステップで失敗したかを確認しやすい
- 専用のサービスアカウントで実行できるため、開発者のローカル認証やアクセストークンに依存しない。「認証が切れて夜間停止だけ動かなくなる」といった問題を避けられる
実際の動きとしては、Cloud Schedulerが決まった時刻にWorkflowsを呼び出し、Workflowsが上記の順序で各操作を実行します。
Cloud Runの更新にはactAs権限も必要
Cloud Runのmin-instancesを変更すると、Cloud Runは新しいリビジョンを作成します。リビジョンには「このコンテナをどのサービスアカウントとして動かすか」という指定が含まれるため、min-instancesを1つ変えるだけでも、内部的には「workerのランタイムサービスアカウントとしてコンテナを起動する」操作になります。
Google Cloudでは、あるサービスアカウントとして何かを起動する行為を「そのサービスアカウントを使う(actAs)」とみなし、専用の権限を要します。このときIAMは「そのAPIを呼び出している本人が、対象のサービスアカウントを使ってよいか」を確認します。今回、Cloud Runを変更するAPIを実際に呼び出しているのはWorkflowsです。そのため、actAsの権限は、呼び出し元であるWorkflowsのサービスアカウントに付ける必要があります。
Workflowsのサービスアカウントに、workerのサービスアカウントに対する roles/iam.serviceAccountUser を付与しているのはこのためです。この権限はプロジェクト全体ではなく対象のサービスアカウントにだけ付けているので、Workflowsがなりすませる相手はworkerのサービスアカウント1つに限られます。
Workflows実装で気をつけたこと
Workflowsは、YAMLで「ステップを上から順に実行する手順書」を書きます。Google CloudのAPIは、コネクタという形で call で呼べます。たとえば googleapis.run.v2.projects.locations.services.patchと書けば、Cloud Runサービスを更新するAPIをYAMLから直接呼び出せます。
Cloud Runのmin-instancesはupdateMaskで部分更新する
その中で、Cloud Run workerのmin-instances変更は次のように書いています。 services.patch というコネクタを呼び、 updateMaskで更新対象を限定しているのがポイントです。
- patch:
call: googleapis.run.v2.projects.locations.services.patch
args:
name: ${worker_service}
updateMask: template.scaling.minInstanceCount
body:
template:
scaling:
minInstanceCount: ${min_instances}
Cloud Runのサービス定義は、イメージ・環境変数・インスタンス数・サービスアカウントなどを含んだ、1つの大きな設定の塊です。今回変えたいのは、その中の template.scaling.minInstanceCount という1フィールドだけです。updateMask は、「bodyの中で、ここに挙げたフィールドだけを反映し、それ以外には触れない」という指示です。
この限定が効くのは、並行して別の更新が走る場合です。サービス全体を取得し、値を書き換えてから丸ごとpatchする方法もありますが、その場合は取得した時点の定義をまるごと送り返すため、取得とpatchの間に別のデプロイ(たとえばCDによる新しいイメージの反映)が入ると、その新しい内容を古い定義で上書きしてしまう可能性があります。
Cloud SQLはactivationPolicyを変更する
Cloud SQLインスタンスには、 activation policy(起動ポリシー) という設定があります。これは「インスタンスを起動した状態に保つかどうか」を決めるもので、 ALWAYSと NEVERの2つの値があります。
- patch:
call: googleapis.sqladmin.v1beta4.instances.patch
args:
project: ${project_id}
instance: ${db_instance}
body:
settings:
activationPolicy: ${policy}
停止時にはNEVER、起動時にはALWAYSを渡します。${policy}には、停止のステップから呼ぶときはNEVER、起動のステップから呼ぶときはALWAYSが渡されます。
Cloud Tasksのキュー名は固定しない
Cloud Tasksのキューは、夜間停止・起動のワークフローが実行されるたびに queues.list で取得し、そのとき存在するすべてのキューをpauseまたはresumeしています。
キュー名をWorkflowsやTerraformに直接書く方法もありますが、その場合はインフラ構築の時点でキュー名が固定されてしまうため、アプリケーション側の変更でキューが増減すると定義とのずれが起きます。
ワークフローの実行時にキューを列挙すれば、常にその時点のキューを対象にできるので、キューを追加しても夜間停止の設定を変更せずに済みます。
実装時に詰まった点
Workflowsは、普段書くアプリケーションのコードとは式の書き方や実行モデルが異なります。ここでは、実装中に想定と違って引っかかった点について記載します。
Workflowsの式内で空のmapを書けない
Schedulerから引数なしでWorkflowsが呼ばれると、argsはnullになります。このnullを空のmapとして扱ってからactionを読み出そうとして、 default(args, {}) のように式の中で空のmapリテラル {} を使ったところ、パースエラーになりました。
これはYAMLの制約ではなく、Workflowsの式(${...}の中)の制約です。YAMLとしては{}を空のマップとして書けますが、Workflowsの式の文法にはmapリテラルがないため、式の中に{}を書くと評価時にエラーになります。
そのため、argsのnull判定は switch で行い、argsがあるときだけ map.get でactionを取り出すようにしました。
- init:
assign:
- action: ""
- read_action:
switch:
- condition: ${args != null}
steps:
- assign_action:
assign:
- action: ${default(map.get(args, "action"), "")}Schedulerの再試行間隔を長めにする
Cloud Schedulerの再試行設定では、min_backoff_durationを60秒にしています。
min_backoff_duration = "60s"Cloud SQLの起動や停止には、通常1〜2分ほどかかります。
SchedulerからWorkflowsを起動するAPIがタイムアウトし、同じ処理が再送された場合、再試行間隔が短いと、先に開始したoperationと後続のoperationが競合する可能性があります。
そのため、先行処理がある程度進んでから再試行されるようにしました。
Terraformとの共存
実装後に問題になったのが、Workflowsによる変更をTerraformが元に戻してしまうことでした。
Terraformは「あるべき状態」を宣言し、 terraform apply のたびに実際の状態を宣言値へ合わせにいきます。そのため、WorkflowsのようにTerraformの外から加えた変更は、次のapplyで宣言値に戻されます。今回の3つの操作は、この「戻される/戻されない」の関係がそれぞれ違いました
対象 | Terraformとの関係 | 対応 |
|---|---|---|
Cloud SQLの | 宣言値との差分が出る |
|
workerの |
| CD実行後に停止状態を再適用 |
Cloud Tasksのpause状態 | providerの管理対象外 | 対応不要 |
Cloud SQLのactivationPolicyは、ignore_changes でTerraformの管理から外すだけで済んでいます。
問題となったのが、Cloud Run workerのmin-instancesです。
min-instances は、上の2つと違って ignore_changes にしていません。これはworkerの本来の稼働値(=1)であり、通常はTerraformに管理させたい値だからです。
その結果、例えば、夜間停止中にmainブランチへ変更がマージされると、CDからterraform applyが実行され、 min-instances がTerraformの宣言値である1に戻ってしまいます。つまり、夜間にもかかわらずworkerだけが再び常駐してしまいます。
そこで、CDの最後に次の処理を追加しました。
- name: 停止中なら自動停止を再実行する
run: |
policy=$(gcloud sql instances describe "${INSTANCE}" \
--format='value(settings.activationPolicy)')
if [ "${policy}" = "NEVER" ]; then
gcloud workflows run dev-cloud-schedule \
--data='{"action":"stop"}'
fi
Cloud SQLのactivationPolicyがNEVERであれば、現在はdev環境の停止時間帯だと判断させ、停止用のWorkflowsを再実行する形にしています。
ポイントはCD内に停止処理を直接書かず、既存のWorkflowsへ処理を委譲している点です。停止対象や処理順が将来変わった場合も、Workflowsだけを修正すれば済むようにしています。
手動操作と監視も用意しておく
もちろん、臨時でdev環境を利用したい場合に備えて、同じ3つの操作を同じ順序で実行するgcloudベースのシェルスクリプトも用意しています。
夜間や休日になんらかの事情でdev環境を使いたい場合は、手動でstartを実行します。その後は、次の自動停止時刻になれば再び停止します。
また、WorkflowsとCloud Schedulerの失敗は、ログベースのアラートで通知しています。 このWorkflowsは1日2回しか実行されないため、 call_log_level を LOG_ALL_CALLS にして、すべての呼び出しとその結果を記録しています。ログ量を抑えることよりも、失敗時にどこまで成功して、どの処理で・どんな値で止まったのかを追いやすくすることを優先しています。
call_log_level = "LOG_ALL_CALLS"まとめ
今回の実装で重要だったのは、Cloud SQLを止めること自体よりも、その周辺を含めて停止状態を設計することでした。
dev環境の稼働時間は、おおよその概算ではありますが、週168時間から70時間になりました。これにより、Cloud SQLと常駐workerの稼働(課金対象)時間を約6割削減することができるようになりました。

Resonalエンジニアリング部
Google Cloudや生成AIの導入、プロダクト開発における役立つコンテンツをお届けします。

