Best Practices
Guidelines for designing, deploying, and operating DataFlow Operator to achieve optimal performance, reliability, and security.
Pipeline Design
Choosing Between DataFlow and DataFlowCron
Use DataFlow when: - Continuous streaming is needed - Source is Kafka (streaming) - Low latency is required - There is no explicit "end" to the data stream
Use DataFlowCron when: - Task needs to run on a schedule - Source is a polling database (PostgreSQL, ClickHouse, Trino, Nessie) - Post-processing steps (triggers) are needed - Process has a beginning and end (batch processing)
Pipeline Architecture
1. Single Responsibility Each DataFlow should solve one task:
# Good: clear purpose
name: user-events-enrichment
source: kafka://user-events
sink: postgresql://analytics.users
# Bad: mixing purposes
name: everything-pipeline
source: kafka://all-topics # Too broad
2. Intermediate Topics For complex routes, use Kafka as an intermediate buffer:
DataFlow A: Source → Kafka Topic A
DataFlow B: Kafka Topic A → Transform → Kafka Topic B
DataFlow C: Kafka Topic B → Sink
3. Error Handling Strategy
# Always configure error sink for production
spec:
errors:
type: kafka
config:
brokers: [kafka:9092]
topic: error-messages-${ENV}
ackPolicy: afterWrite
Security
Secrets Management
Never store credentials in manifests:
# Bad: plaintext credentials
sink:
config:
connectionString: "postgres://user:password@host/db" # ❌
# Good: SecretRef
sink:
config:
connectionStringSecretRef:
name: db-credentials
key: connection-string
Creating Secrets:
# Create secret from literal
kubectl create secret generic db-credentials \
--from-literal=connection-string="postgres://user:pass@host/db" \
--from-literal=password="secure-password"
# Or from file
echo -n "secure-password" > password.txt
kubectl create secret generic db-credentials \
--from-file=password=password.txt
rm password.txt
TLS/SSL Configuration
Always use TLS for production Kafka:
source:
type: kafka
config:
securityProtocol: SASL_SSL
tls:
caFile: /etc/certs/ca.crt
certFile: /etc/certs/client.crt
keyFile: /etc/certs/client.key
sasl:
mechanism: scram-sha-512
usernameSecretRef:
name: kafka-credentials
key: username
passwordSecretRef:
name: kafka-credentials
key: password
Mounting Certificates:
apiVersion: v1
kind: Secret
metadata:
name: kafka-certs
type: Opaque
data:
ca.crt: <base64-encoded>
client.crt: <base64-encoded>
client.key: <base64-encoded>
Network Policies
Restrict network access:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: dataflow-processor
spec:
podSelector:
matchLabels:
app: dataflow-processor
policyTypes:
- Ingress
- Egress
egress:
- to:
- podSelector:
matchLabels:
app: kafka
ports:
- protocol: TCP
port: 9092
- to:
- podSelector:
matchLabels:
app: postgresql
ports:
- protocol: TCP
port: 5432
Performance
Batch Size Optimization (SQL sinks)
Physical write-batch size (sink.config.batchSize) and source ack timing (spec.ackGranularity) are separate knobs. With default collapseBatchOnMessageAck: true, ackGranularity: message still forces MaxBatchSize = 1 — set collapseBatchOnMessageAck: false if you need bulk SQL writes and per-message source commit. Details: Fault Tolerance — decoupling ack and sink batch.
| Sink | Runtime default (если batchSize не задан) |
Recommended | Admission warning if set below |
|---|---|---|---|
| PostgreSQL | 100 (sinkbatch.DefaultPostgreSQLBatchSize) |
100–500 (up to ~1000) | < 100 |
| ClickHouse | 500 (sinkbatch.DefaultClickHouseBatchSize) |
500–1000 | < 500 |
| Trino | 10 (sinkbatch.DefaultTrinoBatchSize) |
≥ 10 (keep modest for large JSON / Iceberg) | < 10 |
sink.config is unstructured (RawExtension), so these floors are applied by the processor when the field is omitted — not by CRD OpenAPI defaulting. Single source of truth: dataflow/pkg/sinkbatch. batchSize: 0 means flush only by timer (batchFlushIntervalSeconds, default 10 via sinkbatch.DefaultBatchFlushIntervalSeconds). Explicit values below the recommended floor produce an admission warning (not a reject).
How each SQL sink writes
PostgreSQL
- Accumulates up to
batchSize(or flush interval), then runs one transaction. - Flush path (automatic):
COPY FROMfor homogeneous plainINSERT(noON CONFLICT); else one multi-rowINSERT … VALUES(includingON CONFLICT/ upsert); mixedUPDATE/heterogeneous shapes usepgx.Batch. - Source ack runs only after a successful Commit (
AckAfterSuccessfulWrite). - One DB connection per sink pod (
pgx.Connect, not a pool). Prefer larger batches over parallel connections. - Soft-delete is queued into the same batch TX (forces
pgx.Batchfor that flush).
sink:
type: postgresql
config:
batchSize: 500
batchFlushIntervalSeconds: 10
upsertMode: true
conflictKey: id
ClickHouse
- Default batch 500. Flush uses native
PrepareBatchwhen available; otherwise one multi-rowINSERT … VALUESvia Exec. - Source ack runs once after a successful flush (not mid-batch). That preserves at-least-once if the flush fails partway.
- Raw mode writes
message.Dataas-is (no JSON Unmarshal→Marshal on the insert path). - Large batches reduce insert frequency and help avoid
TOO_MANY_PARTS. PreferbatchFlushIntervalSeconds: 5–10withbatchSize500–1000.
sink:
type: clickhouse
config:
batchSize: 1000
batchFlushIntervalSeconds: 5
upsertMode: true
conflictKey: id
Trino
- Default batch 10. Each flush is one HTTP statement (multi-row
INSERT ... VALUES) plusnextUripolling untilFINISHED. - Upsert uses
MERGE(Iceberg catalogs) — slower than plain INSERT; admission warns whenupsertMode: true. Keep batches modest and setqueryTimeoutSecondsto cover the full poll window. - Prefer multi-row INSERT for throughput; use MERGE only when you need idempotent updates.
sink:
type: trino
config:
batchSize: 10
batchFlushIntervalSeconds: 10
queryTimeoutSeconds: 600
# upsertMode: true # MERGE slow-path — only when needed
# conflictKey: id
Tuning checklist
- Start from the defaults above; raise PG/CH batch sizes under sustained load until latency or memory bounds appear.
- Pair Kafka high volume with
ackGranularity: batchormessage+collapseBatchOnMessageAck: false. - For ClickHouse
TOO_MANY_PARTS, increasebatchSize/ flush interval before lowering concurrency. - For Trino Iceberg/Nessie, keep
batchSizesmall enough that one statement finishes withinqueryTimeoutSeconds. - For Nessie/Iceberg sources, enable
incrementalBySnapshotand optionallymaxRowsPerPoll/maxBytesPerPollon large tables.
Configurable vs automatic (throughput)
| Knob | Where | Notes |
|---|---|---|
channelBufferSize, transformWorkers, ackGranularity, collapseBatchOnMessageAck |
DataFlow spec |
Pipeline backpressure and ack vs write batch |
checkpointSyncOnAck, checkpointSaveInterval |
DataFlow spec |
Checkpoint flush policy; sync-on-ack flush is async/coalesced (does not block the ack path on ConfigMap Patch) |
Sink batchSize, batchFlushIntervalSeconds |
sink config |
Physical write batch |
Kafka async, compression, flush* |
Kafka sink config |
Defaults: async=true, compression=snappy, flush 100 msgs / 100ms |
incrementalBySnapshot, maxRowsPerPoll, maxBytesPerPoll |
Nessie/Iceberg source config |
Snapshot incremental + poll caps |
PG COPY vs multi-VALUES vs pgx.Batch |
Automatic | Chosen per flush shape |
CH PrepareBatch vs Exec |
Automatic | Native driver when available |
| Lakehouse file-delta / parquet fast path | Automatic | When incremental mode finds added files without deletes |
Buffer Sizing
Channels between source, transform, and sink are buffered and blocking (default size 100). A larger buffer absorbs short sink stalls. Watch dataflow_channel_fill_ratio{channel="source|routing|…"} for sustained saturation (CDC and lakehouse sources sample the source channel). Sink flush already overlaps the next batch (double-buffer). For CPU-heavy transforms, raise transformWorkers (ordered emit). Details: Architecture — Pipeline concurrency.
Channel Buffer:
spec:
# For normal load
channelBufferSize: 100 # default
# For high-volume streams
channelBufferSize: 1000
# For limited memory
channelBufferSize: 50
Transform workers (in-pod parallelism):
spec:
transformWorkers: 4 # default 1; range 1–64
Use replicas > 1 only with a Kafka source. For polling/CDC sources keep replicas: 1 and raise readBatchSize / sink batchSize / resources / transformWorkers instead.
Resource Allocation
Baseline Resources:
spec:
resources:
requests:
cpu: "200m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "512Mi"
High-Volume Processing:
spec:
resources:
requests:
cpu: "1000m"
memory: "1Gi"
limits:
cpu: "2000m"
memory: "2Gi"
Resource Guidelines: | Scenario | CPU Request | Memory Request | CPU Limit | Memory Limit | |----------|-------------|----------------|-----------|--------------| | Light load | 100m | 128Mi | 500m | 256Mi | | Normal load | 200m | 256Mi | 1000m | 512Mi | | Heavy load | 500m | 512Mi | 2000m | 1Gi | | High volume | 1000m | 1Gi | 2000m | 2Gi |
Polling Source Optimization
PostgreSQL Source:
source:
type: postgresql
config:
# Frequent poll for real-time
pollInterval: 5 # seconds
# Rare poll for batch
pollInterval: 300 # 5 minutes
# Batch read size
readBatchSize: 1000
# Column for change tracking
changeTrackingColumn: updated_at
orderByColumn: id
Fault Tolerance
Idempotency Configuration
Always configure idempotency:
spec:
sink:
type: postgresql
config:
upsertMode: true
conflictKey: id
Different Upsert Strategies:
PostgreSQL:
sink:
type: postgresql
config:
upsertMode: true
conflictKey: id
upsertStrategy: ifNewer # or replace
upsertVersionColumn: updated_at
ClickHouse:
sink:
type: clickhouse
config:
upsertMode: true
conflictKey: id
replacingVersionColumn: updated_at
tableEngine: ReplacingMergeTree
Checkpoint Configuration
Polling Sources (PostgreSQL, ClickHouse, Trino):
spec:
checkpointPersistence: true # default
checkpointSyncOnAck: true # for critical data
checkpointSaveInterval: 30s
Kafka Source:
spec:
# checkpointPersistence not needed for Kafka
# uses consumer group offset
ackGranularity: message # faster offset commit
collapseBatchOnMessageAck: false # keep sink.config.batchSize for throughput
Details: Fault Tolerance — decoupling ack and sink batch.
Graceful Shutdown
Termination Grace Period:
spec:
# Enough time to flush batch
terminationGracePeriodSeconds: 600
PreStop Hook:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 30"]
Dead Letter Queue Pattern
spec:
errors:
type: kafka
config:
brokers: [kafka:9092]
topic: dlq-${ENV}
ackPolicy: afterWrite
transformations:
- type: filter
config:
condition: "$.required_field" # Check required fields
Monitoring
Key Metrics to Watch
Throughput:
dataflow_processed_messages_total
rate(dataflow_processed_messages_total[5m])
Error Rate:
dataflow_errors_total
rate(dataflow_errors_total[5m])
Lag (for Kafka):
kafka_consumer_group_lag
Latency:
dataflow_processing_duration_seconds
Health Checks
Liveness Probe:
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 30
Readiness Probe:
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
Alerting Rules
Prometheus Alerts:
groups:
- name: dataflow
rules:
- alert: DataFlowHighErrorRate
expr: rate(dataflow_errors_total[5m]) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "High error rate in DataFlow"
- alert: DataFlowNoMessages
expr: rate(dataflow_processed_messages_total[10m]) == 0
for: 10m
labels:
severity: warning
annotations:
summary: "DataFlow stopped processing messages"
- alert: DataFlowKafkaLagHigh
expr: kafka_consumer_group_lag > 10000
for: 5m
labels:
severity: critical
annotations:
summary: "Kafka consumer lag is high"
Operations
Deployment Strategy
Canary Deployment:
# 1. Create new DataFlow with different name
kubectl apply -f dataflow-canary.yaml
# 2. Check metrics
kubectl top pods -l app=dataflow-processor
# 3. Delete old and rename canary
kubectl delete dataflow old-pipeline
kubectl patch dataflow canary-pipeline -p '{"metadata":{"name":"new-pipeline"}}'
Blue-Green Deployment:
# blue.yaml
metadata:
name: pipeline-blue
labels:
version: blue
# green.yaml
metadata:
name: pipeline-green
labels:
version: green
Backup and Recovery
Backup Checkpoint ConfigMap:
# Export checkpoint
kubectl get configmap df-<name>-checkpoint -o yaml > checkpoint-backup.yaml
# Restore
kubectl apply -f checkpoint-backup.yaml
Reset Checkpoint:
# One-shot reset
spec:
checkpointReset: true
Maintenance Windows
Scheduled Maintenance:
spec:
maintenance:
- startTime: "2024-01-15T02:00:00Z"
duration: 2h
repeat: weekly
timezone: UTC
Manual Suspension:
spec:
suspended: true
Testing
Unit Testing Transformations
Test JSONPath Expressions:
# Use gjson cli or online playground
echo '{"user":{"active":true}}' | gjson "user.active"
# Output: true
Integration Testing
Test DataFlow:
# 1. Create DataFlow for test
kubectl apply -f test-dataflow.yaml
# 2. Send test message
echo '{"test": true}' | kafka-console-producer --topic test-topic
# 3. Check result
kubectl logs -l app=dataflow-processor --tail=10
# 4. Clean up
kubectl delete dataflow test-dataflow
Load Testing
Generate Load:
# Use kcat or kafka-producer-perf-test
kafka-producer-perf-test \
--topic test-topic \
--num-records 1000000 \
--record-size 1000 \
--throughput 10000 \
--producer-props bootstrap.servers=localhost:9092
Monitor Resources:
watch kubectl top pods -l app=dataflow-processor
Production Checklist
Pre-Deployment
- [ ] Correct Kind version (DataFlow vs DataFlowCron)
- [ ] Secrets via
*SecretRef(not plaintext) - [ ] Idempotent sink:
upsertMode: true+conflictKey - [ ] For polling/cron:
checkpointSyncOnAck: true+ upsert - [ ]
replicas: 1for non-Kafka sources - [ ] For CPU-heavy transforms:
transformWorkerstuned (keep1if transforms are cheap) - [ ] Error sink configured
- [ ] Resources (requests/limits) set
- [ ]
batchSize/ackGranularity/collapseBatchOnMessageAckbalanced (message ack does not have to meanbatchSize: 1) - [ ] For Trino:
queryTimeoutSecondswith margin
Post-Deployment
- [ ] Pod in Running status
- [ ] Metrics for processing/published messages
- [ ] No errors in logs
- [ ] Monitoring and alerts configured
- [ ] Alerting rules active
- [ ] Backup checkpoint configured
Security
- [ ] TLS for Kafka
- [ ] Network Policies applied
- [ ] RBAC configured
- [ ] Secrets rotated
- [ ] Security headers checked
Documentation
- [ ] Manifest documented
- [ ] Architecture diagram updated
- [ ] Runbook created
- [ ] Contact list for on-call