In the previous blog we explained what Kafka Connect is, what we can do with it, and what are some of the most important components that we can use to establish a reliable and fault-tolerant big data pipeline.

In this blog, we will deploy a small-scale but production-ready Kafka Connect cluster where we will take Avro and Protobuf messages from different topics and store them in Snowflake tables. We will use Schema Registry to validate our Avro and Protobuf schemas.

In theory, it seems like a straightforward process but in practice, it can get a bit complex especially if you are setting up Kafka Connect for the first time. So many properties can be easily overlooked, but precisely those properties can save you from losing your precious Kafka messages.

We will go over the whole process and show you how we can set up a production-ready pipeline from scratch.


Environment setup

  • Kubernetes cluster*
  • Kafka cluster
  • Schema registry
  • Snowflake account
    *Kubernetes cluster is not required, but if you plan to set up Kafka Connect somewhere else (like EC2, VMs), you must make some adjustments.

All the above services (besides Snowflake) can be deployed locally or you can use managed services.
To make this blog less complex since the main focus is on Kafka Connect, (for our example), we will use Confluent Platform for the Kafka cluster and schema registry.

We understand that not everybody can use Confluent Platform for commercial projects, and you do not have to. The process is not tied to Confluent, which can be achieved without using any Confluent products; instead, it can be done using open-source software. For reference, our company is not using anything related to Confluent, not even the schema registry or converters.
If you want to use Confluent Platform to follow along, there is a 30 day free trial that is more than enough to replicate everything from this blog. The same 30 day trial exists in Snowflake too.

To confirm that it also works without the Confluent Platform we also deployed Kafka using Koperator on our Kubernetes cluster, and the setup was the same. For the schema registry, you can try out Karapace.


Setting up Kafka Connect to store messages in Snowflake

To make our example more straightforward, we will imagine that we are a sushi restaurant that uses Kafka, Kubernetes and Snowflake to manage their customers, orders and reviews (this is probably not the best approach but is easier to understand).Even though we will use a small sample dataset, the same setup will work with any dataset size.

Our company is using a similar pipeline in production to process millions of records each day, and it works without any issues. Kafka Connect clusters are horizontally scalable, which means that you will only need to increase the replica count to support larger workloads.

Our application will have three topics: two will use Protobuf, and one will use Avro. We wanted to cover multiple data serialization formats to demonstrate how to handle various different Kafka Connect clusters.

The diagram that we will follow to set up our Kafka to Snowflake pipeline is below:

 setting up our Kafka to Snowflake pipeline

We will be using Kubernetes, but if you want to deploy it somewhere else, the Kafka Connect setup is the same; the only difference is how you want to deploy it.

Feel free to use this Docker image dksadx/kafka-connect-snowflake, or if you want to deploy it on bare metal, you can use this Dockerfile as a guide.


Setting up the demo environment (optional)

This section is optional; if you already have Kafka, Kubernetes, and Snowflake set up, feel free to skip this and go straight to the Configuring Kafka Connect section.

If you want to follow along, we need to set up a demo environment first.


Creating a Kubernetes cluster

We will use Podman with Kind to set up a local Kubernetes cluster. You can use any other tool to set up your cluster or deploy a managed cluster via your preferred cloud provider.

# Install Podman, QEMO, Kind and kubectl (MacOS only)
$ brew install podman qemu kind kubectl helm

# Create and start a VM for the cluster (increase resources if deploying Kafka)
$ podman machine init --cpus 4 --disk-size 30 --memory 8192 --now demo-vm

# Create a Kubernetes cluster with Kind
$ KIND_EXPERIMENTAL_PROVIDER=podman kind create cluster --name kafka_connect_demo

# Set the context
$ kubectl config set-context kafka_connect_demo


Check if everything works correctly:

$ kubectl get all
#   NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
#   service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   2m48s


Setting up Kafka on Kubernetes

If you do not have a Kafka cluster, you can create one for free via the Confluent Platform.
After creating the cluster, create three topics called avro-customers, proto-orders, and proto-reviews.

If you do not want to use the Confluent Platform and instead want to deploy it locally on the Kubernetes cluster we previously created, the easiest way to set it up is via the Koperator Helm chart. To set up the latest and most up-to-date version of the chart, use their guide, which can be found in the README.md here.

After the setup, we can create three topics for our sushi demo app.

kubectl create -n kafka -f - <<EOF
apiVersion: kafka.banzaicloud.io/v1alpha1
kind: KafkaTopic
metadata:
    name: avro-customers
spec:
    clusterRef:
        name: kafka
    name: customers
    partitions: 1
    replicationFactor: 1
    config:
        "retention.ms": "604800000"
        "cleanup.policy": "delete"
EOF

Do the same for the other two topics: proto-reviews, and proto-orders.


Setting up the Snowflake database

For storing Kafka messages, we will use Snowflake, and thus, we will need a Snowflake account. If you do not have an account, you can sign up for a free 30-day trial on their page: www.snowflake.com.
After creating an account, we will need to create a warehouse. If you are not familiar with Snowflake, warehouses are basically compute instances in Snowflake.

You can create a warehouse by going to the Admin panel and clicking the blue + WAREHOUSE button in the top right corner. Choose XS size for this demo.

After creating a warehouse, go to Projects -> Worksheets -> SQL Worksheet.

Now, we need to configure Snowflake so that we can actually use the Kafka Connect connector.

There are few steps that we have to do:

  1. Create a database and schema:

    CREATE DATABASE kafka_connect_demo;
    CREATE SCHEMA kafka_connect_demo.sushi_restaurant;

     

  2. Create user, role and configure all the privileges:

    -- Use a role that can create and manage roles and privileges.
    USE ROLE accountadmin;
    
    -- Create a Snowflake role with the privileges to work with the connector.
    CREATE ROLE kafka_connector_role_sushi;
    
    -- Create a Snowflake user that will be used by the kafka connect connector to access Snowflake.
    CREATE USER IF NOT EXISTS kafka_connect_sushi_user
        PASSWORD = "my_very_strong_password"
        LOGIN_NAME = "kafka_connect_sushi_user"
        DEFAULT_ROLE = "kafka_connector_role_sushi"
        DEFAULT_SECONDARY_ROLES = ('ALL');
    
    -- Grant privileges on the database.
    GRANT USAGE ON DATABASE kafka_connect_demo TO ROLE kafka_connector_role_sushi;
    
    -- Grant privileges on the schema.
    GRANT USAGE ON SCHEMA kafka_connect_demo.sushi_restaurant TO ROLE kafka_connector_role_sushi;
    GRANT CREATE TABLE ON SCHEMA kafka_connect_demo.sushi_restaurant TO ROLE kafka_connector_role_sushi;
    GRANT CREATE STAGE ON SCHEMA kafka_connect_demo.sushi_restaurant TO ROLE kafka_connector_role_sushi;
    GRANT CREATE PIPE ON SCHEMA kafka_connect_demo.sushi_restaurant TO ROLE kafka_connector_role_sushi;
    
    -- Grant the custom role to an existing user.
    GRANT ROLE kafka_connector_role_sushi TO USER kafka_connect_sushi_user;
    
    -- Grant the custom role to your user (assuming you are using ACCOUNTADMIN)
    GRANT ROLE kafka_connector_role_sushi TO USER ACCOUNTADMIN;
    
    -- Set the custom role as the default role for the user.
    -- If you encounter an 'Insufficient privileges' error, verify the role that has the OWNERSHIP privilege on the user.
    ALTER USER kafka_connect_sushi_user SET DEFAULT_ROLE = kafka_connector_role_sushi;
    
    -- Grant privileges on a warehouse to our role.
    GRANT ALL PRIVILEGES ON WAREHOUSE kafkaconnect TO ROLE kafka_connector_role_sushi;

     

  3. Generate an RSA key pair. If you are unfamiliar with how to do that, you can use this guide: Using key pair authentication & key rotation.
  4. Add the public key to the user you created in step 2, in our case the user is called kafka_connect_sushi_user.
    You must remove all the new lines and also the headers and footers (—–BEGIN PUBLIC KEY—– , —–END PUBLIC KEY—–) from the public key.

    -- Add RSA public key to user. This is how your public key needs to look like, without headers and new lines.
    ALTER USER kafka_connect_sushi_user SET RSA_PUBLIC_KEY='MIIBIj...';

     

  5. While still signed in to Snowflake, go to the home pages and save the url. The url should look like this: https://app.snowflake.com/<orgname>/<accountname>/worksheets.
    We will need this information when setting up Kafka Connect.

 

Setting up Schema Registry

Having a schema registry is not required, and you can do the whole setup without it but I highly recommend using it. If you are working or planning to work on a larger project, having a schema registry can save you a lot of trouble. If you do not plan to use schema registry, skip this section and remove all config properties that have schema in its name and you should be good to go.

To set up a schema registry there are two choices:

  1. sign up to Confluent Platform and use schema registry as a service,
  2. host your own.

Whichever option you choose, the rest will be the same; only the self-hosted one will require additional steps. To reduce the complexity of the blog, we will use Confluent Platform for this demo.

If you choose to use Confluent Platform, go to their site, create an account, and start a 30-day free trial.

After finishing all the required steps, your environment and cluster should be ready. The only thing that we need to do here is find the endpoint url and generate an API key. You can do that by clicking on your environment and in the right sidebar you can find the endpoint. Below the endpoint, you can generate an API key. Save the above info because we will need it in the next step.


Configuring Kafka Connect

After everything is set up and deployed, we are ready for the main part, configuring and deploying Kafka Connect clusters.
For our example, we will need three clusters since we have avro, protobuf, and string converters.

We will configure and deploy Kafka Connect in a distributed mode because it is the most reliable and fault-tolerant. In most cases, you will want to use this mode.

There are two configuration files that we need to edit:

  1. connect-distributed.properties – a file that configures the Kafka Connect cluster,
  2. snowflake.json – file that configures the Snowflake connector.

I will not go into too much detail about what all properties mean because you can find that in the official documentation. I will go over the most interesting ones for our use case.

connect-distributed.properties
This file configures the Kafka Connect cluster, it does not configure any connectors. We will add connectors after the cluster is running and in a healthy state.

Since we are deploying our cluster in distributed mode, we will need three additional topics that will be used to store the configs, statuses, and offsets for the clusters.

This is what the configuration for our avro cluster looks like:

# Unique name for the cluster, used in forming the Connect cluster group. Note that this must not conflict with consumer group IDs
group.id=kafka-connect-avro

# Topic to use for storing offsets. This topic should have many partitions and be replicated and compacted.
# Kafka Connect will attempt to create the topic automatically when needed, but you can always manually create
# the topic before starting Kafka Connect if a specific topic configuration is needed.
offset.storage.topic=kafka-connect-avro-offsets
offset.storage.replication.factor=3
offset.storage.partitions=3

# Topic to use for storing connector and task configurations; note that this should be a single partition, highly replicated,
# and compacted topic. Kafka Connect will attempt to create the topic automatically when needed, but you can always manually create
# the topic before starting Kafka Connect if a specific topic configuration is needed.
config.storage.topic=kafka-connect-avro-configs
config.storage.replication.factor=3
config.storage.partitions=1

# Topic to use for storing statuses. This topic can have multiple partitions and should be replicated and compacted.
# Kafka Connect will attempt to create the topic automatically when needed, but you can always manually create
# the topic before starting Kafka Connect if a specific topic configuration is needed.
status.storage.topic=kafka-connect-avro-statuses
status.storage.replication.factor=3
status.storage.partitions=3


You can create the three above topics yourself or let Kafka Connect create it for you.

To configure the schema registry, we will use the information that we got from the previous step:

value.converter.basic.auth.credentials.source=USER_INFO
value.converter.basic.auth.user.info=YOUR_API_KEY:YOUR_API_SECRET
value.converter.expected.schema.name=customers
value.converter.schema.registry.url=https://YOUR_ACCOUNT.confluent.cloud


The most interesting properties for us are the converter properties because if those are wrong, we will not be able to serialize or deserialize messages correctly.

We need to define which converters we want to use for both keys and values. Usually you can use StringConverter for keys but for values you need specific converters.

There are many converters out there, some are officially supported and some are custom made for specific use cases by the community. An example of such a converter is registryless-avro-converter, which is an avro converter that does not depend on the Confluent Schema Registry.

We will use 3 types of converters in our example:

  1. Avro – io.confluent.connect.avro.AvroConverter
  2. Protobuf – io.confluent.connect.protobuf.ProtobufConverter
  3. String – org.apache.kafka.connect.storage.StringConverter
key.converter=org.apache.kafka.connect.storage.StringConverter
# Using avro converter
value.converter=io.confluent.connect.avro.AvroConverter


snowflake.json

This file configures the Snowflake connector for Kafka Connect. It is a JSON file with all the secrets and configurations required for the connector to function. Since this is a connector configuration file, we do not specify it while starting the cluster; instead, we need to send a POST request with the file content as payload to the Kafka Connect endpoint once the cluster is up and running.

You can send the request from anywhere; it does not need to be from the container or pod, but I recommend that you do not expose the Kafka Connect endpoint externally if you really do not need to, mainly for security reasons.

Having the snowflake.json file along with the connect-distributed.properties file can be beneficial, as it allows us to recreate the connector if something unexpected happens automatically.
If you are running the cluster for months, something may break the cluster, and having a backup solution will be a lifesaver in those moments.

It rarely happens, but occurs sometimes, so configuring liveness and startup probes to take care of this is a big plus.

You can find detailed instructions on configuring a Snowflake connector in their official documentation here.
Most of the configuration is self-explanatory but I will point out a few fields that may cause problems:

name
Name of the connector. This name will be part of the url, and it will be used when performing any actions on the connector, e.g., updating the config properties. If you plan to have multiple connectors on a single cluster, make sure they have different names.

snowflake.url.name
Format for the url is <orgname>-<accountname>.snowflakecomputing.com:443. You can find the <orgname> and <accountname> in the url when you log in to your account via the browser. The url should be similar to this: https://app.snowflake.com/kalsvgw/ab20210/worksheets where <orgname> is kalsvgw and <accountname> is ab20210.

snowflake.private.key
This is the encrypted RSA key that we generated in the section where we did the Snowflake setup. Make sure that you remove all line breaks, whitespace and headers/footers before pasting the key.

snowflake.private.key.passphrase
This is the passphrase used while creating the key.


Deploying Kafka Connect clusters

We are almost ready to deploy our Kafka Connect clusters once the configurations for Kafka Connect and Snowflake connector are ready.

Starting the Kafka Connect in distributed mode is as simple as running the connect-distributed.sh script that comes with every Kafka installation and providing the configuration properties as the first argument:

$ ./connect-distributed.sh connect-distributed.properties

Keep in mind that you need different dependencies (jar files) for different connectors and converters. To see which dependencies are required for our example you can check this Dockerfile.

If everything is configured correctly, we will see that three new topics for offsets, statuses, and configs appeared in our Kafka cluster. These topics are used by the cluster instances/replicas to communicate with each other and distribute the load.

After the cluster is up and there are no errors in the logs, we can add connectors. To add a new connector, we need to send a POST request to the cluster’s endpoint:

curl -X POST -H "Content-Type: application/json" --data @snowflake.json http://localhost:8083/connectors


We should see messages arriving in our Snowflake tables if everything is configured correctly.

Since we will be deploying the cluster in Kubernetes for our sushi restaurant example, we prepared a Helm chart that will take care of most of the setup. We only have to configure some of the cluster and connector properties like bootstrap servers and credentials.

  1. First we need to get the chart
     

    git clone https://github.com/DKSadx/kafka-connect-blog.git
    cd deploy/helm
    
    
  2. You will see three types of configuration files in the configs directory, connect-distributed-<TYPE>.properties, snowflake-<TYPE>.json, and config.yaml. Fill out the empty fields with your Kafka and Snowflake information in all of those configs.
    If you are confused about what config.yaml is, that is a configuration file that is used by our simple demo Go app called sushi-restaurant-kafka-demo-app.
    The purpose of the app is to read some example dummy messages from app/data/ directory and publish those messages to corresponding topics specified in config.yaml.
    Besides topics, the configuration file also contains information about the Kafka cluster so that it can connect and publish those messages.
  3. After everything is configured, we are ready to install the helm chart:
helm install sushi-restaurant .

Let’s check the pods and wait for everything to be in RUNNING state:

$ kubectl get pods
NAME                                                          READY   STATUS      RESTARTS   AGE
demo-app-dqw6n                                                0/1     Completed   0          9s
sushi-restaurant-customers-avro-0-c8d7c8689-brmm7             1/1     Running     0          9s
sushi-restaurant-customers-avro-0-c8d7c8689-sh4h9             1/1     Running     0          9s
sushi-restaurant-dlq-string-2-56f7cf687-58qb9                 1/1     Running     0          9s
sushi-restaurant-dlq-string-2-56f7cf687-kwhpg                 1/1     Running     0          9s
sushi-restaurant-reviews-orders-protobuf-1-6546fd87fb-5882s   0/1     Running     0          9s
sushi-restaurant-reviews-orders-protobuf-1-6546fd87fb-9dskd   0/1     Running     0          9s


When you inspect the logs of the demo app, they need to look like this. If that is the case, that means that the messages are being published to our topics:

$ kubectl get logs demo-app-dqw6n

2024/09/02 12:08:40 Created Producer rdkafka#producer-1
2024/09/02 12:08:40 Publishing orders to proto-orders topic
2024/09/02 12:08:40 Publishing customers to avro-customers topic
2024/09/02 12:08:40 Publishing reviews to proto-reviews topic
2024/09/02 12:08:41 Delivered message to topic proto-reviews [1] at offset 0
2024/09/02 12:08:41 Delivered message to topic proto-orders [1] at offset 0
2024/09/02 12:08:41 Delivered message to topic avro-customers [0] at offset 0
2024/09/02 12:08:41 Delivered message to topic proto-orders [1] at offset 1
2024/09/02 12:08:41 Delivered message to topic avro-customers [1] at offset 0
2024/09/02 12:08:41 Delivered message to topic proto-orders [1] at offset 2


When you now check logs of any of the Kafka Connect pods, if the log looks similar to this, then it means that messages are successfully being consumed from topics and pushed to Snowflake:

$ kubectl logs sushi-restaurant-customers-avro-0-c8d7c8689-6gqkj 

2024-09-02 12:11:32,784] INFO [snowflake_sushi_customers|task-2] [SF_INGEST] Channel=CUSTOMERS_2 created for table=CUSTOMERS (net.snowflake.ingest.streaming.internal.SnowflakeStreamingIngestChannelInternal:58)
[2024-09-02 12:11:33,140] INFO [snowflake_sushi_customers|task-0] [SF_KAFKA_CONNECTOR] Fetched offsetToken for channelName:KAFKA_CONNECT_DEMO.SUSHI_RESTAURANT.CUSTOMERS.CUSTOMERS_0, offset:1 (com.snowflake.kafka.connector.internal.streaming.TopicPartitionChannel:46)
[2024-09-02 12:11:33,168] INFO [snowflake_sushi_customers|task-0] [SF_KAFKA_CONNECTOR] task opened with 1 partitions, execution time: 2955 milliseconds (com.snowflake.kafka.connector.SnowflakeSinkTask:46)
[2024-09-02 12:11:33,169] INFO [snowflake_sushi_customers|task-0] [Consumer clientId=connector-consumer-snowflake_sushi_customers-0, groupId=avro-customers] Seeking to offset 2 for partition customers-0 (org.apache.kafka.clients.consumer.KafkaConsumer:1585)
[2024-09-02 12:11:33,469] INFO [snowflake_sushi_customers|task-2] [SF_KAFKA_CONNECTOR] Fetched offsetToken for channelName:KAFKA_CONNECT_DEMO.SUSHI_RESTAURANT.CUSTOMERS.CUSTOMERS_2, offset:2 (com.snowflake.kafka.connector.internal.streaming.TopicPartitionChannel:46)
[2024-09-02 12:11:33,471] INFO [snowflake_sushi_customers|task-2] [SF_KAFKA_CONNECTOR] task opened with 1 partitions, execution time: 3255 milliseconds (com.snowflake.kafka.connector.SnowflakeSinkTask:46)
[2024-09-02 12:11:33,471] INFO [snowflake_sushi_customers|task-2] [Consumer clientId=connector-consumer-snowflake_sushi_customers-2, groupId=avro-customers] Seeking to offset 3 for partition customers-2 (org.apache.kafka.clients.consumer.KafkaConsumer:1585)
[2024-09-02 12:11:36,038] INFO [snowflake_sushi_customers|task-0] [SF_KAFKA_CONNECTOR] Fetched offsetToken for channelName:KAFKA_CONNECT_DEMO.SUSHI_RESTAURANT.CUSTOMERS.CUSTOMERS_0, offset:1 (com.snowflake.kafka.connector.internal.streaming.TopicPartitionChannel:46)


You are probably wondering, how does it work if we did not add the Snowflake connector. The reason for this is that we automated the process using
Startup Probes and this script. The script will check if the connector exists, and if not, it will add it automatically.

Now, if we go to Snowflake and check any of the new tables, you will see the example messages there:

 

Consuming and Storing Kafka Messages in Snowflake Using Kafka Connect

Dead letter queue to the rescue

Great, the pipeline works, but what happens if somebody sends a message that was serialized using a different converter, e.g., sends a protobuf message on an avro topic.

We can not ignore those messages, maybe those are some really important transactions that were sent by mistake.

To handle such cases, we need a dead letter queue or DLQ.

If you remember from the previous blog or check the example configuration file snowflake.json in the Helm chart, you will see that DLQ properties are already configured:

"errors.deadletterqueue.topic.name": "kafka_connect_dlq",
"errors.deadletterqueue.topic.replication.factor": 3,
"errors.deadletterqueue.context.headers.enable": true


You will also see one more property that is not directly related to DLQ but is important to set it up correctly.

"errors.tolerance": "all"


By default, error.tolerance is set to none, which means that if a message can not be deserialized, the Kafka Connect instance will throw an error and stop.

For some use cases, that can be exactly what we want, but for some, we can not afford to stop the whole pipeline if someone publishes a message to a wrong topic. In such cases it is good to have a Kafka Connect cluster specifically for DLQ with the property error.tolerance set to all.

With that approach you have a dedicated cluster that will take all the failed messages that went to the DLQ and push those messages to Snowflake as they are.

Now, we can continue consuming messages while also saving the failed messages to be analyzed later. For production environments, you will want to configure alerts on the Snowflake side so that you will be notified if new messages are added to the DLQ table.


Conclusion

When faced with the challenge of transferring messages from Kafka to another destination, in our case, it was Snowflake, Kafka Connect will be the best approach for most cases. 

We used Snowflake as an example since we are using it heavily on our projects and it is becoming a go-to platform when it comes to big data pipelines. Snowflake is just one sink connector, but there are hundreds more. If you want to take messages from Kafka topics and upload them somewhere or vice-versa, there is a good chance that someone already wrote a connector for that.

Having written custom consumers and discovering bugs when least expecting them is never a good feeling. That is one of the reasons why using a battle-tested tool with many users and a large community behind it is, in most cases, the way to go.

Leave a comment

Your email address will not be published. Required fields are marked *