Deploy Kafka on Kubernetes with Strimzi
Deploy a 3-broker KRaft Kafka cluster with Strimzi, build keyed producer and consumer apps, sink to Postgres with Kafka Connect, monitor lag in Grafana.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project deploys a replicated Apache Kafka cluster on Kubernetes with the Strimzi operator in KRaft mode (no ZooKeeper), then builds the pipeline around it: a producer publishes order events, a consumer group processes them, Kafka Connect sinks them into PostgreSQL, and Prometheus and Grafana show broker health and consumer lag.
What you build, learn and can show
- Build: a 3-broker KRaft cluster, replicated topics, a keyed idempotent producer, a consumer group, a JDBC sink connector into PostgreSQL and a monitoring stack
- Learn: how Kubernetes operators manage Kafka declaratively, how replication factor,
min.insync.replicasandacks=allwork together to prevent data loss, and how to read consumer lag - Show: a repository with the manifests, a broker-failure test that loses no data, and a Grafana view of lag and under-replicated partitions
2 pods] --> K[Kafka cluster
3 brokers] K --> C[Order consumer
3 pods] K --> KC[Kafka Connect
JDBC sink] KC --> PG[(PostgreSQL)] K -.->|JMX metrics| PR[Prometheus] PR --> G[Grafana] classDef blue fill:#326CE5,color:#ffffff classDef orange fill:#F97316,color:#ffffff classDef green fill:#10B981,color:#ffffff classDef purple fill:#8B5CF6,color:#ffffff class P,C blue class K orange class KC,PG green class PR,G purple
The problem this solves
Without an event streaming platform, microservices call each other directly. The order service calls payments, notifications and analytics synchronously, so if any one is slow or down, the order service hangs. That is tight coupling, and it gets more fragile as you add services.
With Kafka the order service publishes an event and is done. A brokerage like Zerodha or a food-delivery app like Swiggy has the same shape: one order event, many independent consumers such as risk, ledger, notifications and analytics. Each consumer reads at its own pace and can be down for hours and catch up later, without the order service noticing.
Which versions this was written against
Strimzi 0.46 and later removed ZooKeeper support entirely, so a modern Strimzi deployment is KRaft only and uses KafkaNodePool resources. This project was written against Strimzi 0.47.0 and Apache Kafka 4.0.0. If a newer Strimzi exists when you build it, check its release notes for supported Kafka versions and update the two version values below together.
Prerequisites
- A cluster with enough room for three Kafka nodes, Connect, PostgreSQL and a monitoring stack. Plan for roughly 12 GB of free memory, or reduce replicas for a smaller lab (see Milestone 2).
- A container registry you can push to (Docker Hub, GHCR or ECR). Kafka Connect builds a custom image and needs somewhere to store it.
kubectl,helm,curlandopenssl
Milestone 1: Install the Strimzi Operator
This milestone installs Strimzi, the operator that turns Kafka custom resources into running brokers.
Strimzi manages Kafka as a native Kubernetes workload. You declare what you want in YAML and the operator handles rolling upgrades, configuration changes and scaling. The operator itself does not run Kafka. It watches for Kafka resources and creates the pods.
export STRIMZI_VERSION=0.47.0 kubectl create namespace kafka curl -sL "https://github.com/strimzi/strimzi-kafka-operator/releases/download/${STRIMZI_VERSION}/strimzi-cluster-operator-${STRIMZI_VERSION}.yaml" \ | sed 's/namespace: .*/namespace: kafka/' \ | kubectl create -f - -n kafka kubectl rollout status deployment/strimzi-cluster-operator -n kafka kubectl get crd | grep strimzi## Expect: kafkas, kafkanodepools, kafkatopics, kafkausers,## kafkaconnects and kafkaconnectors under strimzi.ioRememberThis is the Kubernetes operator pattern: you declare intent, the operator makes it true and keeps it true.
Milestone 2: Deploy a 3-Broker KRaft Kafka Cluster
This milestone deploys three Kafka nodes that act as both controllers and brokers, with replication settings that survive the loss of one node.
In KRaft mode Kafka stores its own metadata, so there is no separate ZooKeeper ensemble. A KafkaNodePool defines the nodes and their roles, and the Kafka resource defines the cluster. Three combined controller and broker nodes is a good lab and small-production shape. Larger production clusters usually split controllers from brokers.
First the metrics configuration, so Prometheus can read broker health later:
kubectl apply -n kafka -f - <<'EOF'apiVersion: v1kind: ConfigMapmetadata: name: kafka-metricsdata: kafka-metrics-config.yml: | lowercaseOutputName: true rules: - pattern: kafka.server<type=(.+), name=(.+)PerSec\w*><>Count name: kafka_server_$1_$2_total - pattern: kafka.server<type=BrokerTopicMetrics, name=(.+)PerSec, topic=(.+)><>Count name: kafka_server_brokertopicmetrics_$1_total labels: topic: "$2" - pattern: kafka.server<type=ReplicaManager, name=(.+)><>Value name: kafka_server_replicamanager_$1 - pattern: kafka.controller<type=KafkaController, name=(.+)><>Value name: kafka_controller_kafkacontroller_$1EOFNow the node pool and cluster:
kubectl apply -n kafka -f - <<'EOF'apiVersion: kafka.strimzi.io/v1beta2kind: KafkaNodePoolmetadata: name: dual-role labels: strimzi.io/cluster: production-clusterspec: replicas: 3 roles: - controller - broker storage: type: persistent-claim size: 20Gi deleteClaim: false resources: requests: memory: 1Gi cpu: 250m limits: memory: 2Gi cpu: "1"---apiVersion: kafka.strimzi.io/v1beta2kind: Kafkametadata: name: production-cluster annotations: strimzi.io/node-pools: enabled strimzi.io/kraft: enabledspec: kafka: version: 4.0.0 metadataVersion: 4.0-IV3 listeners: - name: plain port: 9092 type: internal tls: false - name: tls port: 9093 type: internal tls: true config: offsets.topic.replication.factor: 3 transaction.state.log.replication.factor: 3 transaction.state.log.min.isr: 2 default.replication.factor: 3 min.insync.replicas: 2 auto.create.topics.enable: false metricsConfig: type: jmxPrometheusExporter valueFrom: configMapKeyRef: name: kafka-metrics key: kafka-metrics-config.yml entityOperator: topicOperator: {} userOperator: {} kafkaExporter: topicRegex: ".*" groupRegex: ".*"EOF kubectl wait kafka/production-cluster --for=condition=Ready --timeout=600s -n kafkakubectl get pods -n kafka -l strimzi.io/cluster=production-cluster## Expect production-cluster-dual-role-0, -1, -2 and the entity operator and exporter pods RunningThe three settings that protect your data: default.replication.factor: 3 keeps three copies, min.insync.replicas: 2 refuses writes when fewer than two copies are in sync, and the producer later uses acks=all. Together they mean an acknowledged write exists on at least two brokers.
SecurityThe plain listener has no encryption or authentication and is acceptable only in a lab. For anything real, use the TLS listener with
KafkaUserresources and authorization. Strimzi's user operator manages those declaratively.
TipFor a small lab, set
replicas: 3but lower the memory requests, or use a single-node pool andreplication.factor: 1while you learn. You will not be able to run the failure test in the Validation step without three nodes.
Milestone 3: Create Topics with Proper Configuration
This milestone declares three topics as Kubernetes resources, with partitions and retention chosen deliberately.
Creating topics with a KafkaTopic resource beats kafka-topics.sh because the topic is declarative and version-controlled, and Strimzi reconciles it if it drifts. Auto-creation is turned off in the cluster config, so a typo in a client topic name fails loudly instead of silently creating a new topic.
kubectl apply -n kafka -f - <<'EOF'apiVersion: kafka.strimzi.io/v1beta2kind: KafkaTopicmetadata: name: orders labels: strimzi.io/cluster: production-clusterspec: partitions: 6 replicas: 3 config: retention.ms: 604800000 cleanup.policy: delete min.insync.replicas: "2" compression.type: lz4---apiVersion: kafka.strimzi.io/v1beta2kind: KafkaTopicmetadata: name: payments labels: strimzi.io/cluster: production-clusterspec: partitions: 6 replicas: 3 config: retention.ms: 2592000000 min.insync.replicas: "2"---apiVersion: kafka.strimzi.io/v1beta2kind: KafkaTopicmetadata: name: notifications labels: strimzi.io/cluster: production-clusterspec: partitions: 3 replicas: 3 config: retention.ms: 86400000EOF kubectl get kafkatopics -n kafka kubectl exec -n kafka production-cluster-dual-role-0 -- \ bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --topic orders## Expect 6 partitions, each with a leader, 3 replicas and 3 in-sync replicas (Isr)Why these numbers: six partitions let up to six consumers in one group work in parallel and spread leaders across three brokers. Payment events keep 30 days because they are audited later. Notifications keep 1 day because stale notifications are worthless.
TipMessages with the same key always go to the same partition, which preserves per-key ordering. Choose the key (here the order id) based on what must stay in order.
Milestone 4: Build Producer and Consumer Services
This milestone deploys a keyed, idempotent producer and a consumer group, both written as small Python programs, and checks the lag.
The producer uses acks=all and idempotence, so a retry after a broker failure cannot create duplicates. It sends each event with a schema envelope, because the JDBC sink in the next milestone needs to know column types. The consumer commits offsets only after it has processed a message, which gives at-least-once delivery.
mkdir -p apps cat > apps/producer.py << 'EOF'import jsonimport osimport randomimport timefrom datetime import datetime, timezone from confluent_kafka import Producer producer = Producer({ "bootstrap.servers": os.environ["BOOTSTRAP"], "acks": "all", "enable.idempotence": True, "compression.type": "lz4", "linger.ms": 20,}) SCHEMA = { "type": "struct", "name": "order", "optional": False, "fields": [ {"field": "order_id", "type": "int64", "optional": False}, {"field": "user_id", "type": "int32", "optional": False}, {"field": "amount", "type": "int32", "optional": False}, {"field": "status", "type": "string", "optional": False}, {"field": "event_time", "type": "string", "optional": False}, ],} def on_delivery(err, msg): if err is not None: print(f"DELIVERY FAILED: {err}", flush=True) while True: order_id = time.time_ns() // 1000 event = { "order_id": order_id, "user_id": random.randint(1, 100), "amount": random.randint(100, 10000), "status": "placed", "event_time": datetime.now(timezone.utc).isoformat(), } producer.produce( "orders", key=str(order_id), value=json.dumps({"schema": SCHEMA, "payload": event}), on_delivery=on_delivery, ) producer.poll(0) print(f"produced order {order_id}", flush=True) time.sleep(1)EOF cat > apps/consumer.py << 'EOF'import jsonimport os from confluent_kafka import Consumer consumer = Consumer({ "bootstrap.servers": os.environ["BOOTSTRAP"], "group.id": "order-processor", "auto.offset.reset": "earliest", "enable.auto.commit": False,})consumer.subscribe(["orders"]) while True: msg = consumer.poll(1.0) if msg is None: continue if msg.error(): print(f"consumer error: {msg.error()}", flush=True) continue order = json.loads(msg.value())["payload"] print(f"partition={msg.partition()} offset={msg.offset()} " f"order={order['order_id']} amount={order['amount']}", flush=True) # Commit only after processing: at-least-once delivery. # In real services, commit in batches to cut overhead. consumer.commit(msg=msg, asynchronous=False)EOF kubectl create namespace orders-demokubectl create configmap orders-apps -n orders-demo \ --from-file=apps/producer.py --from-file=apps/consumer.pykubectl apply -n orders-demo -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata: name: order-producerspec: replicas: 2 selector: matchLabels: app: order-producer template: metadata: labels: app: order-producer spec: containers: - name: producer image: python:3.12-slim command: - sh - -c - "pip install --no-cache-dir confluent-kafka==2.5.0 && python /app/producer.py" env: - name: BOOTSTRAP value: production-cluster-kafka-bootstrap.kafka.svc:9092 volumeMounts: - name: app mountPath: /app volumes: - name: app configMap: name: orders-apps---apiVersion: apps/v1kind: Deploymentmetadata: name: order-consumerspec: replicas: 3 selector: matchLabels: app: order-consumer template: metadata: labels: app: order-consumer spec: containers: - name: consumer image: python:3.12-slim command: - sh - -c - "pip install --no-cache-dir confluent-kafka==2.5.0 && python /app/consumer.py" env: - name: BOOTSTRAP value: production-cluster-kafka-bootstrap.kafka.svc:9092 volumeMounts: - name: app mountPath: /app volumes: - name: app configMap: name: orders-appsEOF kubectl logs -n orders-demo deployment/order-producer --tail=5## Expect "produced order ..." every second kubectl logs -n orders-demo deployment/order-consumer --tail=5## Expect partition, offset and order lines kubectl exec -n kafka production-cluster-dual-role-0 -- \ bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \ --describe --group order-processor## LAG should be small and stable. Growing LAG means the consumers are falling behind.The three consumer replicas share the six partitions, two each. A fourth to seventh consumer would sit idle, because a partition is read by at most one consumer in a group.
Common MistakeRunning more consumers than partitions and expecting more throughput. Extra consumers in a group stay idle. Scale partitions first, and remember partition count is hard to reduce later.
TipThe
pip installat container start keeps this lab short. In a real service you bake dependencies into an image so a PyPI outage cannot stop a pod from starting.
Milestone 5: Sink Orders into PostgreSQL with Kafka Connect
This milestone deploys PostgreSQL and a Kafka Connect cluster with the JDBC connector, so every order event lands in a database table automatically.
Kafka Connect moves data between Kafka and other systems without custom code. The JDBC sink connector reads the schema envelope the producer sends and creates the table for you. Passwords stay out of the manifests: Connect mounts a Kubernetes Secret and reads it through a config provider.
Deploy PostgreSQL
export PG_PASSWORD="$(openssl rand -hex 16)" kubectl create secret generic postgres-credentials -n orders-demo \ --from-literal=password="$PG_PASSWORD" kubectl create secret generic pg-connect-credentials -n kafka \ --from-literal=username=postgres \ --from-literal=password="$PG_PASSWORD" kubectl apply -n orders-demo -f - <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata: name: postgresspec: replicas: 1 selector: matchLabels: app: postgres template: metadata: labels: app: postgres spec: containers: - name: postgres image: postgres:16-alpine env: - name: POSTGRES_DB value: orders_db - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-credentials key: password ports: - containerPort: 5432---apiVersion: v1kind: Servicemetadata: name: postgresspec: selector: app: postgres ports: - port: 5432EOFThis Postgres has no persistent volume, so data disappears if the pod restarts. That is fine for a lab and wrong for anything else.
Deploy Kafka Connect with the JDBC plugin
Strimzi builds a custom Connect image containing the plugin and pushes it to your registry. Replace the three registry placeholders with your own.
kubectl create secret docker-registry registry-credentials -n kafka \ --docker-server="$REGISTRY_SERVER" \ --docker-username="$REGISTRY_USER" \ --docker-password="$REGISTRY_TOKEN" kubectl apply -n kafka -f - <<EOFapiVersion: kafka.strimzi.io/v1beta2kind: KafkaConnectmetadata: name: production-connect annotations: strimzi.io/use-connector-resources: "true"spec: version: 4.0.0 replicas: 2 bootstrapServers: production-cluster-kafka-bootstrap:9092 config: group.id: connect-cluster offset.storage.topic: connect-cluster-offsets config.storage.topic: connect-cluster-configs status.storage.topic: connect-cluster-status offset.storage.replication.factor: 3 config.storage.replication.factor: 3 status.storage.replication.factor: 3 key.converter: org.apache.kafka.connect.storage.StringConverter value.converter: org.apache.kafka.connect.json.JsonConverter value.converter.schemas.enable: true config.providers: directory config.providers.directory.class: org.apache.kafka.common.config.provider.DirectoryConfigProvider build: output: type: docker image: ${REGISTRY_SERVER}/${REGISTRY_USER}/kafka-connect-jdbc:1.0.0 pushSecret: registry-credentials plugins: - name: jdbc-connector artifacts: - type: zip url: https://hub-downloads.confluent.io/api/plugins/confluentinc/kafka-connect-jdbc/versions/10.7.4/confluentinc-kafka-connect-jdbc-10.7.4.zip template: pod: volumes: - name: pg-credentials secret: secretName: pg-connect-credentials connectContainer: volumeMounts: - name: pg-credentials mountPath: /opt/kafka/external-configuration/pg-credentialsEOF kubectl wait kafkaconnect/production-connect --for=condition=Ready --timeout=600s -n kafkaThe Confluent JDBC connector is free to use under the Confluent Community License, which restricts offering it as a competing hosted service. If you need a fully Apache-licensed option, the Aiven JDBC connector is a common alternative.
Create the sink connector
kubectl apply -n kafka -f - <<'EOF'apiVersion: kafka.strimzi.io/v1beta2kind: KafkaConnectormetadata: name: orders-postgres-sink labels: strimzi.io/cluster: production-connectspec: class: io.confluent.connect.jdbc.JdbcSinkConnector tasksMax: 3 config: topics: orders connection.url: jdbc:postgresql://postgres.orders-demo.svc:5432/orders_db connection.user: ${directory:/opt/kafka/external-configuration/pg-credentials:username} connection.password: ${directory:/opt/kafka/external-configuration/pg-credentials:password} table.name.format: orders insert.mode: upsert pk.mode: record_value pk.fields: order_id auto.create: true auto.evolve: trueEOF kubectl get kafkaconnector -n kafka## Expect orders-postgres-sink READY True kubectl exec -n orders-demo deployment/postgres -- \ psql -U postgres -d orders_db \ -c "SELECT COUNT(*), MAX(event_time) FROM orders;"## Expect a growing count and a recent timestampupsert with pk.fields: order_id makes the sink idempotent: if Kafka redelivers an event, the row is overwritten rather than duplicated.
Common MistakeSending plain JSON with no schema and pointing the JDBC sink at it. The sink needs a schema to create columns and map types, and fails with an error about missing schemas. Either use the schema envelope as here, or use a schema registry with Avro.
SecurityNever put a database password in a connector config or manifest. Mount a Secret and reference it with the directory provider, as above.
Milestone 6: Monitor Kafka with Prometheus and Grafana
This milestone installs Prometheus and Grafana, scrapes the brokers and the exporter, and shows the metrics that matter in a dashboard.
helm repo add prometheus-community https://prometheus-community.github.io/helm-chartshelm repo update kubectl create namespace monitoringexport GRAFANA_PASSWORD="$(openssl rand -base64 18)"echo "Grafana admin password: $GRAFANA_PASSWORD" helm install kube-prometheus-stack \ prometheus-community/kube-prometheus-stack \ --namespace monitoring \ --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \ --set grafana.adminPassword="$GRAFANA_PASSWORD" \ --waitA single PodMonitor covers both the brokers and the Kafka exporter, which share the Strimzi labels and a metrics port named tcp-prometheus.
kubectl apply -n kafka -f - <<'EOF'apiVersion: monitoring.coreos.com/v1kind: PodMonitormetadata: name: strimzi-kafkaspec: selector: matchExpressions: - key: strimzi.io/kind operator: In values: [Kafka] podMetricsEndpoints: - path: /metrics port: tcp-prometheusEOFImport the Strimzi dashboards from the project repository at the same version you installed. Grafana's sidecar loads any ConfigMap labelled grafana_dashboard=1.
BASE="https://raw.githubusercontent.com/strimzi/strimzi-kafka-operator/${STRIMZI_VERSION}/examples/metrics/grafana-dashboards" curl -sL "$BASE/strimzi-kafka.json" -o strimzi-kafka.jsoncurl -sL "$BASE/strimzi-kafka-exporter.json" -o strimzi-kafka-exporter.json kubectl create configmap strimzi-kafka-dashboards -n monitoring \ --from-file=strimzi-kafka.json --from-file=strimzi-kafka-exporter.jsonkubectl label configmap strimzi-kafka-dashboards -n monitoring grafana_dashboard=1 kubectl port-forward svc/kube-prometheus-stack-grafana 3000:80 -n monitoringOpen http://localhost:3000 (user admin). If panels say the data source is missing, select the Prometheus data source from the dropdown at the top of the dashboard.
The metrics to watch, and the PromQL to run if a panel is empty:
| What | Healthy value | PromQL |
|---|---|---|
| Under-replicated partitions | 0 | sum(kafka_server_replicamanager_underreplicatedpartitions) |
| Active controller count | 1 | sum(kafka_controller_kafkacontroller_activecontrollercount) |
| Offline partitions | 0 | sum(kafka_controller_kafkacontroller_offlinepartitionscount) |
| Consumer lag | small and stable | sum by (topic) (kafka_consumergroup_lag{consumergroup="order-processor"}) |
| Messages in per second | tracks producer rate | sum(rate(kafka_server_brokertopicmetrics_messagesin_total[1m])) |
TipAlert on sustained consumer lag growth, not on a single high reading. Lag naturally spikes during rebalances and recovers on its own.
Validation & Testing
This final step proves the cluster is healthy, the data flows end to end and a broker failure loses nothing.
## 1. Cluster is Readykubectl get kafka production-cluster -n kafka \ -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'## Expect: True ## 2. KRaft quorum is healthykubectl exec -n kafka production-cluster-dual-role-0 -- \ bin/kafka-metadata-quorum.sh --bootstrap-server localhost:9092 describe --status## Expect a LeaderId and 3 voters ## 3. Topics are fully replicatedkubectl exec -n kafka production-cluster-dual-role-0 -- \ bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --under-replicated-partitions## Expect no output ## 4. Data is reaching PostgreSQLkubectl exec -n orders-demo deployment/postgres -- \ psql -U postgres -d orders_db \ -c "SELECT order_id, user_id, amount, status FROM orders ORDER BY event_time DESC LIMIT 5;" ## 5. Consumer lag is smallkubectl exec -n kafka production-cluster-dual-role-0 -- \ bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --all-groupsBroker failure test
Kill one broker while the producer runs, and check that nothing was lost. With three replicas, min.insync.replicas=2 and acks=all, the cluster keeps accepting writes with one broker down.
## Count rows beforekubectl exec -n orders-demo deployment/postgres -- \ psql -U postgres -d orders_db -tc "SELECT COUNT(*) FROM orders;" kubectl delete pod production-cluster-dual-role-1 -n kafka ## Producers should log no DELIVERY FAILED lineskubectl logs -n orders-demo deployment/order-producer --tail=30 | grep -c "DELIVERY FAILED"## Expect: 0 ## Watch the broker rejoin, then confirm replication recoverskubectl get pods -n kafka -wkubectl exec -n kafka production-cluster-dual-role-0 -- \ bin/kafka-topics.sh --bootstrap-server localhost:9092 --describe --under-replicated-partitions## Expect no output once the broker is back in sync ## Count rows after: it should keep growing with no gaps in your producer logsMeasure throughput on a separate topic
Run the performance test on its own topic. If you ran it against orders, the sink would try to insert test records and fail.
kubectl apply -n kafka -f - <<'EOF'apiVersion: kafka.strimzi.io/v1beta2kind: KafkaTopicmetadata: name: perf-test labels: strimzi.io/cluster: production-clusterspec: partitions: 6 replicas: 3EOF kubectl exec -n kafka production-cluster-dual-role-0 -- \ bin/kafka-producer-perf-test.sh \ --topic perf-test \ --num-records 100000 \ --record-size 1000 \ --throughput -1 \ --producer-props bootstrap.servers=localhost:9092 acks=allRecord the records per second and the 99th percentile latency it prints. Throughput depends heavily on your node sizes, disks and network, so treat your own number as the result and note the hardware beside it.
What to commit
- The Kafka, node pool, topic, Connect and connector manifests
apps/producer.pyandapps/consumer.py- A short
RESULTS.mdwith your measured throughput, the broker-failure outcome and a Grafana screenshot of lag and under-replicated partitions
Common mistakes
- Following an older Strimzi tutorial that uses a
zookeeper:section. Strimzi 0.46 and later reject it, so useKafkaNodePooland KRaft. - Setting
replication.factor: 3but leavingmin.insync.replicas: 1. A single failure can then lose acknowledged writes. Set it to 2 and useacks=all. - Using
kubectl execto create topics. Declare them asKafkaTopicresources so they live in Git. - Hardcoding database passwords in manifests. Use Secrets and the config provider.
- Reading lag without context. A short spike during a rebalance is normal. Sustained growth is the signal.