Providing Technology Training and Mentoring For Modern Technology Adoption
This tutorial is adapted from Web Age course Technical Introduction to Microservices.
Heroku, a platform as a service (PaaS) provider, established general principles for creating useful web apps known as the Twelve-Factor Application. Applying 12-factor to microservices requires modification of the original PaaS definitions. The goal of combining microservices, twelve-factor app and app modernization is a general purpose reference architecture enabling continuous delivery.
The Twelve-Factor App recommends one codebase per app. In a microservices architecture, the correct approach is one codebase per service. This codebase should be in version control, either distributed, e.g. git, or centralized, e.g. SVN.
As suggested in The Twelve-Factor App, regardless of what platform your application is running on, use the dependency manager included with your language or framework. Do not assume that the tool, library or application your code depends on will be there. How you install an operating system or platform dependencies depends on the platform. In noncontainerized environments, use a configuration management tool (Chef, Puppet, Salt, Ansible) to install system dependencies. In a containerized environment, do this in the Dockerfile.
Anything that varies between deployments can be considered configuration. All configuration data should be stored in a separate place from the code, and read in by the code at runtime, e.g. when you deploy code to an environment, you copy the correct configuration files into the codebase at that time. The Twelve-Factor App guidelines recommend storing all configuration in the environment, rather than committing it to the source code repository. Use non-version controlled .env files for local development. Docker supports the loading of these files at runtime. Keep all .env files in a secure storage system, such as Hashicorp Vault, to keep the files available to the development teams, but not committed to Git. Use an environment variable for anything that can change at runtime, and for any secrets that should not be committed to the shared repository. Once you have deployed your application to a delivery platform, use the delivery platform’s mechanism for managing environment variables.
The Twelve-Factor App guidelines define a backing service as “any service the app consumes over the network as part of its normal operation.” Anything external to a service is treated as an attached resource, including other services. This ensures that every service is completely portable and loosely coupled to the other resources in the system. Strict separation increases flexibility during development – developers only need to run the service(s) they are modifying, not others. A database, cache, queueing system, etc. These should all be referenced by a simple endpoint (URL) and credentials, if necessary.
To support strict separation of build, release, and run stages, as recommended by The Twelve-Factor App, use a continuous integration/continuous delivery (CI/CD) tool to automate builds. Docker images make it easy to separate the build and run stages. Ideally, images are created from every commit and treated as deployment artifacts.
For microservices, the application needs to be stateless. Stateless services scale a service horizontally by simply adding more instances of that service. Store any stateful data, or data that needs to be shared between instances, in a backing service.
The twelve-factor app is completely self-contained and does not rely on runtime injection of a webserver into the execution environment to create a web-facing service. The web app exports HTTP as a service by binding to a port, and listening to requests coming in on that port. In a local development environment, the developer visits a service URL like http://localhost:5000/ to access the service exported by their app. In deployment, a routing layer handles routing requests from a publicfacing hostname to the port-bound web processes. This is typically implemented by using dependency declaration to add a webserver library to the app, such as Tornado for Python, Thin for Ruby, or Jetty for Java and other JVM-based languages. This happens entirely in user space, that is, within the app’s code. The contract with the execution environment is binding to a port to serve requests. Nearly any kind of server software can be run via a process binding to a port and awaiting incoming requests. Examples include ejabberd (speaking XMPP), and Redis (speaking the Redis protocol). The port-binding approach means that one app can become the backing service for another app, by providing the URL to the backing app as a resource handle in the config for the consuming app.
The Unix and Mainframe process models are predecessors to a true microservices architecture, allowing specialization and resource sharing for different tasks within a monolithic application. For microservices architecture, we horizontally scale each service independently, to the extent supported by the underlying infrastructure. Docker or other containerized services, provide service concurrency.
Instances of a service need to be disposable so they can be started, stopped, and redeployed quickly, and with no loss of data. Services deployed in Docker containers satisfy this requirement automatically, as it’s an inherent feature of containers that they can be stopped and started instantly.Storing state or session data in queues or other backing services ensures that a request is handled seamlessly in the event of a container crash. Backing stores support crash-only design.
Keep all of your environments – development, staging, production, and so on as identical as possible, to reduce the risk that bugs show up only in some environments. Containers enable you to run exactly the same execution environment all the way from local development through production. Differences in the underlying data can still result in runtime changes in application behavior.
Use a log-management solution in a microservice for routing or storing logs. Define logging strategy as part of the architecture standards, so all services generate logs in a similar fashion. Log strategy should be part of a larger Application Performance Management (APM) or Digital Performance Management (DPM) solution tied to the Everything as a Service model (XaaS).
In a production environment, run administrative and maintenance tasks separately from the app. Containers make this very easy, as you can spin up a container just to run a task and then shut it down. Examples include doing data cleanup, running analytics for a presentation, or turning on and off features for A/B testing.
Kubernetes makes heavy use of declarative constructs. All parts of a Kubernetes application are described with text-based representations in YAML or JSON. The referenced containers are themselves described in source code as a Dockerfile. Because everything from the image to the container deployment behavior is encapsulated in text, you are able to easily source control all the things, typically using git.
A microservice is only as reliable as its most unreliable dependency. Kubernetes includes readinessProbes and livenessProbes that enable you to do ongoing dependency checking. The readinessProbe allows you to validate whether you have backing services that are healthy and you’re able to accept requests. The livenessProbe allows you to confirm that your microservice is healthy on its own. If either probe fails over a given window of time and threshold attempts, the Pod will be restarted.
The Config factor requires storing configuration sources in your process environment table (e.g. ENV VARs). Kubernetes provides ConfigMaps and Secrets that can be managed in source repositories. Secrets should never be source controlled without an additional layer of encryption. Containers can retrieve the config details at runtime.
When you have network dependencies, we treat that dependency as a “Backing Service”. At any time, a backing service could be attached or detached and our microservice must be able to respond appropriately. For example, you have an application that interacts with a web server, you should isolate all interaction to that web server with some connection details (either dynamic service discovery or via Config in a Kubernetes Secret). Then consider whether your network requests implement fault tolerance such that if the backing service fails at runtime, your microservice does not trigger a cascading failure. That service may also be running in a separate container or somewhere off-cluster. Your microservice should not care as all interactions then occur through APIs to interact with the database.
Once you commit the code, a build occurs and the container image is built and published to an image registry. If you’re using Helm, your Kubernetes application may also be packaged and published into a Helm registry as well. These “releases” are then re-used and deployed across multiple environments to ensure that an unexpected change is not introduced somewhere in the process (by re-building the binary or image for each environment).
In Kubernetes, a container image runs as a container process within a Pod. Kubernetes (and containers in general) provide a facade to provide better isolation of the container process from other containers running on the same host. Using a process model enables easier management for scaling and failure recover (e.g. restarts). Typically, the process should be stateless to support scaling the workload out through replication. For any state used by the application, you should use a persistent data store that all instances of your application process will discover via your Config. In Kubernetes-based applications where multiple copies of pods are running, requests can go to any pod, hence the microservice cannot assume sticky sessions.
You can use Kubernetes Service objects to declare the network endpoints of your microservices and to resolve the network endpoints of other services in the cluster or off-cluster. Without containers, whenever you deployed a new service (or new version), you would have to perform some amount of collision avoidance for ports that are already in use on each host. Container isolation allows you to run every process (including multiple versions of the same microservice) on the same port (by using network namespaces in the Linux kernel) on a single host.
Kubernetes allows you to scale the stateless application at runtime with various kinds of lifecycle controllers. The desired number of replicas are defined in the declarative model and can be changed at runtime. Kubernetes defines many lifecycle controllers for concurrency including ReplicationControllers, ReplicaSets, Deployments, StatefulSets, Jobs, and DaemonSets. Kubernetes supports autoscaling based on compute resource thresholds around CPU and memory or other external metrics. The Horizontal Pod Autoscaler (HPA) allows you to automatically scale the number of pods within a Deployment or ReplicaSet.
Within Kubernetes, you focus on the simple unit of deployment of Pods which can be created and destroyed as needed — no single Pod is all that valuable. When you achieve disposability, you can start up fast and the microservices can die at any time with no impact on user experience. With the livenessProbes and readinessProbes, Kubernetes will actually destroy Pods that are not healthy over a given window of time.
Containers (and to a large extent Kubernetes) standardize how you deliver your application and its running dependencies, meaning that you’re able to deploy everything the same way everywhere. For example, if you’re using MySQL in a highly available configuration in production, you can deploy the same architecture of MySQL in your dev cluster. By establishing parity of production architectures in earlier dev environments, you can typically avoid unforeseen differences that are important to how the application runs (or more importantly how it fails).
For containers, you will typically write all logs to stdout and stderr file descriptors. The important design point is that a container should not attempt to manage internal files for log output, but instead delegate to the container orchestration system around it to collect logs and handle analysis and archival. Often in Kubernetes, you’ll configure Log collection as one of the common services to manage Kubernetes. For example, you can enable an Elasticsearch-Logstash-Kibana (ELK) stack within the cluster.
Within Kubernetes, the Job controller allows you to create Pods that are run once or on a schedule to perform various activities. A Job might implement business logic, but because Kubernetes mounts API tokens into the Pod, you can also use them for interacting with the Kubernetes orchestrator as well. By isolating these kinds of administrative tasks, you can further simplify the behavior of your microservice.
The twelve-factor methodology can be applied to apps written in any programming language, and which use any combination of backing services (database, queue, memory cache, etc). The twelve-factor methodology is highly useful when creating microservices architecture based applications.
Your email address will not be published. Required fields are marked *