DEV Community

Andrew
Andrew

Posted on

Building Event-Driven Architecture with MSK and Lambda: The Python Developer's Guide to Not Shooting Yourself in the Foot

Breaking free from traditional Kafka patterns when AWS does the heavy lifting

The Allure of "Serverless" Kafka

Picture this: You're tasked with building an event-driven solution for your business. Naturally, you're a serverless enthusiast so you look at AWS and what you see?

  • AWS MSK for managed Kafka? Check ✅
  • Python Lambda for serverless compute? Check ✅
  • The confluent-kafka library for that sweet, sweet performance? Double check ✅✅

You fire up your IDE, start writing familiar Kafka consumer code with .poll() loops, and then... reality hits. This isn't your typical Kafka setup. Welcome to the world of Lambda Event Source Mappings (ESM), where everything you know about Kafka consumers gets turned upside down.

After building multiple production EDA systems with this exact stack, I've learned that success isn't about fighting the constraints — it's about embracing them. Here's how I'd now start an on-boarding session:

The Mental Model Shift Nobody Warns You About

From Pull to Push: Your Consumer Doesn't Consume Anymore

The biggest mind-bender? You don't write Kafka consumers anymore.

# What you THINK you'll write (traditional Kafka)
def traditional_kafka_consumer():
    consumer = confluent_kafka.Consumer(config)
    consumer.subscribe(['user-events'])

    while True:
        messages = consumer.poll(timeout=1.0)
        for message in messages:
            process_message(message)

# What you ACTUALLY write (Lambda ESM)
def lambda_handler(event, context):
    # ESM already consumed the messages for you
    # event['records'] contains the batch
    for record in event['records']:
        process_message(record)
Enter fullscreen mode Exit fullscreen mode

AWS Event Source Mapping becomes your consumer. It polls Kafka, manages offsets, handles retries, and pushes batches to your Lambda. You just get handed a batch of messages and told "deal with it."

This shift brings some harsh realities:

❌ No control over polling intervals

ESM decides when to poll (500ms batching window). You can't implement backpressure or custom polling strategies.

❌ Little control over pollers number
ESM event pollers are said to scale automatically based on the load, generating a lot of consumer group rebalances in practice (provisioned mode does help).

❌ Batch size becomes critical

Configure it wrong, and you'll either overwhelm your Lambda (too big) or waste invocations (too small). Start with 10-50 messages and tune based on your processing time.

❌ No per-message error handling

One message fails? The entire batch gets reprocessed. Design for idempotency from day one.

The Batch Failure Reality Check

Here's the kicker that catches everyone off-guard: Lambda failure semantics are brutal for Kafka.

def lambda_handler(event, context):
    processed_count = 0

    for record in event['records']:
        try:
            result = process_message(record)  # This might fail on message #47
            processed_count += 1
        except Exception as e:
            logger.error(f"Failed processing message: {e}")
            raise  # Entire batch gets retried

    logger.info(f"Successfully processed {processed_count} messages")
    # If we get here, all messages succeeded
Enter fullscreen mode Exit fullscreen mode

When your Lambda throws an exception:

  • ⚠️ All processed messages get reprocessed
  • ⚠️ The failing message gets another chance
  • ⚠️ Messages after the failure also get reprocessed

Forget about exactly-once semantics. You're in at-least-once territory now. Make everything idempotent or suffer the consequences.

Dead Letter Queues: Your Safety Net

Configure a Dead Letter Topic before you go to production. Trust me on this one.

# ESM Configuration (Terraform example)
resource "aws_lambda_event_source_mapping" "kafka_trigger" {
  event_source_arn = aws_msk_cluster.cluster.arn
  function_name    = aws_lambda_function.processor.arn

  topics                             = ["user-events"]
  batch_size                         = 25
  maximum_batching_window_in_seconds = 5

  # This saves your bacon
  destination_config {
    on_failure {
      destination_arn = aws_sns_topic.dlq.arn
    }
  }

  # Retry 3 times before giving up
  maximum_retry_attempts = 3
}
Enter fullscreen mode Exit fullscreen mode

Without DLQ setup, poison messages will block your entire partition indefinitely. I've seen production systems grind to a halt because of one malformed message.

The Producer Side: Where C Meets Python

Here's where things get interesting. While your Lambda receives messages through ESM, you'll still need to produce messages to other topics. This is where confluent-kafka shines — and where logging becomes critical.

import logging
import confluent_kafka

# DO THIS: Pass your logger to the producer
logger = logging.getLogger(__name__)

producer = confluent_kafka.Producer({
    "bootstrap.servers": os.environ["MSK_BOOTSTRAP_SERVERS"],
    "security.protocol": "SASL_SSL",
    "sasl.mechanisms": "AWS_MSK_IAM",
    "sasl.username": "AWS_MSK_IAM",
    "sasl.password": "AWS_MSK_IAM",
}, logger=logger)  # ← This line saves hours of debugging

# Without the logger, librdkafka errors vanish into the void
Enter fullscreen mode Exit fullscreen mode

Why does this matter? The confluent-kafka library is a wrapper around the C library librdkafka. When network issues, authentication failures, or broker problems occur, those errors happen in C-land. Without explicitly passing your logger, you'll see your messages disappear into the ether with zero indication of what went wrong.

Static Initialization: The Cold Start Dance

Lambda's cold start behavior creates interesting challenges for Kafka producers:

import confluent_kafka
import logging

# Static initialization - runs during cold start
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)

# Initialize producer outside the handler
producer = confluent_kafka.Producer({
    "bootstrap.servers": os.environ["MSK_BOOTSTRAP_SERVERS"],
    "security.protocol": "SASL_SSL",
    "sasl.mechanisms": "AWS_MSK_IAM",
    "sasl.username": "AWS_MSK_IAM", 
    "sasl.password": "AWS_MSK_IAM",
}, logger=logger)

def lambda_handler(event, context):
    # Check connectivity in the handler
    # Producer might have disconnected during cold periods
    try:
        # Quick connectivity check
        metadata = producer.list_topics(timeout=5)
        logger.info(f"Connected to {len(metadata.brokers)} brokers")
    except Exception as e:
        logger.error(f"Kafka connectivity issue: {e}")
        # Consider reinitializing producer here

    for record in event['records']:
        process_and_produce(record)

    # Always flush before handler completes
    producer.flush(timeout=10)
Enter fullscreen mode Exit fullscreen mode

Pro tip: You'll be surprised how often connections drop during Lambda's idle periods. Always verify connectivity at the start of your handler.

SnapStart: Just Don't

AWS Lambda SnapStart now supports Python 3.12, and you might be tempted to enable it for faster cold starts. Don't.

Remember those uniqueness issues we discussed with SnapStart? The librdkafka C library is essentially a black box. It maintains internal state, generates unique IDs, establishes connections, and manages all sorts of random number generation for things like:

  • Client IDs and correlation IDs
  • Connection retry jitter
  • Internal message sequencing
  • SSL session management

When SnapStart creates a snapshot and reuses it across multiple execution environments, you risk:

  • Duplicate client IDs connecting to brokers
  • Non-unique message correlation IDs
  • Shared SSL sessions across instances
  • Broken internal state assumptions

The performance gain from SnapStart isn't worth the debugging nightmare when your Kafka producers start behaving erratically.

The Philosophy Shift

Building EDA with Lambda ESM requires a different mindset:

  • Embrace push-based thinking - You react to batches, not control consumption
  • Design for idempotency - Messages will be reprocessed, plan for it
  • Monitor batch metrics - Tune batch size based on processing time, not message count
  • Invest in observability - When things go wrong, you need visibility into both Lambda and Kafka metrics
  • Plan for failure modes - DLQ configuration is not optional

The Bottom Line

MSK + Lambda + confluent-kafka-python is a powerful stack for event-driven systems, but it's not traditional Kafka development. The constraints imposed by Event Source Mapping fundamentally change how you think about consuming messages.

Stop fighting the platform and start designing with it. Your consumers become stateless message processors. Your error handling becomes batch-oriented. Your observability becomes multi-layered.

Once you embrace these patterns, you'll find that serverless EDA can be incredibly productive. Just don't expect it to work like the Kafka applications you're used to building.

Ready to dive deeper? Check out the AWS Lambda ESM documentation and start small. Build a simple message processor first, then gradually add complexity as you understand the platform's quirks.

What's been your biggest surprise when building serverless event-driven systems? Share your war stories in the comments below!


Have you wrestled with similar challenges in your EDA journey? Found other gotchas worth sharing? Drop them in the comments—the community learns from our collective debugging pain! 😅

Top comments (0)