# Cloud Run のアプリに月1バッチを足す — Cloud Scheduler でやってみた

> Cloud Run にはスケジュール実行の仕組みがないので、Cloud Scheduler から HTTP エンドポイントを叩いて月次バッチを組んでみました。ジョブ作成から、来月を待たずに動作確認するところまでの記録です。

- 公開日: 2026-07-22
- 著者: 古川
- タグ: Cloud Run, インフラ
- URL: https://tech.anycloud.co.jp/articles/cloud-run-cloud-scheduler-cron

---

## 「月に1回だけ動かしたい処理」が出てきた

運用していると、月に1回だけ回したい処理が出てきます。溜まったデータを集計して通知する、取りこぼしを検知する、といったやつです。これまでは手元から手動でスクリプトを叩いていたのですが、毎月やるのは単純に忘れますし、面倒でもあります。自動化したくなりました。

対象のアプリは Cloud Run で動いています。ここで少し手が止まりました。Cloud Run 自体には「毎月1日の4時に動かす」ようなスケジュール実行の仕組みが組み込まれていません。常駐しているサーバに時計を持たせる（`node-cron` のような）手もありますが、Cloud Run はリクエストに応じてインスタンスが増減するので、複数インスタンスで多重実行されたり、逆にインスタンスがいなくて動かなかったりして筋が悪い。

調べると、Cloud Run で定期実行したいときの定石は **Cloud Scheduler から HTTP エンドポイントを叩く**ことでした。実際に組んで、来月を待たずに疎通確認するところまでやってみたので記録します。

## 構成はシンプル

Cloud Scheduler は「決めた時刻に、指定した URL へ HTTP リクエストを送るだけ」のサービスです。処理の中身は持っていません。

```
毎月1日 04:00 になったら
  Cloud Scheduler ──[ GET ]──▶ Cloud Run のエンドポイント ──▶ 処理を実行
```

なので用意するものは2つです。叩かれたら処理が走るエンドポイントを1本アプリに生やして、それを定期的に叩くジョブを Cloud Scheduler 側に作る。それだけです。

## アプリ側：叩かれるエンドポイントを用意する

Express だとこんな形になります。

```ts
// Cloud Scheduler から叩かれたら月次処理を走らせるエンドポイント
router.get('/cron/monthly-batch', async (req, res) => {
  // Cloud Scheduler が付けるヘッダで簡易的にガードする
  if (req.header('X-CloudScheduler') !== 'true') {
    return res.status(400).json({ message: 'Invalid request' })
  }
  const result = await runMonthlyBatch()
  await notifySlack(`月次バッチ完了：${result.length} 件`) // 結果を Slack に通知
  res.json({ success: true, count: result.length })
})
```

今回の例では、処理の結果を Slack に通知するところまでをエンドポイントに含めています。定期実行は普段は誰も見ていないので、動いたこと・結果を通知で残しておくと安心ですし、あとで動作確認するときも「通知が届くか」で経路が通ったかを判断できます。

もう一つ気に留めておきたいのが、Cloud Run のサービスは `--allow-unauthenticated` で公開していることが多く、その場合このエンドポイントも素のままだと URL を知っていれば誰でも叩けてしまう点です。とりあえず今回は「Cloud Scheduler が付けるヘッダの有無」で弾いていますが、これは後述するとおり本物の認証ではありません。

## Cloud Scheduler のジョブを作る

`gcloud` で作成します。

```sh
gcloud scheduler jobs create http monthly-batch \
  --location=asia-northeast1 \
  --schedule="0 4 1 * *" \
  --time-zone="Asia/Tokyo" \
  --uri="https://<your-service>-xxxx.a.run.app/cron/monthly-batch" \
  --http-method=GET \
  --headers="X-CloudScheduler=true"
```

`--schedule` は crontab 形式です。5つの値を半角スペースで区切って実行タイミングを表す書き方で、左から **分・時・日・月・曜日** の順に並びます。`*` は「毎」を意味します。

今回の `0 4 1 * *` を左から読むと、分=0、時=4、日=1、月=毎月、曜日=毎日なので「毎月1日の 4:00」になります。日時を変えたいときは、この5つを差し替えるだけです。よく使う形を並べておきます。

| やりたいこと | 書き方 |
| --- | --- |
| 毎月1日 4:00 | `0 4 1 * *` |
| 毎日 4:00 | `0 4 * * *` |
| 毎週月曜 9:30 | `30 9 * * 1` |
| 毎月15日 0:00 | `0 0 15 * *` |
| 1時間ごと（毎時0分） | `0 * * * *` |

曜日は `0` が日曜で `6` が土曜です（`1` が月曜）。ここだけ 0 始まりなので、時刻の感覚で `7` などと書かないよう注意します。あと、「毎月末」のような条件は crontab だけでは素直に書けません。凝った日付判定が必要なときは、いっそ毎日動かしてアプリ側で「今日が月末か」を判定する、という逃げ方もあります。

なお `--time-zone` を省くと UTC 基準で解釈されるので、JST の時刻で書きたいなら `Asia/Tokyo` を明示します。`--headers` はさっきのガード用ヘッダを乗せているだけです。

作成すると、設定内容が返ってきます（プロジェクト名や URL はマスクしています）。

```yaml
name: projects/<project>/locations/asia-northeast1/jobs/monthly-batch
schedule: 0 4 1 * *
timeZone: Asia/Tokyo
state: ENABLED
attemptDeadline: 180s
httpTarget:
  uri: https://<your-service>-xxxx.a.run.app/cron/monthly-batch
  httpMethod: GET
  headers:
    X-CloudScheduler: 'true'
scheduleTime: '2026-07-31T19:00:00Z'
```

ここで少し混乱したのが `scheduleTime` です。`2026-07-31T19:00:00Z` と UTC で返ってくるので一瞬「4時じゃない？」と思うのですが、UTC の 19:00 は JST の翌日 04:00。`Asia/Tokyo` はちゃんと効いていて、表示が UTC なだけでした。次回実行が意図どおりか確認するときは、頭の中で +9 時間するのを忘れないようにします。

## 来月を待たずに動作確認する

月1のジョブが本当に動くのかを、実際に来月まで待って確かめるのは現実的ではありません。Cloud Scheduler にはジョブを手動で即実行する機能があります。

```sh
gcloud scheduler jobs run monthly-batch --location=asia-northeast1
```

これでその場でエンドポイントを叩いてくれます。実際に走らせたら Slack 通知が飛んできて、`Cloud Scheduler → Cloud Run → 処理 → 通知` の経路が最後まで通っていることを確認できました。ここまで見えると安心感が違います。

エンドポイント単体の疎通は `curl` でも確認できます。ついでにヘッダの有無で挙動が変わることも見ておきました。

```sh
# ヘッダあり → 通る
$ curl -s -H "X-CloudScheduler: true" https://<your-service>-xxxx.a.run.app/cron/monthly-batch
{"success":true,"count":0}

# ヘッダなし → 弾かれる
$ curl -s -w " (%{http_code})" https://<your-service>-xxxx.a.run.app/cron/monthly-batch
{"message":"Invalid request"} (400)
```

ガードが効いていることと、エンドポイント自体が本番で生きていることの両方が確認できました。

## ハマる前に押さえておきたい点

### ヘッダチェックは「認証」ではない

`X-CloudScheduler: true` は誰でも付けられるただのヘッダなので、URL さえ知っていれば第三者にも叩けます。今回は読み取りと通知だけの処理なので簡易チェックで妥協しましたが、書き込みを伴う処理を自動化するならここは省けません。

本筋は、Cloud Scheduler に OIDC トークンを付けさせて（`--oidc-service-account-email` と `--oidc-token-audience`）、アプリ側でそのトークンを検証する方法です。これなら「正規のサービスアカウントが署名した短命トークンを持つ呼び出しだけ通す」ことができます。なお Cloud Run 全体を認証必須（`--no-allow-unauthenticated`）にする手もありますが、同じサービスで公開 API も配信していると全体が閉じてしまうので、その場合はアプリ層で該当エンドポイントだけトークンを検証することになります。

### 料金はほぼかからない

Cloud Scheduler は無料枠があり、[公式の料金ページ](https://cloud.google.com/scheduler/pricing)によると月あたり3ジョブまで無料、超過分が1ジョブ月 $0.10 です。1ジョブなら無料枠に収まります。Cloud Run 側も、月1回のリクエストが乗るだけなので実質的な増分はほぼありません。

### 重い処理には向かない（Cloud Run Jobs という選択肢）

`attemptDeadline` はデフォルト180秒です。重いバッチだと途中で打ち切られるので、その場合はこの「Services のエンドポイントを叩く」方式ではなく、**Cloud Run Jobs** を検討したほうがよさそうです。

同じ Cloud Run でも、今回使っている **Services** と **Jobs** は性質が違います。

| | Cloud Run **Services**（今回使った方） | Cloud Run **Jobs** |
| --- | --- | --- |
| 性質 | 常駐して HTTP リクエストに応答し続ける | 一度実行して完了したら終わる（サーバではない） |
| 起動のされ方 | URL にリクエストが来ると動く | `gcloud run jobs execute` や Scheduler から実行を指示 |
| 向いてる用途 | Web API・アプリ本体 | バッチ、集計、マイグレーション |
| 実行時間の上限 | リクエスト単位（今回の入口は 180 秒） | 最大 24 時間 |
| リトライ・並列 | HTTP の世界で自前 | タスク単位のリトライ・並列実行が組み込み |

ざっくり言うと、Services は常時待ち受ける Web サーバ、Jobs は実行したら走り切って終わるバッチ用、です。重いバッチなら `Cloud Scheduler → Cloud Run Jobs`（Scheduler は Jobs を直接キックできます）の構成が本来の適材適所になります。

今回あえて Services + エンドポイント方式にしたのは、処理が数秒で終わる軽いもので 180 秒に当たらないことと、既にある Services にエンドポイントを1本足すだけで済み、新しいコンテナ定義やデプロイ経路を増やさずに済んだからです。処理が重くなったら Jobs に寄せる、というのが判断の分かれ目です。

## まとめ

Cloud Run のアプリに定期実行を足すなら、Cloud Scheduler から HTTP エンドポイントを叩くのが定石です。ジョブを作ったら `jobs run` で来月を待たずに確認できますし、`curl` でエンドポイント単体の疎通も見られます。ヘッダチェックはあくまで簡易ガードなので、書き込みを伴う処理では OIDC でちゃんと固める。月1で軽い処理ならコストはほぼゼロで組めました。
