Chapter 5. Deploy Microservices at Scale with Docker and Kubernetes

Up to now, we’ve talked about microservices at a higher level, covering organizational agility, designing with dependency thinking, domain-driven design, and promise theory. Then we took a deep dive into the weeds with three popular Java frameworks for developing microservices: Spring Boot, Dropwizard, and WildFly Swarm. We can leverage powerful out-of-the-box capabilities easily by exposing and consuming REST endpoints, utilizing environment configuration options, packaging as all-in-one executable JAR files, and exposing metrics. These concepts all revolve around a single instance of a microservice. But what happens when you need to manage dependencies, get consistent startup or shutdown, do health checks, and load balance your microservices at scale? In this chapter, we’re going to discuss those high-level concepts to understand more about the challenges of deploying microservices, regardless of language, at scale.

When we start to break out applications and services into microservices, we end up with more moving pieces by definition: we have more services, more binaries, more configuration, more interaction points, etc. We’ve traditionally dealt with deploying Java applications by building binary artifacts (JARs, WARs, and EARs), staging them somewhere (shared disks, JIRAs, and artifact repositories), opening a ticket, and hoping the operations team deploys them into an application server as we intended, with the correct permissions and environment variables and configurations. We also deploy our application servers in clusters with redundant hardware, load balancers, and shared disks and try to keep things from failing as much as possible. We may have built some automation around the infrastructure that supports this with great tools like Chef or Ansible, but somehow deploying applications still tends to be fraught with mistakes, configuration drift, and unexpected behaviors.

With this model, we do a lot of hoping, which tends to break down quickly in current environments, nevermind at scale. Is the application server configured in Dev/QA/Prod like it is on our machine? If it’s not, have we completely captured the changes that need to be made and expressed to the operations folks? Do any of our changes impact other applications also running in the same application server(s)? Are the runtime components like the operating system, JVM, and associated dependencies exactly the same as on our development machine? The JVM on which you run your application is very much a highly coupled implementation detail of our application in terms of how we configure, tune, and run, so these variations across environments can wreak havoc. When you start to deliver microservices, do you run them in separate processes on traditional servers? Is process isolation enough? What happens if one JVM goes berserk and takes over 100% of the CPU? Or the network IO? Or a shared disk? What if all of the services running on that host crash? Are your applications designed to accommodate that? As we split our applications into smaller pieces, these issues become magnified.

Immutable Delivery

Immutable delivery concepts help us reason about these problems. With immutable delivery, we try to reduce the number of moving pieces into prebaked images as part of the build process. For example, imagine in your build process you could output a fully baked image with the operating system, the intended version of the JVM, any sidecar applications, and all configuration? You could then deploy this in one environment, test it, and migrate it along a delivery pipeline toward production without worrying about “whether the environment or application is configured consistently.” If you needed to make a change to your application, you rerun this pipeline, which produces a new immutable image of your application, and then do a rolling upgrade to deliver it. If it doesn’t work, you can rollback by deploying the previous image. No more worrying about configuration or environment drift or whether things were properly restored on a rollback.

This sounds great, but how do we do this? Executable JARs is one facet to get us part of the way there, but still falls short. The JVM is an implementation detail of our microservice, so how do we bundle the JVM? JVMs are written in native code and have native OS-level dependencies that we’ll need to also package. We will also need configuration, environment variables, permissions, file directories, and other things that must be packaged. All of these details cannot be captured within a single executable JAR. Other binary formats like virtual machine (VM) images can properly encapsulate these details. However, for each microservice that may have different packaging requirements (JVM? NodeJS? Golang? properties files? private keys?), we could easily see an explosion of VM images and combinations of language runtimes. If you automate this with infrastructure as code and have access to infrastructure as a service with properly exposed APIs, you can certainly accomplish this. In fact, building up VMs as part of an automated delivery pipeline is exactly what Netflix did to achieve this level of immutable delivery. But VMs become hard to manage, patch, and change. Each VM virtualizes an entire machine with required device drivers, operating systems, and management tooling.

What other lightweight packaging and image formats can we explore?

Docker, Docker, Docker

Docker came along a few years ago with an elegant solution to immutable delivery. Docker allows us to package our applications with all of the dependencies it needs (OS, JVM, other application dependencies, etc.) in a lightweight, layered, image format. Additionally, Docker uses these images to run instances which run our applications inside Linux containers with isolated CPU, memory, network, and disk usage. In a way, these containers are a form of application virtualization or process virtualization. They allow a process to execute thinking it’s the only thing running (e.g., list processes with ps and you see only your application’s process there), and that it has full access to the CPUs, memory, disk, network, and other resources, when in reality, it doesn’t. It can only use resources it’s allocated. For example, I can start a Docker container with a slice of CPU, a segment of memory, and limits on how much network IO can be used. From outside the Linux container, on the host, the application just looks like another process. No virtualization of device drivers, operating systems, or network stacks, and special hypervisors. It’s just a process. This fact also means we can get even more applications running on a single set of hardware for higher density without the overhead of additional operating systems and other pieces of a VM which would be required to achieve similar isolation qualities.

What’s happening under the covers is nothing revolutionary either. Features called cgroups, namespaces, and chroot, which have been built into the Linux kernel (and have for some time), are used to create the appearance of this application virtualization. Linux containers have been around for over 10 years, and process virtualization existed in Solaris and FreeBSD even before that. Traditionally, using these underlying Linux primitives, or even higher-level abstractions like lxc, have been complicated at best. Docker came along and simplified the API and user experience around Linux containers. Docker brought a single client CLI that can easily spin up these Linux containers based on the Docker image format, which has now been opened to the larger community in the Open Container Initiative (OCI). This ease of use and image format is changing the way we package and deliver software.

Once you have an image, spinning up many of these Linux containers becomes trivial. The layers are built as deltas between a base image (e.g., RHEL, Debian, or some other Linux operating system) and the application files. Distributing new applications just distributes the new layers on top of existing base layers. This makes distributing images much easier than shuttling around bloated cloud machine images. Also, if a vulnerability (e.g., shell shock, heartbleed, etc.) is found in the base image, the base images can be rebuilt without having to try patch each and every VM. This makes it easy to run a container anywhere: they can then be moved from a developer’s desktop to dev, QA, or production in a portable way without having to manually hope that all of the correct dependencies are in the right place (does this application use JVM 1.6, 1.7, 1.8?). If we need to redeploy with a change (new app) or fix a base image, doing so just changes the layers in the image that require changes.

When we have standard APIs and open formats, we can build tooling that doesn’t have to know or care what’s running in the container. How do we start an application? How do we stop? How do we do health checking? How do we aggregate logs, metrics, and insight? We can build or leverage tooling that does these things in a technology-agnostic way. Powerful clustering mechanics like service discovery, load balancing, fault tolerance, and configuration also can be pushed to lower layers in the application stack so that application developers don’t have to try and hand cobble this together and complicate their application code.

Kubernetes

Google is known for running Linux containers at scale. In fact, “everything” running at Google runs in Linux containers and is managed by their Borg cluster management platform. Former Google engineer Joe Beda said the company starts over two billion containers per week. Google even had a hand in creating the underlying Linux technology that makes containers possible. In 2006 they started working on “process containers,” which eventually became cgroups and was merged into the Linux kernel code base and released in 2008. With its breadth and background of operating containers at scale, it’s not a surprise Google has had such an influence on platforms built around containers. For example, some popular container management projects that preceded Kubernetes were influenced by Google:

  • The original Cloud Foundry creators (Derek Collison and Vadim Spivak) worked at Google and spent several years using Google’s Borg cluster management solution.

  • Apache Mesos was created for a PhD thesis, and its creator (Ben Hindman) interned at Google and had many conversations with Google engineers around container clustering, scheduling, and management.

  • Kubernetes, an open source container cluster management platform and community, was originally created by the same engineers who built Borg at Google.

Back in 2013 when Docker rocked the technology industry, Google decided it was time to open source their next-generation successor to Borg, which they named Kubernetes. Today, Kubernetes is a large, open, and rapidly growing community with contributions from Google, Red Hat, CoreOS, and many others (including lots of independent individuals!). Kubernetes brings a lot of functionality for running clusters of microservices inside Linux containers at scale. Google has packaged over a decade of experience into Kubernetes, so being able to leverage this knowledge and functionality for our own microservices deployments is game changing. The web-scale companies have been doing this for years, and a lot of them (Netflix, Amazon, etc.) had to hand build a lot of the primitives that Kubernetes now has baked in. Kubernetes has a handful of simple primitives that you should understand before we dig into examples. In this chapter, we’ll introduce you to these concepts; and in the following chapter, we’ll make use of them for managing a cluster of microservices.

Pods

A pod is a grouping of one or more Docker containers (like a pod of whales?). A typical deployment of a pod, however, will often be one-to-one with a Docker container. If you have sidecar, ambassador, or adapter deployments that must always be co-located with the application, a pod is the way to group them. This abstraction is also a way to guarantee container affinity (i.e., Docker container A will always be deployed alongside Docker container B on the same host).

Kubernetes orchestrates, schedules, and manages pods. When we refer to an application running inside of Kubernetes, it’s running within a Docker container inside of a pod. A pod is given its own IP address, and all containers within the pod share this IP (which is different from plain Docker, where each container gets an IP address). When volumes are mounted to the pod, they are also shared between the individual Docker containers running in the pod.

One last thing to know about pods: they are fungible. This means they can disappear at any time (either because the service crashed or the cluster killed it). They are not like a VM, which you care for and nurture. Pods can be destroyed at any point. This falls within our expectation in a microservice world that things will (and do) fail, so we are strongly encouraged to write our microservices with this premise in mind. This is an important distinction as we talk about some of the other concepts in the following sections.

Labels

Labels are simple key/value pairs that we can assign to pods like release=stable or tier=backend. Pods (and other resources, but we’ll focus on pods) can have multiple labels that group and categorize in a loosely coupled fashion, which becomes quite apparent the more you use Kubernetes. It’s not a surprise that Google has identified such simple constructs from which we can build powerful clusters at scale. After we’ve labeled our pods, we can use label selectors to find which pods belong in which group. For example, if we had some pods labeled tier=backend and others labeled tier=frontend, using a label selector expression of tier != frontend. We can select all of the pods that are not labeled “frontend.” Label selectors are used under the covers for the next two concepts: replication controllers and services.

Replication Controllers

When talking about running microservices at scale, we will probably be talking about multiple instances of any given microservice. Kubernetes has a concept called ReplicationController that manages the number of replicas for a given set of microservices. For example, let’s say we wanted to manage the number of pods labeled with tier=backend and release=stable. We could create a replication controller with the appropriate label selector and then be able to control the number of those pods in the cluster with the value of replica on our ReplicationController. If we set the replica count equal to 10, then Kubernetes will reconcile its current state to reflect 10 pods running for a given ReplicationController. If there are only five running at the moment, Kubernetes will spin up five more. If there are 20 running, Kubernetes will kill 10 (which 10 it kills is nondeterministic, as far as your app is concerned). Kubernetes will do whatever it needs to converge with the desired state of 10 replicas. You can imagine controlling the size of a cluster very easily with a ReplicationController. We will see examples of ReplicationController in action in the next chapter.

Services

The last Kubernetes concept we should understand is the Kubernetes Service. ReplicationControllers can control the number of replicas of a service we have. We also saw that pods can be killed (either crash on their own, or be killed, maybe as part of a ReplicationController scale-down event). Therefore, when we try to communicate with a group of pods, we should not rely on their direct IP addresses (each pod will have its own IP address) as pods can come and go. What we need is a way to group pods to discover where they are, how to communicate with them, and possibly load balance against them. That’s exactly what the Kubernetes Service does. It allows us to use a label selector to group our pods and abstract them with a single virtual (cluster) IP that we can then use to discover them and interact with them. We’ll show concrete examples in the next chapter.

With these simple concepts, pods, labels, ReplicationControllers, and services, we can manage and scale our microservices the way Google has learned to (or learned not to). It takes many years and many failures to identify simple solutions to complex problems, so we highly encourage you to learn these concepts and experience the power of managing containers with Kubernetes for your microservices.

Getting Started with Kubernetes

Docker and Kubernetes are both Linux-native technologies; therefore, they must run in a Linux host operating system. We assume most Java developers work with either a Windows or Mac developer machine, so in order for us to take advantage of the great features Docker and Kubernetes bring, we’ll need to use a guest Linux VM on our host operating system. You could download Docker machine and toolbox for your environment but then you’d need to go about manually installing Kubernetes (which can be a little tricky). You could use the upstream Kubernetes vagrant images, but like any fast-moving, open source project, those can change swiftly and be unstable at times. Additionally, to take full advantage of Docker’s portability, it’s best to use at least the same kernel of Linux between environments but optimally the same Linux distribution and version. What other options do we have?

Microservices and Linux Containers

To get started developing microservices with Docker and Kubernetes, we’re going to leverage a set of developer tools called the Red Hat Container Development Kit (CDK). The CDK is free and is a small, self-contained VM that runs on a developer’s machine that contains Docker, Kubernetes, and a web console (actually, it’s Red Hat OpenShift, which is basically an enterprise-ready version of Kubernetes with other developer self-service and application lifecycle management features; but for this book we’ll just be using the Kubernetes APIs).

OpenShift?

Red Hat OpenShift 3.x is an Apache v2, licensed, open source developer self-service platform OpenShift Origin that has been revamped to use Docker and Kubernetes. OpenShift at one point had its own cluster management and orchestration engine, but with the knowledge, simplicity, and power that Kubernetes brings to the world of container cluster management, it would have been silly to try and re-create yet another one. The broader community is converging around Kubernetes and Red Hat is all in with Kubernetes.

OpenShift has many features, but one of the most important is that it’s still native Kubernetes under the covers and supports role-based access control, out-of-the-box software-defined networking, security, logins, developer builds, and many other things. We mention it here because the flavor of Kubernetes that we’ll use for the rest of this book is based on OpenShift. We’ll also use the oc OpenShift command-line tools, which give us a better user experience and allow us to easily log in to our Kubernetes cluster and control which project into which we’re deploying. The CDK that we mentioned has both vanilla Kubernetes and OpenShift. For the rest of this book, we’ll be referring to OpenShift and Kubernetes interchangeably but using OpenShift.

Getting Started with the CDK

With the CDK, you can build, deploy, and run your microservices as Docker containers right on your laptop and then opt to deliver your microservice in a delivery pipeline through other application lifecycle management features inside of OpenShift or with your own tooling. The best part of the CDK is that it runs in a RHEL VM, which should match your development, QA, and production environments.

Instructions for installing the CDK can be found at http://red.ht/1SVUp6g. There are multiple flavors of virtualization (e.g., VirtualBox, VMWare, KVM) that you can use with the CDK. The installation documents for the CDK contain all of the details you need for getting up and running. To continue with the examples and idioms in the rest of this book, please install the CDK (or any other Docker/Kubernetes local VM) following the instructions.

To start up the CDK, navigate to the installation directory and to the ./components/rhel/rhel-ose subdirectory and type the following:

$ vagrant up

This should take you through the provisioning process and boot the VM. The VM will expose a Docker daemon at tcp://10.1.2.2:2376 and the Kubernetes API at https://10.1.2.2:8443. We will next need to install the OpenShift oc command-line tools for your environment. This will allow us to log in to OpenShift/Kubernetes and manage our projects/namespaces. You could use the kubectl commands yourself, but logging in is easier with the oc login command, so for these examples, we’ll use oc. Download and install the oc client tools.

Once you’ve downloaded and installed both the CDK and the oc command-line tools, the last thing we want to do is set a couple of environment variables so our tooling will be able to find the OpenShift installation and Docker daemon. To do this, navigate to the ./components/rhel/rhel-ose directory and run the following command:

$ eval "$(vagrant service-manager env docker)"

This will set up your environment variables. You can output the environment variables and manually configure them if you wish:

$ vagrant service-manager env docker
export DOCKER_HOST=tcp://192.168.121.195:2376
export DOCKER_CERT_PATH=/home/john/cdk/components/rhel/rhel-ose/
.vagrant/machines/default/libvirt/.docker
export DOCKER_TLS_VERIFY=1
export DOCKER_MACHINE_NAME=081d3cd

We should now be able to log in to the OpenShift running in the CDK:

$ oc login 10.1.2.2:8443
The server uses a certificate signed by an unknown authority.
You can bypass the certificate check, but any data you send to
the server could be intercepted by others.
Use insecure connections? (y/n): y

Authentication required for https://10.1.2.2:8443 (openshift)
Username: admin
Password:
Login successful.

You have access to the following projects and can switch between
them with 'oc project <projectname>':

  * default

Using project "default".
Welcome! See 'oc help' to get started.

Let’s create a new project/namespace into which we’ll deploy our microservices:

$ oc new-project microservice-book
Now using project "microservice-book" on server
"https://10.1.2.2:8443".

You should be ready to go to the next steps!

Although not required to run these examples, installing the Docker CLI for your native developer laptop is useful as well. This will allow you to list Docker images and Docker containers right from your developer laptop as opposed to having to log in to the vagrant VM. Once you have the Docker CLI installed, you should be able to run Docker directly from your command-line shell (note, the environment variables previously discussed should be set up):

$ docker ps
$ docker images

Where to Look Next

In this chapter, we learned a little about the pains of deploying and managing microservices at scale and how Linux containers can help. We can leverage immutable delivery to reduce configuration drift, enable repeatable deployments, and help us reason about our applications regardless of whether they’re running. We can use Linux containers to enable service isolation, rapid delivery, and portability. We can leverage scalable container management systems like Kubernetes and take advantage of a lot of distributed-system features like service discovery, failover, health-checking (and more!) that are built in. You don’t need complicated port swizzling or complex service discovery systems when deploying on Kubernetes because these are problems that have been solved within the infrastructure itself. To learn more, please review the following links:

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.218.187.108