Apache Kafka is designed for
performance and large volumes of data. Kafka's append-only log format,
sequential I/O access, and zero copying all support high throughput with
low latency. Its partition-based data distribution lets it scale
horizontally to hundreds of thousands of partitions.
Because of these capabilities, it can be tempting to use a single
monolithic Kafka cluster for all of your eventing needs. Using one
cluster reduces your operational overhead and development complexities
to a minimum. But is "a single Kafka cluster to rule them all" the ideal
architecture, or is it better to split Kafka clusters?
To answer that question, we have to consider the segregation
strategies for maximizing performance and optimizing cost while
increasing Kafka adoption. We also have to understand the impact of
using Kafka as a service,
on a public cloud, or managing it yourself on-premise (Are you looking to experiment with Kafka? Get started in minutes with a no-cost Kafka service trial). This article
explores these questions and more, offering a structured way to decide
whether or not to segregate Kafka clusters in your organization. Figure 1 summarizes the questions explored in this article.
Figure 1. A mind map for Apache Kafka cluster segregation
strategies shows the concerns that can drive a multiple-cluster setup.
Benefits of a monolithic Kafka cluster
To start, let's explore some of the benefits of using a single,
monolithic Kafka cluster. Note that by this I don't mean literally a
single Kafka cluster for all environments, but a single production Kafka
cluster for the entire organization. The different environments would
still typically be fully isolated with their respective Kafka clusters. A
single production Kafka cluster is simpler to use and operate and is a
no-brainer as a starting point.
Global event hub
Many companies are sold on the idea of having a single "Kafka
backbone" and the value they can get from it. The possibility of
combining data from different topics from across the company arbitrarily
in response to future and yet unknown business needs is a huge
motivation. As a result, some organizations end up using Kafka as a
centralized enterprise service bus (ESB) where they put all their
messages under a single cluster. The chain of streaming applications is
deeply interconnected.
This approach can work for companies with a small number of
applications and development teams, and with no hard departmental data
boundaries that are enforced in large corporations by business and
regulatory forces. (Note that this singleton Kafka environment expects
no organizational boundaries.)
The monolithic setup reduces thinking about event boundaries, speeds
up development, and works well until an operational or a process
limitation kicks in.
No technical constraints
Certain technical features are available only within a single Kafka
cluster. For example, a common pattern used by stream processing
applications is to perform read-process-write operations in a sequence
without any tolerances for errors that could lead to duplicates or loss
of messages. To address that strict requirement, Kafka offers
transactions that ensure that each message is consumed from the source
topic and published to a target topic in exactly-once processing
semantics. That guarantee is possible only when the source and target
topics are within the same Kafka cluster.
A consumer group, such as a Kafka Streams-based application,
can process data from a single Kafka cluster only. Therefore,
multi-topic subscriptions or load balancing across the consumers in a
consumer group are possible only within a single Kafka cluster. In a
multi-Kafka setup, enabling such stream processing requires data
replication across clusters.
Each Kafka cluster has a unique URL, a few authentication mechanisms,
Kafka-wide authorization configurations, and other cluster-level
settings. With a single cluster, all applications can make the same
assumptions, use the same configurations, and send all events to the
same location. These are all good technical reasons for sharing a single
Kafka cluster whenever possible.
Lower cost of ownership
I assume that you use Kafka because you have a huge volume of data,
or you want to do low latency asynchronous interactions, or take
advantage of both of these with added high availability—not because you
have modest data needs and Kafka is a fashionable technology. Offering
high-volume, low-latency Kafka processing in a production environment
has a significant cost. Even a lightly used Kafka cluster deployed for
production purposes requires three to six brokers and three to five
ZooKeeper nodes. The components should be spread across multiple
availability zones for redundancy.
You have to budget for base compute, networking, storage, and
operating costs for every Kafka cluster. This cost applies whether you
self-manage a Kafka cluster on-premises with something like Strimzi
or consume Kafka as a service. There are attempts at "serverless" Kafka
offerings that try to be more creative and hide the cost per cluster in
other cost lines, but somebody still has to pay for resources.
Generally, running and operating multiple Kafka clusters costs more
than a single larger cluster. There are exceptions to this rule, where
you achieve local cost optimizations by running a cluster at the point
where the data and processing happens or by avoiding replication of
large volumes of non-critical data, and so on.
Benefits of multiple Kafka clusters
Although Kafka can scale beyond the needs of a single team, it is not designed for multi-tenancy.
Sharing a single Kafka cluster across multiple teams and different use
cases requires precise application and cluster configuration, a rigorous
governance process, standard naming conventions, and best practices for
preventing abuse of the shared resources. Using multiple Kafka clusters
is an alternative approach to address these concerns. Let's explore a
few of the reasons that you might choose to implement multiple Kafka
clusters.
Operational decoupling
Kafka's sweet spot is real-time messaging and distributed data
processing. Providing that at scale requires operational excellence.
Here are a few manageability concerns that apply to operating Kafka.
Workload criticality
Not all Kafka clusters are equal. A batch processing Kafka cluster
that can be populated from source again and again with derived data
doesn't have to replicate data into multiple sites for higher
availability. An ETL data pipeline can afford more downtime than a
real-time messaging infrastructure for frontline applications.
Segregating workloads by service availability and data criticality helps
you pick the most suitable deployment architecture, optimize
infrastructure costs, and direct the right level of operating attention
to every workload.
Maintainability
The larger a cluster gets, the longer it can take to upgrade and
expand the cluster due to rolling restarts, data replication, and
rebalancing. In addition to the length of the change window, the time
when the change is performed might also be important. A customer-facing
application might have an upgrade window that differs from a customer
service application. Using separate Kafka clusters allows faster
upgrades and more control over the time and the sequence of rolling out a
change.
Regulatory compliance
Regulations and certifications typically leave no room for
compromise. You might have to host a Kafka cluster on a specific cloud
provider or region. You might have to allow access only to support
personnel from a specific country. All personally identifiable
information (PII) data might have to be on a particular cluster with
short retention, separate administrative access, and network
segmentation. You might want to hold the data encryption keys for
specific clusters. The larger your company is, the longer the
requirements list gets.
Tenant isolation
The secret for happy application coexistence on a shared
infrastructure relies on having good primitives for access, resource,
and logical isolation. Unlike Kubernetes,
Kafka has no concept like namespaces for enforcing quotas and access
control or avoiding topic naming collisions. Let's explore some of the
resulting challenges for isolating tenants.
Resource isolation
Although Kafka has mechanisms to control resource use, it doesn't
prevent a bad tenant from monopolizing the cluster resources. Storage
size can be controlled per topic through retention size, but cannot be
limited for a group of topics corresponding to an application or tenant.
Network utilization can be enforced through quotas, but it is applied
at the client connection level. There is no means to prevent an
application from creating an unlimited number of topics or partitions
until the whole cluster gets to a halt.
All of that means you have to enforce these resource control
mechanisms while operating at different granularity levels, and enforce
additional conventions for the healthy coexistence of multiple teams on a
single cluster. An alternative is to assign separate Kafka clusters to
each functional area and use cluster-level resource isolation.
Security boundary
Kafka's access control with the default authorization mechanism
(ACLs) is more flexible than the quota mechanism and can apply to
multiple resources at once through pattern matching. But you have to
ensure good naming convention hygiene. The structure for topic name
prefixes becomes part of your security policy.
ACLs control which users can perform which actions on which
resources, but a user with admin access to a Kafka instance has access
to all the topics in that Kafka instance. With multiple clusters, each
team can have admin rights only to their Kafka instance.
The alternative is to ask someone with admin rights to edit the ACLs
and update topics rights and such. Nobody likes having to open a ticket
to another team to get a project rolling.
Logical decoupling
A single cluster shared across multiple teams and applications with
different needs can quickly get cluttered and difficult to navigate. You
might have teams that need very few topics and others that generate
hundreds of them. Some teams might even generate topics on the fly from
existing data sources by turning microservices inside-out.
You might need hundreds of granular ACLs for some applications that are
less trusted, and coarse-grained ACLs for others. You might have a
large number of producers and consumers. In the absence of namespaces,
properties, and labels that can be used for logical segregation of
resources, the only option left is to use naming conventions creatively.
Use case optimization
So far we have looked at the manageability and multi-tenancy needs
that apply to most shared platforms in common. Next, we will look at a
few examples of Kafka cluster segregation for specific use cases. The
goal of this section is to list the long tail of reasons for segregating
Kafka clusters that varies for every organization and demonstrate that
there is no "wrong" reason for creating another Kafka cluster.
Data locality
Data has gravity, meaning that a useful dataset tends to attract
related services and applications. The larger a dataset is, the harder
it is to move around. Data can originate from a constrained or offline
environment, preventing it from streaming into the cloud. Large volumes
of data might reside in a specific region, making it economically
unfeasible to replicate the data to other locations. Therefore, you
might create separate Kafka clusters at regions, cloud providers, or
even at the edge to benefit from data's gravitational characteristics.
Fine-tuning
Fine-tuning is the process of precisely adjusting the
parameters of a system to fit certain objectives. In the Kafka world,
the primary interactions that an application has with a cluster center
on the concept of topics. And while every topic has separate and
fine-tuning configurations, there are also cluster-wide settings that
apply to all applications.
For instance, cluster-wide configurations such as redundancy factor
(RF) and in-sync replicas (ISR) apply to all topics if not explicitly
overridden per topic. In addition, some constraints apply to the whole
cluster and all users, such as the allowed authentication and
authorization mechanisms, IP whitelists, the maximum message size,
whether dynamic topic creation is allowed, and so on.
Therefore, you might create separate clusters for large messages,
less-secure authentication mechanisms, and other oddities to localize
and isolate the effect of such configurations from the rest of the
tenants.
Domain ownership
Previous sections described examples of cluster segregation to
address data and application concerns, but what about business domains?
Aligning Kafka clusters by business domain can enforce ownership and
give users more responsibilities. Domain-specific clusters can offer
more freedom to the domain owners and reduce reliance on a central team.
This division can also reduce cross-cluster data replication needs
because most joins are likely to happen within the boundaries of a
business domain.
Purpose-built
Kafka clusters can be created and configured for a particular use case. Some clusters might be born while modernizing existing legacy applications and others created while implementing event-driven distributed transaction patterns.
Some clusters might be created to handle unpredictable loads, whereas
others might be optimized for stable and predictable processing.
For example, Wise uses separate Kafka clusters
for stream processing with topic compaction enabled, separate clusters
for service communication with short message retention, and a logging
cluster for log aggregation. Netflix uses separate clusters
for producers and consumers. The so-called fronting clusters are
responsible for getting messages from all applications and buffering,
while consumer clusters contain only a subset of the data needed for
stream processing.
These decisions for classifying clusters are based on high-level
criteria, but you might also have low-level criteria for separate
clusters. For example, to benefit from page caching at the
operating-system level, you might create a separate cluster for
consumers that re-read topics from the beginning each time. The separate
cluster would prevent any disruption of the page caches for real-time
consumers that read data from the current head of each topic. You might
also create a separate cluster for the odd use case of a single topic
that uses the whole cluster. The reasons can be endless.
Summary
The argument "one thing to rule them all" has been used for pretty
much any technology: mainframes, databases, application servers, ESBs,
Kubernetes, cloud providers, and so on. But generally, the principle
falls apart. At some point, decentralizing and scaling with multiple
instances offer more benefits than continuing with one centralized
instance. Then a new threshold is reached, and the technology cycle
starts to centralize again, which sparks the next phase of innovation.
Kafka is following this historical pattern.
In this article, we looked at common motivations for growing a
monolithic Kafka cluster along with reasons for splitting it out. But
not all points apply to all organizations in every circumstance. Every
organization has different business goals and execution strategies, team
structure, application architecture, and data processing needs. Every
organization is at a different stage of its journey to the hybrid cloud, a cloud-based architecture, edge computing, data mesh—you name it.
You might run on-premises Kafka clusters for good reason and give
more weight to the operational concerns you have to deal with.
Software-as-a-Service (SaaS) offerings such as Red Hat OpenShift Streams for Apache Kafka
can provision a Kafka cluster with a single click and remove the
concerns around maintainability, workload criticality, and compliance.
With such services, you might pay more attention to governance, logical
isolation, and controlling data locality.
If you have a reasonably sized organization, you will have hybrid and
multi-cloud Kafka deployments and a new set of concerns around
optimizing and reusing Kafka skills, patterns, and best practices across
the organization. These concerns are topics for another article.
I hope this guide provides a way to structure your decision-making process for segregating Kafka clusters. Follow me at @bibryam to join my journey of learning Apache Kafka.This post was originally published on Red Hat Developers. To read the original post, check here.
“We build our computers the way we build our cities—over time, without a plan, on top of ruins.”
Ellen Ullman wrote this in 1998, but it applies just as much today to the way we build modern applications; that is, over time, with short-term plans, on top of legacy software. In this article, I will introduce a few patterns and tools that I believe work well for thoughtfully modernizing legacy applications and building modern event-driven systems.
Application modernization refers to the process of taking an existing legacy application and modernizing its infrastructure—the internal architecture—to improve the velocity of new feature delivery, improve performance and scalability, expose the functionality for new use cases, and so on. Luckily, there is already a good classification of modernization and migration types, as shown in Figure 1.
Depending on your needs and appetite for change, there are a few levels of modernization:
Retention: The easiest thing you can do is to retain what you have and ignore the application's modernization needs. This makes sense if the needs are not yet pressing.
Retirement: Another thing you could do is retire and get rid of the legacy application. That is possible if you discover the application is no longer being used.
Rehosting: The next thing you could do is to rehost the application, which typically means taking an application as-is and hosting it on new infrastructure such as cloud infrastructure, or even on Kubernetes through something like KubeVirt. This is not a bad option if your application cannot be containerized, but you still want to reuse your Kubernetes skills, best practices, and infrastructure to manage a virtual machine as a container.
Replatforming: When changing the infrastructure is not enough and you are doing a bit of alteration at the edges of the application without changing its architecture, replatforming is an option. Maybe you are changing the way the application is configured so that it can be containerized, or moving from a legacy Java EE runtime to an open source runtime. Here, you could use a tool like windup to analyze your application and return a report with what needs to be done.
Refactoring: Much application modernization today focuses on migrating monolithic, on-premises applications to a cloud-native microservices architecture that supports faster release cycles. That involves refactoring and rearchitecting your application, which is the focus of this article.
For this article, we will assume we are working with a monolithic, on-premise application, which is a common starting point for modernization. The approach discussed here could also apply to other scenarios, such as a cloud migration initiative.
Challenges of migrating monolithic legacy applications
Deployment frequency is a common challenge for migrating monolithic legacy applications. Another challenge is scaling development so that more developers and teams can work on a common code base without stepping on each other’s toes. Scaling the application to handle an increasing load in a reliable way is another concern. On the other hand, the expected benefits from a modernization include reduced time to market, increased team autonomy on the codebase, and dynamic scaling to handle the service load more efficiently. Each of these benefits offsets the work involved in modernization. Figure 2 shows an example infrastructure for scaling a legacy application for increased load.
Envisioning the target state and measuring success
For our use case, the target state is an architectural style that follows microservices principles using open source technologies such as Kubernetes, Apache Kafka, and Debezium. We want to end up with independently deployable services modeled around a business domain. Each service should own its own data, emit its own events, and so on.
When we plan for modernization, it is also important to consider how we will measure the outcomes or results of our efforts. For that purpose, we can use metrics such as lead time for changes, deployment frequency, time to recovery, concurrent users, and so on.
The next sections will introduce three design patterns and three open source technologies—Kubernetes, Apache Kafka, and Debezium—that you can use to migrate from brown-field systems toward green-field, modern, event-driven services. We will start with the Strangler pattern.
The Strangler pattern
The Strangler pattern is the most popular technique used for application migrations. Martin Fowler introduced and popularized this pattern under the name of Strangler Fig Application, which was inspired by a type of fig that seeds itself in the upper branches of a tree and gradually evolves around the original tree, eventually replacing it. The parallel with application migration is that our new service is initially set up to wrap the existing system. In this way, the old and the new systems can coexist, giving the new system time to grow and potentially replace the old system. Figure 3 shows the main components of the Strangler pattern for a legacy application migration.
The key benefit of the Strangler pattern is that it allows low-risk, incremental migration from a legacy system to a new one. Let’s look at each of the main steps involved in this pattern.
Step 1: Identify functional boundaries
The very first question is where to start the migration. Here, we can use domain-driven design to help us identify aggregates and the bounded contexts where each represents a potential unit of decomposition and a potential boundary for microservices. Or, we can use the event storming technique created by Antonio Brandolini to gain a shared understanding of the domain model. Other important considerations here would be how these models interact with the database and what work is required for database decomposition. Once we have a list of these factors, the next step is to identify the relationships and dependencies between the bounded contexts to get an idea of the relative difficulty of the extraction.
Armed with this information, we can proceed with the next question: Do we want to start with the service that has the least amount of dependencies, for an easy win, or should we start with the most difficult part of the system? A good compromise is to pick a service that is representative of many others and can help us build a good technology foundation. That foundation can then serve as a base for estimating and migrating other modules.
Step 2: Migrate the functionality
For the strangler pattern to work, we must be able to clearly map inbound calls to the functionality we want to move. We must also be able to redirect these calls to the new service and back if needed. Depending on the state of the legacy application, client applications, and other constraints, weighing our options for this interception might be straightforward or difficult:
The easiest option would be to change the client application and redirect inbound calls to the new service. Job done.
If the legacy application uses HTTP, then we’re off to a good start. HTTP is very amenable to redirection and we have a wealth of transparent proxy options to choose from.
In practice, it likely that our application will not only be using REST APIs, but will have SOAP, FTP, RPC, or some kind of traditional messaging endpoints, too. In this case, we may need to build a custom protocol translation layer with something like Apache Camel.
Interception is a potentially dangerous slippery slope: If we start building a custom protocol translation layer that is shared by multiple services, we risk adding too much intelligence to the shared proxy that services depend on. This would move us away from the "smart microservices, dumb pipes” mantra. A better option is to use the Sidecar pattern, illustrated in Figure 4.
Rather than placing custom proxy logic in a shared layer, make it part of the new service. But rather than embedding the custom proxy in the service at compile-time, we use the Kubernetes sidecar pattern and make the proxy a runtime binding activity. With this pattern, legacy clients use the protocol-translating proxy and new clients are offered the new service API. Inside the proxy, calls are translated and directed to the new service. That allows us to reuse the proxy if needed. More importantly, we can easily decommission the proxy when it is no longer needed by legacy clients, with minimal impact on the newer services.
Step 3: Migrate the database
Once we have identified the functional boundary and the interception method, we need to decide how we will approach database strangulation—that is, separating our legacy database from application services. We have a few paths to choose from.
Database first
In a database-first approach, we separate the schema first, which could potentially impact the legacy application. For example, a SELECT might require pulling data from two databases, and an UPDATE can lead to the need for distributed transactions. This option requires changes to the source application and doesn’t help us demonstrate progress in the short term. That is not what we are looking for.
Code first
A code-first approach lets us get to independently deployed services quickly and reuse the legacy database, but it could give us a false sense of progress. Separating the database can turn out to be challenging and hide future performance bottlenecks. But it is a move in the right direction and can help us discover the data ownership and what needs to be split into the database layer later.
Code and database together
Working on the code and database together can be difficult to aim for from the get-go, but it is ultimately the end state we want to get to. Regardless of how we do it, we want to end up with a separate service and database; starting with that in mind will help us avoid refactoring later.
Figure 4.1: Database strangulation strategies
Having a separate database requires data synchronization. Once again, we can choose from a few common technology approaches.
Triggers
Most databases allow us to execute custom behavior when data is changed. In some cases, that could even be calling a web service and integrating with another system. But how triggers are implemented and what we can do with them varies between databases. Another significant drawback here is that using triggers requires changing the legacy database, which we might be reluctant to do.
Queries
We can use queries to regularly check the source database for changes. The changes are typically detected with implementation strategies such as timestamps, version numbers, or status column changes in the source database. Regardless of the implementation strategy, polling always leads to the dilemma between polling often and creating overhead over the source database, or missing frequent updates. While queries are simple to install and use, this approach has significant limitations. It is unsuitable for mission-critical applications with frequent database interactions.
Log readers
Log readers identify changes by scanning the database transaction log files. Log files exist for database backup and recovery purposes and provide a reliable way to capture all changes including DELETEs. Using log readers is the least disruptive option because they require no modification to the source database and they don’t have a query load. The main downside of this approach is that there is no common standard for the transaction log files and we'll need specialized tools to process them. This is where Debezium fits in.
Before moving on to the next step, let's see how using Debezium with the log reader approach works.
Change data capture with Debezium
When an application writes to the database, changes are recorded in log files, then the database tables are updated. For MySQL, the log file is binlog; for PostgreSQL, it is the write-ahead-log; and for MongoDB it's the op log. The good news is Debezium has connectors for different databases, so it does the hard work for us of understanding the format of all of these log files. Debezium can read the log files and produce a generic abstract event into a messaging system such as Apache Kafka, which contains the data changes. Figure 5 shows Debezium connectors as the interface for a variety of databases.
Debezium is the most widely used open source change data capture (CDC) project with multiple connectors and features that make it a great fit for the Strangler pattern.
Why is Debezium a good fit for the Strangler pattern?
One of the most important reasons to consider the Strangler pattern for migrating monolithic legacy applications is reduced risk and the ability to fall back to the legacy application. Similarly, Debezium is completely transparent to the legacy application, and it doesn’t require any changes to the legacy data model. Figure 6 shows Debezium in an example microservices architecture.
With a minimal configuration to the legacy database, we can capture all the required data. So at any point, we can remove Debezium and fall back to the legacy application if we need to.
Debezium features that support legacy migrations
Here are some of Debezium's specific features that support migrating a monolithic legacy application with the Strangler pattern:
Snapshots: Debezium can take a snapshot of the current state of the source database, which we can use for bulk data imports. Once a snapshot is completed, Debezium will start streaming the changes to keep the target system in sync.
Filters: Debezium lets us pick which databases, tables, and columns to stream changes from. With the Strangler pattern, we are not moving the whole application.
Single message transformation (SMT): This feature can act like an anti-corruption layer and protect our new data model from legacy naming, data formats, and even let us filter out obsolete data
Using Debezium with a schema registry: We can use a schema registry such as Apicurio with Debezium for schema validation, and also use it to enforce version compatibility checks when the source database model changes. This can prevent changes from the source database from impacting and breaking the new downstream message consumers.
Using Debezium with Apache Kafka: There are many reasons why Debezium and Apache Kafka work well together for application migration and modernization. Guaranteed ordering of database changes, message compaction, the ability to re-read changes as many times as needed, and tracking transaction log offsets are all good examples of why we might choose to use these tools together.
Step 4: Releasing services
With that quick overview of Debezium, let’s see where we are with the Strangler pattern. Assume that, so far, we have done the following:
Identified a functional boundary.
Migrated the functionality.
Migrated the database.
Deployed the service into a Kubernetes environment.
Migrated the data with Debezium and kept Debezium running to synchronize ongoing changes.
At this point, there is not yet any traffic routed to the new services, but we are ready to release the new services. Depending on our routing layer's capabilities, we can use techniques such as dark launching, parallel runs, and canary releasing to reduce or remove the risk of rolling out the new service, as shown in Figure 7.
What we can also do here is to only direct read requests to our new service initially, while continuing to send the writes to the legacy system. This is required as we are replicating changes in a single direction only.
When we see that the read operations are going through without issues, we can then direct the write traffic to the new service. At this point, if we still need the legacy application to operate for whatever reason, we will need to stream changes from the new services toward the legacy application database. Next, we'll want to stop any write or mutating activity in the legacy module and stop the data replication from it. Figure 8 illustrates this part of the pattern implementation.
Since we still have legacy read operations in place, we are continuing the replication from the new service to the legacy application. Eventually, we'll stop all operations in the legacy module and stop the data replication. At this point, we will be able to decommission the migrated module.
We've had a broad look at using the Strangler pattern to migrate a monolithic legacy application, but we are not quite done with modernizing our new microservices-based architecture. Next, let’s consider some of the challenges that come later in the modernization process and how Debezium, Apache Kafka, and Kubernetes might help.
After the migration: Modernization challenges
The most important reason to consider using the Strangler pattern for migration is the reduced risk. This pattern gives value steadily and allows us to demonstrate progress through frequent releases. But migration alone, without enhancements or new “business value” can be a hard sell to some stakeholders. In the longer-term modernization process, we also want to enhance our existing services and add new ones. With modernization initiatives, very often, we are also tasked with setting the foundation and best practices for building modern applications that will follow. By migrating more and more services, adding new ones, and in general by transitioning to the microservices architecture, new challenges will come up, including the following:
Automating the deployment and operating a large number of services.
Performing dual-writes and orchestrating long-running business processes in a reliable and scalable manner.
Addressing the analytical and reporting needs.
There are all challenges that might not have existed in the legacy world. Let’s explore how we can address a few of them using a combination of design patterns and technologies.
Challenge 1: Operating event-driven services at scale
While peeling off more and more services from the legacy monolithic application, and also creating new services to satisfy emerging business requirements, the need for automated deployments, rollbacks, placements, configuration management, upgrades, self-healing becomes apparent. These are the exact features that make Kubernetes a great fit for operating large-scale microservices. Figure 9 illustrates.
When we are working with event-driven services, we will quickly find that we need to automate and integrate with an event-driven infrastructure—which is where Apache Kafka and other projects in its ecosystem might come in. Moreover, we can use Kubernetes Operators to help automate the management of Kafka and the following supporting services:
Apicurio Registry provides an Operator for managing Apicurio Schema Registry on Kubernetes.
Strimzi offers Operators for managing Kafka and Kafka Connect clusters declaratively on Kubernetes.
KEDA (Kubernetes Event-Driven Autoscaling) offers workload auto-scalers for scaling up and down services that consume from Kafka. So, if the consumer lag passes a threshold, the Operator will start more consumers up to the number of partitions to catch up with message production.
Knative Eventing offers event-driven abstractions backed by Apache Kafka.
Note: Kubernetes not only provides a target platform for application modernization but also allows you to grow your applications on top of the same foundation into a large-scale event-driven architecture. It does that through automation of user workloads, Kafka workloads, and other tools from the Kafka ecosystem. That said, not everything has to run on your Kubernetes. For example, you can use a fully managed Apache Kafka or a schema registry service from Red Hat and automatically bind it to your application using Kubernetes Operators. Creating a multi-availability-zone (multi-AZ) Kafka cluster on Red Hat OpenShift Streams for Apache Kafka takes less than a minute and is completely free during our trial period. Give it a try and help us shape it with your early feedback.
Now, let’s see how we can meet the remaining two modernization challenges using design patterns.
Challenge 2: Avoiding dual-writes
Once you build a couple of microservices, you quickly realize that the hardest part about them is data. As part of their business logic, microservices often have to update their local data store. At the same time, they also need to notify other services about the changes that happened. This challenge is not so obvious in the world of monolithic applications and legacy distributed transactions. How can we avoid or resolve this situation the cloud-native way? The answer is to only modify one of the two resources—the database—and then drive the update of the second one, such as Apache Kafka, in an eventually consistent manner. Figure 10 illustrates this approach.
Using the Outbox pattern with Debezium lets services execute these two tasks in a safe and consistent manner. Instead of directly sending a message to Kafka when updating the database, the service uses a single transaction to both perform the normal update and insert the message into a specific outbox table within its database. Once the transaction has been written to the database’s transaction log, Debezium can pick up the outbox message from there and send it to Apache Kafka. This approach gives us very nice properties. By synchronously writing to the database in a single transaction, the service benefits from "read your own writes" semantics, where a subsequent query to the service will return the newly persisted record. At the same time, we get reliable, asynchronous, propagation to other services via Apache Kafka. The Outbox pattern is a proven approach for avoiding dual-writes for scalable event-driven microservices. It solves the inter-service communication challenge very elegantly without requiring all participants to be available at the same time, including Kafka. I believe Outbox will become one of the foundational patterns for designing scalable event-driven microservices.
Challenge 3: Long-running transactions
While the Outbox pattern solves the simpler inter-service communication problem, it is not sufficient alone for solving the more complex long-running, distributed business transactions use case. The latter requires executing multiple operations across multiple microservices and applying consistent all-or-nothing semantics. A common example for demonstrating this requirement is the booking-a-trip use case consisting of multiple parts where the flight and accommodation must be booked together. In the legacy world, or with a monolithic architecture, you might not be aware of this problem as the coordination between the modules is done in a single process and a single transactional context. The distributed world requires a different approach, as illustrated in Figure 11.
Figure 11: The Saga pattern implemented with Debezium.
The Saga pattern offers a solution to this problem by splitting up an overarching business transaction into a series of multiple local database transactions, which are executed by the participating services. Generally, there are two ways to implement distributed sagas:
Choreography: In this approach, one participating service sends a message to the next one after it has executed its local transaction.
Orchestration: In this approach, one central coordinating service coordinates and invokes the participating services.
Communication between the participating services might be either synchronous, via HTTP or gRPC, or asynchronous, via messaging such as Apache Kafka.
The cool thing here is that you can implement sagas using Debezium, Apache Kafka, and the Outbox pattern. With these tools, it is possible to take advantage of the orchestration approach and have one place to manage the flow of a saga and check the status of the overarching saga transaction. We can also combine orchestration with asynchronous communication to decouple the coordinating service from the availability of participating services and even from the availability of Kafka. That gives us the best of both worlds: orchestration and asynchronous, non-blocking, parallel communication with participating services, without temporal coupling.
Combining the Outbox pattern with the Sagas pattern is an awesome, event-driven implementation option for the long-running business transactions use case in the distributed services world. SeeSaga Orchestration for Microservices Using the Outbox Pattern (InfoQ) for a detailed description. Also see an implementation example of this pattern on GitHub.
Conclusion
The Strangler pattern, Outbox pattern, and Saga pattern can help you migrate from brown-field systems, but at the same time, they can help you build green-field, modern, event-driven services that are future-proof.
Kubernetes, Apache Kafka, and Debezium are open source projects that have turned into de facto standards in their respective fields. You can use them to create standardized solutions with a rich ecosystem of supporting tools and best practices.
The one takeaway from this article is the realization that modern software systems are like cities: They evolve over time, on top of legacy systems. Using proven patterns, standardized tools, and open ecosystems will help you create long-lasting systems that grow and change with your needs.
This post was originally published on Red Hat Developers. To read the original post, check here.