Learning Architecture Design from Kubernetes
Grace Collins
Solutions Engineer · Leapcell

Do Not Eliminate Duplication Immediately
We must be careful not to fall into a reactive mode where we feel compelled to immediately eliminate any duplication we encounter. It’s essential to ensure that our actions to remove duplication are only targeted at what is truly duplicate in a meaningful sense.
If there are two pieces of code that look similar but follow different evolutionary paths—meaning they have different rates of change and different reasons for change—then they are not truly duplicate code.
Having code today that doesn’t hinder future code is a highly important skill, and it often takes years to master. This is because real trouble usually doesn’t arise while running the software, but rather during the development, deployment, and subsequent maintenance of the software system.
Many people, especially in the beginning, enjoy abstracting out designs—abstracting some things that might be shared in the future. However, in today’s rapid product iteration, many things that initially look similar will eventually follow very different evolutionary paths. In such cases, these pieces of code are not, in the true sense, duplicates.
If at this stage, we start with a rigid design, this part of the code can become a significant obstacle in the future. If we break down our original methods as granularly as possible and let the users themselves assemble them, this approach might not be necessary for writing fixed-function middleware. But for ordinary business logic, it might be the better approach, as the potential for reuse could be higher than having one big, all-encompassing method.
This is because in a comprehensive, catch-all function, there are bound to be minor differences that are not required by new requirements. At this point, there are two ways: one is to add if-else
branches to the original method to handle different logic, or, if an interface was left in the beginning, different implementation classes can be injected to accomplish the task.
Both methods involve modifying the original method. If a system is too complex and outdated, and its maintainers have already changed (a situation that occurs often), we may not know the extent of the side effects that modifications will cause. In this case, a more reliable approach is to redefine the code entry point and reuse small granular methods.
However, this also introduces a new problem: methods with too fine a granularity can become scattered, making assembly relatively more complex. But the advantage is that the process becomes more controllable.
In Kubernetes, we can also see many methods and interfaces with clear semantics. The benefit of this approach is that it allows newcomers to flexibly replace implementations without being constrained by previous designs. This way, we don’t need to spend a lot of effort reassembling all logic, and we can make local changes as needed.
Be careful not to over-design. When writing, always ask yourself why. Avoid designing until the content becomes stable; only abstract once similar content actually appears. Keep an object as simple as possible. When refactoring, you can then merge and reduce repetition for the same content.
For example, the CRI abstraction wasn’t present in Kubernetes from the very beginning. Initially, Kubernetes was strongly dependent on DockerManager, sending commands to the Docker Engine via HTTP through the Docker Manager. Later, to decouple from Docker and adapt to more environments, Kubernetes introduced CRI (Container Runtime Interface) in version 1.5 and above, which defined the standard for how container runtimes should interface with kubelet.
However, because CRI appeared after Docker, Kubernetes uses DockerShim as an adaptation layer between Docker and CRI, which communicates with the Docker Engine via HTTP. (Looking at the source code of DockerShim is a good way to explain the Adapter pattern.)
Eventually, containers exist in the form of containerd, and the call chain eliminates the presence of the Docker Engine, turning into:
K8s Master → kubelet → KubeGenericRuntimeManager → containerd → runC
So, in the process of abstraction, we can introduce middle layers for compatibility. For example, k3s does not have a hard dependency on ETCD as storage, but if features like watch are required, a wrapper layer for the corresponding storage is needed. The choice depends on a combination of factors like performance and complexity.
Of course, this process can be standardized, and the implementation can be assisted by other developers in open-source form to adapt to different environments.
Programming Paradigms Tell Us What Not to Do
Structured programming tells us not to use goto
, but instead to use if-else
so that code can later be split into smaller submodules.
Structured programming restricts and standardizes the direct transfer of program control, so control cannot be transferred using goto
statements.
Object-oriented programming restricts the abuse of function pointers. When we need to reuse a structure, we can abstract it into an object and use the singleton pattern. However, if we don’t want two entities to influence each other, we need to construct two independent entities. Although their attributes may be the same, their values are entirely different, representing two separate entities in the real world.
The advantage of this approach is that it helps to isolate changes in attributes between entities, and allows us to better simulate real-life requirements. For example, both are students, but Student A and Student B are two independent people. When we need to collect statistics on them, we can operate on them separately.
Functional programming restricts our assignment behaviors to avoid modifying variable values in place, which is more prone to errors.
package main import ( "fmt" ) // The Map function takes a slice of integers and a function as arguments, // and returns a new slice containing the results of applying the function to each element. func MapInts(slice []int, f func(int) int) []int { result := make([]int, len(slice)) for i, v := range slice { result[i] = f(v) } return result } func main() { // The original slice of integers nums := []int{1, 2, 3, 4, 5} // Use functional programming to square each element in the slice squared := MapInts(nums, func(x int) int { return x * x }) // Print results fmt.Println("Original:", nums) fmt.Println("Squared:", squared) }
The purpose of every programming paradigm is to set restrictions. These paradigms are mainly designed to tell us what not to do, rather than what we can do.
Programming practices that are explicitly forbidden are the things we must not do. For everything else, as long as it helps us complete our software, we should feel free to combine and experiment to make the project more agile.
Do Not Rely Heavily on Frameworks
For example, data structures related to Gin in Golang, such as gin.Context
, are best confined to the API layer and should not be passed into the application layer.
Dependencies on frameworks should be managed by creating proxy classes.
Framework implementation is more about the details and belongs to the firmware code. When a framework is deprecated and our code needs to modify its dependencies, if we directly depend on the framework, changes can be troublesome, as every business user needs to modify their code.
A so-called service is simply a slightly higher-cost form of dividing application behavior compared to function calls, and it is unrelated to system architecture.
For instance, if a method has been abstracted, whether the call is to kernel code or to RPC is just an implementation detail, only affecting the complexity of implementation and debugging. When doing overall architectural design, this should be abstracted away.
Single Responsibility Principle
A module or entity should do only one thing—this is the single responsibility principle.
Whether it is in the design of entities or services, Kubernetes adheres quite well to this rule.
For example, the kube-scheduler
is mainly responsible for scheduling pods to nodes. After defining this responsibility, the code can be broken down step by step: first, score all nodes for eligibility, filter out unsuitable nodes, and finally bind a running Node to the Pod, which is then managed by kubelet to maintain the state of all pods on that Node. At this point, CNI and CRI are responsible for the network and runtime sandbox environment of containers, and are called by Kubelet as needed.
You can see that each component has a specific job to do. If a job is complex, it is accomplished by composing other components.
Specify Requirements, Not Implementation Details
When changing the state of resources, we should tell Kubernetes the desired state, not how to achieve it. This is also the reason why kubelet's rolling-update was deprecated. After specifying the desired state, kubelet can take appropriate actions on its own, without excessive external intervention.
For example, cAdvisor monitors the containers deployed by Kubernetes. First, we need to see what metrics can be monitored, and then use those metrics for decisions in auto-scaling.
This is also a principle we follow when designing components for different tasks: clarify the requirements to be fulfilled, focus only on inputs and outputs when passing information, and keep the internal implementation cohesive. Do not expose internals to the outside; make external usage as simple as possible.
Open/Closed Principle
Open for extension, closed for modification—this means that an entity should be able to change its behavior without altering its source code.
Golang syntax further encourages us to extend existing entities through composition, and to use interfaces for implicit conversions, shielding unnecessary details from the users.
type Kubelet struct{} func (kl *Kubelet) HandlePodAdditions(pods []*Pod) { for _, pod := range pods { fmt.Printf("create pods : %s\n", pod.Status) } } func (kl *Kubelet) Run(updates <-chan Pod) { fmt.Println(" run kubelet") go kl.syncLoop(updates, kl) } func (kl *Kubelet) syncLoop(updates <-chan Pod, handler SyncHandler) { for { select { case pod := <-updates: handler.HandlePodAdditions([]*Pod{&pod}) } } } type SyncHandler interface { HandlePodAdditions(pods []*Pod) }
Here, when we want to extend the functionality of kubelet, we do not need to modify the original logic. SyncHandler
can serve as a type, and in practice, we only need to hold SyncHandler
. When changes are needed, we can directly extend methods for Kubelet, and then abstract through the interface for the users. The benefit of this approach is that it extends functionality on Kubelet without modifying the original code.
The Most Reusable Code is Business Logic
Business logic should be the most independent and reusable code in a system.
The core of Kubernetes lies in the management of container orchestration states. As for what kind of storage to use to save the state of containers—whether it’s ETCD or a relational database—it is actually not the most important part and can be replaced flexibly. Similarly, for setting up the network using CNI, we can choose according to real scenarios. But the orchestration and management of containers, once determined at the beginning, is generally the most stable part.
Make it Work First, Then Make it Better
- “Make the code work first”—If the code doesn’t work, it can’t create value. So in the early versions of Kubernetes, there were many strong dependencies, such as on the Flannel network plugin, or on Docker as the container runtime.
- “Then try to make it better”—Through refactoring, we allow ourselves and others to better understand and continually modify the code as requirements change. In later stages, Kubernetes removed these strong dependencies by creating specifications such as CNI and CRI, allowing different vendors to develop based on their actual physical environments. This is part of the effort to make it better.
- “Finally, try to make it run faster”—Optimize code based on performance improvement requirements. This is the maturity phase of the software. In the beginning, when implementing functionality, there will be many details that are not handled finely enough, and even the monitoring system may not be mature enough to help us find problems. Once the functionality is complete, we can optimize locally, replace specific algorithms, and improve overall performance.
We are Leapcell, your top choice for hosting Go projects.
Leapcell is the Next-Gen Serverless Platform for Web Hosting, Async Tasks, and Redis:
Multi-Language Support
- Develop with Node.js, Python, Go, or Rust.
Deploy unlimited projects for free
- pay only for usage — no requests, no charges.
Unbeatable Cost Efficiency
- Pay-as-you-go with no idle charges.
- Example: $25 supports 6.94M requests at a 60ms average response time.
Streamlined Developer Experience
- Intuitive UI for effortless setup.
- Fully automated CI/CD pipelines and GitOps integration.
- Real-time metrics and logging for actionable insights.
Effortless Scalability and High Performance
- Auto-scaling to handle high concurrency with ease.
- Zero operational overhead — just focus on building.
Explore more in the Documentation!
Follow us on X: @LeapcellHQ