How to Deploy Apache Kafka as an AWS Kinesis Data Firehose Alternative

Apache Kafka is a distributed event streaming platform that handles high-throughput, fault-tolerant messaging with persistent storage and replay capabilities. Combined with Kafka Connect, it provides a connector framework for reliably moving data between Kafka and external systems. Strimzi is a Kubernetes operator that simplifies deploying, managing, and scaling Kafka clusters through Custom Resource Definitions (CRDs). This combination provides a self-hosted alternative to Amazon Data Firehose, a managed service that automatically loads streaming data into destinations like Amazon S3, Amazon Redshift, Amazon OpenSearch Service, and Splunk with built-in transformations and auto-scaling.
This article explains how to deploy Apache Kafka with Strimzi on Kubernetes as an alternative to Amazon Data Firehose. It covers installing the Strimzi operator, deploying a Kafka cluster in KRaft mode, and configuring Kafka Connect with sink connectors for Object Storage. It also covers stream transformations with Single Message Transforms (SMTs), authentication and encryption, monitoring with Prometheus and Grafana, and migration from Amazon Data Firehose.
Understanding Kafka and Kafka Connect Architecture
Apache Kafka and Kafka Connect map directly to Amazon Data Firehose components, providing equivalent functionality through a self-hosted Kafka and Kafka Connect deployment.
| Amazon Data Firehose | Kafka / Kafka Connect | Description |
|---|---|---|
| Amazon Kinesis Data Streams (source) | Kafka Topics | Persistent, partitioned message streams that producers write to and consumers read from. |
| Firehose stream | Kafka Connect Sink Connector | Worker processes that consume from Kafka topics and write to external systems. |
| Amazon S3 destination | S3 Sink Connector | Writes records to S3-compatible storage in configurable formats (JSON, Avro, Parquet). |
| Amazon OpenSearch Service destination | Elasticsearch Sink Connector | Indexes records into Elasticsearch or OpenSearch clusters for search and analytics. |
| Lambda transform | Kafka Streams / SMTs | Process and transform records in-flight using Single Message Transforms or Kafka Streams applications. |
Buffering (SizeInMBs / IntervalInSeconds) |
Flush configuration | Controls when connectors write batched data based on record count, byte size, or time interval. |
| Record format conversion | Format converters | Convert between JSON, Avro, and other formats using Kafka Connect converters. |
| CloudWatch Metrics | Prometheus + Grafana | Collect and visualize Kafka and Kafka Connect metrics via Strimzi's built-in Prometheus exporters. |
The Kafka with Strimzi architecture consists of:
- Kafka Brokers: Distributed servers that store and replicate topic partitions. Strimzi deploys brokers as StatefulSets with persistent volumes.
- KRaft mode: Kafka's built-in consensus protocol replacing ZooKeeper for metadata management, simplifying operations and reducing component count.
- Kafka Connect: Framework for running source and sink connectors that move data between Kafka and external systems.
- Strimzi Operator: Kubernetes operator that manages Kafka, KafkaConnect, KafkaTopic, and KafkaUser resources through declarative CRDs.
- Schema Registry: Optional component for managing Avro, JSON Schema, and Protobuf schemas when using structured data formats.
Prerequisites
Before you begin, you need to:
- Deploy a Kubernetes cluster with at least 3 worker nodes and enough memory to run Kafka, Kafka Connect, Prometheus, and Grafana concurrently (8 GB RAM per node recommended).
- Install and configure kubectl with cluster access.
- Install Helm 3.
- Provision object storage with an S3-compatible API and create a bucket for the connector to write to.
- Provision a container registry to host the custom Kafka Connect image.
Install the Strimzi Kafka Operator
The Strimzi operator watches for Kafka-related Custom Resources and manages the underlying Kubernetes resources (StatefulSets, Services, ConfigMaps) automatically.
Add the Strimzi Helm repository.
console$ helm repo add strimzi https://strimzi.io/charts/
Update the Helm repository cache.
console$ helm repo update
Create a dedicated namespace for Kafka resources.
console$ kubectl create namespace kafka
Install the Strimzi Kafka operator. The
--versionflag pins the operator to a release whose CRDs still serve thekafka.strimzi.io/v1beta2API used throughout this article. Starting with Strimzi 1.0.0, Strimzi supports only thev1CRD API, so manifests that usev1beta2are not compatible with Strimzi 1.0.0 or newer.console$ helm install strimzi-kafka-operator strimzi/strimzi-kafka-operator \ --namespace kafka \ --version 0.46.0 \ --set watchAnyNamespace=true
Verify that the operator pod is running.
console$ kubectl get pods -n kafka
The output displays a pod named
strimzi-cluster-operator-*with statusRunning.Verify that the CRDs are installed.
console$ kubectl get crds | grep strimzi
The output lists CRDs including
kafkas.kafka.strimzi.io,kafkanodepools.kafka.strimzi.io,kafkatopics.kafka.strimzi.io,kafkaconnects.kafka.strimzi.io, andkafkausers.kafka.strimzi.io.
Deploy Kafka Cluster
In KRaft mode (Kafka Raft), Kafka manages its own metadata quorum and no longer depends on ZooKeeper. Strimzi models this with KafkaNodePool resources that define groups of nodes acting as brokers, controllers, or both.
Create the KafkaNodePool manifest for the controller and broker nodes.
console$ nano kafka-node-pools.yaml
Add the following configuration.
yaml--- apiVersion: kafka.strimzi.io/v1beta2 kind: KafkaNodePool metadata: name: controller namespace: kafka labels: strimzi.io/cluster: kafka-cluster spec: replicas: 3 roles: - controller storage: type: persistent-claim size: 40Gi deleteClaim: false resources: requests: memory: 1Gi cpu: 250m limits: memory: 2Gi cpu: 1000m --- apiVersion: kafka.strimzi.io/v1beta2 kind: KafkaNodePool metadata: name: broker namespace: kafka labels: strimzi.io/cluster: kafka-cluster spec: replicas: 3 roles: - broker storage: type: persistent-claim size: 50Gi deleteClaim: false resources: requests: memory: 2Gi cpu: 500m limits: memory: 4Gi cpu: 2000m
Save and close the file.
In this configuration:
controllerpool: Three nodes that participate in the KRaft quorum for metadata management.brokerpool: Three nodes that store topic partitions and serve client traffic.roles: Defines whether each node acts as a controller, broker, or both. Separating roles is recommended for production.storage.type: persistent-claim: Uses Persistent Volume Claims for data durability across pod restarts. The sizes above (40 GB for controllers, 50 GB for brokers) meet common block storage minimum volume sizes. Increase the broker volumes to match your topic retention and partition footprint.
Apply the KafkaNodePool manifest.
console$ kubectl apply -f kafka-node-pools.yaml
Create the Kafka cluster manifest.
console$ nano kafka-cluster.yaml
Add the following configuration.
yamlapiVersion: kafka.strimzi.io/v1beta2 kind: Kafka metadata: name: kafka-cluster namespace: kafka annotations: strimzi.io/node-pools: enabled strimzi.io/kraft: enabled spec: kafka: version: 3.9.0 metadataVersion: 3.9-IV0 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 entityOperator: topicOperator: {} userOperator: {}
Save and close the file.
In this configuration:
strimzi.io/node-pools: enabled: Enables theKafkaNodePoolresources defined in the previous step.strimzi.io/kraft: enabled: Activates KRaft mode, removing the ZooKeeper dependency.metadataVersion: Sets the KRaft metadata version compatible with the configured Kafka version.listeners: Configures both plaintext (port 9092) and Transport Layer Security (TLS) encrypted (port 9093) internal listeners.min.insync.replicas: 2: Requires at least two replicas to acknowledge writes for durability.entityOperator: Deploys the Topic and User operators for managing KafkaTopic and KafkaUser resources.
Apply the Kafka cluster manifest.
console$ kubectl apply -f kafka-cluster.yaml
Monitor the cluster deployment progress.
console$ kubectl get kafka -n kafka -w
Wait until the
READYcolumn showsTrue. This may take several minutes as Strimzi provisions the StatefulSets and waits for all pods to become ready.Verify that all Kafka pods are running.
console$ kubectl get pods -n kafka -l strimzi.io/cluster=kafka-cluster
The output displays three controller pods, three broker pods, and entity operator pods, all with status
Running.
Create Ingestion Topics
Kafka topics are the primary mechanism for organizing and storing messages. Sink connectors consume from these topics, so the topics must exist before the connectors are configured.
Create the KafkaTopic manifest for the main data ingestion topic.
console$ nano events-topic.yaml
Add the following configuration.
yamlapiVersion: kafka.strimzi.io/v1beta2 kind: KafkaTopic metadata: name: events namespace: kafka labels: strimzi.io/cluster: kafka-cluster spec: partitions: 12 replicas: 3 config: retention.ms: 604800000 cleanup.policy: delete segment.bytes: 1073741824 min.insync.replicas: 2
Save and close the file.
In this configuration:
partitions: 12: Creates 12 partitions for parallel processing. Choose a partition count based on expected throughput and consumer parallelism.replicas: 3: Replicates each partition across all three brokers for fault tolerance.retention.ms: 604800000: Retains messages for 7 days (604800000 milliseconds).cleanup.policy: delete: Removes messages after the retention period expires.segment.bytes: 1073741824: Sets segment file size to 1 GB for efficient disk I/O.
Apply the topic manifest.
console$ kubectl apply -f events-topic.yaml
Create additional topics for different data streams.
console$ nano additional-topics.yaml
Add the following configuration.
yaml--- apiVersion: kafka.strimzi.io/v1beta2 kind: KafkaTopic metadata: name: logs namespace: kafka labels: strimzi.io/cluster: kafka-cluster spec: partitions: 6 replicas: 3 config: retention.ms: 259200000 cleanup.policy: delete --- apiVersion: kafka.strimzi.io/v1beta2 kind: KafkaTopic metadata: name: metrics namespace: kafka labels: strimzi.io/cluster: kafka-cluster spec: partitions: 6 replicas: 3 config: retention.ms: 86400000 cleanup.policy: delete
Save and close the file.
Apply the additional topics.
console$ kubectl apply -f additional-topics.yaml
Verify that all topics are created.
console$ kubectl get kafkatopics -n kafka
The output lists the
events,logs, andmetricstopics with their partition and replica counts.
Deploy Kafka Connect
Kafka Connect provides a framework for running connectors that stream data between Kafka and external systems. Strimzi builds a custom Connect image with the S3 sink connector plugin and runs it as a KafkaConnect resource.
Create a Kubernetes Secret that holds your container registry credentials. Strimzi uses this Secret to push the custom Connect image, and the cluster's worker nodes use it to pull the image. Replace
REGISTRY-HOSTNAME,REGISTRY-USERNAME, andREGISTRY-PASSWORDwith values from your container registry (for example, the hostname, username, and API key from the Overview tab). SetREGISTRY-HOSTNAMEto the registry host only, such asams.vultrcr.com, without thehttps://scheme and without the registry name path. The registry name belongs in the image path in a later step, not in the server value.console$ kubectl create secret docker-registry registry-credentials \ --namespace kafka \ --docker-server=REGISTRY-HOSTNAME \ --docker-username=REGISTRY-USERNAME \ --docker-password=REGISTRY-PASSWORD
Create the KafkaConnect manifest with the S3 sink connector plugin.
console$ nano kafka-connect.yaml
Add the following configuration, replacing
REGISTRY-HOSTNAMEwith your registry hostname andREGISTRY-NAMESPACEwith your registry namespace or project path.yamlapiVersion: kafka.strimzi.io/v1beta2 kind: KafkaConnect metadata: name: kafka-connect namespace: kafka annotations: strimzi.io/use-connector-resources: "true" spec: version: 3.9.0 replicas: 2 bootstrapServers: kafka-cluster-kafka-bootstrap:9092 config: group.id: connect-cluster offset.storage.topic: connect-offsets config.storage.topic: connect-configs status.storage.topic: connect-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: false resources: requests: memory: 1Gi cpu: 500m limits: memory: 2Gi cpu: 1000m template: pod: imagePullSecrets: - name: registry-credentials build: output: type: docker image: REGISTRY-HOSTNAME/REGISTRY-NAMESPACE/kafka-connect-s3:latest pushSecret: registry-credentials plugins: - name: kafka-connect-s3 artifacts: - type: zip url: https://hub-downloads.confluent.io/api/plugins/confluentinc/kafka-connect-s3/versions/10.6.7/confluentinc-kafka-connect-s3-10.6.7.zip
Save and close the file.
In this configuration:
annotations.strimzi.io/use-connector-resources: Enables managing connectors via KafkaConnector CRDs instead of the REST API.replicas: 2: Deploys two Connect workers for high availability and load distribution.bootstrapServers: Points to the Kafka cluster's internal bootstrap service.offset.storage.topic,config.storage.topic,status.storage.topic: Internal topics for storing connector state.template.pod.imagePullSecrets: Lets the worker pods pull the built image from the private container registry.build: Builds a custom Connect image with the S3 sink connector plugin using Strimzi's build capability and pushes it to the configured container registry.
Apply the KafkaConnect manifest.
console$ kubectl apply -f kafka-connect.yaml
Monitor the build and deployment progress.
console$ kubectl get kafkaconnect -n kafka -w
Wait until the
READYcolumn showsTrue. The initial build may take several minutes.Verify that the Connect worker pods are running.
console$ kubectl get pods -n kafka -l strimzi.io/cluster=kafka-connect
The output displays two Connect worker pods with status
Running.Check the available connector plugins.
console$ kubectl exec -n kafka kafka-connect-connect-0 -- curl -s localhost:8083/connector-plugins | jq '.[].class'
The output includes
io.confluent.connect.s3.S3SinkConnectorconfirming the S3 plugin is installed.
Configure S3-Compatible Sink Connector
A sink connector delivers Kafka messages to S3-compatible object storage. Storing credentials in a Kubernetes Secret and non-sensitive settings (bucket name, region, endpoint) in a ConfigMap keeps the connector manifests free of environment-specific values.
Create a Kubernetes Secret with object storage credentials. Replace
OBJECT-STORAGE-ACCESS-KEYandOBJECT-STORAGE-SECRET-KEYwith your S3-compatible access credentials.console$ kubectl create secret generic s3-credentials \ --namespace kafka \ --from-literal=aws.access.key.id=OBJECT-STORAGE-ACCESS-KEY \ --from-literal=aws.secret.access.key=OBJECT-STORAGE-SECRET-KEY
Create a Kubernetes ConfigMap with the bucket and endpoint settings. Replace
OBJECT-STORAGE-BUCKET,OBJECT-STORAGE-REGION, andOBJECT-STORAGE-ENDPOINTwith values from your object storage provider. SetOBJECT-STORAGE-ENDPOINTto the hostname only (withouthttps://).console$ kubectl create configmap s3-config \ --namespace kafka \ --from-literal=bucket=OBJECT-STORAGE-BUCKET \ --from-literal=region=OBJECT-STORAGE-REGION \ --from-literal=endpoint=https://OBJECT-STORAGE-ENDPOINT
Update the KafkaConnect resource to expose the Secret and ConfigMap values as environment variables on the Connect worker pods.
console$ nano kafka-connect.yaml
Add the following
connectContainerblock under the existingspec.templateblock, at the same indentation as thepodblock already present there.yamlconnectContainer: env: - name: AWS_ACCESS_KEY_ID valueFrom: secretKeyRef: name: s3-credentials key: aws.access.key.id - name: AWS_SECRET_ACCESS_KEY valueFrom: secretKeyRef: name: s3-credentials key: aws.secret.access.key - name: S3_BUCKET valueFrom: configMapKeyRef: name: s3-config key: bucket - name: S3_REGION valueFrom: configMapKeyRef: name: s3-config key: region - name: S3_ENDPOINT valueFrom: configMapKeyRef: name: s3-config key: endpoint
Save and close the file.
The Strimzi operator injects each value as an environment variable into the Connect worker container. Connector configurations reference these variables using the
${strimzienv:VAR_NAME}substitution syntax, which Strimzi auto-registers via theEnvVarConfigProvideron every Connect worker.Apply the updated KafkaConnect configuration.
console$ kubectl apply -f kafka-connect.yaml
Wait for the Connect cluster to roll the worker pods.
console$ kubectl wait --for=condition=Ready kafkaconnect/kafka-connect -n kafka --timeout=300s
Create the S3 sink connector manifest.
console$ nano s3-sink-connector.yaml
Add the following configuration.
yamlapiVersion: kafka.strimzi.io/v1beta2 kind: KafkaConnector metadata: name: s3-sink-events namespace: kafka labels: strimzi.io/cluster: kafka-connect spec: class: io.confluent.connect.s3.S3SinkConnector tasksMax: 3 config: topics: events s3.bucket.name: ${strimzienv:S3_BUCKET} s3.region: ${strimzienv:S3_REGION} store.url: ${strimzienv:S3_ENDPOINT} aws.access.key.id: ${strimzienv:AWS_ACCESS_KEY_ID} aws.secret.access.key: ${strimzienv:AWS_SECRET_ACCESS_KEY} storage.class: io.confluent.connect.s3.storage.S3Storage format.class: io.confluent.connect.s3.format.json.JsonFormat partitioner.class: io.confluent.connect.storage.partitioner.TimeBasedPartitioner path.format: "'year'=YYYY/'month'=MM/'day'=dd/'hour'=HH" partition.duration.ms: 3600000 locale: en-US timezone: UTC flush.size: 1000 rotate.interval.ms: 600000 s3.part.size: 5242880 topics.dir: kafka-events behavior.on.null.values: ignore
Save and close the file.
In this configuration:
tasksMax: 3: Creates three connector tasks for parallel processing across topic partitions.s3.bucket.name,s3.region,store.url: Resolved at runtime from theS3_BUCKET,S3_REGION, andS3_ENDPOINTenvironment variables sourced from thes3-configConfigMap.aws.access.key.id,aws.secret.access.key: Resolved at runtime from theAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYenvironment variables sourced from thes3-credentialsSecret.format.class: Writes records as JSON files. Alternatives includeAvroFormatandParquetFormat.partitioner.class: TimeBasedPartitioner: Organizes files by time-based directory structure.path.format: Creates directories likeyear=2024/month=06/day=15/hour=14.flush.size: 1000: Writes a file after receiving 1000 records.rotate.interval.ms: 600000: Forces file rotation every 10 minutes regardless of record count.
Apply the S3 sink connector.
console$ kubectl apply -f s3-sink-connector.yaml
Verify that the connector is running.
console$ kubectl get kafkaconnector -n kafka
The output shows the
s3-sink-eventsconnector withREADYstatusTrue.Check the connector status for any errors.
console$ kubectl describe kafkaconnector s3-sink-events -n kafka
Configure Additional Sink Connectors
Configure additional connectors to deliver data to multiple destinations from the same Kafka topics, similar to Amazon Data Firehose's multi-destination capability. Each collapsible below is optional and requires a backing service deployed in the cluster before you configure the connector.
The Elasticsearch sink connector indexes Kafka records into Elasticsearch 7.x for search and analytics. OpenSearch is not used in this example because the Confluent Elasticsearch connector performs a strict Elasticsearch version check at startup.
Before you begin, deploy a single-node Elasticsearch cluster in the kafka namespace and wait for the kafka-connect image rebuild that adds the Elasticsearch plugin to finish.
Create the Elasticsearch deployment manifest.
console$ nano elasticsearch.yaml
Add the following configuration.
yamlapiVersion: apps/v1 kind: Deployment metadata: name: elasticsearch namespace: kafka spec: replicas: 1 selector: matchLabels: app: elasticsearch template: metadata: labels: app: elasticsearch spec: containers: - name: elasticsearch image: docker.io/elasticsearch:7.17.21 ports: - containerPort: 9200 env: - name: discovery.type value: single-node - name: ES_JAVA_OPTS value: -Xms512m -Xmx512m - name: xpack.security.enabled value: "false" resources: requests: memory: 768Mi cpu: 250m limits: memory: 1536Mi cpu: 1000m --- apiVersion: v1 kind: Service metadata: name: elasticsearch namespace: kafka spec: selector: app: elasticsearch ports: - port: 9200 targetPort: 9200
Save and close the file.
Apply the manifest and wait for Elasticsearch to become ready.
console$ kubectl apply -f elasticsearch.yaml
console$ kubectl wait --for=condition=Available deployment/elasticsearch -n kafka --timeout=300s
Update the KafkaConnect build to include the Elasticsearch connector plugin.
console$ nano kafka-connect.yaml
Add the following entry to the existing
spec.build.pluginslist, below thekafka-connect-s3entry.yaml- name: kafka-connect-elasticsearch artifacts: - type: zip url: https://hub-downloads.confluent.io/api/plugins/confluentinc/kafka-connect-elasticsearch/versions/14.1.1/confluentinc-kafka-connect-elasticsearch-14.1.1.zip
Save and close the file.
Apply the updated KafkaConnect configuration and wait for the rebuild to finish.
console$ kubectl apply -f kafka-connect.yaml
console$ kubectl wait --for=condition=Ready kafkaconnect/kafka-connect -n kafka --timeout=600s
Create a ConfigMap with the in-cluster Elasticsearch endpoint.
console$ kubectl create configmap elasticsearch-config \ --namespace kafka \ --from-literal=connection.url=http://elasticsearch:9200
Open the KafkaConnect manifest.
console$ nano kafka-connect.yaml
Add the following entry to the existing
spec.template.connectContainer.envlist, below the entries already present.yaml- name: ES_CONNECTION_URL valueFrom: configMapKeyRef: name: elasticsearch-config key: connection.url
Apply the updated KafkaConnect configuration.
console$ kubectl apply -f kafka-connect.yaml
console$ kubectl wait --for=condition=Ready kafkaconnect/kafka-connect -n kafka --timeout=300s
Create the Elasticsearch sink connector manifest.
console$ nano elasticsearch-sink-connector.yaml
Add the following configuration.
yamlapiVersion: kafka.strimzi.io/v1beta2 kind: KafkaConnector metadata: name: elasticsearch-sink-logs namespace: kafka labels: strimzi.io/cluster: kafka-connect spec: class: io.confluent.connect.elasticsearch.ElasticsearchSinkConnector tasksMax: 2 config: topics: logs connection.url: ${strimzienv:ES_CONNECTION_URL} type.name: _doc key.ignore: true schema.ignore: true behavior.on.malformed.documents: warn batch.size: 500 linger.ms: 1000 flush.timeout.ms: 120000 max.retries: 5 retry.backoff.ms: 1000
Save and close the file.
Apply the Elasticsearch connector.
console$ kubectl apply -f elasticsearch-sink-connector.yaml
Produce sample JSON records to the
logstopic. The connector creates thelogsindex in Elasticsearch on the first record it receives.console$ kubectl exec -n kafka kafka-cluster-broker-0 -- /bin/bash -c 'printf "{\"level\":\"info\",\"message\":\"test log 1\"}\n{\"level\":\"warn\",\"message\":\"test log 2\"}\n" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server kafka-cluster-kafka-bootstrap:9092 --topic logs'
Query Elasticsearch to confirm the records were indexed.
console$ kubectl exec -n kafka deploy/elasticsearch -- curl -s 'http://localhost:9200/logs/_search?size=2'
The output returns a
hitsarray containing the documents produced to thelogstopic. A404withindex_not_found_exceptionmeans no records have reached the connector yet.
The JDBC sink connector writes Kafka records to a relational database such as PostgreSQL. The Confluent JDBC sink requires JSON records with an embedded schema when using JsonConverter, so the connector configuration below sets value.converter.schemas.enable: true and pk.mode: kafka.
Before you begin, deploy PostgreSQL in the kafka namespace, add the PostgreSQL JDBC driver to the KafkaConnect build, and wait for the image rebuild to finish.
Create the PostgreSQL deployment manifest.
console$ nano postgres.yaml
Add the following configuration. Replace
POSTGRES-USER,POSTGRES-PASSWORD, andPOSTGRES-DATABASEwith a database username, a strong password, and a database name of your choice. Use the same values in the JDBC Secret in a later step.yamlapiVersion: apps/v1 kind: Deployment metadata: name: postgres namespace: kafka spec: replicas: 1 selector: matchLabels: app: postgres template: metadata: labels: app: postgres spec: containers: - name: postgres image: postgres:16-alpine ports: - containerPort: 5432 env: - name: POSTGRES_USER value: POSTGRES-USER - name: POSTGRES_PASSWORD value: POSTGRES-PASSWORD - name: POSTGRES_DB value: POSTGRES-DATABASE resources: requests: memory: 256Mi cpu: 100m limits: memory: 512Mi cpu: 500m --- apiVersion: v1 kind: Service metadata: name: postgres namespace: kafka spec: selector: app: postgres ports: - port: 5432 targetPort: 5432
Save and close the file.
Apply the manifest and wait for PostgreSQL to become ready.
console$ kubectl apply -f postgres.yaml
console$ kubectl wait --for=condition=Available deployment/postgres -n kafka --timeout=120s
Update the KafkaConnect build to include the JDBC connector plugin and the PostgreSQL JDBC driver.
console$ nano kafka-connect.yaml
Add the following entry to the existing
spec.build.pluginslist, below the entries already present.yaml- name: kafka-connect-jdbc artifacts: - type: zip url: https://hub-downloads.confluent.io/api/plugins/confluentinc/kafka-connect-jdbc/versions/10.7.6/confluentinc-kafka-connect-jdbc-10.7.6.zip - type: jar url: https://jdbc.postgresql.org/download/postgresql-42.7.4.jar
Save and close the file.
Apply the updated KafkaConnect configuration and wait for the rebuild to finish.
console$ kubectl apply -f kafka-connect.yaml
console$ kubectl wait --for=condition=Ready kafkaconnect/kafka-connect -n kafka --timeout=600s
Create a Secret for database credentials. Replace
POSTGRES-DATABASE,POSTGRES-USER, andPOSTGRES-PASSWORDwith the same values you set in the PostgreSQL manifest.console$ kubectl create secret generic jdbc-credentials \ --namespace kafka \ --from-literal=connection.url=jdbc:postgresql://postgres:5432/POSTGRES-DATABASE \ --from-literal=connection.user=POSTGRES-USER \ --from-literal=connection.password=POSTGRES-PASSWORD
Open the KafkaConnect manifest.
console$ nano kafka-connect.yaml
Add the following entries to the existing
spec.template.connectContainer.envlist, below the entries already present.yaml- name: JDBC_URL valueFrom: secretKeyRef: name: jdbc-credentials key: connection.url - name: JDBC_USER valueFrom: secretKeyRef: name: jdbc-credentials key: connection.user - name: JDBC_PASSWORD valueFrom: secretKeyRef: name: jdbc-credentials key: connection.password
Save and close the file.
Apply the updated KafkaConnect configuration and wait for the rebuild to finish.
console$ kubectl apply -f kafka-connect.yaml
console$ kubectl wait --for=condition=Ready kafkaconnect/kafka-connect -n kafka --timeout=300s
Create the JDBC sink connector manifest.
console$ nano jdbc-sink-connector.yaml
Add the following configuration.
yamlapiVersion: kafka.strimzi.io/v1beta2 kind: KafkaConnector metadata: name: jdbc-sink-metrics namespace: kafka labels: strimzi.io/cluster: kafka-connect spec: class: io.confluent.connect.jdbc.JdbcSinkConnector tasksMax: 2 config: topics: metrics connection.url: ${strimzienv:JDBC_URL} connection.user: ${strimzienv:JDBC_USER} connection.password: ${strimzienv:JDBC_PASSWORD} insert.mode: insert auto.create: true auto.evolve: true pk.mode: kafka batch.size: 1000 value.converter: org.apache.kafka.connect.json.JsonConverter value.converter.schemas.enable: "true"
Save and close the file.
In this configuration:
pk.mode: kafka: Uses the Kafka topic, partition, and offset as the primary key columns (__connect_topic,__connect_partition,__connect_offset).value.converter.schemas.enable: "true": Requires producers to send JSON records with an embedded schema envelope.
Apply the JDBC sink connector.
console$ kubectl apply -f jdbc-sink-connector.yaml
Produce a schema-wrapped JSON record to the
metricstopic from a broker pod. The JDBC sink requires theschemaandpayloadenvelope, so keep that structure intact.console$ kubectl exec -n kafka kafka-cluster-broker-0 -- /bin/bash -c 'printf "%s\n" "{\"schema\":{\"type\":\"struct\",\"fields\":[{\"field\":\"metric\",\"type\":\"string\",\"optional\":true},{\"field\":\"value\",\"type\":\"int32\",\"optional\":true}],\"optional\":false,\"name\":\"metric_record\"},\"payload\":{\"metric\":\"cpu\",\"value\":42}}" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server kafka-cluster-kafka-bootstrap:9092 --topic metrics'
Verify that the record landed in PostgreSQL. Replace
POSTGRES-USERandPOSTGRES-DATABASEwith the values you set in the PostgreSQL manifest.console$ kubectl exec -n kafka deploy/postgres -- psql -U POSTGRES-USER -d POSTGRES-DATABASE -c 'SELECT * FROM metrics;'
The output displays rows written by the JDBC sink connector.
Set Up Stream Transformations
Single Message Transforms (SMTs) modify records as they flow through Kafka Connect, providing functionality similar to Amazon Data Firehose Lambda transforms.
Create a connector with SMTs for data enrichment.
console$ nano s3-sink-transformed.yaml
Add the following configuration with transformation chains.
yamlapiVersion: kafka.strimzi.io/v1beta2 kind: KafkaConnector metadata: name: s3-sink-transformed namespace: kafka labels: strimzi.io/cluster: kafka-connect spec: class: io.confluent.connect.s3.S3SinkConnector tasksMax: 2 config: topics: events s3.bucket.name: ${strimzienv:S3_BUCKET} s3.region: ${strimzienv:S3_REGION} store.url: ${strimzienv:S3_ENDPOINT} aws.access.key.id: ${strimzienv:AWS_ACCESS_KEY_ID} aws.secret.access.key: ${strimzienv:AWS_SECRET_ACCESS_KEY} storage.class: io.confluent.connect.s3.storage.S3Storage format.class: io.confluent.connect.s3.format.json.JsonFormat flush.size: 500 rotate.interval.ms: 300000 topics.dir: transformed-events transforms: addTimestamp,addSource,filterNull,maskSensitive transforms.addTimestamp.type: org.apache.kafka.connect.transforms.InsertField$Value transforms.addTimestamp.timestamp.field: processed_at transforms.addSource.type: org.apache.kafka.connect.transforms.InsertField$Value transforms.addSource.static.field: source transforms.addSource.static.value: kafka-connect transforms.filterNull.type: org.apache.kafka.connect.transforms.Filter transforms.filterNull.predicate: isNullValue transforms.maskSensitive.type: org.apache.kafka.connect.transforms.MaskField$Value transforms.maskSensitive.fields: email,ssn transforms.maskSensitive.replacement: "[REDACTED]" predicates: isNullValue predicates.isNullValue.type: org.apache.kafka.connect.transforms.predicates.RecordIsTombstone
Save and close the file.
The configuration applies a chain of four transforms to every record:
addTimestamp: Inserts aprocessed_atfield with the time the connector handled the record.addSource: Inserts a staticsourcefield set tokafka-connectfor downstream lineage.filterNull: Drops records that match theisNullValuepredicate, which usesRecordIsTombstoneto identify Kafka tombstone records.maskSensitive: Replaces the values of theemailandssnfields with[REDACTED]to prevent personally identifiable information from reaching the sink.
Apply the transformed sink connector.
console$ kubectl apply -f s3-sink-transformed.yaml
Configure Buffering and Batching
Tune connector flush intervals and batch sizes to balance latency versus throughput, similar to Amazon Data Firehose buffering configuration.
Create a connector with optimized batching for high-throughput scenarios.
console$ nano s3-sink-batched.yaml
Add the following configuration.
yamlapiVersion: kafka.strimzi.io/v1beta2 kind: KafkaConnector metadata: name: s3-sink-batched namespace: kafka labels: strimzi.io/cluster: kafka-connect spec: class: io.confluent.connect.s3.S3SinkConnector tasksMax: 6 config: topics: events,logs,metrics s3.bucket.name: ${strimzienv:S3_BUCKET} s3.region: ${strimzienv:S3_REGION} store.url: ${strimzienv:S3_ENDPOINT} aws.access.key.id: ${strimzienv:AWS_ACCESS_KEY_ID} aws.secret.access.key: ${strimzienv:AWS_SECRET_ACCESS_KEY} storage.class: io.confluent.connect.s3.storage.S3Storage format.class: io.confluent.connect.s3.format.json.JsonFormat flush.size: 10000 rotate.interval.ms: 900000 rotate.schedule.interval.ms: 3600000 s3.part.size: 26214400 s3.retry.backoff.ms: 200 s3.retries: 3 partitioner.class: io.confluent.connect.storage.partitioner.TimeBasedPartitioner path.format: "'year'=YYYY/'month'=MM/'day'=dd" partition.duration.ms: 86400000 locale: en-US timezone: UTC errors.tolerance: all errors.deadletterqueue.topic.name: dlq-s3-sink errors.deadletterqueue.topic.replication.factor: 3 errors.log.enable: true errors.log.include.messages: true
Save and close the file.
In this configuration:
flush.size: 10000: Writes a file after receiving 10,000 records.rotate.interval.ms: 900000: Forces file rotation every 15 minutes.rotate.schedule.interval.ms: 3600000: Schedules rotation at hourly boundaries.s3.part.size: 26214400: Uses 25 MB parts for multipart uploads.errors.deadletterqueue.topic.name: Routes failed records to a dead-letter queue topic for later analysis.
Apply the batched sink connector.
console$ kubectl apply -f s3-sink-batched.yaml
Create the dead-letter queue topic.
console$ nano dlq-topic.yaml
Add the following configuration.
yamlapiVersion: kafka.strimzi.io/v1beta2 kind: KafkaTopic metadata: name: dlq-s3-sink namespace: kafka labels: strimzi.io/cluster: kafka-cluster spec: partitions: 3 replicas: 3 config: retention.ms: 2592000000
Save and close the file.
Apply the dead-letter queue topic.
console$ kubectl apply -f dlq-topic.yaml
Set Up Authentication and Encryption
Configure Simple Authentication and Security Layer (SASL) authentication with the Salted Challenge Response Authentication Mechanism (SCRAM) and TLS encryption to secure Kafka communication, replacing Amazon Data Firehose's IAM-based security model. Enable the authorizer on the Kafka cluster first so that KafkaUser Access Control List (ACL) rules are accepted by the operator.
Update the Kafka cluster to add a TLS-protected SASL listener and enable simple authorization.
console$ nano kafka-cluster.yaml
Add an
authorizationblock underspec.kafka, at the same indentation as the existinglistenersandconfigkeys.yamlauthorization: type: simple
Add the following
sasllistener entry to the existingspec.kafka.listenerslist, below thetlsentry.yaml- name: sasl port: 9094 type: internal tls: true authentication: type: scram-sha-512
Save and close the file.
In this configuration:
authorization.type: simple: Enables Kafka's built-in ACL authorizer. Without this block, the User Operator rejects anyKafkaUserthat declares ACL rules.sasllistener: Adds a TLS-encrypted listener on port 9094 that authenticates clients with SCRAM-SHA-512 credentials.
Apply the updated Kafka cluster configuration and wait for the rolling update to finish.
console$ kubectl apply -f kafka-cluster.yaml
console$ kubectl wait --for=condition=Ready kafka/kafka-cluster -n kafka --timeout=600s
Create the KafkaUser manifest.
console$ nano kafka-user.yaml
Add the following configuration.
yamlapiVersion: kafka.strimzi.io/v1beta2 kind: KafkaUser metadata: name: connect-user namespace: kafka labels: strimzi.io/cluster: kafka-cluster spec: authentication: type: scram-sha-512 authorization: type: simple acls: - resource: type: topic name: events patternType: literal operations: - Read - Write - Describe - resource: type: topic name: logs patternType: literal operations: - Read - Write - Describe - resource: type: topic name: metrics patternType: literal operations: - Read - Write - Describe - resource: type: topic name: connect- patternType: prefix operations: - Read - Write - Describe - Create - resource: type: topic name: dlq-s3-sink patternType: literal operations: - Read - Write - Describe - resource: type: group name: connect-cluster patternType: literal operations: - Read - Describe - resource: type: group name: connect- patternType: prefix operations: - Read - Describe - resource: type: group name: verify-group patternType: literal operations: - Read - Describe
Save and close the file.
The
connect-prefix ACLs on topics and consumer groups let each sink connector create its internal offset topic and join a dedicated consumer group named after the connector (for example,connect-s3-sink-events).Apply the KafkaUser.
console$ kubectl apply -f kafka-user.yaml
Verify that the user is
Readyand that a credentials Secret was generated.console$ kubectl get kafkauser connect-user -n kafka
The output shows
Trueunder theREADYcolumn once the User Operator has reconciled.To connect a client that runs outside the cluster, retrieve the plaintext SCRAM password from the generated Secret withNotekubectl get secret connect-user -n kafka -o jsonpath='{.data.password}' | base64 -d. In-cluster components such as KafkaConnect read the password directly from the Secret, so this value is not needed elsewhere in this guide.Update KafkaConnect to use SASL authentication.
console$ nano kafka-connect.yaml
Change the existing
spec.bootstrapServersvalue from port9092to the SASL listener on port9094.yamlbootstrapServers: kafka-cluster-kafka-bootstrap:9094
Add the following
tlsandauthenticationblocks underspec, at the same indentation asbootstrapServers.yamltls: trustedCertificates: - secretName: kafka-cluster-cluster-ca-cert certificate: ca.crt authentication: type: scram-sha-512 username: connect-user passwordSecret: secretName: connect-user password: password
Save and close the file.
Apply the updated KafkaConnect configuration and wait for the worker pods to roll onto the new SASL/TLS configuration.
console$ kubectl apply -f kafka-connect.yaml
console$ kubectl wait --for=condition=Ready kafkaconnect/kafka-connect -n kafka --timeout=300s
The first worker pod to restart under the new configuration can briefly crash loop withNoteNoSuchFileException: /tmp/kafka/cluster.truststore.p12, since the mounted CA certificate Secret volume is not always fully populated before the container's truststore-generation step runs on the first attempt. The pod recovers automatically on a subsequent restart within about a minute. If thekubectl waitcommand above times out, checkkubectl get pods -n kafka -l strimzi.io/cluster=kafka-connectbefore troubleshooting further; the pods are often alreadyRunningby then.
Configure Monitoring
Prometheus scrapes the Kafka metrics that Strimzi's JMX exporters expose, and Grafana visualizes them, replacing CloudWatch monitoring.
Enable Strimzi metrics exporters in the Kafka cluster.
console$ nano kafka-cluster.yaml
Add the
metricsConfigsection underspec.kafka:yamlspec: kafka: # ... existing configuration ... metricsConfig: type: jmxPrometheusExporter valueFrom: configMapKeyRef: name: kafka-metrics key: kafka-metrics-config.yml
Save and close the file.
Create the metrics configuration ConfigMap.
console$ nano kafka-metrics-configmap.yaml
Add the following configuration.
yamlapiVersion: v1 kind: ConfigMap metadata: name: kafka-metrics namespace: kafka data: kafka-metrics-config.yml: | lowercaseOutputName: true rules: - pattern: kafka.server<type=(.+), name=(.+), clientId=(.+), topic=(.+), partition=(.*)><>Value name: kafka_server_$1_$2 type: GAUGE labels: clientId: "$3" topic: "$4" partition: "$5" - pattern: kafka.server<type=(.+), name=(.+), clientId=(.+), brokerHost=(.+), brokerPort=(.+)><>Value name: kafka_server_$1_$2 type: GAUGE labels: clientId: "$3" broker: "$4:$5" - pattern: kafka.server<type=(.+), cipher=(.+), protocol=(.+), listener=(.+), networkProcessor=(.+)><>connections name: kafka_server_$1_connections_tls_info type: GAUGE labels: cipher: "$2" protocol: "$3" listener: "$4" networkProcessor: "$5" - pattern: kafka.server<type=(.+), clientSoftwareName=(.+), clientSoftwareVersion=(.+), listener=(.+), networkProcessor=(.+)><>connections name: kafka_server_$1_connections_software type: GAUGE labels: clientSoftwareName: "$2" clientSoftwareVersion: "$3" listener: "$4" networkProcessor: "$5" - pattern: "kafka.server<type=(.+), listener=(.+), networkProcessor=(.+)><>(.+-total):" name: kafka_server_$1_$4 type: COUNTER labels: listener: "$2" networkProcessor: "$3" - pattern: "kafka.server<type=(.+), listener=(.+), networkProcessor=(.+)><>(.+):" name: kafka_server_$1_$4 type: GAUGE labels: listener: "$2" networkProcessor: "$3" - pattern: kafka.server<type=(.+), listener=(.+), networkProcessor=(.+)><>(.+-total) name: kafka_server_$1_$4 type: COUNTER labels: listener: "$2" networkProcessor: "$3" - pattern: kafka.server<type=(.+), listener=(.+), networkProcessor=(.+)><>(.+) name: kafka_server_$1_$4 type: GAUGE labels: listener: "$2" networkProcessor: "$3" - pattern: kafka.(\w+)<type=(.+), name=(.+)Percent\w*><>MeanRate name: kafka_$1_$2_$3_percent type: GAUGE - pattern: kafka.(\w+)<type=(.+), name=(.+)Percent\w*><>Value name: kafka_$1_$2_$3_percent type: GAUGE - pattern: kafka.(\w+)<type=(.+), name=(.+)Percent\w*, (.+)=(.+)><>Value name: kafka_$1_$2_$3_percent type: GAUGE labels: "$4": "$5" - pattern: kafka.(\w+)<type=(.+), name=(.+)PerSec\w*, (.+)=(.+), (.+)=(.+)><>Count name: kafka_$1_$2_$3_total type: COUNTER labels: "$4": "$5" "$6": "$7" - pattern: kafka.(\w+)<type=(.+), name=(.+)PerSec\w*, (.+)=(.+)><>Count name: kafka_$1_$2_$3_total type: COUNTER labels: "$4": "$5" - pattern: kafka.(\w+)<type=(.+), name=(.+)PerSec\w*><>Count name: kafka_$1_$2_$3_total type: COUNTER - pattern: kafka.(\w+)<type=(.+), name=(.+), (.+)=(.+), (.+)=(.+)><>Value name: kafka_$1_$2_$3 type: GAUGE labels: "$4": "$5" "$6": "$7" - pattern: kafka.(\w+)<type=(.+), name=(.+), (.+)=(.+)><>Value name: kafka_$1_$2_$3 type: GAUGE labels: "$4": "$5" - pattern: kafka.(\w+)<type=(.+), name=(.+)><>Value name: kafka_$1_$2_$3 type: GAUGE - pattern: kafka.(\w+)<type=(.+), name=(.+), (.+)=(.+), (.+)=(.+)><>Count name: kafka_$1_$2_$3_count type: COUNTER labels: "$4": "$5" "$6": "$7" - pattern: kafka.(\w+)<type=(.+), name=(.+), (.+)=(.*), (.+)=(.+)><>(\d+)thPercentile name: kafka_$1_$2_$3 type: GAUGE labels: "$4": "$5" "$6": "$7" quantile: "0.$8" - pattern: kafka.(\w+)<type=(.+), name=(.+), (.+)=(.+)><>Count name: kafka_$1_$2_$3_count type: COUNTER labels: "$4": "$5" - pattern: kafka.(\w+)<type=(.+), name=(.+), (.+)=(.*)><>(\d+)thPercentile name: kafka_$1_$2_$3 type: GAUGE labels: "$4": "$5" quantile: "0.$6" - pattern: kafka.(\w+)<type=(.+), name=(.+)><>Count name: kafka_$1_$2_$3_count type: COUNTER - pattern: kafka.(\w+)<type=(.+), name=(.+)><>(\d+)thPercentile name: kafka_$1_$2_$3 type: GAUGE labels: quantile: "0.$4" - pattern: "kafka.server<type=raft-metrics><>(.+-total|.+-max):" name: kafka_server_raftmetrics_$1 type: COUNTER - pattern: "kafka.server<type=raft-metrics><>(current-state): (.+)" name: kafka_server_raftmetrics_$1 value: 1 type: UNTYPED labels: $1: "$2" - pattern: "kafka.server<type=raft-metrics><>(.+):" name: kafka_server_raftmetrics_$1 type: GAUGE - pattern: "kafka.server<type=raft-channel-metrics><>(.+-total|.+-max):" name: kafka_server_raftchannelmetrics_$1 type: COUNTER - pattern: "kafka.server<type=raft-channel-metrics><>(.+):" name: kafka_server_raftchannelmetrics_$1 type: GAUGE - pattern: "kafka.server<type=broker-metadata-metrics><>(.+):" name: kafka_server_brokermetadatametrics_$1 type: GAUGE
Save and close the file.
Apply the metrics ConfigMap.
console$ kubectl apply -f kafka-metrics-configmap.yaml
Apply the updated Kafka cluster configuration.
console$ kubectl apply -f kafka-cluster.yaml
Strimzi now exposes Prometheus metrics on port
9404of each broker and controller pod. The next steps deploy the Prometheus Operator and create aPodMonitorthat scrapes those endpoints directly, since the pods expose this port but the Kafka Services do not. ThePodMonitorresource depends on themonitoring.coreos.com/v1Custom Resource Definition that the Prometheus Operator installs.Add the Prometheus Community Helm repository.
console$ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
Update the Helm repository cache.
console$ helm repo update
Install the kube-prometheus-stack.
console$ helm install prometheus prometheus-community/kube-prometheus-stack \ --namespace monitoring \ --create-namespace \ --set grafana.adminPassword=admin \ --set prometheus.prometheusSpec.podMonitorSelectorNilUsesHelmValues=false \ --set grafana.resources.requests.memory=256Mi \ --set grafana.resources.limits.memory=512Mi
The
podMonitorSelectorNilUsesHelmValues=falsesetting allows Prometheus to discoverPodMonitorresources outside the Helm release, including the KafkaPodMonitoryou create in the next steps.Wait for the Prometheus and Grafana pods to become ready.
console$ kubectl wait --for=condition=Ready pod \ -l app.kubernetes.io/name=grafana -n monitoring --timeout=300s
console$ kubectl wait --for=condition=Ready pod \ -l app.kubernetes.io/name=prometheus -n monitoring --timeout=300s
If Grafana remains in
ErrororCrashLoopBackOff, inspect the pod withkubectl describe pod -l app.kubernetes.io/name=grafana -n monitoringand increase memory limits or add a node with more capacity.Create the PodMonitor manifest that tells Prometheus to scrape Kafka metrics. A PodMonitor is required instead of a ServiceMonitor because Strimzi exposes the
tcp-prometheusmetrics port directly on the broker and controller pods; the headless*-kafka-brokersService it generates does not include that port.console$ nano kafka-podmonitor.yaml
Add the following configuration.
yamlapiVersion: monitoring.coreos.com/v1 kind: PodMonitor metadata: name: kafka-resources-metrics namespace: kafka labels: app: strimzi spec: selector: matchExpressions: - key: "strimzi.io/kind" operator: In values: ["Kafka", "KafkaConnect", "KafkaMirrorMaker2"] namespaceSelector: matchNames: - kafka podMetricsEndpoints: - path: /metrics port: tcp-prometheus relabelings: - separator: ; regex: __meta_kubernetes_pod_label_(strimzi_io_.+) replacement: $1 action: labelmap - sourceLabels: [__meta_kubernetes_namespace] targetLabel: namespace action: replace - sourceLabels: [__meta_kubernetes_pod_name] targetLabel: kubernetes_pod_name action: replace - sourceLabels: [__meta_kubernetes_pod_node_name] targetLabel: node_name action: replace - sourceLabels: [__meta_kubernetes_pod_host_ip] targetLabel: node_ip action: replace
Save and close the file.
The
matchExpressionsselector matches pods from any Kafka, KafkaConnect, or KafkaMirrorMaker2 resource in the namespace, so this single PodMonitor also picks up the KafkaConnect worker pods once you enablemetricsConfigon the KafkaConnect resource the same way you did for the Kafka cluster.Apply the PodMonitor.
console$ kubectl apply -f kafka-podmonitor.yaml
Verify that Prometheus discovered the Kafka scrape targets.
console$ kubectl get podmonitor kafka-resources-metrics -n kafka
Port-forward the Prometheus UI and open Status, followed by Targets, to confirm
kafka-resources-metricsendpoints appear as UP.console$ kubectl port-forward svc/prometheus-kube-prometheus-prometheus -n monitoring 9090:9090
Open
http://localhost:9090/targetsin a web browser.Access Grafana using port-forwarding.
console$ kubectl port-forward svc/prometheus-grafana -n monitoring 3000:80
Open
http://localhost:3000in a web browser and log in with usernameadminand passwordadmin.Open Connections, then Data sources in the left-side menu, and confirm that a Prometheus data source is listed. The kube-prometheus-stack chart provisions it automatically through a Grafana sidecar, but this step can fail silently. If a Prometheus data source already appears, continue to the next step.
If no Prometheus data source is present, click Add new data source and select Prometheus. Set the Prometheus server URL to the in-cluster Prometheus service, then scroll to the bottom and click Save & test. Verify that Grafana reports the data source is working.
http://prometheus-kube-prometheus-prometheus.monitoring.svc.cluster.local:9090Download a Strimzi dashboard definition. This example downloads the Kafka broker dashboard. The grafana-dashboards folder also includes
strimzi-kafka-connect.jsonfor Kafka Connect metrics.console$ wget https://raw.githubusercontent.com/strimzi/strimzi-kafka-operator/main/examples/metrics/grafana-dashboards/strimzi-kafka.json
In Grafana, click the + icon in the top-right corner and select Import dashboard. Under Upload dashboard JSON file, drag in or browse to the downloaded
strimzi-kafka.json, or paste its contents into the Import via dashboard JSON model field. Click Load.In the import screen, set the Prometheus data source selector to the Prometheus data source you verified earlier, then click Import.
The dashboard populates once Prometheus has scraped the
kafka-resources-metricstargets.
Verify the Deployment
Validate the complete deployment by testing end-to-end data flow from a producer through Kafka to the S3 sink. The Kafka cluster now requires SASL/SCRAM authentication over TLS, so the test client uses the connect-user credentials and the cluster CA certificate.
Verify that the connector and all its tasks are running.
console$ kubectl get kafkaconnector s3-sink-events -n kafka
The
READYcolumn showsTrue. If any task reportsFAILED, inspect the trace withkubectl exec -n kafka kafka-connect-connect-0 -- curl -s localhost:8083/connectors/s3-sink-events/status.Create a client pod manifest that mounts the cluster CA certificate and exposes the SCRAM password as an environment variable.
console$ nano kafka-client.yaml
Add the following configuration.
yamlapiVersion: v1 kind: Pod metadata: name: kafka-client namespace: kafka spec: restartPolicy: Never containers: - name: client image: quay.io/strimzi/kafka:0.46.0-kafka-3.9.0 command: ["sleep", "infinity"] env: - name: PASSWORD valueFrom: secretKeyRef: name: connect-user key: password volumeMounts: - name: cluster-ca mountPath: /etc/kafka-ca readOnly: true volumes: - name: cluster-ca secret: secretName: kafka-cluster-cluster-ca-cert
Save and close the file.
Apply the client pod manifest and wait for it to become ready.
console$ kubectl apply -f kafka-client.yaml
console$ kubectl wait --for=condition=Ready pod/kafka-client -n kafka --timeout=120s
Generate the client properties file inside the pod with the SASL credentials and the cluster truststore path. Use
/bin/bash -lcso the pod'sPASSWORDenvironment variable expands inside the container rather than in your local shell.console$ kubectl exec -n kafka kafka-client -- /bin/bash -lc 'cat > /tmp/client.properties <<EOF security.protocol=SASL_SSL sasl.mechanism=SCRAM-SHA-512 sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required username="connect-user" password="${PASSWORD}"; ssl.truststore.type=PEM ssl.truststore.location=/etc/kafka-ca/ca.crt EOF'
Produce a batch of 1500 test messages with the same key so they land on one topic partition and cross the connector's
flush.sizethreshold. Files materialize in the bucket within seconds rather than waiting onrotate.interval.ms.console$ kubectl exec -n kafka kafka-client -- /bin/bash -lc 'awk "BEGIN{for(i=1;i<=1500;i++) printf(\"verify:{\\\"event_id\\\":\\\"evt-%04d\\\",\\\"type\\\":\\\"test\\\",\\\"i\\\":%d}\n\",i,i)}" | bin/kafka-console-producer.sh --bootstrap-server kafka-cluster-kafka-bootstrap:9094 --topic events --property parse.key=true --property key.separator=: --producer.config /tmp/client.properties'
Wait about 30 seconds for the connector to commit the batch.
List the objects in the bucket. Replace
OBJECT-STORAGE-ACCESS-KEY,OBJECT-STORAGE-SECRET-KEY,OBJECT-STORAGE-REGION,OBJECT-STORAGE-ENDPOINT, andOBJECT-STORAGE-BUCKETwith the values from yours3-credentialsSecret ands3-configConfigMap.console$ kubectl run awscli --rm -it --restart=Never --image=amazon/aws-cli \ --env AWS_ACCESS_KEY_ID=OBJECT-STORAGE-ACCESS-KEY \ --env AWS_SECRET_ACCESS_KEY=OBJECT-STORAGE-SECRET-KEY \ --env AWS_DEFAULT_REGION=OBJECT-STORAGE-REGION \ -- --endpoint-url https://OBJECT-STORAGE-ENDPOINT s3 ls s3://OBJECT-STORAGE-BUCKET/kafka-events/ --recursive
This command runs the AWS CLI in a temporary pod, so no local installation is required. The output displays JSON files organized by the time-based partitioning structure, for example
kafka-events/events/year=2026/month=06/day=25/hour=17/events+0+0000000000.json.This command passes the Object Storage secret key on the command line, so it is recorded in the pod's container logs. TheWarning--rmflag deletes the pod immediately after the command exits, but rotate the key afterward if the cluster is not disposable, or browse the bucket in the Vultr Console instead to avoid exposing the credentials.Verify the data format by downloading and inspecting one of the listed files. Replace the placeholders as before, and replace the object key with one from the previous listing. This command carries the same credential-exposure risk described in the previous step.
console$ kubectl run awscli --rm -it --restart=Never --image=amazon/aws-cli \ --env AWS_ACCESS_KEY_ID=OBJECT-STORAGE-ACCESS-KEY \ --env AWS_SECRET_ACCESS_KEY=OBJECT-STORAGE-SECRET-KEY \ --env AWS_DEFAULT_REGION=OBJECT-STORAGE-REGION \ -- --endpoint-url https://OBJECT-STORAGE-ENDPOINT s3 cp s3://OBJECT-STORAGE-BUCKET/kafka-events/events/year=2026/month=06/day=25/hour=17/events+0+0000000000.json -
Each line is a JSON record matching the messages produced earlier.
Verify topic delivery by consuming directly from Kafka. Pass
--group verify-groupso the consumer joins the group authorized in theconnect-userACLs.console$ kubectl exec -it -n kafka kafka-client -- bin/kafka-console-consumer.sh \ --bootstrap-server kafka-cluster-kafka-bootstrap:9094 \ --topic events \ --from-beginning \ --max-messages 10 \ --group verify-group \ --consumer.config /tmp/client.properties
The output displays the messages produced earlier.
Delete the client pod once verification completes.
console$ kubectl delete pod kafka-client -n kafka
Migrate from Amazon Data Firehose to Kafka Connect
Migrating from Amazon Data Firehose to Kafka Connect involves mapping Firehose streams to topic and connector pairs, updating producer applications, and converting Lambda-based transforms to Kafka Connect SMTs or Kafka Streams. The service was formerly known as Amazon Kinesis Data Firehose; API resources still use names such as DeliveryStreamName. The guidance below assumes you completed Deploy Kafka Cluster, Deploy Kafka Connect, and Set Up Authentication and Encryption earlier in this article.
Firehose Stream Migration
An Amazon Data Firehose stream bundles a source, a destination, and buffering rules into a single managed resource. Kafka Connect splits these responsibilities across two Strimzi resources: a KafkaTopic that holds the records and a KafkaConnector that delivers them to the destination.
- Firehose stream name: Choose a Kafka topic name and a matching
KafkaConnectorname for each Firehose stream. In the Firehose API, this isDeliveryStreamName. - Source: Amazon Data Firehose supports Direct PUT, Amazon Kinesis Data Streams, and Amazon MSK as sources. A Kinesis data stream or MSK cluster backing a Firehose stream maps to a Kafka topic that producers write to directly after migration. Direct PUT streams map to a topic that producer applications write to using the Kafka client instead of the Firehose
PutRecordAPI. - Destination: Common Firehose destination types and their Kafka Connect equivalents:
- Amazon S3: Confluent S3 Sink Connector
- Amazon OpenSearch Service / OpenSearch Serverless: Elasticsearch Sink Connector (requires Elasticsearch 7.x compatibility; see the optional collapsible in Configure Additional Sink Connectors)
- Amazon Redshift: S3 Sink Connector staging to Object Storage, then a separate load step. Firehose delivers to S3 first and runs a Redshift
COPYcommand; it does not write directly to Redshift. Model this with an S3 sink connector and your existing warehouse load tooling rather than the JDBC sink alone. - Splunk: Splunk Sink Connector.
- HTTP endpoints and third-party observability targets (Datadog, New Relic, and similar): HTTP Sink Connector or a custom sink connector
- Buffering: Firehose
BufferingHintsusesIntervalInSeconds(0–900, default 300) andSizeInMBs(1–128 MiB, default 5). If you set one, you must set the other. MapIntervalInSecondsto the connector'srotate.interval.ms(multiply seconds by 1000).SizeInMBsis a byte-size threshold with no direct Kafka Connect equivalent; approximate it by tuningflush.sizebased on your average record size. Uses3.part.sizefor multipart upload chunk sizing, not as a direct Firehose buffer equivalent.
Producer Migration
Producer changes depend on how data enters Firehose.
If producers use Firehose Direct PUT (the most common pattern), replace the Firehose API with Apache Kafka client libraries:
PutRecord/PutRecordBatch:producer.send()(Amazon Data Firehose usesPutRecordBatch, not Amazon Kinesis Data StreamsPutRecords)DeliveryStreamName: Kafka topic name- Firehose records are opaque data blobs with no partition key; use a Kafka message key in your producer if you need partition affinity
If producers write to Amazon Kinesis Data Streams that Amazon Data Firehose reads as a source, replace the Kinesis Data Streams API and optionally the Kinesis Producer Library (KPL):
PutRecord/PutRecords:producer.send()StreamName: Kafka topic namePartitionKey: Kafka message key, which Kafka hashes to assign records to partitions- KPL aggregation and batching: Kafka producer
batch.size,linger.ms, andcompression.type
In both cases, update producers to use SASL/SCRAM credentials and the cluster TLS certificate configured in Set Up Authentication and Encryption. Refer to the Kafka producer configuration documentation for tuning options.
Transform Migration
Firehose invokes Lambda transforms synchronously on each buffered batch before delivery. Lambda buffering uses separate hints (BufferSizeInMBs up to 3 MB and IntervalInSeconds up to 900 in the processing configuration), distinct from destination BufferingHints.
Kafka Connect offers two equivalent mechanisms with different operational characteristics:
- Single Message Transforms (SMTs): Suitable for stateless, per-record operations such as field rename, field insertion, format conversion, and filtering with predicates. SMTs run inside the Kafka Connect worker process with no additional infrastructure. Refer to the Confluent SMT reference.
- Kafka Streams: Suitable for stateful operations, windowed aggregations, joins, and complex business logic that exceeds the SMT scope. Kafka Streams applications run as long-lived processes that consume from a source topic, transform records, and produce to a destination topic that the sink connector then reads. Refer to the Kafka Streams documentation.
Unlike Lambda transforms, which scale to zero between invocations, Kafka Streams applications run continuously and consume dedicated compute resources. Account for this cost-model difference when planning capacity.
Destination Configuration Migration
Firehose destination settings such as S3 prefix patterns, file formats, and compression map to Kafka Connect sink connector configuration keys.
- S3 prefix: Firehose
Prefixexpressions such as!{timestamp:yyyy/MM/dd/HH}map to the connector'stopics.dir,partitioner.class, andpath.formatsettings. Refer to the Firehose custom prefix documentation and the S3 Sink Connector partitioning documentation. - Error output prefix: Firehose
ErrorOutputPrefix(for exampleerrors/!{firehose:error-output-type}/!{timestamp:yyyy/MM/dd}) maps to the connector's dead-letter queue topic configured witherrors.deadletterqueue.topic.name. Route that topic to Object Storage with a second sink connector if you require failed records on disk, similar to Firehose writing failed records underErrorOutputPrefix. - Compression: Firehose
CompressionFormatvalues (GZIP,ZIP,Snappy,HADOOP_SNAPPY, orUNCOMPRESSED) map to the connector's format class ands3.compression.typesetting. - File format: Firehose record format conversion from JSON to Parquet or ORC maps to the connector's
format.classsetting combined with a schema-aware converter such as Avro or Protobuf and an AWS Glue schema equivalent in your Kafka ecosystem. - Buffering: Firehose
BufferingHints(IntervalInSeconds,SizeInMBs) map torotate.interval.ms,flush.size, ands3.part.sizeas described under Configure Buffering and Batching.
Data Storage Considerations
For destinations with existing data that needs to be preserved:
- Bucket reuse: Point the S3 sink connector at the same bucket Firehose was writing to, and reserve a new top-level prefix for Kafka Connect output to avoid clashes with existing objects.
- Historical backfill: Replay historical records by reading existing S3 objects with a custom producer script that publishes them back to the source topic, allowing the sink connector to write them under the new prefix structure.
- File format compatibility: Confirm downstream consumers such as data lakes, query engines, and ETL jobs can read the connector's output format. Kafka Connect writes JSON, Avro, or Parquet depending on the configured
format.class. - Partitioning alignment: Match the connector's
path.formatto the existing FirehosePrefixscheme so that downstream queries and data catalogs continue to discover new partitions automatically.
Migration Considerations
Review the following operational differences before cutting production traffic over to the new deployment.
- Auto-scaling: Firehose auto-scales transparently. Kafka Connect requires explicit scaling by adjusting
replicasandtasksMaxon theKafkaConnectresource, or by configuring a Horizontal Pod Autoscaler driven by consumer lag metrics. - Lambda transforms versus Kafka Streams: Firehose Lambda transforms are serverless and billed per invocation. Kafka Streams applications run as always-on workloads, which changes the cost model and the failure surface.
- Monitoring transition: Replace Firehose CloudWatch metrics with the Prometheus metrics and Grafana dashboards configured under Configure Monitoring. Common mappings include
IncomingRecords(ingestion volume),DeliveryToS3.Success(successful S3 deliveries), andBackupToS3.Records(records written to the S3 backup bucket when backup is enabled). - Delivery semantics: Both Firehose and Kafka Connect provide at-least-once delivery by default. Firehose retries failed deliveries and may produce duplicates on producer retries. Implement idempotent writes or deduplication in downstream systems when exactly-once is required.
- Cost model change: Firehose charges per GB of ingested and delivered data. Kafka on Kubernetes costs are infrastructure-based, covering compute, storage, and network. Calculate total cost of ownership including operational overhead before cutover.
- Identity translation: Replace AWS IAM roles and policies with Kubernetes RBAC and
KafkaUserACLs. Map each Firehose IAM role to aKafkaUserwith the minimum set of topic and group ACLs required for its workload. - Throughput limits: Firehose enforces per-stream quotas such as
RecordsPerSecondLimitandBytesPerSecondLimit. Kafka throughput is bounded by your cluster's broker count, partition count, and disk and network capacity.
Conclusion
You have deployed Apache Kafka with Strimzi on Kubernetes as a self-hosted alternative to Amazon Data Firehose. The deployment includes a Kafka cluster, Kafka Connect with S3-compatible sink connectors, stream transformations using SMTs, SASL/SCRAM authentication with TLS encryption, and Prometheus monitoring. For additional configuration options, connector plugins, and advanced features, visit the official Strimzi documentation and Kafka Connect documentation.