What React and Kubernetes Teach Us About Resilient Code
A Similarity Among Widely Different Tech
A brief intro to control theory and declarative programming
#blog #techThis post was initially published as part of the Beam Engineering Blog here and cross posted here on my blog.
Two Concepts
Here at Beam, we're on a quest to make our technology work for everyone. As part of this quest and as a need stemming from our hyper-growth, my development team is shifting our focus away from feature development toward platform technology and developer experience.
On the infrastructure side, we've been working on a CLI tool for our devs that will let them easily manage infrastructure from the command line. They can spin up entire AWS environments and deploy code from multiple repositories with a single command — no more waiting on other developers for testing and staging environments to be free, and no more inconsistency between production and testing environments. The quest for consistency led us to containerization, and our quest for easy infrastructure management led us to infrastructure as code. Our containers and infrastructure then lead us to a need for better orchestration of those containers. We needed tools that would make the often complex task of deploying, integrating, scaling, and managing services simple and routine — enter Kubernetes.
In parallel to our infrastructure development, we've been modernizing our front-end applications. This process involves defining and refining best practices for our React codebase so that we can build standard component and utility libraries for our devs to use. By standardizing these libraries, developers can focus on putting together incredible apps for both internal and external clients without having to reinvent the wheel or search through the codebase to see if someone else has already solved that problem.
At first glance, it doesn't seem like there's much in common between Kubernetes and React. Kubernetes is a system for automated deployment, scaling, and management of containerized applications. React is a front-end JavaScript library for creating user interfaces. Kubernetes is firmly a back-end, infrastructure technology, and React is so far on the front-end, it most often runs in the user's browser on their machine. So what do they have in common?
Control Theory
Underlying Kubernetes and React is control theory. As the name suggests, control theory concerns itself with the regulation, or control, of a system. In a general sense, it's about keeping a continuous process, or system, in some desired state. In order to ensure that the system remains in the desired state, we use something called a controller that can do a few things:
- Measure the actual state of the system
- Determine the difference in the real state compared to the desired state
- Use that difference as input to a feedback loop
- Act in some way that can bring the actual state into compliance with the desired state.
- Go back to step 1 and repeat this loop ad infinitum.
To find a great example of control theory in action, look no further than your oven. An oven should maintain a constant temperature. It does this by taking continuous measurements and applying a corrective action based on the thermostat's feedback. The oven gets too cold, the control system sees that the temperature is below desired and knows to turn on the burners to increase the heat. Likewise, it works the other way by turning the burners off or even venting excess heat. In essence, the oven is resilient to fluctuations in temperature regardless of the cause of the fluctuation.
Declarative Programming
Another thing that these two pieces of tech have in common is that their APIs are declarative. A declarative program describes what I want to be done. Conversely, an imperative program describes how I want to do something.
Take as an example SQL. SQL is declarative. We describe what we want in the result set, not how to find and combine the data. The underlying technology of the database is responsible for finding a way to return the results.
Another example of declarative programming is the brilliant project Sentient that can solve exceedingly complex problems with minimal amounts of code. Under the hood, it uses arcane SAT solvers and cutting-edge research to decompose and evaluate problems. The brilliance of sentient is that the programmer doesn't need to know any of this to write a program. They only need to know how to express the problem in a declarative way, and the language takes care of the rest.
It's easy to see how technology based on control theory naturally lends itself to declarative programming and vice versa. If we have a declarative API that can describe our desired end state, we need reconcilers, interpreters, or controllers that can measure and change the state of the system. Likewise, if we have a system based on control theory, it's easier to define a declarative API that describes the end result than to expose an API that includes all of the possible ways that the state can be affected. This is the difference between a person having to set the temperature on the oven, letting it regulate itself, and the person having to watch the oven and tell it to vent heat or turn on the burners depending on what's happening.
Two Technologies
Armed with these new concepts, let's take a look at how Kubernetes and React use control theory and declarative programming to great effect.
Kubernetes: Controllers, Reconciliation, and Specs
The distance between theory and implementation is very small in Kubernetes. It implements control theory through things called — surprise, surprise — controllers. The Kubebuilder Book gives a good, simple overview of what a Kubernetes controller does:
It's a controller's job to ensure that, for any given object, the actual state of the world (both the cluster state, and potentially external state like running containers for Kubelet or load balancers for a cloud provider) matches the desired state in the object.
We call this process reconciling.
Each Kubernetes object has a spec and a status. The spec is the desired state of the system, and the status is the actual state at a given time. Using these specs and statuses, controllers can do what they're supposed to; they see the current state of the system, decide if it matches the desired state, and reconcile differences. This is obviously a direct implementation of control theory.
Concerning declarative programming, Kubernetes gives us a straightforward declarative API that can be used to manage objects. This takes advantage of the spec and status pattern described above to create a unique system that has changed and simplified the way we orchestrate containers.
NOTE: Kubernetes also gives us imperative APIs as alternatives to the declarative API. There are advantages and disadvantages for both approaches described at length in the documentation.
React Reconciler and Component Declaration
If we inspect the React source code, we can see the react-reconciler
package. This is where control theory really comes into play for React. This package is in charge of reconciling the actual state of the virtual DOM with the desired state. It uses a heuristic tree-diffing algorithm to detect the differences in the two states and act on only what needs to be changed, added, or deleted. Sound familiar?
React also takes advantage of the close relationship between control theory and declarative programming. Using its declarative API, we can declare what components should look like including how to react to different lifecycle events. This makes components and user interfaces easy to reason about. If the API was imperative, we'd need to keep all of the possible states and events in our heads at once so that they're always handled properly (like manually controlling the burners and vents in an oven). Since it is declarative, we can simply define the inputs and the end state. This drastically reduces complexity and mental overhead, which in turn reduces bugs.
React is a wildly successful technology precisely because it gives this power of dynamic control over the system. It makes it trivial for developers to create reactive, stateful, robust user interfaces.
As you can see, control theory and declarative languages are simple concepts, but they're also deeply powerful. It shows us how to create self-correcting systems. It informed the design of two of the most interesting and widely used technologies in recent years. Most importantly, it shows us a straightforward path for making any piece of technology resilient.
Everyday Code
At this point, I know you're saying "Ok cool, Mike, but I'm not authoring React or Kubernetes. How do I apply this on the application level?" I'm glad you asked because even though these paradigms are baked into the core technology of React and Kubernetes, there are also ways to take advantage of them while writing everyday feature code. Let's head back to the concrete world and talk about Beam's journey again.
Making CRD's and Operators
Mostly, developers only need to invoke the declarative API Kubernetes provides. Occasionally, though, you'll run across the need to create a Custom Resource (CR). In order to do this, you'll have to replicate some of this control theory on your own. Luckily, there's a common pattern for doing this called the operator pattern. Operators are basically user-defined controllers that provide a Custom Resource Definition (CRD) and handle workloads specific to the defined CR.
This is where the story of Kubernetes at Beam picks back up. We needed to define custom resources that could manage specific workloads on our cluster. So, we defined them and naturally realized that we needed to create a controller and some additional workload lifecycle surrounding the CR. At this point, we were making an operator. To implement it though, we needed to define the controller's reconciliation process while taking a few challenges into account:
- We don't know when the
Reconcile
method will be called, which may occur in the middle of another reconciliation attempt. - We don't know how many times the
Reconcile
method will be called. - We don't know what the actual state of the system will be when
Reconcile
is called.
In general, we code operators and their reconcilers for our custom resources so that they are resilient to these challenges. We make sure that we can request a reconciliation from anywhere at any time and respond appropriately to the request. We write code that measures the state and sees if it needs to change. We write code that handles creating, updating, and deleting resources.
As you can see, this is exactly what we've been talking about this whole post. These are the things that control theory and declarative programming solve. They take the situational nature of the problems here and obviate them for developers by putting them inside a controlled system that can be purely thought of in terms of current and desired states. In this case, you have to create some of that niceness from scratch which is why it's imperative to understand the theory behind the pattern.
Resilient React Components
It's a little trickier to apply control theory to React because most of the heavy lifting has been done for us in the reconciler. Unlike Kubernetes, when we make custom components in React, we don't have to define a new way to reconcile them. So, let's take a step back and think more broadly about reconciliation. In particular, I want to touch on reconciling the UI that's displayed on the device's screen with the input that the app is receiving (from the user, an external API, etc.).
Like many React developers out there, I'm a big fan of Dan Abramov's blog Overreacted. There's one post specifically that I think is tremendously helpful for React developers of any skill level — Writing Resilient Components. It's a great piece on the React design patterns that result in good, clean components that are resilient to bugs and subtle mistakes.
One of the key concepts in the post is "always be ready to render" which boils down to the idea that every time a component renders, it should be treated the same way. A pitfall that many people fall into here is treating the initial render vs. subsequent re-renders differently. This introduces timing bugs and assumptions about how the component will be called or used that can't be enforced. For a component to be resilient, we need to be able to render it from anywhere, at any time, as frequently or infrequently as we want.
This "always be ready to render" idea encapsulates much of what we're talking about in declarative programming and control theory. To start, it ensures that your component declarations are complete and accurate. This reduces bugs and developer confusion drastically. Another aspect of being ready to render is that we shouldn't be thinking about how the rendering is being achieved. Instead, we entrust the controlled system underlying the declarative API to handle it for us. For example, in a purely declarative language, since we can't guarantee that the program will use the same method for solving a problem every time, we shouldn't be basing any of our code on the idea that it would use a specific method. Similarly, when writing components, we shouldn't have any assumptions about how React, a developer, or a user will be using the component. In essence, it should always be ready to render, or rather reconcile the UI with input, regardless of the input or whether or not it gets interrupted halfway through.
Resilient Code
Like all good scientists, we're at a point where we want to take what we learned and make a universal law out of it. Let's start by summarizing what we've covered so far.
Control theory and declarative programming provide tools for creating uniquely resilient systems.
This might be a bit too abstract to be useful. We'd probably prefer something that would work outside of our nicely controlled systems and declarative languages. For this, I propose a simpler definition of what it means for code to be resilient.
A resilient piece of code is one that can be unconditionally executed from anywhere at any time and return sane results.
I know that thinking about my code like this has changed its quality for the better. Hopefully, it will help you on your journey too.
What do you think about all this? What are some other ways to achieve resilience in code? Leave a comment below, or better yet, come work for us! We're always interested to hear from passionate developers with the desire to write resilient code with awesome tech.