In the last post we presented a 10-step guide to go all in with serverless adoption. In this post, let’s discuss specific challenges with migrating from an existing monolith. We will also look at some strategies for overcoming them.
With such migrations, you’re moving to a microservices architecture built with serverless technologies, which means you’re undertaking two paradigm shifts at once!
I will draw from my own experience of migrating a social network, Yubl, to serverless.
As we migrated our system to serverless we reduced operational cost by about 90%. The speed of delivery increased ten-fold without hiring more developers. In under 6 months, we went from 4-6 production deployments a month to around 80. Our system also became more stable, and more scalable as well.
The payoff was tremendous, and I will share many of the lessons I learnt with you in this post.
1. Reverse Conway’s Law
Conway’s law states that organizations are bound to produce software that resemble their organizational communication structures. This insight tells us that we need to structure our organization to match the software that we want to produce.
We want to build software that are comprised of small, loosely-coupled components that can be independently deployed and scaled. We want these components to be autonomous and capable of evolving independently and quickly.
To achieve that, we need to structure the organization into many small, autonomous teams that are empowered to and responsible of managing their own services.
2. Identify service boundaries
As mentioned in the last article, we should start with low-risk, non-critical business processes. To do that, we need to first identify boundaries within the monolith so we can start carving them out into separate services.
A service should be the authority for some business capability.
Here are some guiding principles you should follow:
- Services are autonomous
- Services have clear boundaries
- Services own their data, and are the authoritative sources for those data
- Services are loosely coupled through shared contract and schema
These services don’t have to be built around entities like user, cart, product, and so on. An entity’s boundary doesn’t have to be a service’s boundary.
For example, a mind map of the Yubl social network looks like this.
Unsurprisingly, user is at the center of everything! Instead of an all-encompassing User service that stores and maintains every aspect of a user, we might model it with several services:
- User-Details - user’s identifiable information, such as name and phone number.
- User-Preferences - user’s personal preferences and settings.
- Relationships - relationships between users.
- Timeline - what to show on a user’s timeline.
- Chat - group and 1-2-1 conversations.
Identify the service boundaries you want to create. Then migrate features from the monolith one piece at a time. You can implement each service with serverless technologies such as Amazon API Gateway, AWS Lambda and DynamoDB.
The next question is, how should you organize your codebase for these new services?
3. Organize your codebase
The source code for your monolith is likely all in one repository. It makes sense given the whole system is a single deployable unit.
Should you pursue the same line of thinking going forward? You could use the same repository to host all the services. In this setup, each service would reside in its own subfolder.
However, this monorepo approach is hard to scale with the number of services and people. Here are some of the challenges with this approach.
- Steep learning curve for new joiners. Stepping into a monorepo with many services can be overwhelming. It takes time to build up a mental “map” of where things are and their relations to one another.
- Concepts and abstractions often leak through service boundaries. Which in turn creates accidental coupling between services. This happens through accidental sharing because sharing code inside the same repository is easy.
- When sharing code between services this way, it also makes tracking changes more difficult. Commits against a service’s subdirectory no longer reveal all the changes that are deployed with the service.
Most teams that migrate from a monolithic system to microservices would adopt an approach whereby each service is put into one repository. Everything you need to know about that service is captured in this repository. Code reuse is achieved through shared libraries or services.
This isolation by repository limits the scope of what you need to know to understand a service. Michael Nygard’s post on coherence penalty offers a good explanation for why this matters.
4. Pick a deployment framework, and stick with it
Serverless platforms such as AWS Lambda make deployment very simple. Just package your code, upload it to S3, and use CloudFormation to deploy it. The whole thing takes less than a minute!
But, there are many other resources you need to provision and configure, including:
- IAM permissions
- API Gateway endpoints
- Subscription to SNS topics, or Kinesis streams
This is where the deployment frameworks come in.
There are more than a dozen frameworks available. My personal recommendation would be the Serverless framework. It’s easy to learn and very extensible through its plugin system. As the most adopted framework, it enjoys a rich ecosystem of community-lead plugins. These plugins cater for even the more advanced and edge use cases. If your needs are not met by any existing plugins, you can always write your own plugin, too.
Once you have chosen a framework, mandate it on the team. You want to maximise knowledge sharing and minimise deviations in the deployment toolchain. As developers move between projects, they shouldn’t have to waste energy to learn a new deployment framework.
How you deploy your code should be consistent across all your projects.
5. Keep functions simple
With serverless, the unit of deployment and scaling has become the function. I recommend that you follow the single responsibility principle (SRP). Make each function responsible for one thing and one thing only. This keeps the functions simple and reduces the cognitive load on the development team.
When functions have a single purpose and they are named accordingly, it’s easy to see what business capabilities you have at a glance. The Serverless framework enforces a naming convention, which also makes it easy to see all the related functions by prefix.
Single-purposed functions are also more secure. You can apply strict IAM policies for each function and restrict it to only the permissions it needs. This significantly reduces the attack surface of your serverless architecture.
With single-purposed functions, you can easily identify functions where refactoring can yield significant business value. For example, shaving 100ms off the average duration of a frequently invoked function can create meaningful savings. If many things are lumped into one function, then it’s harder to identify where you should optimize.
6. Plan for graceful migration
As you carve out parts of the monolith into services, you want to migrate to them gracefully without impacting your users.
A tried and tested approach is to maintain API compatibility and then switch traffic to the new services gradually. You can do this by updating the corresponding handlers in the monolith to forward X% of requests to the new service. As you gain confidence in the new service, you can increase X gradually via configuration.
When you’re ready, update the client applications to talk to the new services directly. Once the migration is complete, don’t forget to remove the old code from the monolith.
As part of the migration, you often want to improve the API design or to use a different database. Remember, services should own their data and avoid using a shared database.
But, this adds risk and created unnecessary complications for the migration process. Instead, you should make these changes in several steps. First migrate the ownership of specific business functions to a service. Then, you can make database changes behind the scenes without affecting your users.
7. Rethink testing
Microservices have different failure modes to a monolith. For starters, you have a lot more API calls. These API calls can timeout, and they can fail due to many reasons. Network partitioning can cause failures, as do improper configuration. Or the external system is experiencing an outage and unable to process your request.
You see a pattern whereby the things that can go wrong in a microservices architecture has shifted towards a service’s integration points. Which is why many are now calling for a stronger focus on integration tests.
As we discussed in the last post, the serverless paradigm has also added its own twist. As the unit of deployment becomes smaller, the number of deployed units explodes. So too does the number of configurations and the chance of misconfiguration.
The way you test your code needs to change as well, and focus more on integration and acceptance tests. Use real downstream systems to test the happy path. Reserve the use of mocks and stubs for simulating error cases that are hard to replicate.
8. Build resilience into the system
There are many patterns that can help you build resilience into your serverless application. They are beyond the scope of this post, but here are a few for you to consider:
- Use short and adaptive timeouts
- Bulkhead - isolate blast radius of failures
- Circuit Breaker - prevent cascading failures across many services
- Queue-based Load Leveling - amortize spikes in traffic between services
- Saga - manage failures in a distributed transaction
- Swimlanes - protect against data center outages
Michael Nygard’s seminal book Release It! is also a required reading for anyone building resilient systems.
When you migrate from an existing monolith to serverless you are making a big architectural change. It can seem daunting at first, and you will learn many lessons along the way. But, in my experience, the payoff at the end was worth all that effort and more.
In the next post, we will discuss how to migrate an existing microservices to run on serverless. If you are an early adopter of microservices and have built one using serverful technologies such as EC2 or Docker, then this post is for you. Come back next week to check it out!