In recent years, the landscape of the IT industry has dramatically shifted. The rise of highly interactive mobile applications, cloud computing, and streaming media has pushed the limits of the existing IT infrastructure. Users who were once happy with web browsing and email are now taking advantage of the highly interactive services that are available and are continually demanding higher bandwidth, reliability, and more features. In the wake of this shift, IT departments and application developers are continually attempting to find ways to keep up with the increased demand to remain relevant to consumers who depend on their services.
As an application developer, infrastructure support specialist, or DevOps engineer, you have no doubt seen the radical shift in how infrastructure is supported and maintained. Gone are the days when a developer could write an application in isolation, deploy it across an enterprise, and hand over the keys to operations folks who may only have had a basic understanding of how the application functioned. Today, the development and operations paradigms are intrinsically interwoven in what most enterprises are calling DevOps. In the DevOps mindset, operations and support staff work directly with application developers in order to write applications, as well as infrastructure as code. Leveraging this new mindset allows services to go live that may scale multiple tiers and spread between hundreds of servers, data centers, and cloud providers. Once an organization adopts a DevOps mindset, this creates a cultural shift between the various departments. A new team mentality usually emerges, in which developers and operations staff feel a new sense of camaraderie. Developers are happy to contribute to code that makes application deployments easier, and operations staff are happy with the increased ease of use, scaling, and repeatability that comes with new DevOps-enabled applications.
Even within the world of DevOps, containerization has been actively growing and expanding across organizations as a newer and better way to deploy and maintain applications. Like anything else in the world of information technology, we need controlled processes around how containers are built, deployed, and scaled across an organization. Ansible Container provides an abstracted and simple-to-implement methodology for building and running containers at scale. Before we start to learn about Ansible and containerization platforms, we must first examine how applications and services were deployed historically.
Before we get started, let's look at the topics we will address in this chapter:
- A historical overview of the DevOps and IT infrastructure:
- Manual deployments
- An introduction to automation
- The virtualization of applications
- The containerization of applications
- The orchestrating of containerized applications
- Building your first Docker container
- Setting up a lab environment
- Starting your first Docker container
- Building your first Docker container
- Container life cycle management
Let's take a quick look at the evolution of many IT departments, and the response to this radical shift across the industry. Before we delve into learning about containers, it is important to understand the history of deploying applications and services in order to realize which problems containerization addresses, as well as how infrastructure has changed and evolved over the decades.
The manual deployment of large monolithic applications is where most application deployments start out, and the state of most infrastructure in the late 1990's and early to mid-2000's. This approach normally goes something like this:
- An organization decides they want to create a new service or application.
- The organization commissions a team of developers to write the new service.
- New servers and networking equipment are racked and stacked to support the new service.
- The new service is deployed by the operations and engineering teams, who may have little to no understanding of what the new service actually does.
Usually, this approach to deploying an application is characterized by little to no use of automation tools, basic shell or batch scripts, and large complex overheads to maintain the application or deploy upgrades. Culturally, this approach creates information silos in teams, and individuals become responsible for small portions of a complicated overall picture. If a team member is transferred between departments or leaves the organization, havoc can arise when the people who are then responsible for the service are forced to reverse engineer the original thought processes of those who originally developed the application. Documentation may be vague if it exists at all.
The next step in the evolution towards a more flexible, DevOps-oriented architecture is the inclusion of an automation platform that allows operation and support engineers to simplify many aspects of deployment and maintenance tasks within an organization. Automation tools are numerous and varied, depending on the extent to which you wish to automate your applications. Some automation tools work only at an OS-level to ensure that the operating system and applications are running as expected. Other automation tools can use interfaces such as IPMI to remotely power-on bare-metal servers in order to deploy everything from the operating system upward.
Automation tools are based around the configuration management concepts of current state and desired state. The goal of an automation platform is to evaluate the current state of a server against a programmatic template that defines the servers desired state and only applies actions on the server that are required to bring it into the desired state. For example, an automation platform checking for NGINX in a running state may look at an Ubuntu 16.04 server and see that NGINX is not currently installed.
To bring this server into the desired state, it may run the command
apt-get install nginx on the backend to bring that server into compliance. When that same automation tool is evaluating a CentOS server, it may determine that NGINX is installed but not running. To bring this server into compliance, it would run
systemctl start nginx to bring that server into compliance. Notice that it did not attempt to re-install NGINX. To expand our example, if the automation tool was examining a server that had NGINX both installed and running, it would take no action on that server, as it is already in the desired state. The key to a good automation platform is that the tool only executes the steps required to bring that server into the desired state. This concept is known as idempotency, and is a hallmark of most automation platforms.
We will now look at a handful of open source automation tools and examine how they work and what makes them unique. Having a firm understanding of automation tools and how they work will help you to understand how Ansible Container works, and why it is an invaluable tool for container orchestration:
- Chef: Chef is a configuration management tool written by Adam Jacobs in 2008 to address specific use cases he was tasked with at the time. Chef code is written in a Ruby-based domain-specific language known as recipes. A collection of recipes grouped together for a specific purpose is known as a cookbook. Cookbooks are stored on a server, from which clients can periodically download updated recipes using the client software running as a daemon. The Chef Client is responsible for evaluating the current state against the desired states described in the cookbooks.
- Puppet: Puppet was written in 2005 by Luke Kaines and, similar to Chef, works on a client-server model. Puppet manifests are written in a Ruby DSL and stored on a dedicated server known as the Puppet Master. Clients run a daemon known as the Puppet Agent, which is responsible for downloading Puppet manifests and executing them locally across the clients.
- Salt: configuration management tool written by Thomas Hatch in 2011. Similar to Puppet and Chef, Salt works primarily on a client-server model in which states stored on the Salt Master are executed on the minions to bring about the desired state. Salt is notable in that it is one of the fastest and most efficient configuration management platforms, as it employs a message bus architecture (ZeroMQ) between the master and nodes. Levering this message bus, it is quickly able to evaluate these messages and take the corresponding action.
- Ansible: Ansible is perhaps one of the more unique automation platforms of the ones we have looked at thus far. Ansible was written in 2012 by Michael DeHaan to provide a minimal, yet powerful configuration management tool. Ansible playbooks are simple YAML files that detail the actions and parameters that will be executed on target hosts in a very readable format. By default, Ansible is agentless and leverages a push model, in which playbooks are executed from a centralized location (your laptop, or a dedicated host on the network), and evaluated on a target host over SSH. The only requirements to deploy Ansible are that the hosts you are running playbooks against need to be accessible over SSH, and they must have the correct version of Python installed (2.7 at the time of writing). If these requirements are satisfied, Ansible is an incredibly powerful tool that requires very little effort in terms of knowledge and resources to get started using it. More recently, Ansible launched the Ansible Container project, with the purpose of bringing configuration management paradigms to building and deploying container-based platforms. Ansible is an incredibly flexible and reliable platform for configuration management with a large and healthy open source ecosystem.
So far, we have seen how introducing automation into our infrastructure can help bring us one step closer to realizing the goals of DevOps. With a solid automation platform in place, and the correct workflows to introduce change, we can leverage these tools to truly have control over our infrastructure. While the benefits of automation are great indeed, there are major drawbacks. Incorrectly implemented automation introduces a point of failure into our infrastructure. Before selecting an automation platform, one must consider what will happen in the event that our master server goes down (applicable to tools such as Salt, Chef, and Puppet). Or what will happen if a state, recipe, playbook, or manifest fails to execute on one of your bare metal infrastructure servers. Using configuration management and automation tools is essentially a requirement in today's landscape, and ways to deploy applications which actually simplify and sometimes negate these potential issues are emerging.
With the rise of cloud computing in recent years, the virtualization of applications and infrastructure has for many organizations replaced traditional in-house deployments of applications and services. Currently, it is proving to be more cost-effective for individuals and companies to rent hardware resources from companies such as Amazon, Microsoft, and Google and spin up virtual instances of servers with exactly the hardware profiles required to run their services.
Many configuration management and automation tools today are adding direct API access to these cloud providers to extend the flexibility of your infrastructure. Using Ansible, for example, you can describe exactly the server configuration you require in a playbook, as well as your cloud provider credentials. Executing this playbook will not only spin up your required instances but will also configure them to run your application. What happens if a virtual instance fails? Blow it away and create a new one. With the ushering in of cloud computing, so too comes a new way to look at infrastructure. No longer is a single server or group of servers considered to be special and maintained in a specific way. The cloud is introducing DevOps practitioners to the very real concept that infrastructure can be disposable.
Virtualization, however, is not limited to just cloud providers. Many organizations are currently implementing virtualization in-house using platforms such as ESXi, Xen, and KVM. These platforms allow large servers with a lot of storage, RAM, and CPU resources to host multiple virtual machines that use a portion of the host operating system's resources.
Considering the benefits that virtualization and automation bring to the table, there are still many drawbacks to adopting such an architecture. For one, virtualization in all its forms can be quite expensive. The more virtual servers you create in a cloud provider, the more expensive your monthly overhead fee will be, not considering the added cost of large hardware profile virtual machines. Furthermore, deployments such as these can be quite resourced-intensive. Even with low specifications, spinning up a large number of virtual machines can take large amounts of storage, RAM, and CPU from the hypervisor hardware.
Finally, consideration must also be paid to the maintenance and patching of the virtual machine operating systems, as well as the hypervisor operating system. Even though automation platforms and modern hypervisors allow virtual machines to be quickly spun up and destroyed, patching and updates still must be considered for instances that might be kept for weeks or months. Remember, even though the operating system has been virtualized, it is still prone to security vulnerabilities, patching, and maintenance.
Containerization made an entrance on the DevOps scene when Docker was launched in the month of March of 2013. Even though the concepts of containerization predate Docker, for many working in the field, it was their first introduction to the concept of running an application inside a container. Before we go forward, we must first establish what a container is and what it is not.
A container is an isolated process in a Linux system that has control groups and kernel namespaces associated with it. Within a container, there is a very thin operating system layer, which has just enough resources to launch and run other processes. The base operating system layer can be based on any operating system, even a different operating system from the one that is running on the host. When a container is run, the container engine allocates access to the host operating system kernel to run the container in isolation from other processes on the host. From the perspective of the application inside the container, it appears to be the only process on that host, even though that same host could be running multiple versions of that container simultaneously.
The following illustration shows the relationship between the host OS, the container engine, and the containers running on the host:
Figure 1: An Ubuntu 16.04 host running multiple containers with different base operating systems
Many beginners at containerization mistake containers for lightweight virtual machines and attempt to fix or modify running containers as you would a VM or a bare metal server that isn't running correctly. Containers are meant to be truly disposable. If a container is not running correctly, they are lightweight enough that one can terminate the existing container and rebuild a new one from scratch in a matter of seconds. If virtual machines and bare metal servers are to be treated as pets (cared for, watered, and fed), containers are to be treated as cattle (here one minute, deleted and replaced the next minute). I think you get the idea.
This implementation differs significantly from traditional virtualization, in that a container can be built quickly from a container source file and start running on a host OS, similar to any other process or daemon in the Linux kernel. Since containers are isolated and extremely thin, one does not have to be concerned about running any unnecessary processes inside of the container, such as SSH, security tools, or monitoring tools. That container exists for a specific purpose, to run a single application. Container runtime environments, such as Docker, provide the necessary resources so that the container can run successfully and provide an interface to the host's software and hardware resources, such as storage and networking.
By their very nature, containers are designed to be portable. A container using a CentOS base image running the Apache web server can be loaded on a CentOS host, an Ubuntu host, or even a Windows host; they all have the same container runtime environment and run in exactly the same way. The benefits of having this type of modularity are immense. For example, a developer can build a container image for MyAwesomeApplication 1.0 on his or her laptop, using only a few megabytes of storage and memory, and be confident that the container will run exactly the same in production as it does on their laptop. When it's time to upgrade the MyAwesomeApplication to version 2.0, the upgrade path is to simply replace the running container image with the newer container image version, significantly simplifying the upgrade process.
Combining the portability of running containers in a runtime environment such as Docker with automation tools such as Ansible can provide software developers and operations teams with a powerful combination. New software can be deployed faster, run more reliably, and have a lower maintenance overhead. It is this idea that we will explore further in this book.
Working towards a more flexible, DevOps-oriented infrastructure does not stop with running applications and tools in containers. By their very nature, containers are portable and flexible. As with anything else in the IT industry, the portability and flexibility that containers bring can be built upon to make something even more useful. Kubernetes and Docker Swarm are two container scheduling platforms that make maintaining and deploying containers even easier.
Kubernetes and Docker Swarm can proactively maintain the containers running across the hosts in your cluster, making scaling and upgrading containers very easy. If you want to increase the number of containers running in your cluster, you can simply tell the scheduling API to increase the number of replicas, and the containers will automatically scale in real time across nodes in the cluster.
If you want to upgrade the application version, you can similarly instruct these tools to leverage the new container version, and you can watch the rolling upgrade process happen almost instantly. These tools can even provide networking and DNS services between containers, such that the container network traffic can be abstracted away from the host networking altogether. This is just a taste of what container orchestration and scheduling tools such as Docker Swarm and Kubernetes can do for your containerized infrastructure. However, these will be discussed in much greater detail later in the book.
Now that we have covered some introductory information that will serve to bring the reader up to speed on DevOps, configuration management, and containerization, it's time to get our hands dirty and actually build our first Docker container from scratch. This portion of the chapter will walk you through building containers manually and with scripted Dockerfiles. This will provide a foundational knowledge of how the Ansible Container platform works on the backend to automate the building and deployment of container images.
When working with container images, it is important to understand the difference between container images and running instances of containers. When you build a container using Ansible Container or manually using Dockerfiles, there is a two-part process required to run a container: Building the container image, and running an instance of the container:
- Building a Container: The build process involves downloading a base container OS image, and executing the steps outlined in the Dockerfile or Ansible Container playbooks to bring the container into the desired state. The result of the build process is a cached container image that is ready to launch container instances. The
docker pullcommand can also be used to download container images from the internet for your local Docker host to cache.
- Running a Container: The process of starting a cached container image and running it is known as running a container. You can start as many containers you want from a single container image. If you attempt to run a container image that is not already cached on your local Docker host, Docker will attempt to download that container image from the internet.
I would encourage you to follow along as we perform these lab exercises. To simplify the process of getting an environment that has the tools covered in this book up-and-running, I have created a Git repository with many example lab scenarios covered throughout this book. We will start off by running through a quick tutorial on how to set up the lab on your local workstation or laptop. To install the lab components, I would suggest using a computer with at least 8 GB of RAM, a virtualization-enabled CPU (Intel Core i5 or equivalent), and a 128 GB or higher hard drive. Linux or macOS are the preferred operating systems for installing the lab, as these tools generally work better on Unix-like operating systems. However, all of these tools also support Windows, but your mileage may vary.
The lab environment will spin up a disposable Ubuntu 16.04 Vagrant VM which comes preloaded with Docker, Ansible Container, and the various tools you will need to successfully become familiar with how Ansible Container works. A text editor geared towards development is also required and will be used to create and edit examples and lab exercises throughout this book. I would suggest using GitHub Atom or Vim, as both editors support syntax highlighting for YAML documents and Dockerfiles. Both GitHub Atom and Vim are available as free and opensource software and are available cross-platform.
Please note, you do not have to install this lab environment in order to learn and understand Ansible Container. It is helpful to follow along and have hands-on experience of working with the technology, but it is not required.
The book should be simple enough to understand without instantiating the lab if you lack the available resources. It should also be noted that you can instantiate your own lab environment on your workstation as well, by installing Ansible, Ansible Container, and Docker. Later in the book, we will cover Kubernetes and OpenShift, so will need those as well for later chapters. These references can be found at the back of the book.
Below are the steps required to set up the lab environment on your local workstation. Details on installing Vagrant and Virtualbox for your respective platform can be found on the main websites. Try and download similar version numbers to what is listed to ensure maximum compatibility:
git clone https://github.com/aric49/ansible_container_lab.git
In your Terminal, navigate to the
ansible_container_lab Git repository and run:
vagrant up to start the virtual machine:
cd Ansible_Container_Lab vagrant up
If Vagrant and VirtualBox are installed and configured correctly, you should start to see the VM launching on your workstation, similar to the following:
[email protected]:$ vagrant up Bringing machine 'node01' up with 'virtualbox' provider... ==> node01: Importing base box 'ubuntu/xenial64'... ==> node01: Matching MAC address for NAT networking... ==> node01: Checking if box 'ubuntu/xenial64' is up to date... ==> node01: Setting the name of the VM: AnsibleBook_node01_1496327441174_45550 ==> node01: Clearing any previously set network interfaces... ==> node01: Preparing network interfaces based on configuration... node01: Adapter 1: nat ==> node01: Forwarding ports... node01: 22 (guest) => 2022 (host) (adapter 1) ==> node01: Running 'pre-boot' VM customizations... ==> node01: Booting VM... ==> node01: Waiting for machine to boot. This may take a few minutes... node01: SSH address: 127.0.0.1:2022
Once the Vagrant box has successfully booted up, you can execute the command:
vagrant ssh node01 to get access to the VM.
When you are done working in the Vagrant virtual machine, you can use the command:
vagrant destroy -f to terminate the VM. Destroying the VM should be done when you are finished working with the machine for the day, or when you wish to delete and re-create the VM from scratch, should you need to reset it to the original settings.
Please note: Any work that is not saved in the
/vagrant directory in the lab VM will be deleted and will be unrecoverable. The
/vagrant directory is a shared folder between the root of the
lab directory on your localhost and the Vagrant VM. Save files here if you want to make them available in the future.
By default, the lab environment begins running with the Docker engine already started and running as a service. If you need to install the Docker engine manually, you can do so on Ubuntu or Debian-based distributions of Linux using:
sudo apt-get install docker.io. Once Docker is installed and running, you can check the status of running containers by executing
docker ps -a:
[email protected]:~$ docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES [email protected]:~$
We can see in the preceding output that we have column headers, but no actual information. That's because we don't have any container instances running. Let's check how many container images Docker knows about, using the
docker images command:
[email protected]:~$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE
Not much going on there either. That's because we don't have any container images to play around with yet. Let's run our first container, the Docker
hello-world container, using the
docker run command:
[email protected]:~$ docker run hello-world Unable to find image 'hello-world:latest' locally latest: Pulling from library/hello-world 78445dd45222: Pull complete Digest: sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7 Status: Downloaded newer image for hello-world:latest Hello from Docker! This message shows that your installation appears to be working correctly. To generate this message, Docker took the following steps: 1. The Docker client contacted the Docker daemon. 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. 3. The Docker daemon created a new container from that image which runs the executable that produces the output you are currently reading. 4. The Docker daemon streamed that output to the Docker client, which sent it to your terminal. To try something more ambitious, you can run an Ubuntu container with: $ docker run -it ubuntu bash Share images, automate workflows, and more with a free Docker ID: https://cloud.docker.com/ For more examples and ideas, visit: https://docs.docker.com/engine/userguide/
The command we executed was:
docker run hello-world. A lot of things happened when we ran that command. The command
docker run is the Docker command required to start and run a container within the Docker engine. The container we are running is
hello-world. If you look through the output, you can see that Docker reports that it is
Unable to find image 'hello-world:latest' locally. The first step of the Docker run is Docker testing to see if it already has the container image cached locally, so it doesn't have to download and redownload containers that the host is already running. We validated earlier that we currently have no container images in Docker using the
docker images command, so Docker searched its default registry (Docker Hub) to download the image from the internet. When Docker downloads a container image, it downloads the image one layer at a time and calculates a hash to ensure that the image was pulled correctly and with integrity. You can see from the preceding output that Docker provides the
sha256 digest, so we can be certain that the correct image was downloaded. Since we didn't specify a container version, Docker searched the Docker Hub registry for an image called,
hello-world and downloaded the latest version. When the container executed, it printed the
Hello From Docker output, which is the job the container is designed to perform.
You can also use the
docker ps command without the
-a flag to show only containers that are currently running, not exited or stopped containers.
Docker containers are built based on layers. Every time you build a Docker image, each command you run to create the image is a layer in the Docker image. When Docker builds or pulls an image, Docker processes each layer individually, ensuring that the entire container image is pulled or built intact. When you begin to build your own Docker images, it is important to remember: the fewer the layers, the smaller the file size, and the more efficient the image will be. Downloading an image with a lot of layers is not ideal for users consuming your service, nor is it convenient for you to quickly upgrade services if your Docker images take a long time to download.
Now that we have downloaded and run our first container image, let's take a look at our list of local Docker images again:
[email protected]:~$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE hello-world latest 48b5124b2768 4 months ago 1.84 kB [email protected]:~$
As you can see, we have the
hello-world image cached locally. If we reran this container, it would no longer have to pull down the image, unless we specify a higher image version number than what was stored in the local cache. We can now take another look at our
docker ps -a output:
[email protected]:~$ docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES b0c4093ab38f hello-world "/hello" 28 minutes ago Exited (0) 28 minutes ago romantic_easley
From the preceding output, you can see that Docker created a new running container with the container ID:
b0c4093ab38f. It also listed the name of the source image used to spawn this container, the command executed, (in this case:
/hello), the time it was created, as well as the current status and container name. You can see that this particular container is no longer running, as the status is
Exited (0). This particular container is designed in such a way that it performs one single job and quits once that job has finished. The
Exited (0) status lets the user know that the execution completed successfully. This functions very similarly to a binary executable, such as
echo commands in a Unix-based system. These commands perform a single job and stop once that job has completed. Building this type of container is useful if your purpose is to provide a user with a container that provides an output, such as parsing text, performing calculations, or even executing jobs on the Docker host. As you will see later, you can even pass parameters to the docker
run command so that we can modify how the applications inside the container run.
Now that we have an understanding of how Docker containers run, as well as how the Docker engine downloads and caches container images, we can start building containers that run services such as web servers and databases. In this lesson, we will build a container from a Dockerfile that will run the Apache web server. We will then expose ports in the Docker engine that will allow us to access the running web service we just instantiated. Let's get started.
As we learned previously, Docker containers consist of layers that are essentially stacked on top of each other to form a Docker container image. These layers consist of commands in a plain-text file that the Docker engine will sequentially execute to build a final image. Each line of a Dockerfile represents a layer in the Docker image. The goal of building our Dockerfiles is to keep them as small and concise as possible so that our container images are not larger than necessary. In the
/vagrant directory of your VM, create a plain-text file called,
Dockerfile, and open it in the text editor of your choice. We will start with the following lines, which we will explore one by one:
FROM ubuntu:16.04 RUN apt-get update; apt-get install -y apache2 EXPOSE 80 ENTRYPOINT ["apache2ctl"] CMD ["-DFOREGROUND"]
Let's take a look at this Dockerfile line-by-line:
FROM: Indicates the base image from which we want our container to be built. In this case, it is the Ubuntu base image, version 16.04. There are multiple base images, and images with applications prebuilt, that you can leverage, available for free on Docker Hub.
RUN: Any commands you want the container to execute during the build process get passed in with the RUN parameter. We are executing
apt-get updatein tandem with
apt-get install. We are executing both of these commands using the same
RUNline in order to keep our container layers as small as possible. It is also a good practice to group package management commands in the same
RUNlines as well. This ensures that
apt-get installdoes not get executed without first updating the sources list. It is important to note that, when a Docker image gets rebuilt, it will only execute the lines that have been changed or added.
EXPOSEline instructs Docker about which ports should be open on the container to accept incoming connections. If a service requires more than one port, they can be listed separately with spaces.
ENTRYPOINTdefines which command you want the container to run by default when the container launches. In this example, we are starting the
apache2web server using
apache2ctl. If you want your container to be persistent, it is important that you run your application in a daemon mode or a background mode that will not immediately throw an
EXITsignal. Later in the book, we will look at an open source project called,
dumb-init, which is an init system for running services in containers.
CMDin this example defines the parameters passed into the
ENTRYPOINTcommand at runtime. These parameters can be overridden at the time the container is launched by providing additional arguments at the end of your Docker
runcommand. All of the commands or arguments you provide in
CMDare prefixed by
/bin/sh -c, making it possible to pass in environment variables at runtime. It should also be noted that, depending on how you want the default shell to interpret the application that is being launched inside the container, you can use
CMDsomewhat interchangeably. The online Docker documentation goes into more in-depth details about best practices for using
Each line within Dockerfile forms a separate layer in the final Docker container image as seen in the following illustration. Usually, developers want to try to make container images as small as possible to minimize disk usage, download, and build time. This is usually accomplished by running multiple commands on the same line in the Dockerfile.
In this example, we are running
apt-get udpate; apt-get install apache2 in order to try and minimize the size of the resulting container image.
Figure 2: Layers in the Apache2 container image
This is by no means an exhaustive list of the commands available for you to use in a Dockerfile. You can export environment variables using
ENV, copy configuration files and scripts into the container at build time, and even create mount points in the container using the
VOLUME command. More commands such as these can be found in the official Dockerfile reference guide at https://docs.docker.com/engine/reference/builder/.
Now that we understand what goes into the Dockerfile, let's build in a functional container using the
docker build command. By default,
docker build will search in your current directory for a file called
Dockerfile and will attempt to create a container layer by layer. Execute the following command on your virtual machine:
docker build -t webservercontainer:1.0 .
It is important to pass in an image build tag using the
-t flag. In this case, we are tagging the image with the name
webservercontainer and the version
1.0. This ensures that you can identify the versions you have built from the
docker image list output.
If you execute the
docker images command again, you will see that the newly built image is now stored in the local image cache:
[email protected]:$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE webservercontainer 1.0 3f055adaab20 7 seconds ago 255.1 MB
We can launch new container instances now using
docker run -d --name "ApacheServer1" -p 80:80 webservercontainer:1.0
This time, we are passing new parameters into
-d: Indicates that we are going to run this container in detached or background mode. Running containers in this mode will not immediately log the user into the container shell upon starting. Rather, the container will start directly in the background.
--name: Gives our container a human-readable name so that we can easily understand what the container's purpose is. If you don't pass in a name flag, Docker will assign a random name to your container.
-p: Allows us to open ports on the host that will be forwarded to the exposed port on the container. In this example, we are forwarding port
80on the host to port
80on the container. The syntax for the
-pflag is always
You can test if this container is running by executing the
curl command on the VM against localhost on port
80. If all goes well, you should see the default Ubuntu Apache welcome page:
[email protected]:~$ curl localhost:80 <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" ...
This indicates to us that Docker is listening on the localhost on port
80 and forwarding that connection to the container, also listening on port
80. The great thing about containers is that you can launch multiple instances of the same container, provided they are listening on the different port numbers. In a matter of seconds, you can create a fleet of containers providing various services, and just as quickly wipe them out.
Let's create two more Apache web server containers listening on ports
200 of the host's networking interfaces. Note that in the following example, I have provided different name parameters as well as different host ports:
docker run -d --name "ApacheServer2" -p 100:80 webservercontainer:1.0 docker run -d --name "ApacheServer3" -p 200:80 webservercontainer:1.0
If you run the same
curl command again, this time on port
200, you will see the same Ubuntu default web server page. That's boring. Let's give our containers more personality. We can use the
docker exec command to log in to running containers and customize them slightly:
[email protected]:~$ docker exec -it ApacheServer1 /bin/bash [email protected]:/#
docker exec requires the following flags to access a running container:
docker execinteractively, since we are going to be launching into a Bash shell
-t: Allocate a pseudo-tty, or terminal session
ApacheServer1: The name (or container ID) of the container we want to log into
/bin/bash: The terminal or command we want to launch using the
docker exec command should drop you directly into the Bash shell of the first Apache container. Run the following command to change the
index.html file in the Docker container. When you've finished, you can exit out of the container's shell session by typing
[email protected]:/# echo "Web Server 1" > /var/www/html/index.html
From the Docker host, run the
curl command again on port
80. You should see that the page your Apache web server is using has changed:
[email protected]:~$ curl localhost:80 Web Server 1
docker exec to log into the other two containers and use
echo to change the default
index.html page to something unique to all three web server containers. Your
curl results should reflect the changes you've made:
[email protected]:~$ curl localhost:80 Web Server 1 [email protected]:~$ curl localhost:100 Web Server 2 [email protected]:~$ curl localhost:200 Web Server 3
Note: This exercise is for the purposes of demonstrating the
docker exec command.
docker exec is not a recommended way to update, fix, or maintain running containers. From a best practices standpoint, you should always rebuild your Docker containers, incrementing the version tag when changes need to be made. This ensures that changes are always recorded in the Dockerfile so containers can be stood up and torn down as quickly as possible.
You may also have noticed that various Linux operating system tools, text editors, and other utilities are not present in the Docker containers. The goal of containers is to provide the bare-minimal footprint required to run your applications. When building your own Dockerfiles, or later, when we explore Ansible Container environments, think through what is going inside your containers and whether or not your container meets the best practices for designing microservices.
Docker gives you the benefit of process isolation using Linux control groups and namespaces. Similar to processes in Unix-like operating systems, these processes can be started, stopped, and restarted to implement changes throughout the lifecycle of the container. Docker gives you direct control of the state of your containers by giving you the options to start, stop, reload, and even view containers logs that might be misbehaving, as needed. Docker gives you the benefit of using either the container's internal ID number or using the container name we assign it when we start using
docker run. The following is a list of Docker native commands that can be used to manage the lifecycle of a container as you build and iterate through various versions:
docker stop <ContainerID or Name>: Stops the running container and processes within the container.
docker start <ContainerID or Name>: Starts a stopped or exited container.
docker reload <ContainerID or Name>: If the container is running, reload will gracefully stop the container and start the container to bring it back into a running state. If the container is stopped, reload will start the running container.
docker logs <ContainerID or Name>: Displays any logs generated by the container or the application running inside the container leveraging
STDERR. Logs are useful for debugging a misbehaving container without having to
execinside the container.
docker logs have a
--follow flag, useful for streaming live log output. This can be accessed using
docker logs --follow <ContainerID or Name>.
From the preceding example, we can start, stop, reload, or view the logs of any of the Apache web server containers we built earlier, like so:
docker stop ApacheServer2 docker start ApacheServer2 docker reload ApacheServer2 docker logs ApacheServer2
Similar to this example, you can validate the status of any containers by looking at the output of
docker ps -a.
In this chapter, we looked at the history of application deployments across IT infrastructure, as well as the history of containers and why they are revolutionizing software development. We also took our first steps in building Docker containers by running containers manually, as well as by building them from scratch through Dockerfiles.
I hope that, if you are new to containerization and Docker, this chapter gave you a good starting point from which you can get hands-on in the world of containerization. Dockerfiles are excellent tools for building containers, as they are lightweight, easily version-controlled, and quite portable. However, they are quite limited in the sense that they are the equivalent of a Bash shell script in the world of DevOps. What happens if you need to tweak configuration files, dynamically configure services based on the states of services, or configure containers based on the environmental conditions they will be deployed into? If you have spent time working on configuration management, you will know that, while shell scripts can do the job, there are much better and easier tools available. Ansible Container is exactly the tool we need in order to apply the power of configuration management to the portability and flexibility that containers bring to our infrastructure. In Chapter 2, Working with Ansible Container, you will learn about Ansible Container and see first-hand how quickly we can build and deploy containers.