Cloud Run Service vs Job: One Go Binary, Two Entry Points
A Cloud Run Service and Job can share one Go image and one build. Here is how a single binary splits into a serve mode and batch jobs, and when not to.
Cloud Run gives you two ways to run a container. A Service stays up and answers HTTP requests. A Job starts, does work, and exits. Most guides treat them as separate products with separate images. They do not have to be. We run a web server and its batch jobs from one Go binary, one image, and one build tag. The binary looks at its first argument and decides which mode to be. This post shows the dispatch code, the deploy shape, and the line where splitting into two images would have been the right call instead.
How one binary picks its entry point
The whole trick is that a container image has one ENTRYPOINT, but Cloud Run lets each Service or Job pass its own arguments to that entrypoint. So the binary reads the first argument and branches.
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])
}
}
The Dockerfile sets the entrypoint to the binary and stops there. It does not bake in a mode.
ENTRYPOINT ["/blog-engine"]
The Service is created with serve as its argument. Each Job is created with its own verb. Same image digest, different first token.
gcloud run deploy blog-engine \
--image "$IMAGE" --args serve
gcloud run jobs create blog-engine-ingest \
--image "$IMAGE" --args ingest
One build produces one artifact. The Service and every Job pull the exact same bytes, so there is no version skew between the code that serves a page and the code that wrote that page into the database.
The serve mode is a Cloud Run Service
serve is a request-response process that is supposed to never end on its own. Cloud Run keeps it warm, routes HTTP to it, and scales the number of instances with traffic. Because the blog needs a fast first byte for search crawlers, the Service runs with min-instances 1, so there is always one instance ready and no cold start on the first request of a quiet morning.
The one platform detail that a Service must handle correctly is shutdown. When Cloud Run scales an instance down or rolls a new revision, it sends SIGTERM and gives the process a short grace period before SIGKILL. A server that ignores SIGTERM will drop in-flight requests. So serve blocks on the signal and drains.
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
}
The shape is simple: start the listener, wait for a termination signal, then give open connections a bounded window to complete. Everything about the Service assumes the process outlives any single request.
The ingest and index modes are Cloud Run Jobs
A Job is the opposite contract. It is not attached to a port and receives no traffic. It runs to completion and exits, and its exit code is the result. Exit zero means the task succeeded. Any non-zero exit marks the task as failed, which is what feeds retries.
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
}
For the blog, ingest parses the Markdown posts, sends changed sections to the translation model, and writes the results into Firestore. index creates the database indexes once and exits. Neither belongs in a web request. They can run for minutes, they are triggered on a schedule or by hand, and their success is a yes or no, not an HTTP body.
Jobs also carry their own execution settings that a Service does not have. These are the ones that matter for a batch step.
| Setting | What it controls | Ingest value |
|---|---|---|
task-timeout |
Max wall time before the task is killed | 3600s |
max-retries |
Retries per failed task | 0 |
parallelism |
Tasks running at once | 1 |
The blog sets max-retries 0 because a half-finished translation run should not be silently repeated. A failure should stop and be looked at, not loop. Another workload might want retries and high parallelism, for example a fan-out that processes independent files. The point is that these knobs live on the Job, and the same binary running as a Service never sees them.
Why one binary instead of two repos
The reason to fuse them is that the web server and the batch jobs are not strangers. They share almost everything below the entry point. The serving code reads posts from the same store package that ingest writes them with. Both use the same data model, the same taxonomy loader, the same Firestore connection code, the same config parsing.
Split that into two repositories or two images and you now maintain two copies of the store layer, or a shared library with its own versioning. The two sides drift. The server starts expecting a field that the ingest side has not written yet, or the ingest writes a shape the server was updated to stop reading. That class of bug is invisible until the data flows through both halves at different versions.
With one binary, there is one place the Post struct is defined, one place the query shape lives, and one compile that either succeeds for both modes or fails for both. If ingest starts writing a new field, the serve code is compiled against the same definition in the same build. Version skew between the writer and the reader becomes impossible, because they are the same program.
The cost of this is honest and small. The image carries the code for modes it is not currently running. The serve instance has the ingest code sitting in the binary unused, and the ingest Job has the HTTP server compiled in and never listens. For a Go binary that is a few extra megabytes, not a real constraint. You also need the dispatch logic in main, which is the switch statement above. That is the entire tax.
Deploying the shared image without wiping secrets
Sharing one image changes what a redeploy looks like. There is one build. When it finishes, the Service and every Job get pointed at the new image, and nothing else about them changes.
gcloud run services update blog-engine --image "$IMAGE"
gcloud run jobs update blog-engine-ingest --image "$IMAGE"
The rule that took a scar to learn: on redeploy, touch --image and nothing else. The environment variables and secrets were set once at creation time. If a redeploy also passes --set-secrets or --set-env-vars, those flags replace the entire existing set rather than merging into it. Leave one secret off the redeploy command and it is gone from the running service. The first time that happens in production, the server boots with an empty database URL and every page is a 500. So the create command is where env and secrets go, and the update command carries only the image.
The Jobs are worth managing as a list rather than a single hardcoded name, because a batch workload rarely stays at one Job for long.
JOBS=("blog-engine-ingest")
for job in "${JOBS[@]}"; do
gcloud run jobs update "$job" --image "$IMAGE"
done
When a second Job appears, say a nightly sitemap rebuild, it goes into the array and the loop updates it with the same image. Forget to add it and that Job keeps running an old image with no warning, because a stale Job does not fail, it just quietly does the wrong thing. The array makes the full set of Jobs one thing you can see and extend.
When to use a Job instead of a background goroutine
The tempting shortcut is to keep everything in the Service. You already have a long-lived process, so why not kick off the heavy work in a goroutine after responding to a request? For anything that needs real CPU or minutes of wall time, this is the wrong instinct on Cloud Run.
A Service instance is billed and scaled around requests. Background work in a goroutine can be cut off when the instance scales down after the request that started it returns, and it competes with request handling for the CPU the instance was sized for. There is no task timeout, no retry, and no clean success or failure signal for that work. It is invisible to the platform.
A Job is the right home for anything that runs outside a request. The decision is close to mechanical:
- Responding to an HTTP request, holding a session, serving a page: Service.
- A scheduled task, a data migration, a batch import, a nightly rebuild: Job.
- Real CPU or long wall time needed outside a request: Job, not a goroutine hanging off a Service.
The blog's ingest is the textbook case. It runs on a schedule, it can take minutes, it needs a firm timeout and a clear failed-or-not result, and it must not steal cycles from page rendering. That is a Job, even though the Service is already running the same binary and could technically do the work.
The tradeoffs, stated plainly
One binary with two entry points buys you a single build, a single version, and no drift between the code that writes your data and the code that reads it. Those are large wins for a system where a server and a batch job share a data model. You pay for them with a slightly larger image and a small dispatch switch in main. For most services that share code between an online and an offline path, that is a good trade.
It stops being a good trade when the two sides stop sharing. If your Job pulls in a heavy dependency tree the server does not need, for example a machine learning runtime or a large data toolkit, folding it into the server image bloats every serving instance and its cold start for code it never runs. If the two paths are owned by different teams and deploy on different rhythms, forcing them into one build tag couples releases that want to move independently. And if they genuinely share nothing, no model, no store, no config, then the single binary is just two unrelated programs wearing one entrypoint, and two images would be clearer.
The test is the shared code below the entry point. When the Service and the Job read and write the same types through the same packages, keep them in one binary and let the first argument choose the mode. When they diverge in dependencies, ownership, or data, split the image and let each side build on its own terms.
Related posts
Three Layers That Keep Cloud Run Traffic Behind Cloudflare
A public run.app URL lets anyone bypass your CDN and hit Cloud Run directly. Three layers close that gap: ingress limits, load balancer, secret header.
How Cloud Run CPU Throttling Breaks Background Goroutines
Cloud Run gives your container CPU only during requests, so tickers, heartbeats, and cache refreshes quietly stall. Here is why, and four ways to fix it.
Hot-Swapping Cron Schedules with MongoDB Change Streams
Hardcoded cron means a redeploy every time a schedule changes. Store schedules in the database and have a daemon react to edits with change streams. Here is the design and its failure handling.
Load Balancing a GPU Worker Pool with Redis Keyspace Notifications and Leases
GPUs are expensive and run one heavy job at a time, so round-robin routing stalls behind busy workers. Here is a busy-aware scheduler built from Redis leases and keyspace notifications.
Lease Locks with CAS and Heartbeat for Single-Runner Jobs
A plain lock deadlocks forever when the holder dies. A lease expires. Here is how to build one with compare-and-swap acquisition and a heartbeat, and the failure modes that decide the design.
Deterministic ULIDs for Idempotent Upserts
When a database forbids automatic write retries, every write must be idempotent on its own. A content-derived id makes that free. Here is the derivation and the one invariant that keeps it safe.