インフラ

Cloud Run サービスとジョブの比較: 1 つの Go バイナリ、2 つのエントリポイント

Cloud Run サービスとジョブは、1つの Go イメージと1つのビルドを共有できます。ここでは、単一のバイナリをサーブモードとバッチジョブに分割する方法と、そうすべきでない場合について説明します。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

Cloud Runには、コンテナを実行する方法が2つあります。Serviceは常時稼働し、HTTPリクエストに応答します。Jobは起動し、処理を実行して、終了します。ほとんどのガイドでは、これらを別々のイメージを持つ別々のプロダクトとして扱っています。そうである必要はありません。私たちは、1つのGoバイナリ、1つのイメージ、1つのビルドタグから、Webサーバーとそのバッチジョブを実行しています。バイナリは最初の引数を見て、どちらのモードになるかを決定します。この記事では、ディスパッチコード、デプロイの構成、そして代わりに2つのイメージに分割することが正解だったであろう境界線について説明します。

1つのバイナリがどのようにエントリポイントを選択するか

ここでの秘訣は、コンテナイメージには1つのENTRYPOINTしかないのに対し、Cloud Runでは各ServiceまたはJobがそのエントリポイントに独自の引数を渡せるという点です。そのため、バイナリは最初の引数を読み取って分岐します。

func main() {
	if len(os.Args) < 2 {
		log.Fatal("usage: blog-engine <serve|ingest|index>")
	}
	switch os.Args[1] {
	case "serve":
		runServe(os.Args[2:]) // Cloud Run Service: long-lived HTTP
	case "ingest":
		runIngest(os.Args[2:]) // Cloud Run Job: parse, translate, load
	case "index":
		runIndex(os.Args[2:]) // Cloud Run Job: create DB indexes, then exit
	default:
		log.Fatalf("unknown command %q", os.Args[1])
	}
}

Dockerfileはエントリポイントをバイナリに設定し、そこで終了します。モードを組み込むことはしません。

ENTRYPOINT ["/blog-engine"]

サービスは、引数として serve を指定して作成されます。各ジョブは、独自の動詞で作成されます。イメージダイジェストは同じですが、最初のトークンが異なります。

gcloud run deploy blog-engine \
  --image "$IMAGE" --args serve

gcloud run jobs create blog-engine-ingest \
  --image "$IMAGE" --args ingest

1つのビルドで1つのアーティファクトが生成されます。サービスとすべてのジョブはまったく同じバイトをプルするため、ページを配信するコードとそのページをデータベースに書き込んだコードとの間でバージョンのずれは発生しません。

serve モードは Cloud Run サービスです

serve は、それ自体で終了することのないリクエスト/レスポンス プロセスです。Cloud Run はそれをウォーム状態に保ち、HTTP をルーティングし、トラフィックに応じてインスタンス数をスケーリングします。ブログは検索クローラーのために高速な first byte を必要とするため、サービスは min-instances 1 で実行されます。これにより、常に 1 つのインスタンスが準備完了状態となり、静かな朝の最初のリクエストでコールド スタートが発生することはありません。

サービスが正しく処理しなければならないプラットフォームの詳細は、シャットダウンです。Cloud Run がインスタンスをスケールダウンしたり、新しいリビジョンをロールアウトしたりすると、SIGTERM を送信し、SIGKILL の前にプロセスに短い猶予期間を与えます。SIGTERM を無視するサーバーは、処理中のリクエストをドロップします。そのため、serve はシグナルでブロックし、ドレイン処理を行います。

func runServe(args []string) {
	srv := &http.Server{Addr: ":" + port(), Handler: router()}
	go func() {
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("serve: %v", err)
		}
	}()

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
	defer stop()
	<-ctx.Done() // block here for the life of the instance

	shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	_ = srv.Shutdown(shutCtx) // let open requests finish
}

構造は単純です。リスナーを開始し、終了シグナルを待ち、その後、開いている接続に完了までの限られた時間枠を与えます。Serviceに関するすべては、プロセスが個々のリクエストよりも長く存続することを前提としています。

インジェストモードとインデックスモードは Cloud Run ジョブです

ジョブは正反対のコントラクトです。ポートにはアタッチされず、トラフィックも受信しません。完了まで実行されて終了し、その終了コードが結果となります。終了コード 0 は、タスクが成功したことを意味します。ゼロ以外の終了コードはタスクが失敗したことを示し、これが再試行につながります。

func runIngest(args []string) {
	ctx := context.Background()
	if err := ingest.Run(ctx); err != nil {
		log.Fatalf("ingest: %v", err) // non-zero exit, the Job task fails
	}
	// clean return, exit 0, the Job task succeeds
}

ブログでは、ingestがMarkdownの投稿をパースし、変更されたセクションを翻訳モデルに送信し、その結果をFirestoreに書き込みます。indexはデータベースのインデックスを一度作成して終了します。どちらもウェブのリクエストには属しません。これらは数分間実行されることがあり、スケジュールまたは手動でトリガーされます。そして、その成功はHTTPボディではなく、イエスかノーかで判断されます。

ジョブには、サービスにはない独自の実行設定もあります。これらは、バッチステップにとって重要なものです。

設定 制御対象 Ingestの値
task-timeout タスクが強制終了されるまでの最大実時間 3600s
max-retries 失敗したタスクごとのリトライ回数 0
parallelism 同時に実行されるタスク数 1

ブログではmax-retries 0を設定しています。これは、中途半端に終了した翻訳の実行が、通知なく繰り返されるべきではないためです。失敗した場合は、ループするのではなく、停止して調査する必要があります。別のワークロードでは、リトライや高い並列処理が必要になる場合があります。たとえば、独立したファイルを処理するファンアウトなどです。重要なのは、これらの調整項目はジョブに存在し、サービスとして実行されている同じバイナリはそれらを決して参照しないということです。

2つのリポジトリではなく1つのバイナリにする理由

それらを統合する理由は、Webサーバーとバッチジョブが無関係ではないからです。両者はエントリーポイント以下のほぼすべてを共有しています。サービングコードは、インジェストが書き込みに使用するのと同じstoreパッケージから投稿を読み取ります。両方とも同じデータモデル、同じタクソノミーローダー、同じFirestore接続コード、同じ設定解析を使用します。

それを2つのリポジトリや2つのイメージに分割すると、ストア層の2つのコピー、または独自のバージョン管理を持つ共有ライブラリをメンテナンスすることになります。両者の間に乖離が生じます。サーバーはインジェスト側がまだ書き込んでいないフィールドを期待し始めたり、インジェストはサーバーが読み取りを停止するように更新されたシェイプを書き込んだりします。この種のバグは、データが異なるバージョンの両半分を通過するまで見えません。

1つのバイナリであれば、Post構造体が定義される場所は1つ、クエリのシェイプが存在する場所は1つ、そして両方のモードで成功するか両方で失敗するかのどちらかであるコンパイルが1つだけになります。インジェストが新しいフィールドの書き込みを開始した場合、サーブコードは同じビルドで同じ定義に対してコンパイルされます。ライターとリーダー間のバージョンのずれは不可能になります。なぜなら、それらは同じプログラムだからです。

このコストは正直言って小さいものです。イメージには、現在実行していないモードのコードが含まれます。サーブインスタンスはバイナリ内に未使用のインジェストコードを持ち、インジェストジョブはHTTPサーバーがコンパイルされていますが、決してリッスンしません。Goのバイナリにとっては数メガバイトの追加であり、実質的な制約ではありません。また、mainに関数呼び出しを振り分けるロジックも必要です。これは上記のswitch文です。それが支払うべき代償のすべてです。

secret を削除せずに共有イメージをデプロイする

1つのイメージを共有すると、再デプロイの様相が変わります。ビルドは1つです。ビルドが完了すると、サービスとすべてのジョブは新しいイメージを指すようになり、それらに関する他のことは何も変わりません。

gcloud run services update blog-engine --image "$IMAGE"
gcloud run jobs update blog-engine-ingest --image "$IMAGE"

痛い目に遭って学んだルール:再デプロイ時には、--image だけを更新し、他には何も触れない。環境変数とシークレットは作成時に一度設定されます。再デプロイ時に --set-secrets--set-env-vars も渡した場合、それらのフラグは既存のセット全体をマージするのではなく、置き換えてしまいます。再デプロイコマンドからシークレットを1つでも除外すると、それは実行中のサービスから消えてしまいます。本番環境で初めてそれが起こると、サーバーは空のデータベースURLで起動し、すべてのページが500エラーになります。そのため、create コマンドで環境変数とシークレットを設定し、update コマンドではイメージのみを渡します。

Jobs は、単一のハードコードされた名前ではなく、リストとして管理する価値があります。なぜなら、バッチワークロードが長期間単一のジョブにとどまることはめったにないからです。

JOBS=("blog-engine-ingest")

for job in "${JOBS[@]}"; do
  gcloud run jobs update "$job" --image "$IMAGE"
done

2つ目のJob、例えば夜間のサイトマップ再構築が現れた場合、それは配列に追加され、ループによって同じイメージで更新されます。それを追加し忘れると、そのJobは警告なしに古いイメージを実行し続けます。なぜなら、古いままのJobは失敗するのではなく、ただ静かに間違った動作をするからです。配列によって、Jobの全セットが、確認および拡張できる1つのまとまりになります。

バックグラウンドの goroutine の代わりに Job を使用する場合

魅力的な近道は、すべてを Service に保持することです。すでに長時間実行されるプロセスがあるため、リクエストに応答した後に goroutine で重い処理を開始してみませんか?実際の CPU や数分間の実時間が必要なものについては、これは Cloud Run では間違った直感です。

Service インスタンスは、リクエストを中心に課金およびスケーリングされます。goroutine でのバックグラウンド処理は、それを開始したリクエストが返された後にインスタンスがスケールダウンすると中断される可能性があり、インスタンスがサイズ設定された CPU をリクエスト処理と競合します。その処理には、タスクのタイムアウト、再試行、明確な成功または失敗のシグナルがありません。それはプラットフォームからは見えません。

Job は、リクエストの外部で実行されるあらゆるものに適した場所です。その決定は、ほとんど機械的に行えます。

  • HTTP リクエストへの応答、セッションの保持、ページの提供: Service。
  • スケジュールされたタスク、データ移行、バッチインポート、夜間の再構築: Job。
  • リクエストの外部で実際の CPU または長い実時間が必要な場合: Job であり、Service にぶら下がっている goroutine ではありません。

ブログの取り込みは、典型的なケースです。それはスケジュールに従って実行され、数分かかることがあり、厳密なタイムアウトと明確な失敗かどうかの結果が必要であり、ページレンダリングからサイクルを奪ってはなりません。それは Job です。たとえ Service がすでに同じバイナリを実行しており、技術的にはその作業を実行できたとしてもです。

トレードオフを平易に述べる

2つのエントリーポイントを持つ1つのバイナリは、単一のビルド、単一のバージョン、そしてデータを書き込むコードとそれを読み込むコードとの間の乖離がないことをもたらします。これらは、サーバーとバッチジョブがデータモデルを共有するシステムにとって大きな利点です。その代償として、イメージがわずかに大きくなり、mainに小さなディスパッチスイッチが必要になります。オンラインパスとオフラインパスの間でコードを共有するほとんどのサービスにとって、それは良いトレードオフです。

両者が共有しなくなったとき、それは良いトレードオフではなくなります。もしJobが、サーバーが必要としない重い依存関係ツリー、例えば機械学習ランタイムや大規模なデータツールキットなどを取り込む場合、それをサーバーイメージに組み込むと、すべてのサービングインスタンスが肥大化し、実行されることのないコードのためにコールドスタートが遅くなります。もし2つのパスが異なるチームによって所有され、異なるリズムでデプロイされる場合、それらを1つのビルドタグに強制すると、独立して進めたいリリースが結合されてしまいます。そして、もしモデル、ストア、設定など、本当に何も共有していないのであれば、単一のバイナリは1つのエントリーポイントをまとった2つの無関係なプログラムにすぎず、2つのイメージの方がより明確でしょう。

判断基準は、エントリーポイント以下の共有コードです。ServiceとJobが同じパッケージを通じて同じ型を読み書きする場合、それらを1つのバイナリにまとめ、最初の引数でモードを選択させるようにしてください。依存関係、所有権、またはデータにおいてそれらが分岐する場合は、イメージを分割し、それぞれが独自の条件でビルドできるようにしてください。