Skip to content
Corentin GS

When One Go Binary Beats Three Separate Kubernetes Services

№51 · · ·1016 words ·5 min read
In this piece

I needed three ingestion services, but I did not need three Go binaries.

  • Transformer consumes raw OpenTelemetry payloads from Kafka, converts them to NDJSON, and writes batches to S3.
  • Splitter consumes batch notifications, reads those objects, produces the downstream shapes, and publishes new jobs.
  • Aggregator consumes aggregation jobs, coordinates out-of-order spans through Redis, and publishes trace summaries.

They have different inputs, dependencies, and failure modes. They remain separate services. The packaging decision is narrower: all three modes compile into otel-processor, and Kubernetes runs that image in three deployments.

Three artifacts

Transformer

image · release path · deployment

Splitter

image · release path · deployment

Aggregator

image · release path · deployment

One artifact

Transformer — deployment 1

Splitter — deployment 2

Aggregator — deployment 3

The shared boundary is the artifact, not the Kubernetes deployment.

What one binary means

The binary exposes one mode flag:

otel-processor --mode=transformer
otel-processor --mode=splitter
otel-processor --mode=aggregator

main.go loads configuration, performs the checks required by that mode, builds the dependency-injection container, and calls RunService. The runner selects one service and subscribes it to one Kafka topic:

switch cfg.Mode {
case ModeAggregator:
    return runAggregator(ctx, injector, broker, cfg, mainLogger)
case ModeTransformer:
    return runTransformer(ctx, injector, broker, cfg, mainLogger)
case ModeSplitter:
    return runSplitter(ctx, injector, broker, cfg, mainLogger)
default:
    return os.ErrInvalid
}

The modes do not run together in one process. Each pod starts one mode.

ModeKafka inputMain dependenciesWork
TransformerRaw ingestionS3Parse payloads and write NDJSON batches
SplitterBatches readyS3Read batches and fan out downstream jobs
AggregatorTrace aggregation jobsRedisCoordinate spans and publish summaries

Composition follows the mode. Transformer and Splitter validate their own S3 buckets at startup; Aggregator skips the S3 check. Telemetry receives a mode-specific service name. The same container code wires different adapters without erasing the services’ differences.

Artifact

otel-processor

one image · one dependency graph · one build

Composition

--mode · DI container · ports and adapters

selects the runner, topic, buckets, checks, and service name

Kubernetes

Transformer

own replicas · HPA · rollout

Splitter

own replicas · HPA · rollout

Aggregator

own replicas · HPA · rollout

Compilation is shared. Runtime policy is not.

Why share the artifact

The three modes live in one repository, use the same Go toolchain, broker adapter, telemetry setup, logging, configuration model, and deployment platform. Separate binaries would repeat packaging work without creating a useful runtime boundary.

One image gives the team:

  • One dependency graph. A Go or library upgrade is resolved and tested once.
  • One build and scan path. The Dockerfile, base image, SBOM, and vulnerability scan cannot drift between modes.
  • One composition root. Common telemetry and broker wiring change in one place.
  • One version vocabulary. A tag identifies the code available to every mode, even when the deployments roll out at different times.

Ports make this practical. The services depend on broker, object-storage, and repository interfaces; the composition root supplies the mode-specific implementations. Sharing an artifact works because infrastructure selection stays at the edge. It would be a mess if domain code inspected cfg.Mode throughout the call graph.

The trace-coordination article and ClickHouse schema article cover the work behind two of these modes. This article is only about their packaging and deployment boundary.

What it actually costs

The earlier version of this section confused a shared image with a shared Kubernetes workload. They are different.

A Transformer change does not require Kubernetes to restart Splitter or Aggregator. Each mode has its own Deployment and can have its own replica count, HPA, requests and limits, probes, disruption budget, rollout strategy, and alerts. Different CPU, S3, and Redis pressure belong in those deployment manifests.

The real costs sit at the artifact boundary:

  • Release coupling. One commit produces one image containing all three modes. A team cannot publish a smaller Transformer-only artifact from that repository without splitting the build.
  • Rollback granularity. Deployments roll independently, but each rolls back to an older version of the whole binary. A fix for one mode and a regression in another can make version selection awkward.
  • Shared dependency risk. A bad change in common startup, configuration, telemetry, or broker code can affect every mode when each deployment adopts that image. Kubernetes limits the simultaneous blast radius; it does not remove the common defect.
  • Larger image trust boundary. Every deployment receives code for all modes. That is unacceptable when a security boundary requires a workload to contain only the code and dependencies it needs.
  • Repository coordination. Independent teams still share dependency upgrades, merge policy, and release tags. That becomes expensive when their delivery cadences diverge.

These are concrete costs. “Different scaling” is not one of them: Kubernetes already scales the three deployments independently.

Where to put the boundary

Start by choosing the runtime boundary. Use separate Deployments whenever modes need independent scaling, rollout, probes, resources, disruption budgets, or failure isolation. A shared image does not prevent any of that.

Then decide whether the artifact should split. Build separate images when at least one requirement exists at the image or release level:

  1. Security isolation: a deployment must not contain another mode’s code or dependencies.
  2. Independent release authority: different teams must build, approve, or roll back without coordinating on one artifact version.
  3. Dependency divergence: a mode needs incompatible libraries, toolchains, base images, or system packages.
  4. Build economics: the shared binary is materially slowing builds, distribution, startup, or patching.
  5. Change isolation: common-code regressions repeatedly affect unrelated modes, and separating the composition roots would remove that coupling.

Do not count yes answers. One hard security requirement is enough to split. Five minor differences may still be cheaper to express in three Kubernetes manifests.

For this pipeline, the modes need separate runtime policy but share the artifact-level concerns. The result is one image, three Deployments, and three explicit --mode values. That is the smaller system until an artifact-level requirement proves otherwise.

Explore this subject

More on Devlog