Principles, Patterns, and Anti-Patterns of Container-Based Application Design
The widespread adoption of containers and container orchestration (Kubernetes) makes it easy to build microservice-based “cloud-native” applications. Containers have become the new unit of programming in the cloud era, analogous to the object in object-oriented concepts, the component in J2EE, or the function in functional programming.
In the object-oriented era, there were many well-known design principles, patterns, and anti-patterns, for example:
[SOLID][SOLID] (Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion)
Design Patterns: Elements of Reusable Object-Oriented Software
In the new container context, the corresponding principles and patterns help us build better “cloud-native” applications. As we will see, these principles and patterns are not a wholesale rejection of previous patterns; rather, they are evolutionary versions adapted to the new environment.
Principles
Single Concern Principle (SCP)
Corresponding to the single responsibility of OO, each container should provide a single concern and focus on doing one thing well. A single concern makes a container easier to reuse. Typically a container corresponds to one process, and that process focuses on doing one thing well.
High Observability Principle (HOP)
Containers, like objects, should be well-encapsulated black boxes. However, in a cloud environment, this black box should expose good observability interfaces so that it can be monitored and managed appropriately in the cloud. In this way, the entire application can provide consistent lifecycle management.
Observability includes:
- Providing health checks, or heartbeats
- Providing status
- Outputting logs to standard output (STDOUT) and standard error (STDERR)
- etc.
Life-Cycle Conformance Principle (LCP)
The life-cycle conformance principle means that containers should interact with the platform to handle the corresponding lifecycle changes.
- Capture and respond to the Terminate (SIGTERM) signal to gracefully terminate the service process as quickly as possible, in order to avoid being forcibly killed by the kill (SIGKILL) signal. For example, the following NodeJS code.
1 | process.on('SIGTERM', function () { |
- Return an exit code
1 | process.exit(0); |
Image Immutability Principle (IIP)
At runtime, configurations may differ, but the image should be immutable.
We can think of an image as a class and a container as an object instance: the class is immutable, while the container is an instance of the image with different configuration parameters.
Process Disposability Principle (PDP)
In a cloud environment, we should assume that all containers are ephemeral and may be replaced by other container instances at any time.
This means that a container’s state should be stored outside the container, and containers should start and stop as quickly as possible. Generally, the smaller the container, the easier this is to achieve.
Self-Containment Principle (S-CP)
A container should include all of its dependencies at build time; in other words, a container should not have any external dependencies at runtime.
Runtime Confinement Principle (RCP)
A best practice for containers is to declare the resource configuration requirements at runtime, such as how much memory, CPU, and so on. Doing so enables the container orchestrator to schedule and manage resources more effectively.
Patterns
Many container application patterns relate to the concept of a Pod. A Pod is a concept introduced by Kubernetes to manage containers effectively; it is a collection of containers, which we can think of as a “super-container” (a term I just made up). Containers within a Pod behave as if they are running on the same machine: they share the localhost address, can communicate locally, share volumes, and so on.
Kubernetes is like an OS in the cloud, providing best practices for building cloud-native applications with containers. Let’s look at some of the common patterns.
Sidecar

The Sidecar is the most common pattern. Within the same Pod, we separate different responsibilities into different containers that together provide a complete feature to the outside world.
There are many such examples, for instance:
- The Node backend and the Redis cache in the figure above
- A web server and a log-collection service
- A web server and a service responsible for collecting server performance metrics
This is somewhat analogous to the object-oriented Composite pattern, and there are many benefits:
- Apply the Single Concern Principle: each container focuses on doing one thing well.
- Isolation: containers do not compete with each other for resources. When a secondary function (such as log collection or caching) fails or crashes, the impact on the primary function is minimized.
- Each container can be managed independently throughout its lifecycle.
- Each container can scale elastically and independently.
- Any one container can be easily replaced.
Ambassador (Proxy) Container

Similar to the object-oriented Proxy pattern, this uses a container within the Pod to provide the external access connection. As shown in the figure below, the Node backend always communicates with the outside world through the Service Discovery container.
In this way, the developers of the Node module only need to assume that all communication comes from the local machine, while the complexity of communication is delegated to the ambassador container, which handles concerns such as load balancing, security, request filtering, and, when necessary, terminating communication.
Adapter Container
People often confuse the object-oriented Proxy pattern, Bridge pattern, and Adapter pattern, because the UML diagrams look largely the same. It seems like they are just different names for the same thing. This is indeed the case—just as almost all OO patterns are derivatives of the Composite pattern, all container patterns are derivatives of the Sidecar pattern.
In the example below, if the name “Logging Adapter” did not mention “Adapter,” we would not consider it an adapter pattern.
In fact, the adapter pattern is concerned with how to uniformly expose the functionality of different containers inside the Pod through an adapter. In the figure above, it becomes clearer if we add one more container that also writes logs to the volume. The Logging Adapter adapts the logs produced by different containers through different interfaces and provides a unified access interface.
Container Chain
Similar to the OO Chain of Responsibility pattern, chaining together containers responsible for different functions in dependency order is also a common pattern.
Ready Pod
Typically, a container running as a service has a startup process during which the service is unavailable. Kubernetes provides a Readiness probe feature.
1 | readinessProbe: |
Compared with the other patterns, this is more of a Kubernetes best practice.
Anti-Patterns
Mixing the Build Environment with the Runtime Environment
The image used for the production runtime environment should be as small as possible, and should avoid including leftovers from the build time.
Consider the following Dockerfile example:
1 | FROM ubuntu:14.04 |
The resulting image contains many things that are neither needed nor appropriate for a production environment, such as gcc and the source code hello.c. This is both insecure (directly exposing the source code) and incurs a performance overhead (an oversized image leads to slower loading).
The multi-stage builds introduced in Docker 17.05 can solve this problem.
Using Pods Directly
Avoid using Pods directly; manage Pods with a Deployment. Using a Deployment makes it easy to scale and manage Pods.
Using the latest Tag
The latest tag is meant to mark the most recent stable version. However, when creating containers, you should avoid using the latest tag in production environments as much as possible, even when the imagePullPolicy option is set to Always.
Fast-Failing Jobs
A Job is a Kubernetes container that runs only once, the opposite of a service. Avoid fast failure.
1 | apiVersion: batch/v1 |
If you try to create the above Job in your cluster, you might encounter the following status.
1 | $ kubectl describe jobs |
Because the Job fails fast, Kubernetes considers the Job to have failed to start successfully and tries to create new containers to recover from this failure, causing the cluster to create a large number of containers in a short period of time, which can consume a significant amount of computing resources.
Use .spec.activeDeadlineSeconds in the Spec to avoid this problem. This parameter defines how long to wait before retrying a failed Job.
[SOLID]: https://zh.wikipedia.org/wiki/SOLID_(%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1%E8%AE%BE%E8%AE%A1) “SOLID”