There are many best practices that, if followed, will improve the chances that your cloud-native application will be a success. Following these best practices doesn't guarantee success, just as ignoring them doesn't guarantee failure, but they do encode key practices that have been shown to enhance the chances of success. The most famous set of best practices is the Twelve-Factor App.
Twelve-Factor App
The Twelve-Factor App (https://12factor.net) is a set of 12 best practices that, if followed, can significantly improve the chance of success when building cloud-native applications. Some of the factors would be considered obvious by many software developers even outside of cloud-native, but taken together, they form a popular methodology for building cloud-native applications. The 12 factors are as follows:
- Code base
- Dependencies
- Config
- Backing services
- Build, release, run
- Process
- Port binding
- Concurrency
- Disposability
- Dev/prod parity
- Logs
- Admin processes
I – Code base
The first factor states that a cloud-native application consists of a single code base that is tracked in a version control system, such as Git, and that code base will be deployed multiple times. A deployment might be to a test, staging, or production environment. That doesn't mean that the code in the environments will be identical; a test environment will obviously contain code changes that are proposed but haven't been proven as safe for production, but that is still one code base.
II – Dependencies
It has been common development practice for Java applications to use dependencies stored in Maven repositories such as Maven Central for some time. Tools such as Maven and Gradle require you to express your dependencies in order to build against them. While this practice absolutely requires this, it goes beyond just build-time dependencies to runtime ones as well. A 12-factor application packages its dependencies into the application to ensure that a single development artifact can be reliably deployed in any suitable environment. This means that having an administrator provide the libraries in a well-known place on the filesystem is not acceptable since there is always a chance the administrator-deployed library and the application-required one are not compatible.
When considering this practice, it is important to make a clear decision about what the cloud-native application is, since at some point there will be a split between what the application provides and what the deployment environment provides. This factor triggered a trend in enterprise Java away from WAR
files to executable JAR
files, since many viewed the application server as an implicit dependency. However, that just shifted the implicit dependency down a level; it didn't remove it. Now the implicit dependency is Java. To a certain extent, containerization addresses this issue and at the same time, it removes the need to rearchitect around an executable JAR
file.
III – Config
Since a 12-factor application may have many deployments and each deployment may connect to different systems with different credentials, it is critical that configuration be externalized into the environment. It is also common to read in the media about security issues caused by a developer accidentally checking credentials into a version control system, which would not happen if the configuration was stored externally to the code base.
Although this factor states that configuration is stored in environment variables, there are many who are uneasy about the idea of storing security-sensitive configuration in environment variables. The key thing here is to externalize configuration in a way that can be simply provided in production.
IV – Backing services
Backing services are treated as attached resources. It should be possible to change from one database to another with a simple change in configuration.
V – Build, release, run
All applications go through some kind of build, release, run process, but a 12-factor application has strict separation between those phases. The build phase involves turning the application source into the application artifact. The release phase combines the application artifact with the configuration so it can be deployed. The run phase is when it is actually executing. This strict separation means that a configuration change is never made in the run phase since there is no way to roll it back to the release stage. Instead, if a configuration change is required, a new release is made and run. The same is true if a code change is required. There is no changing the code that is running without going through a build and a run. This makes sure that you always know what is running and can easily reproduce issues or roll back to a prior version.
VI – Process
A 12-factor application consists of one or more stateless processes. This does not mean that each request is mapped to a single process; it is perfectly reasonable in Java to have a single JVM processing multiple requests at the same time. This means that the application should not rely on any one process being available from one request to another. If a single client is making 20 requests, the assumption must be that each request is handled by a separate process with no state being retained between processes. It is a common pattern to store the server-side state associated with a user. This state should always be persisted to an external datastore, so if a follow-on request is sent to a different process, there is no impact on the client.
VII – Port binding
Applications export services via port binding. What this means is that an HTTP application should not rely on being installed into a web container, but instead it should declare a dependency on the HTTP server and cause it to open a port during startup. This has led many to take the view that a 12-factor Java application must be built as an uber-jar, but this is just one realization of the idea of building a single deployment artifact that binds to ports. An alternative and significantly more useful interpretation is to use containers; containers are very much built around the idea of port binding. It should be noted that this practice does not always apply; for example, a microservice driven by a Kafka message would not bind to a port. Also, many FaaS platforms do not provide an API for port binding.
VIII – Concurrency
Concurrency in Java is typically achieved by increasing the resources allocated to a process so more threads can be created. With 12-factor, you increase the number of instances rather than the compute capacity. There is a limit to how easy it is to add compute capacity to a single machine, but adding a new virtual machine of equivalent size is relatively easy. This practice is related to factor VI, so they complement and reinforce each other. Although this could be read to suggest a single process per request model, a Java-based application is more than capable of running multiple threads more efficiently than having a 1:1 ratio between process and request.
IX – Disposability
Every application should be treated as disposable. This means making sure the process starts quickly, shuts down promptly, and copes with termination. Taking this approach makes the application scale out well and quickly, as well as being resilient to unexpected failure, since a process can be quickly and easily restarted from the last release.
X – Dev/prod parity
Lots of application problems manifest themselves because of differences between development and staging environments. In the past, this happened because installing and starting all the downstream software was difficult, but the advent of containers has significantly simplified this experience, making it possible to run many of these systems in earlier environments. The advantage of this is that you no longer experience problems because your development database interprets SQL differently from the dev environment.
XI – Logs
Applications should write logs, and these should be written to the process output as opposed to being written to the filesystem. When deployed, the execution environment will take the process output and forward it to a final destination for viewing and long-term storage. This is very useful in Kubernetes, where logs stored inside the container do not persist if the container is destroyed, and they are easier to obtain using the Kubernetes log
function, which follows the process output and not the log files.
XII: Admin processes
Admin processes should be run as one-off processes separate from the application and they should not run in line with application startup. The code for these application processes should be managed with the main application such that the release used for normal flow can be used to execute the admin task. This makes sure the application and the admin code do not diverge.
Other best practices
The concept of the 12-factor application has been around for a while; it is important to remember with any methodology that what works for some people may not work for others, and sometimes the methodology needs to evolve as our understanding of how to be successful does. As a result, several other best practices are often added to the 12 factors discussed previously. The most common relates to the importance of describing the service API and how to test it to ensure that changes to one service do not require the coordinated deployment of client services.
APIs and contract testing
While the 12-factor methodology details a lot of useful practices for the creation and execution of cloud-native applications, it does little to talk about how application services interact and how to ensure that changing one doesn't cause another to need to change. Well-designed and clearly documented APIs are critical to ensuring that changes to a service do not affect the clients.
It isn't enough to just have documentation for the API; it is also important to ensure that changes to the service provider do not negatively affect the client. Since any bug fix could result in a change, it is often possible for the provider to believe a change is safe and accidentally break a client. This is where contract testing can come in. The advantage of contract testing is that each system (the client and the server) can be tested to ensure that changes to either do not violate the contract.
Security
One of the most noticeable gaps in the 12-factor methodology is the lack of best practices around security. From a certain perspective, this is because there is an existing set of best practices for securing applications and these apply as much to cloud-native applications as they do to traditional applications. For example, the third practice on config addresses, at least partly, how to protect credentials (or other secrets) by externalizing them outside of the application, However, this factor doesn't talk about how to securely inject secrets into the environment and how they are stored and secured. Something that depends on the deployment environment. This is discussed in more detail in Chapter 7, MicroProfile Ecosystem with Open Liberty, Docker, and Kubernetes.
Breaking things down into microservices adds additional complexity that doesn't apply in a monolith. With a monolith, you can trust the various components of the application because they are co-deployed often in the same process space. However, when a monolith is broken down into microservices and network connections are used, other mechanisms need to be used to maintain trust. The use of JSON Web Tokens (JWTs) is one such mechanism of managing and establishing trust between microservices. This is discussed in more detail in Chapter 5, Enhancing Cloud-Native Applications.
GraphQL
There is a default assumption involved in much of cloud-native thought that the APIs exposed are REST-based ones. However, this can lead to increased network calls and excessive data being sent across the network. GraphQL is a relatively new innovation that allows a service client to request the exact information it needs from a data store over an HTTP connection. A traditional REST API has to provide all the data about the resource, but often only a subset is required. Network bandwidth and client-side data processing is often wasted when using RESTful APIs since data is provided that the client does not use. GraphQL solves this by allowing the client to send a query to the service requesting exactly the data they need and no more. This reduces the data being transported and fetched from the backing data store. MicroProfile provides a Java-based API for writing a GraphQL backend, which makes it easy to write a service that provides such a query-based API for clients.