Operator: worker provisioning
The k8s operator (cmd/operator) reconciles one CDCPipeline custom
resource into a coordinator StatefulSet, its ServiceAccount/Role, a
headless Service, and a ConfigMap. It does not create worker
Deployments itself — that split mirrors Spark's driver/executor model:
the operator builds the mold (a Pod template per table), and the
coordinator clones it into live Deployments once it's actually running
and knows its own per-table partition names.
Two different Workers, one resolution
There are two separate types named Workers in this codebase, at two
different layers, and they don't share all their fields:
api/v1alpha1.WorkerDefaults — the CRD's spec.worker, a
pipeline-wide default:
type WorkerDefaults struct {
CPU string `json:"cpu,omitempty"`
CPUOverhead string `json:"cpu_overhead,omitempty"`
Memory string `json:"memory,omitempty"`
MemoryOverhead string `json:"memory_overhead,omitempty"`
}
spec.WorkerSpec — the pipeline YAML's table.workers, a per-table
override:
type WorkerSpec struct {
Number int `json:"number,omitempty"`
CPU string `json:"cpu,omitempty"`
Memory string `json:"memory,omitempty"`
}
Neither has number+cpu+memory+cpuOverhead+memoryOverhead all at
once. The default carries the overhead knobs (a default is baseline
sizing policy, not a partition count); the per-table override carries the
partition count and can override CPU/Memory but never overhead — every
worker's overhead always comes from the pipeline-wide default,
regardless of whether its CPU/Memory came from the default or a
per-table override.
Table.Workers == nil or Number <= 1 means today's single-worker
behavior: no partitioning, one group, byte-for-byte unchanged from before
this feature existed.
Resolution: request vs. limit
For each table, the coordinator's worker Deployment resources resolve as:
func workerResources(cr *CDCPipeline, t spec.Table) corev1.ResourceRequirements {
cpu, memory := cr.Spec.Worker.CPU, cr.Spec.Worker.Memory
if t.Workers != nil {
if t.Workers.CPU != "" { cpu = t.Workers.CPU }
if t.Workers.Memory != "" { memory = t.Workers.Memory }
}
// overhead always comes from the pipeline-wide default
return resourceRequirements(cpu, cr.Spec.Worker.CPUOverhead, memory, cr.Spec.Worker.MemoryOverhead)
}
CPU/Memory become the container's resource request. CPU+CPUOverhead
and Memory+MemoryOverhead become its limit. This mirrors Spark's
spark.executor.memoryOverhead: headroom for the process's own
bookkeeping beyond its declared working set. If overhead is empty, limit
equals request — no separate headroom. If neither CPU nor Memory is set
anywhere, the container gets no resources block at all (BestEffort
QoS).
The coordinator's own CPU/Memory (CoordinatorSpec.CPU/Memory) have no
overhead knob — request equals limit always. Rationale: the coordinator
is a single long-lived process, not a pool sized like the workers.
Example
apiVersion: urutau.io/v1alpha1
kind: CDCPipeline
metadata:
name: shop-mysql
spec:
image: ghcr.io/you/urutau:v1.2.3
secrets:
source: shop-mysql-creds
catalog: polaris-creds
worker:
cpu: "1"
memory: "2Gi"
cpu_overhead: "500m"
memory_overhead: "512Mi"
definition:
inline:
pipeline: shop-mysql
source:
kind: mysql
uri: mysql://repl@mysql:3306/shop
serverId: "1101"
sink:
uri: polaris://polaris:8181/api/catalog
namespace: raw
tables:
- source: shop.orders
target: raw.orders
primaryKey: [id]
workers:
number: 3
cpu: "2"
memory: "4Gi"
writeMode: append
raw.orders gets 3 workers, each requesting 2 CPU / 4Gi (table override)
with a limit of 2.5 CPU / 4.5Gi (pipeline-wide overhead). Any other table
in the same pipeline with no workers: block gets 1 worker at the
pipeline default: 1 CPU / 2Gi request, 1.5 CPU / 2.5Gi limit.
Worker group names are always derived, never operator-chosen:
<pipeline>-<target>-<index> — here, shop-mysql-raw.orders-0,
shop-mysql-raw.orders-1, shop-mysql-raw.orders-2. This is the single
source of truth both the coordinator's internal PK-range routing and its
Deployment provisioning use, so they can never disagree on what a
partition is called.
Why range partitioning, not hashing
A table's PK range is split into contiguous ranges up front, not hashed
across workers. No canonical hash function exists that both MySQL and
Postgres sources could agree on (MySQL CRC32 vs. Postgres hashtext
would disagree between the DBLog bootstrap snapshot and the live binlog/
WAL stream). Range boundaries are computed once and reused by both
phases, so a key can never switch partition ownership between snapshot
and stream.
What "provisioned from pod templates" actually means
The operator does not write a literal Kubernetes PodTemplate object.
For each table, it builds a corev1.PodTemplateSpec Go value (image,
command, env — the same Secret-backed credentials as the coordinator's
own container, and the resolved Resources above), marshals it to YAML,
and stores it in the coordinator's ConfigMap under the key
worker-pod-template.<target>.yaml.
This only happens when an image resolves — spec.image on the CR, or
the operator's own --coordinator-image flag as a cluster-wide fallback.
A pipeline that never sets an image gets no worker pod templates and the
coordinator's provisioning path below is a no-op.
Coordinator: cloning the mold into live Deployments
internal/coordinator/workers_k8s.go is where Deployments actually get
created — inside the coordinator binary, not the operator. On boot,
provisionWorkers first checks whether any worker pod template file
exists under /etc/urutau (the same mount as pipeline.yaml). If none
exist — every pipeline that never set spec.image — it returns
immediately: zero Kubernetes API calls, zero in-cluster client
construction. This is what keeps the feature fully backward compatible
with every pipeline that doesn't use it.
If templates exist: the coordinator builds an in-cluster client, resolves
its own Pod (via os.Hostname(), which k8s sets to the Pod name) to
attach an OwnerReference to every Deployment it creates — so when the
coordinator's Pod dies or its StatefulSet scales to 0, Kubernetes garbage
collection cascades and removes every worker Deployment with it. For each
derived worker group name, it loads that table's template, deep-copies
it, and stamps exactly two things the operator couldn't have known ahead
of time: the worker's own --name <name> argument, and the
OwnerReference. Everything else — image, command, env, resources — is
exactly what the operator already assembled.
RBAC
Two separate roles are involved:
- The operator's ClusterRole (
config/rbac/operator.yaml): cluster-wide CRUD oncdcpipelines(+status/finalizers), full CRUD onstatefulsets, read onsecrets, CRUD onconfigmaps/services, and CRUD onserviceaccounts/roles/rolebindings(it mints a per-pipeline identity dynamically). - Each coordinator Pod's own per-pipeline
Role+RoleBinding(created by the operator, scoped byresourceNamesto that one CR): additionally grantsapps/deploymentsget/create/update andcore/podsget — this is what lets the coordinator provision worker Deployments.
Two details are easy to get wrong when writing this RBAC by hand:
list/watch, not justget/create. The controller-runtime manager caches every type it creates, so a role that grants onlyget/createonserviceaccounts/roles/rolebindingsnever syncs its informers and the reconciler stalls.- You cannot grant what you do not hold. RBAC forbids a subject from
creating a Role that carries permissions it lacks, so the operator's
ClusterRole must itself hold
apps/deploymentsget/create/update andcore/podsget — even though the operator never touches a Deployment or a Pod. The coordinator does, through the per-pipeline Role.
Leader election and high availability
The operator Deployment ships with replicas: 2. Only the elected leader runs
the reconciler; the other replica is a hot standby that takes over if the
leader dies. Election coordinates through a coordination.k8s.io Lease
named urutau-operator-lock in the operator's namespace — which is why the
ClusterRole carries a leases rule. Without it the manager fails at startup
on lease acquisition rather than silently skipping election.
The standby is not load-balanced: the operator's work is small and
order-sensitive (one reconciler per CDCPipeline), so extra replicas buy
failover availability, not throughput. The webhook server, by contrast, runs
on every replica — the apiserver load-balances admission requests across them
through the Service.
How the operator applies objects
The reconciler writes every managed object (the coordinator StatefulSet,
Service, ConfigMap, RBAC, and worker templates) with Server-Side Apply,
under a field manager named by --field-manager (default urutau-operator).
Two consequences:
- Fields the operator does not manage are left alone — a mutating webhook's
injected defaults, or an API-server-assigned
clusterIP, survive a reconcile. The operator owns only the fields it declares. - A reconcile that changes nothing writes nothing: Apply is a no-op when the owned fields already match, so a steady-state reconcile does not churn the API.
The field manager name must be unique per controller managing the same objects;
a second controller against the same objects needs a different
--field-manager, or the two contend for field ownership.
serverId uniqueness
source.serverId (MySQL) must be unique across pipelines reading the same
source server: two pipelines sharing one server_id fight over the same
replication stream and corrupt it. The admission webhook rejects a duplicate
serverId within a namespace at apply time.
Concurrent writers: two different fixes for the same problem
Naively, N workers committing to one table race on the position: the last
committer silently wins and can clobber a lagging partition's watermark.
Both built-in sinks solve this (sink.Sink.SupportsConcurrentWriters()
returns true for both), but with different mechanisms:
- Iceberg: workers never commit directly.
TableWriter.WriteStagedwrites each batch's data files without committing them, returning an opaque descriptor. The coordinator collects one binlog cycle's descriptors across all workers and commits them as a single unit viaSink.CommitStaged— one committer, one position write, no race by construction. - ClickHouse: workers commit independently (no staging), but the
ReplacingMergeTreeversion column is the coordinator's own shared sequence, and the durable position is kept per partition in a side table and read back as the minimum safe value across partitions (see Sinks) — so a resume never jumps past a lagging partition even though workers wrote independently.
A sink that returns false from SupportsConcurrentWriters rejects
workers: {number: N > 1} at boot, not silently — the coordinator fails
fast with an error naming the table and pointing at the fix (workers: 1,
or for Couchbase, sink.commitMode: atomic).