You are on page 1of 89

Learn Containers 5 Minutes at a Time

By Eric Gregory

An introductory guide for developers

1
Copyright © 2022 Eric Gregory
All Rights Reserved

First edition

2
Welcome!

One of the biggest challenges for implementing cloud native


technologies is learning the fundamentals—especially when
you need to fit your learning into a busy schedule.

In this book, we’ll break down the basics of containerization


into short, manageable exercises and explainers, so you can
learn five minutes at a time. This book assumes a basic
familiarity with the Linux command line and a Unix-like
operating system—beyond that, you don’t need any special
preparation to get started.

Whether you need a primer on containers or a quick refresher


course, this book will bring you up to speed with concise
explainers and hands-on exercises.

Let’s get started!

3
Table of Contents

Chapter 1: What is a Container? 5

Chapter 2: Creating, Observing, and Deleting Containers 9

Chapter 3: Building Container Images from Dockerfiles 14

Chapter 4: Using an Image Registry 19

Chapter 5: Volumes and Persistent Storage 26

Chapter 6: Container Networking and Opening Container Ports 36

Chapter 7: Running a Containerized App 45

Chapter 8: Multi-Container Apps on User-Defined Networks 54

Chapter 9: Docker Compose 64

Chapter 10: Building a Web App as Containerized Services 69

4
Chapter 1: What is a Container?

Containers are sandboxed software environments that share


common dependencies, such as the operating system kernel.
You might run many containers on the same machine, and
where they depend on different binaries or libraries, they can
use those—while sharing the same operating system layer.

More technically, we can say that containers are groups of


processes isolated by kernel namespaces, control groups, and
restrictions on root privileges and system calls. We’ll see what
that means in the next chapter.

But first, we should think about the purpose of


containerization. Why would we want to isolate our processes
in the first place? Why not simply run programs in parallel on
the same system?
5
Why use containers?

There are many reasons why you might need to isolate


processes, especially in the enterprise. You may wish to keep
processes separate for the sake of security, so that one
program can’t access data from another. You may need to be
certain that a process doesn’t have access to root privileges
and system calls.

Or it may be a simple matter of resource efficiency and


system hygiene. For example, on a given machine you may
have one process that relies on Python 2.7 and another that
calls for 3.1. Once such competing dependency requirements
start to compound, they can create a real headache. Process
isolation goes a long way toward preventing or resolving
those problems.

One way to isolate processes is to run them on dedicated


virtual machines (or VMs). For some use cases, this may be
the most suitable approach, but containers offer advantages
that VMs do not. Because VMs simulate an entire
machine—including the operating system—they are usually
much more resource-intensive. And because containers are so
relatively lightweight, they are more portable and easy to
replicate.

Indeed, the portability and scalability of containers means


they can speed development by providing pre-fabricated
software modules in the form of container images:
easy-to-download container configurations with a certain set
of applications and dependencies ready to go. These container
images provide readily accessible building blocks for

6
developers, as well as a canvas that is easy to standardize
across an organization.

Those are some significant advantages that can transform the


way an organization delivers software. So how do you get
started working with containers? What are the primary
containerization tools? Most beginners will want to start with
Docker.

What is Docker?

Today, “Docker” might refer to the company, Docker Inc., or


the suite of tools that they package in their Docker Desktop
application for Mac and Windows. But all of that is built
around Docker Engine: the application that builds the
sandbox walls and passes messages from the processes inside
to the kernel. When we refer to Docker in this book, we’re
talking about the container engine. It sets up structures like
control groups that isolate containerized processes from the
rest of the system (and, at least initially, from one another).

Today, there are many alternative technologies available,


including our own Mirantis Container Runtime. Often, these
are designed with additional functionality—Mirantis
Container Runtime, for example, provides features for
enterprise security and compliance—and are built on the
same open-source bones as Docker Engine.

For the purposes of this book, we will use Docker Engine,


which is easy to install and includes everything you need to
get started. But as we’ll see, the lessons we learn about Docker
will carry forward into the wider container ecosystem.

7
How to install Docker

In order to install Docker Engine on your system, simply


navigate to the download page for your operating system:
Linux, Mac, or Windows.

If you’re on Mac or Windows, you’ll be downloading Docker


Desktop, a suite of tools packaged with a graphical user
interface for launching and managing containers. You’ll need
to have this running as you work through the exercises in this
book. Docker Desktop is not yet available on Linux; instead,
Linux users will simply install the container engine.

Linux users: Under the “Server” heading on the download


page, choose your Linux distribution and follow the
instructions to install via the relevant package manager,
install manually, or install from binaries. If you’re not sure
which to choose, I recommend using a package manager.

Windows users: In addition to Docker Engine, you will need


a Unix-like terminal. If you don’t already have a favorite tool
for this purpose, Git Bash is a simple solution.

That’s it for now. In the next chapter, we’ll start working with
Docker to create, observe, and delete containers.

8
Chapter 2: Creating, Observing, and Deleting
Containers

In the last chapter, we learned how containers work and


installed a container engine. Now that we have Docker
installed, let’s create a container. In your terminal, enter:

% docker container run alpine echo Hello World

This command will call up Docker and ask it to create a new


container with container run. (Note: throughout this book,
we will indicate terminal commands with the % sign. This is
not part of the command, but a marker that the line in
question is terminal input rather than output.)

We’re telling Docker to use a container image (more about


those in our next chapter) called alpine, which gives our
container the binaries and libraries of a very lightweight
Linux distribution as its foundation.

The next parameters specify a process to run within our new


container: the system command echo, which outputs strings.
Hello World is an argument passed to echo—the string we
would like to output.

When we run this command, Docker should download the


alpine image and then run the process, producing terminal
output like this:

9
Unable to find image 'alpine:latest' locally
latest: Pulling from library/alpine
59bf1c3509f3: Already exists
Digest:
sha256:21a3deaa0d32a8057914f36584b5288d2e5ecc9843
80bc0118285c70fa8c9300
Status: Downloaded newer image for alpine:latest
Hello World

Those using Docker Desktop may open the application and


find the new container represented (with a randomly
generated name) in the graphical user interface.

Now let’s get a little more advanced and create a container


with a continuously running process. We’ll use the same basic
command structure as before, but this time we’ll run the ping
command inside our container:

% docker container run alpine ping localhost

We’ll notice a few differences this time. First, we already had


the alpine image on our machine, so the container started
immediately. Second, the command doesn’t finish the way
echo did. The ping process is ongoing, continuously checking
network connectivity. We can stop the process by pressing
CTRL+C.

Observing containers

After stopping the process, let’s take a look at the containers


on our system using the terminal. We can already see this in

10
Docker Desktop if we’re on Mac or Windows, but it’s useful to
be able to bring up this information quickly in the terminal.

% docker container ls -a

Since we used the -a flag, this will list all our containers,
whether they are running or not—we should see both our
echo and our ping containers, along with some useful
information such as their names and container IDs. Make a
note of the ping container’s ID. Mine, for example, is
5480aa85d1c5.

Now let’s restart our ping container. Using the start


command will run the process separately from our current
shell, so we can enter other commands while the container
process runs. You’ll replace 5480aa85d1c5 below with the ID
of your container.

% docker container start 5480aa85d1c5

The terminal will return the container ID to let us know that


it’s running. We can also use the ls command without the -a
flag:

% docker container ls

This shows us only running containers. Our ping container


should be listed here, but not the echo container.

Before we finish up, let’s inspect our container a little more


closely to better understand what’s happening under the
hood. If we use the top command with a running container

11
ID, we’ll get information about the processes running within
the container from the perspective of the host system:

% docker container top 5480aa85d1c5

This gives us output looking something like this:

UID PID PPID C STIME TTY TIME CMD


root 3509 3481 0 16:17 ? 00:00:00 ping localhost

The second column, PID, indicates the process identifier


number for our containerized ping. This is the number the
host machine’s operating system kernel is using to keep track
of processes running on the machine, and in my case we can
see that it is one of thousands. So we see that the ping
process is using the host OS kernel, and we see how the
kernel perceives the process: number 3509 in a long list.

But we said before that one way containers isolate themselves


is through kernel namespaces: the way processes signify
themselves relative to the kernel within the container.
Metaphorically, we can think of this as the way the process
sees itself versus the way the wider world sees it. Let’s take a
look at the container from its own perspective:

% docker container exec 5480aa85d1c5 ps

This gives us information on the processes running within the


container from its perspective. In the container’s kernel
namespaces, the ping process is PID 1. We’ll also see a
listing for the ps command we just ran within the container.
And that’s it!

12
The ping running as PID 1 within the container is the very
same process as the one signified by PID 3509 on the wider
system, but because the container has its own isolated kernel
namespaces, its system doesn’t see the wider world outside.

Removing containers

Now let’s clean up. We can stop our running container (again,
replacing the numeric ID with your own) using:

% docker container stop 5480aa85d1c5

Now we can clean up using:

% docker container rm 5480aa85d1c5

Removing unused containers is an important piece of system


hygiene. Make sure you remember to remove the echo
container as well—you can use the ls command with the -a
flag to retrieve its ID.

In the next chapter, we’ll take a closer look at container


images.

13
Chapter 3: Building Container Images from
Dockerfiles

In the last chapter, we practiced creating, interrogating, and


deleting containers. When we created a new container, we
built it on the foundation of a container image—now it’s time
to explore exactly what that is, how it works, and how we can
build images from Dockerfiles.

What is a container image?

You can think of a container image as a photograph—a


snapshot of a container’s filesystem state, frozen in time.

Previously, when we wanted to create a new container, we


started with a container image called alpine. This was a
snapshot of a filesystem with only the bare-bones binaries
and libraries of a Linux distribution called Alpine Linux, and
nothing else. This open source Linux distribution is
well-suited to containerization because it is so lightweight: it
packs only what is needed to spin up containers quickly and
efficiently, so if we want to quickly create a container running
a new process, the alpine image is a great choice.

That gives us a nice glimpse of container images’ utility. To


get a process running on an isolated filesystem, we didn’t
have to install and configure a whole new system. Instead,
with a single command, we conjured a prefabricated base
image.

As you’ve probably already guessed, container images can


provide a lot more than empty canvases. If we can capture
14
filesystem states in an image, we can capture states with more
complex prerequisites, configuration options, and processes
ready to go. This allows us to…

● Trivially replicate containers running complex


processes

● Shape more advanced “building blocks” for


development

● Create a container image repository that acts as a


“single source of truth” for software modules used
throughout an organization

... and much more besides. As we’ll see over the course of this
book, the modularity fostered by container images can
transform the way you develop and deploy software.

Using Dockerfiles to create container images

Sometimes, you’ll want to create a container image based on a


container on your system—like taking a live snapshot. We’ll
explore that technique soon, but for the purposes of this
chapter, we’re going to focus on a different approach: using a
Dockerfile.

A Dockerfile is a set of instructions for creating a container,


all set down in a simple text file. Here, we can specify not only
a base image to start from, but applications to install and
processes to run. That allows us to perform much more
complex tasks with easily replicable “recipes.”

15
Let’s see what this looks like in action. Make sure your
container engine is running, and then bring up the terminal.
Try entering the following command:

% docker container run alpine curl google.com

Given the results of our last exercise, we might expect this to


create a new container based on the alpine image and then
run the curl tool (which transfers data from—or to—a server)
with the parameter google.com. With that parameter, we
would expect to see some HTML downloaded from the
address we specified.

Instead, we likely get output that looks something like this:

docker: Error response from daemon: OCI runtime


create failed: container_linux.go:380: starting
container process caused: exec: "curl":
executable file not found in $PATH: unknown.

Translation: our new container doesn’t know what we’re


asking it to do, because it doesn’t have curl installed.

Well, we can take care of that with a Dockerfile. Create a new


folder called curler and inside of that folder, create an empty
file called Dockerfile, which shouldn’t have a file extension.
(If you’re not sure how to do this, use the command touch
Dockerfile in your folder and open the file with the editor of
your choice.)

Now add the following lines to Dockerfile and save:

16
FROM alpine:latest
RUN apk update
RUN apk add curl

This recipe instructs the container engine to build an image


based on the most recent version of the alpine image, and
then to run alpine’s package manager apk, update apk’s
index of available packages, and then add the package for
curl. In other words, we’re installing the curl tool.

Let’s put our Dockerfile into action by creating an image from


these instructions. Run the following command:

% docker image build -t curler .

This command tells the Docker engine to build a new image,


and -t is shorthand for --tag, which we’re using to name our
image curler. Finally, the period at the end gives Docker the
location of the Dockerfile we’re using for this image—in this
case, our current working directory.

The container engine then builds an image based on our


instructions (actually, a series of images in swift succession
based on each step) and adds it to our local selection of
images. If we run…

% docker image ls

…we should see it in our image listing. The images listed will
vary depending on your environment, but you should see
curler among them, and the output should look something
like this:

17
REPOSITORY TAG IMAGE ID CREATED
curler latest a46b2fdd95c9 1 minute ago
nginx latest 605c77e624dd 6 weeks ago
alpine latest c059bfaa849c 2 months ago
...

Now let’s try using it. Run:

% docker container run curler curl google.com

We’re trying to do the same thing as before—run curl with


the parameter google.com. This time…

<HTML><HEAD><meta http-equiv="content-type"
content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="http://www.google.com/">here</A>.
</BODY></HTML>

Success! We downloaded some very simple placeholder


HTML from the address google.com.

That’s it for this chapter—remember to clean up your


containers. You may also wish to delete the new curler
image from among your local images. To do this, you should
first delete any containers depending on it, and then run:

% docker image rm curler

Next time, we’ll dive deeper into the use and management of
images.

18
Chapter 4: Using an Image Registry

In the last chapter, we learned how to create container images


from Dockerfiles. This time, we’ll practice using a public
registry to store and share our container images.

Digging deeper into registries

Here’s a secret: we’ve been quietly using an image registry all


along. Whenever we’ve built on top of container images like
alpine, the Docker engine has downloaded those images
from Docker Hub, a public registry managed by Docker, Inc.
It can seem like magic—the software we want appears almost
as quickly as we can summon it by name!

But what do we mean, exactly, when we say that Docker Hub


is a public image registry? In short, it’s a repository (or
collection of repositories) that anyone can access to download
or upload container images.

In our first chapter, we noted that Docker Hub isn’t the only
container image registry. There are public registries managed
by other entities, and it is also possible for organizations to
create private registries using tools like Mirantis Secure
Registry. Anyone who needs to establish a secure software
supply chain will need to be able to trust the provenance and
contents of their container images, and should therefore use a
private registry of some form.

The swiftness, accessibility, and built-in nature of Docker


Hub make it a natural choice for learners, but it’s a good idea
to cultivate security-consciousness and other best practices
19
from the outset. One way to do this is to investigate images
you intend to use through Docker Hub’s web interface at
https://hub.docker.com/. There are several labels here that
can help you to identify vetted images.

Let’s take a look at alpine, for example, at


https://hub.docker.com/_/alpine.

This has the Official Image label, meaning it is part of a set


of images curated and published by Docker. These are very
commonly used images that most beginners will want to
use—verified as the upstream official version and
exemplifying container best practices in design and
documentation. Many are OS base images like alpine or
ubuntu, but there are also images for popular languages like
Python, Go, or Node; data stores like MySQL or Redis; web
servers like NGINX; and much more.

20
Docker Hub also provides another label for a Verified
Publisher. Docker has confirmed that images with this label
are published and maintained by the entities that produced
them. For example, users of Amazon Web Services (AWS) can
download a container image for their command-line interface
(CLI) that is confirmed to come from Amazon.

There is no such thing as perfect security—by accident or


malice, vulnerabilities can creep into even official
images—but as you get started using containers, develop a
habit of asking questions about the provenance of your
images and seeking out validated sources. The catalog of
Docker images that are either designated Official Images or
from Verified Publishers is a good place to start.

Exercise: Uploading our first container

If you haven’t done so already, you’ll need to sign up for a


Docker ID. If you’re using Docker Desktop on macOS or
Windows, this is the same ID you created to log in. (If you
don’t have an ID yet, you can sign up for one at
https://hub.docker.com/.)

On the command line, type:

% docker login

You’ll be prompted for your username and password. Once


you've successfully done that, you’re ready to get started.

First, we’ll create a new container based on the official Python


image and enter an interactive session with a bash shell:

21
% docker run -it python bash

Now we should be working in a bash shell within our new


container. Here, let’s use the apt package manager to
download the nano command line text editor.

% apt update
% apt install nano

Now we’re going to write a simple Python program within our


container. Use nano to create and open a new Python file:

% nano d6.py

In this file, we’re going to write a simple Python program that


produces a random integer between 1 and 6, inclusive. In
other words, we’re writing a program that rolls a virtual die!
Don’t worry if you’re unfamiliar with Python—you can simply
copy and paste the code below into your file:

#import module
from random import randint

#assign a random integer between 1 and 6,


inclusive, to a variable
roll = randint(1, 6)

#print the variable


print(roll)

Press CTRL+O to write to the file, enter to confirm, and then


CTRL+X to exit nano. You may wish to test our new program
by running:
22
% python d6.py

In your container’s bash shell, you’ll receive a randomized


result from 1 to 6.

% python d6.py
2

Without exiting the container, open another command line


session on your host machine. (In Terminal on macOS, for
example, this is a simple matter of pressing Command+T to
open another tab.) Your Python container should still be
running, so you can enter…

% docker container ls

…to retrieve its container ID.

CONTAINER ID IMAGE COMMAND CREATED STATUS


<Your ID here> python "bash" 1 hour ago Up 4 minutes

Now we’re going to commit the current state of our container


to a new image—including the Python program we just wrote.

% docker commit -m “First commit” <container ID>


d6:1.0

Let’s take a moment to walk through this commit step by step.


The -m flag enables us to append a short descriptive message;
this is a good practice for recording the headline changes
made within a commit, whether you’re working on a team or
leaving breadcrumbs for yourself or others in the future.
23
Next, you’re specifying the container ID that forms the basis
of your commit.

Finally, you’re naming your new image—in this case,


“d6”—and tagging it with a version number (here, that’s 1.0).

The commit has created a new image on our local machine.


We can see it on our system with…

% docker image ls

The top of your image listing should look something like this:

REPOSITORY TAG IMAGE ID CREATED


d6 1.0 <Your ID here> 1 hour ago

Let’s take that one step further and upload our new image to
Docker Hub. We’ll start by tagging our image for upload, and
then push it online:

% docker tag d6:1.0 <Your Docker ID>/d6:1.0


% docker push <Your Docker ID>/d6:1.0

The command line output will show you each of the layers
being pushed:

24
The push refers to repository [docker.io/<Your
Docker ID>/d6]
e06d6e649287: Pushed
51a0aba3d0a4: Mounted from library/python
e14403cd4d18: Mounted from library/python
8a8d6e9f7282: Mounted from library/python
...

Now your containerized die-rolling app is available for


anyone to download as an image through Docker Hub. You
should see it when you navigate to:

https://hub.docker.com/u/<Your Docker ID>

In this chapter, we’ve created a very simple “static” app—it


produces output, but doesn’t have to store any data for later
use. Next time, we’ll explore how to handle persistent data
stores with containers.

25
Chapter 5: Volumes and Persistent Storage

In the last chapter, we learned how to use and share container


images using a public registry such as Docker Hub. This time,
we’re going to learn how to introduce persistent storage to
our containerized applications.

Persistent storage for ephemeral containers

When technologists talk about why containers are useful for


large-scale enterprise applications, they will often describe
containers as “ephemeral”—a word that means short-lived,
here and then gone.

At this point, we should have a firmer sense of why the


ephemerality of containers is useful—we can quickly and very
efficiently spin up a container as needed, only transferring
data for image layers not shared by other containers. This
quality enables many of the systems we now describe as
“cloud native,” including container orchestrators like
Kubernetes.

But in the midst of all this transiency, there is a complication:


most applications store persistent data for later use. Even
simple websites—which can be built on static web servers
with no need for persistent storage—are often created with
content management systems like WordPress, and those
content management systems use databases to save user
logins, draft posts, and other data.

If we want to containerize an application such as WordPress,


we need a way to create a persistent data store that is
26
independent of any given container, so our ephemeral
containers—created and destroyed as needed—can access,
utilize, and update the same data.

With Docker, those persistent data stores are called


volumes. By default, volumes are directories located on the
host filesystem, within a specific directory managed by the
Docker engine—but they can also be defined in remote
locations such as cloud storage.

What about bind mounts?

Docker provides another way for containers to access data on


the host filesystem: bind mounts. This allows the container
to read and write data anywhere on the host filesystem. This
can be useful for experimentation and development on your
local machine, but there are many reasons to prefer volumes
for general usage: they are manageable with the Docker
command line interface, they have a more precisely defined
scope, and they are more suited to cloud native architectures.
For these reasons, we will focus on volumes here, but you will
see an example of bind mounts in our final chapter.

A given volume may be mounted to multiple containers at


once, in either read-write or read-only mode, and will persist
beyond the closure of a particular container. If you’re using
Docker Desktop on macOS or Windows, you will find that
volumes have their own management tab.

27
Now let’s try creating a volume and mounting it to multiple
containers.

Exercise: Opening the first volume

To start, we’ll create a new volume:

% docker volume create d6app

With this command, we’re creating a new volume and giving


it the name d6app.

Next, let’s create a new container from the d6 image we made


in the last chapter. As we create this new container, we’ll
mount the volume we just created.

% docker run --name d6v2 -it -v d6app:/d6app


<Your Docker ID>/d6:1.0 bash

The syntax here can be a little confusing at first glance—let’s


take a moment to break it down:

● We’re executing docker run as usual, specifying the


name d6v2 for our new container.

● The -it tag gives us an interactive shell.


28
● The -v tag identifies and mounts a volume to the
container.

○ The first instance of d6app (before the colon)


specifies the volume to mount—in this case,
d6app, which we just created.

○ /d6app tells us where we will be able to find the


contents of this volume in our new container: a
directory called d6app. We could have called
this directory anything, but in this case, we’re
simply using the same name as the volume.

● Next, we’re specifying the source of the container


image.

● Finally, we’re opening a bash shell.

Now we should be working in a bash shell within our


container. Let’s take a look at our container filesystem with
the ls command. We should see the directory where the
d6app volume is mounted:

% ls
bin boot d6.py d6app dev etc home lib
lib64 media mnt opt proc root run sbin
srv sys tmp usr var

If we change directory and inspect the contents, we should


find it empty.

% cd d6app
% ls
<nothing>
29
While we’re here, we’ll create an empty text file:

% touch d6log.txt

Now we'll go back into the container’s root directory and open
the Python app we wrote last chapter. (We won’t need to
download nano this time—it’s part of the image now.)

% cd ..
% nano d6.py

Let’s add some functionality to our die-rolling app. It’s all well
and good to get randomized die rolls, but wouldn’t it be nice if
we could record those rolls, so we can keep a log of our
amazing (or terrible) luck? Update the d6.py file contents to
look like this…

30
#import module
from random import randint

#open the storage file in append mode


outfile = open('/d6app/d6log.txt', 'a')

#assign a random integer between 1 and 6,


inclusive, to a variable
roll = randint(1, 6)

#print the variable


print(roll)

#convert the value to a string and write to the


file
rollstr = str(roll)
outfile.write(rollstr + '\n')

#close the file


outfile.close()

When we run d6.py now, the program should open the text
file in the d6app volume, convert the randomized output to a
string, and record that string (and a line break) for posterity.
Let’s test it out.

% python d6.py
5

Within the file, we should find our recorded result.

31
% nano d6app/d6log.txt

Try running the app a few more times and check the file
again.

32
But what happens when we stop the container? Well, first
let’s commit and push this updated version of the d6 app to
Docker Hub. With the container still running, open another
terminal session and enter:

% docker commit -m “Updating to v2” d6v2 d6:2.0


% docker tag d6:2.0 <Your Docker ID>/d6:2.0
% docker push <Your Docker ID>/d6:2.0
The push refers to repository [docker.io/<Your
Docker ID>/d6]
3280116fcd01: Pushed
3b2cfa75b8bf: Layer already exists
128b344cad66: Layer already exists
88597958b14e: Layer already exists
db26989c2f90: Layer already exists
a3232401de62: Layer already exists
204e42b3d47b: Layer already exists
613ab28cf833: Layer already exists
bed676ceab7a: Layer already exists
6398d5cccd2c: Layer already exists
0b0f2f2f5279: Layer already exists

Now we’re going to start a new container with nearly the same
command that we used at the beginning of this chapter:

% docker run --name d6test -it -v d6app:/d6app


<Your Docker ID>/d6:1.0 bash

Here, we can see that we’re starting a new container based on


the version 1.0 image of the d6 app—before we made any
changes today. We’re mounting the d6app volume, as before,
and opening an interactive shell session.

33
Read-only?

This is a good place for us to pause and note that not all
containers need to mount volumes with read-write access,
and as a general rule, we don’t want to give containers any
more privileges than they require. To mount a volume with
read-only access, we simply append :ro to the name of the
volume directory within the container. For the command
above, this would look like: d6app:/d6app:ro

If we open the d6.py file, we’ll find it unchanged. This is the


old 1.0 image, after all. But if we open the d6log.txt file in
the mounted d6app volume…

% nano d6app/d6log.txt

Our file has persisted, carrying the data across multiple


containers. We can make a manual edit to the file—typing
“test,” say—and then return to the first container. Here, let’s
34
run the Python app again and then check the log file within
our initial and most current container:

% python d6.py
% nano /d6app/d6log.txt

With persistent storage, we’ve found a way to create some


continuity between disparate containers. In the next chapter,
we’ll go a step further and dive into the fundamentals of
container networking, so we can make multiple containers
work together in real time.

35
Chapter 6: Container Networking and
Opening Container Ports

Last time, we containerized an application with persistent


storage, giving us an essential ingredient for building complex
applications. Now that we can create more powerful and
complicated apps inside our containers, it’s time to explore
how we can make those apps accessible to the outside world.

Isolated but accessible

By design, containers are systems of isolation—but we


typically don’t want the functionality of an application to be
walled off. We want our apps to be isolated but accessible to
outside requests.

So far, when we’ve wished to interact with the contents of a


container, we’ve either started an interactive shell session to
work inside the container itself, or we’ve observed output that
Docker has passed from the container to the terminal.
Unfortunately, these methods won't be very helpful for web
applications with graphical user interfaces (GUIs) accessed
through the web browser.

Besides, we’d like to do more than send information to the


host machine—we want our apps to be able to interact with
the outside world! That means we need to understand
container networking.

36
What is container networking?

Container networking is the system by which containers are


assigned unique addresses and routes through which they
may send and receive information. Containerized applications
may need to communicate with…

● One another (container-to-container)

● The host machine

● Requests from outside the host machine

In each case, many of the fundamental concepts are the same.


First, Docker assigns each container an Internet Protocol (IP)
address—if we want to find the IP address for a container, we
can use the command:

% docker inspect --format='{{range


.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
<container name/ID>

With the inspect command, we’re asking Docker for


information—stored in a JavaScript Object Notation (JSON)
array—about a container instance. The --format argument
helps us specify particular details we would like to retrieve: in
this case, the IP address. By default, Docker starts out using a
range of addresses beginning 172.17.0… If you inspect a
running container, you will likely find an address such as:

172.17.0.2

37
Containers’ IP addresses are created on a local subnet. That
means initially, the assigned IP addresses will only “make
sense” to one another: you can’t access them—using those
addresses, at least—from another machine or even the host
machine.

We can take a high-level look at the Docker network


environment using the docker network ls command. Our
output will look something like this:

NETWORK ID NAME DRIVER SCOPE


099f55813274 bridge bridge local
e9c31cf63c20 host host local
eb6d55a56ee3 none null local

We should find multiple networks here. The host network


and none network are part of the Docker network
stack—machinery that makes Docker run, but that we won’t
interact with directly. The bridge network, however, is where
the action happens: this is the container network where our
container IP addresses are located by default. The bridge
network—sometimes called the docker0 bridge—is
configurable and, most importantly for our purposes, it’s the
container network where our applications live.

38
Wait—I want to create my own container network!

Docker enables you to create highly configurable networks for


a range of use cases with the docker network create
command—and indeed, user-defined networks are an
essential tool. The default bridge network disables Domain
Name System (DNS) service discovery—meaning containers
on this network have to communicate with one another by
their specific IP addresses rather than names. That has big
implications for scalability. For the purposes of this exercise,
we’ll be staying on the default bridge, but user-defined
networks are the preferred method for connecting
multi-container apps, and we’ll be exploring them in the
following chapters.

Web services running on a given container will send and


receive information through a particular port inside the
container. These ports—just like naval ports—are places
where journeys begin and end. Ports are defined by a series of
numbers appended (after a colon) to an IP address. The
designation below would refer to port 8000 for a particular IP
address:

172.17.0.2:8000

Now let’s try observing this in practice—and taking it a step


further.

Exercise: Port mapping

Let’s create a new container based on Docker Hub’s official


image for the NGINX web server:
39
% docker run --name nginx-test -d nginx

The -d argument means we’re running this container in


“detached mode”: the process is detached from our terminal
session and running in the background. We can verify this
with…

% docker container ls

…which should return something like this:

CONTAINER ID IMAGE ... PORTS NAMES


<Container ID> nginx ... 80/tcp nginx-test

Note the port: NGINX is running on port 80 within the


container. Now, if an application was running on port 80 on
our host machine, we could access that by navigating to
localhost:80 in our web browser. Let’s try that now.

40
Hmm. Well, what if we look up the IP address of the
container and try to access it that way?

% docker container inspect --format '{{


.NetworkSettings.IPAddress }}' nginx-test
172.17.0.2

41
Your browser will try to load the address, but to no result.

All right, let’s stop the container, which is currently still


running in the background, then delete the container so we
can start from scratch.

% docker stop nginx-test


% docker rm nginx-test

What’s the problem here?

We aren’t able to access the port because the container’s


network address is still isolated from the host machine.
Fortunately, we can bridge the gap by “publishing” the port.
(Sometimes people refer to this as “port mapping” or “port
binding.”) If you’re familiar with the way a virtual machine

42
can connect to external networks through virtual ports, a
similar idea is in play here.

We’ll make our NGINX container accessible from the host


machine by connecting the container port to a port on the
host machine. Docker provides a powerful range of options
here, but for the time being, we’ll keep things simple and
connect port 8000 on our host machine to port 80 on the
nginx-test container.

% docker run --name nginx-test -d -p 8000:80


nginx

The -p argument helps us specify that we want to use the host


machine’s port 8000 (on the left side of the colon) to access
the container’s port 80 (on the right). The syntax here might
remind you a bit of how we connect volumes to directories
within a container.

After running the command above, we can test whether it


worked by navigating to localhost:8000...

43
Success! Now we can access the containerized application on
our host machine. From here, we could serve it to the outside
world with the right configuration, though we would likely
use a system like a container orchestrator to deploy our apps
to production. We will discuss those systems in more detail in
the final chapter.

In the meantime, stop (and if you wish, remove) the container


we created today.

% docker stop nginx-test


% docker rm nginx-test

Next time, we’ll combine what we’ve learned so far to run a


complex web application with persistent volumes and
published ports.

44
Chapter 7: Running a Containerized App

In our last chapter, we defined some core concepts in


container networking and learned how to publish container
ports, opening a container’s services to traffic from the
outside world. This time, we’re going to bring together most
of what we’ve learned so far to run a containerized web app
with persistent volumes and published ports: in this case, a
knowledge-sharing wiki platform.

Bringing the pieces together

Wikipedia is built on an open source platform called


Mediawiki, and an official Docker image for the application is
maintained on Docker Hub. That means it should be easy to
get our own wiki running quickly—in less than five minutes,
even!

The default architecture for this deployment is very


straightforward: a single container for the application, which
can either write data to a volume all on its own—the default
configuration—or work in tandem with a second container
running a production-grade database—the option you would
likely use if you were pushing the wiki live for real-world use.

In this exercise, we’re going to start with a single-container


configuration—but we won’t stop there. Next time, we’ll also
explore how to link containers for a more production-ready
configuration.

But we’re getting ahead of ourselves. For now, let’s focus on


getting our wiki up and running!
45
Exercise: A wiki as a containerized app

Let’s start by creating a new volume that will store the


persistent data for our wiki:

% docker volume create wiki-data

Now we’ll run a Docker container based on the official


Mediawiki image, publish a port so we can access the
application on our host machine, and add our volume.

% docker run --name solo-wiki -v


wiki-data:/wiki-data -p 8000:80 -d mediawiki

Now when we visit localhost:8000 in our web browser, we


find the app still needs to be set up. Click “Set up the wiki.”

46
Choose your language and Continue.

After passing environmental checks, click Continue.

47
On the database configuration screen, choose SQLite and
specify the directory linked to our volume for the data
directory—in this case: wiki-data/

Then click Continue.

Now you’ll need to name the wiki and create a username and
password for the administrator account. From here, you can
go ahead and complete the installation. You should get a
congratulations screen which will automatically download a
file called LocalSettings.php to your machine. If it doesn’t
do this automatically, click the link.

48
This file takes many of the settings you’ve chosen—the
database type, server, and credentials, for example—and
associates them with variables used by the larger app.

Now we need to put the LocalSettings.php file we’ve just


downloaded onto our host machine and into the base
directory for our wiki installation. There are a number of
different ways we could do this—for now, let’s hop into the
running container, peek under the hood, and add the file
manually. We’ll open a shell session with:

% docker exec -it solo-wiki bash

Note where we’ve landed and have a look around…

49
% root@e9537d7c33e3:/var/www/html# ls -1a
.
..
CODE_OF_CONDUCT.md
COPYING
CREDITS
FAQ
HISTORY
INSTALL
README.md
RELEASE-NOTES-1.37
SECURITY
UPGRADE
api.php
autoload.php
cache
composer.json
composer.local.json-sample
docs
extensions
images
img_auth.php
includes
index.php
jsduck.json
languages
load.php
maintenance
mw-config
opensearch_desc.php
resources
rest.php
50
skins
tests
thumb.php
thumb_handler.php
vendor
wiki-data

This is where we want to put our LocalSettings.php file.


One way we can do this is to open the file in a text editor on
our host machine, copy the contents, then create a new file in
the container:

% touch LocalSettings.php

Download nano:

% apt update
% apt install nano

And paste in the contents. Once we’ve saved the file with
CTRL+O and exited to the shell with CTRL+X, we can delete
nano with apt remove nano. (This is being very assiduous
about optimizing the footprint of our image, but it’s a good
habit, and even small differences add up at scale.)

Now we’ve made some changes to the container filesystem


itself—changes outside the persistent volume–so in a new
terminal tab, we should commit the changes to a new image:

% docker commit solo-wiki solo-wiki

Here we’re committing the state of the container named


solo-wiki to a new image, also called solo-wiki. With the
51
image saved locally, we can stop and delete the container
we’ve been working with:

% docker container stop solo-wiki


% docker container rm solo-wiki

Now let’s create a new container from the image we just


saved, using a command very similar to the first wiki
container we ran:

% docker run --name solo-wiki -v


wiki-data:/wiki-data -p 8000:80 -d solo-wiki

The only thing that will be different this time is that we’re
creating our new container from the solo-wiki base image
we just committed, with a LocalSettings.php file
included–and our volume already has configuration data
inside.

When we navigate to localhost:8000 now, we should have a


fully configured wiki:

52
I recommend logging in with your administrator account and
making changes to the main page. If you stop and restart the
container…

% docker container stop solo-wiki


% docker container start solo-wiki

…you should find that your changes have persisted.

Now we have a functional containerized app running from a


single container. Next time, we’ll continue our survey of
container fundamentals by exploring a multi-container
configuration for the app.

53
Chapter 8: Multi-Container Apps on
User-Defined Networks

Last time, we ran a web app–the open source wiki platform


Mediawiki–from a container in a single-container
configuration, with a persistent volume and a container port
published to the host machine. That gave us a functional
development environment, from which we could modify and
build on the application.

But cloud native deployments often consist of applications


spread across multiple containers. How can we connect the
constituent parts of a single application once they’ve been
containerized? In this tutorial, we’ll learn how to let
containers talk to one another by deploying Mediawiki in a
multi-container configuration on a user-defined network.

User-defined networks

When containers reside on the default bridge network


together, they should in theory be able to communicate with
each other by name via Domain Name System (DNS)–but
they can’t. Instead, those containerized apps need to know
one another’s specific IP addresses to transmit data back and
forth.

This is a deliberate restriction on the default bridge network.


But why? Well, simply by virtue of being default, the docker0
bridge is apt to be a busy place, full of Docker containers
without any necessary relationship to one another—without
any need to communicate. That could be a security risk in a
system predicated on isolation. So on the default bridge,
54
Docker makes containers jump through a few extra hoops to
communicate effectively.

When we have a group of containers that need to


communicate, instead of using the default bridge we can place
them in their own user-defined network. While this isn’t the
only way to let containers communicate, it is the
Docker-preferred way of doing things, since this creates a
precisely scoped layer of isolation. We can create a
user-defined network with the command:

% docker network create <network name>

The -d (or --driver) argument for this command specifies a


model for the new network: bridge, overlay, or a custom
driver option added by the user.

● Bridge networks allow containers within the


network—all of which must be on the same Docker
daemon host—to communicate with one another while
isolating them from other networks.

● Overlay networks allow containers within the


network–which may be spread across multiple Docker
daemon hosts–to communicate with one another while
isolating them from other networks. This driver is used
by the container orchestrator Docker Swarm.

● Custom drivers allow for custom network rules.

The bridge driver is the default, so if you don’t specify a


driver, Docker will create a bridge network. The bridge driver
is what we’ll be using in this chapter.

55
What about linking?

Another way to name-based communication between


containers on the default bridge is container linking, which
involves creating manual links between containers using the
--link argument. This was once the standard technique for
connecting containers, and you’ll still see it used in the docs
for many images. But Docker considers this a legacy option,
which means that it is not recommended and may be disabled
in the future; linking has been superseded by user-defined
networks.

Exercise: A multi-container wiki using container


networking

First, let’s create our new user-defined network. We’ll


explicitly specify the bridge driver and name the new network
wiki-net, so we can identify its role at a glance.

% docker network create -d bridge wiki-net

Now, our new implementation of Mediawiki is going to be


broken into two containers:

1. the application itself, and


2. a MySQL database

Last time, we mentioned that our single-container


configuration of Mediawiki using the SQLite database was
best-suited to a development environment rather than
real-world production deployment. But why is that? Why are
we bothering with a multi-container configuration? For the
56
answer, we should briefly consider the strengths and
weaknesses of our database options.

● SQLite is designed to be lightweight, portable, and


easily embedded within an app. It’s principally a tool
for local data storage. Learn more at
https://www.sqlite.org/index.html

● MySQL needs a container of its own to serve the


database; it has a heavier footprint, and is designed to
handle many simultaneous requests rather than
embedding directly with an app. Learn more at
https://www.mysql.com/

SQLite is great for development: we can set it up easily and it


will run simply and quickly. But it’s not built for large,
scalable datasets intended to grow indefinitely and to be
queried concurrently by many different users. That’s a
different use case, and so it calls for a different tool.

Neither database is “better” or “worse” than the other; they’re


not even really comparable, because they serve different
purposes. And those different purposes directly inform the
container pattern we adopt. This might seem like a simple
point, but it’s easy to forget and will constantly guide our
approach in cloud native development: our solutions should
be determined by our problems and contexts.

Now, let’s create our containerized MySQL database:

% docker run --name wiki-mysql --network=wiki-net


-v wiki-data:/var/lib/mysql -d -e
MYSQL_ROOT_PASSWORD=root mysql

57
That’s a pretty hefty docker run command, so let’s break it
down. We’re…

● Naming the container wiki-mysql

● Using the --network argument to assign the container


to our new wiki-net network

● Mounting the wiki-data volume (the same one from


the previous exercise—if you deleted that volume, you
may need to recreate it) and assigning it to the
directory in the MySQL container where it expects to
be able to save persistent data

● Running in detached mode, as discussed in Chapter 6

● Using the -e argument to specify an environment


variable—in this case, a root user password for the
database. (Never use a password like “root” in
production, but we’ll use it here for the sake of
replicability.)

● Building from the official Docker Hub image for


MySQL

You might find it interesting to launch an nginx container on


the default bridge and compare its IP address to that used by
our MySQL container on wiki-net:

58
% docker run --name nginx-test -d nginx
675eeead7df8d23fbb388826c58403223fd64cf21b9d44917
dfb38091d1b6e7f
% docker inspect --format='{{range
.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
nginx-test
172.17.0.2
% docker inspect --format='{{range
.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'
wiki-mysql
172.19.0.2

Here our networks are differentiated at the second decimal.


Further containers on wiki-net will have addresses in the
172.19… range.

Remove the nginx-test container if you created it, and make


sure you don’t have any extraneous containers running with
docker container ls. All you should see is wiki-mysql.

Now let’s get the wiki app itself up and running:

% docker run --name wiki-app --network=wiki-net -v


wiki-data:/wiki-data -p 8000:80 -d mediawiki

We’re running the app on the same network as the database,


and we’re still mounting a volume, which the app will use for
some configuration data. When we navigate to
localhost:8000, we should see the same set-up screen as last
time.

59
Move forward to the Connect to database screen just like
you did previously, and now select “MariaDB, MySQL, or
compatible.”

For Database host, you can simply enter the name of the
database container—in this case, wiki-mysql. If these
containers are restarted, they’ll still be able to interact with
one another as configured, even if those future instances have
different IP addresses.

For Database name, you can choose any name. You don’t
need to enter anything for the table prefix, and for the
Database password, you’ll enter the password we set via
environment variable (same as username, “root”) when we
created the database container.

60
On the next screen, click Continue.

61
Finalize your administrator information, and then go through
the installation process.

Like last time, you’ll need to download the generated


LocalSettings.php file and include it in the base directory
of the wiki app. (You can review the instructions from the last
exercise if you need some reminders on this process.) Then
we’ll commit our updated image and start a new container.

% docker commit wiki-app wiki-app


% docker container stop wiki-app
% docker container rm wiki-app
% docker run --name wiki-app --network=wiki-net
-v wiki-data:/wiki-data -p 8000:80 -d wiki-app

Now we have a multi-container app with a production-grade


database running on a user-defined network according to
Docker best practices.

62
If we want to deploy this app repeatedly or at scale, we might
wish to go through a little less manual configuration. In our
final chapter, we’ll learn how to streamline the deployment of
multi-container applications.

63
Chapter 9: Docker Compose

In the last chapter, we deployed a web application in a


multi-container configuration on a user-defined network. The
process wasn’t too difficult—but it could be streamlined.
Fortunately, Docker includes tools for simplifying
multi-container deployments.

Exercise: The Composer

When we set up our multi-container deployment of


Mediawiki using MySQL, we needed to manually configure a
number of options: a user-defined network, volumes, ports,
passwords, and so on. It’s not hard to imagine that we might
need to go through this configuration process repeatedly with
the same options.

Under such circumstances, we can use Docker Compose—a


tool for specifying multi-container deployment details in one
YAML file. (YAML stands for Yet Another Markup Language
or YAML Ain’t Markup Language, depending on who you
ask.)

A Docker Compose file for a Mediawiki implementation like


the one we deployed in the last exercise might look something
like this:

64
# MediaWiki with MySQL
#
version: '3'
services:
mediawiki:
image: mediawiki
restart: always
ports:
- 8000:80
volumes:
- /var/www/html/images
database:
image: mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: root

Many of the specifications above will look familiar. We’re


creating two “services”: the Mediawiki app and the MySQL
database. For the app, we’re publishing port 80 to the local
machine’s port 8000. For the database, we’re specifying the
in-container directory for persistent data.

But what do we mean by service? This is an abstraction that


Docker and other systems use to organize components of
large deployments by their roles—the service definition is
essentially an image of a container configured to work in the
context of a larger deployment.

In a complex deployment, specific pieces of functionality are


sometimes subdivided further into dedicated, highly specific,
discrete modules that can communicate with one another
through web interfaces and can be managed by small teams.
65
This approach is called a microservices architecture. Our
simple two-container deployment gives us a highly simplified
way to understand the basics of a services-based architectural
pattern. In this case, the application is broken down into
containerized components with clearly defined jobs: the
application and the database. The application logic could be
further decomposed into modules handling the specific logic
for, say, authenticating accounts, editing posts, serving the
site, and so on.

But for now, we’re concerned with composition, not


decomposition! Go ahead and copy the above into a file called
wiki.yml and save it in a project folder on your host
machine. Take note: this pattern of organizing
multi-container deployments in YAML files will be a
recurrent feature in your journey through cloud native
systems. If you’re starting fresh, this could be your first time
encountering a YAML file, but it certainly won’t be your last.

Make sure you don’t have any containers from previous


chapters running. Then in your terminal, run:

% docker-compose -f wiki.yml up

The -f argument points to a custom file name—in this case,


wiki.yml. (Otherwise, docker-compose expects the YAML
file to be called docker-compose.yml.)

This command will start the process in attached mode, so


you’ll see continuous logs in your terminal tab. When you
navigate to localhost:8000, you’ll go through the exact same
setup process as previously, except that you’ll need to run
docker container ls to find the name of the database
66
container for the database host. (It’s likely
wiki_database_1.) You will have to download
LocalSettings.php again, but this time, you’ll place it in the
same host machine directory as wiki.yml, which you’ll edit
to look as follows:

# MediaWiki with MySQL


#
services:
mediawiki:
image: mediawiki
restart: always
ports:
- 8000:80
volumes:
- /var/www/html/images
-
./LocalSettings.php:/var/www/html/LocalSettings.p
hp
database:
image: mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: root

Now, when Docker Compose launches, it will import the


LocalSettings.php file into the directory associated with
your persistent volume.

Go ahead and end the process in your terminal tab with


CTRL+C and run docker compose again:

67
% docker-compose -f wiki.yml up

We have a running multi-container deployment again—this


time, with less grunt-work in the command line (and an
easy-to-share set of instructions in YAML).

To recap…

In this chapter, we wanted to simplify the deployment of a


multi-container application. So what did we learn?

● We used the Docker Compose tool to configure our


deployment in a single YAML file, which is easy to
copy, share, place in version control, and so on.

● We found that using Docker Compose can significantly


accelerate set-up for an environment.

● We learned that Docker Compose defines services for


large-scale deployments, and that the services model
forms the basis for the microservice architecture
commonly used in cloud native development.

In our final chapter, we’ll bring together everything we’ve


learned so far to create a new web app that can be deployed as
containerized services.

68
Chapter 10: Building a Web App as
Containerized Services

In this final chapter, we’ll set up a web app built on Node.js,


React, and MySQL, to be deployed as containerized services.
Our goal here is to be able to work on this application with
our front-end, back-end, and database all running in
containers, so we can build out our app and eventually deploy
to Kubernetes or Docker Swarm. You can think of this as the
capstone project for our introductory survey of
containers—here, we will draw on all of the concepts we’ve
discussed so far.

MySQL should be familiar by now, but we’re utilizing two new


tools here:

● Node.js is an open source, server-side


implementation of JavaScript designed to drive
real-time, event-driven web apps. From here on out,
we’ll simply refer to it as “Node.” You can learn more
about Node at https://nodejs.org/

● React is an open source JavaScript library designed to


facilitate the creation of user interfaces. It is
maintained by Meta (née Facebook) and an open
source community. More information on React is
available at https://reactjs.org/

Since we’re bringing all the pieces together and building our
own app, this chapter will take a little longer than five
minutes—but likely no more than thirty. So settle in with a
coffee (or refreshment of your choice) and let’s get started.

69
Network, volume, and database

We’ll start by creating a user-defined bridge network where


our containers can reach each other by DNS. This project is a
test app—essentially a template on which we could build
other projects—so we’ll name our network accordingly.

% docker network create -d bridge test-net

Since we’ll be running a MySQL database, we’ll also need a


volume for persistent storage. Our containers themselves will
be highly ephemeral, but we want our database configuration
and app data to last beyond the lifespan of any particular
container. Volumes give us a way to do that. We’ll see another
approach in a few minutes. In the meantime, let’s create our
volume:

% docker volume create test-data

Now we have a network and we have storage. Let’s create a


container to make use of those resources. Our first container
will run the MySQL database:

% docker run --name test-mysql --network=test-net


-v test-data:/var/lib/mysql -d -e
MYSQL_ROOT_PASSWORD=octoberfest mysql

Let’s break down this command.

● We’re using docker run to start a new container, and


we’re naming it test-mysql.

70
● The --network argument specifies that the container
is going to use our user-defined test-net network.

● The -v argument says that the container will use our


new volume, and associates the volume with the
directory in the MySQL container where it expects to
be able to save persistent data.

● The -d argument means we’re going to run the


container in “detached” mode, which means the
container process won’t be bound to our current
terminal session. We’ll be able to keep working rather
than just watch it run.

● The -e argument specifies an environment


variable—in this case, a root user password for the
database. I’m using the password “octoberfest” here.

● Finally, we’re building from the official Docker Hub


image for MySQL.

All right—now we have our containerized database running.


We have two more services to go, and both are ultimately
built on Node. Our backend is going to be a simple Express
server, while our frontend is going to use the React library.

Setting up the front-end and back-end

For this setup, we’re going to assume that we want to be able


to use code editors like Visual Studio Code or Sublime Text on
our local machine, but we want to run Node from containers.
So let’s set up a simple project directory on our host machine.
I’m going to create an overall project directory called
test-demo. This is where we’ll be working from here on out.
71
% mkdir test-demo

Inside the overall project directory, we’ll create a directory for


the back-end app.

% mkdir test-app

Next we’re going to initialize our projects with Node, which


means we’re going to use the npm package manager to create
some core configurations and download packages we’ll need
for our app. We could do this with a version of Node running
on our local machine, but we’re not going to. Instead, we’re
going to keep our workflow nice and consistent by running
our setup from a Node container.

In this command, we’ll run Node from the official container


image on Docker Hub:

% docker run --name node-setup -it --mount


type=bind,source="$(pwd)",target=/usr/src/app -w
/usr/src/app –rm node bash

There are a couple of details we should point out here:

● We’re using the --mount argument to connect the


container directly to our hard drive, and we’re telling it
to start the mount at our present working directory—in
this case, that should be our test-demo project
directory. We’re also telling Docker to map that
directory to usr/src/app inside the container
filesystem.

72
● The -w argument defines a working directory inside
the container–so that’s where we’ll land when we run
bash.

Inside the container, we can use ls, and we should see our
test-app directory. This is our actual directory, not a copy,
so anything we do here will be reflected on the host machine.
Let’s hop into our app directory…

% cd test-app

…and since we’re inside a Node container, we can initialize


the project with the container’s instance of npm.

% npm init -y

This will set up our package.json file. We should also install


the mysql2 driver so our backend app can connect to the
database. Note that we’re using the mysql2 driver. You’ll
encounter errors if you try to use the vanilla MySQL driver.

% npm install mysql2

We also want to use the Express web framework, so we’ll go


ahead and install that now too. Express is a common module
of Node code that simplifies the development of web servers
and APIs.

% npm install express

That’s all the setup we need to do on the back-end side, so


we’ll head on over to the overall project directory. Here we’re
going to run…
73
% npx create-react-app test-client # If the npx
command produces an error resolving dependencies, try
running the following line beforehand: npm config set
legacy-peer-deps true

This will create a new directory called test-client and


populate the directory with files for our React-based
front-end service.

That’s it for our initial setup. We can type exit to exit out of
the container shell session. If we check the project directories
through our host system, we’ll see all the new files we’ve
created.

Now it’s time to build out the foundation of our application.


We’ll start with the backend. Create a file called index.js in
the test-app directory.

% cd test-app
% touch index.js

We’ll place the following code in the file:

74
// Dependencies

const mysql = require("mysql2");


const express = require("express");

// MySQL config - be careful of passwords!

const db = mysql.createConnection({
host: "test-mysql",
user: "root",
password: "octoberfest",
});

db.connect((err) => {
if (err) {
console.log("Error!", err);
} else {
dbStatus = "Connected to MySQL";
console.log(`${dbStatus}`);
}
});

Since this lesson is about containerizing our services, we


won’t dwell too much on the application code itself—but we
should still discuss, at a high level, what we’re actually doing
here. In the first block of code we’ve declared a dependency:
the MySQL driver. In the second block, we’ve defined our
database connection with our credentials.

75
Note: we’re able to use the hostname of the test-mysql
container, and it will resolve just fine via DNS. That’s a nice
model that we can scale pretty easily when deploying to a
container orchestrator. Make sure to be careful of your
passwords, though–we’re keeping things simple here for the
sake of a quick walkthrough, but in practice you’ll want to
make sure that sensitive passwords aren’t hanging out in the
open on unsecured git repositories. In real-world
deployments, you should use the Secrets functionality of your
container orchestrator.

In the last section of code, we’re connecting to the database


and checking to see if everything is working properly. If the
connection succeeds, we’ll generate a confirmation message
in the console. If it fails, we’ll throw an error. Let’s test this
and see if it works.

% docker run --name test-app --network=test-net


--mount
type=bind,source="$(pwd)"/test-app,target=/usr/sr
c/app -w /usr/src/app --rm node node index.js

With this command, we’re running a container based on the


Node image again. We’re running on the test-net network
and mounting the hard drive as before. We’ve added the --rm
flag, which will automatically delete the container once it’s
stopped. And we’re using Node to run index.js.

76
Everything is working nicely–the containerized app
connected with the containerized database. But we want to
take our app template a little further and bring in the
front-end. So in another terminal tab, we’ll stop the test-app
container.

% docker container stop test-app

Let’s add a little more logic to our back-end now. We’ll simply
add the code below after what we’ve written previously in
index.js:

77
// Express config

const app = express();


const PORT = process.env.PORT || 3001;

app.get("/api", (req, res) => {


res.json({ message: `${dbStatus}`});
});

app.listen(PORT, () => {
console.log(`Server listening on port
${PORT}`)
});

Now we’ve added the Express framework as a dependency.


Using Express, we’ve opened port 3001 for traffic and created
the bare bones beginning of an API. In this case, we’re simply
answering calls to the API with the results of our database
connection check–now rendered in JavaScript Object
Notation (JSON) format.

Let’s save these updates, and then we’ll run the back-end on a
container again, this time with the detached flag and some
port mapping.

% docker run -d --name test-app


--network=test-net -p 3001:3001 --mount
type=bind,source="$(pwd)"/test-app,target=/usr/sr
c/app -w /usr/src/app node node index.js

78
Since we’re mapping the container’s port 3001 to localhost,
we should be able to check out our API at localhost:3001/api/

Great, there’s our JSON message.

That’s two services down. Let’s turn our attention to the


front-end client. We can test the container with…

% docker run --name test-client


--network=test-net -p 3000:3000 --mount
type=bind,source="$(pwd)"/test-client,target=/usr
/src/app -w /usr/src/app node npm start

The React front-end is exposed on port 3000, which we’ve


mapped to localhost:3000. So we can check that out in the
browser.

79
Perfect. Everything is running, and as it says here on the
default landing page, we’ll want to edit App.js in the source
folder in the client directory. So let’s open that and make a
few changes. Delete the contents of the file and replace them
with the code below:

80
import React from "react";
import logo from "./logo.svg";
import "./App.css";

function App() {
const [data, setData] = React.useState(null);

React.useEffect(() => {
fetch("/api")
.then((res) => res.json())
.then((data) => setData(data.message));
}, []);

return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo"
alt="logo" />
<p>{!data ? "Checking connection..." :
data}</p>
</header>
</div>
);
}

export default App;

We haven’t changed too much here, really. We’ve added a way


for this front-end to fetch a JSON message from the API and

81
then pass it onto the frontpage. But there’s one
wrinkle–there’s no API running here. This is a dedicated
front-end service. To deal with that, we’ll open the
package.json file for the client and add a line establishing a
proxy at test-app port 3001.

"proxy": "http://test-app:3001"

With that, our pieces are all in place. Let’s save and run the
client container again.

% docker run --name test-client


--network=test-net -p 3000:3000 --mount
type=bind,source="$(pwd)"/test-client,target=/usr
/src/app -w /usr/src/app --rm -d node npm start

We now have three containerized services all linked up and


ready to serve as a foundation for whatever you create. This is
obviously just the skeleton of an app, and there are all kinds

82
of quality of life improvements we might want to add. But
there’s one major efficiency we should definitely talk about,
and that’s Docker Compose. You’re not going to want to have
to launch all of these services independently with a bunch of
unwieldy arguments every time you work on your app.
Fortunately, we can create a Docker Compose file that does all
of that for us.

First, let’s save the current state of our container images.

% docker commit test-app test-app


% docker commit test-client test-client
% docker commit test-mysql test-mysql

With our images saved locally, we’ll stop the current


containers, and they should be removed automatically.

docker stop $(docker ps -a -q)

Now, in the test-demo directory, we’ll write a YAML file that


contains all the configurations we’ve used for each of our
containers. This new file should be called
docker-compose.yml and it should include the following
specifications:

services:
test-app:
image: test-app
hostname: test-app
networks:
- test-net
expose:
- "3001"
83
ports:
- "3001:3001"
volumes:
- ./test-app:/usr/src/app
working_dir: /usr/src/app
command: node index.js
test-client:
image: test-client
hostname: test-client
networks:
- test-net
expose:
- "3000"
ports:
- "3000:3000"
volumes:
- ./test-client:/usr/src/app
working_dir: /usr/src/app
command: npm start
test-mysql:
image: test-mysql
hostname: test-mysql
networks:
- test-net
restart: always
volumes:
- test-data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: octoberfest
volumes:
test-data:
external: true
84
name: test-data
networks:
test-net:
external: true
name: test-net

Let’s walk through what’s happening here. In the final two


sections, we’re declaring the volume and user-defined
network we’ve been using, which here are defined as
“external,” which simply means that they’re outside the scope
of the Docker Compose file itself. Above, we’re setting the
same configurations for each container that we’ve used
before. Be careful of passwords in YAML files–use the Secrets
functionality of your container orchestrator in real-world
deployments, and take care not to post YAML files with
sensitive information in unsecure repositories. Now, we can
simply run…

% docker-compose up

…from our project directory. Our app should be up and


running, and we can bring up our containers with a simple
docker-compose up command. We’re ready to build out this
app however we wish.

Note: the code we’ve used in this chapter is available at


https://github.com/ericgregory/test-demo

Where do we go next?

In Chapter 1, we noted that Docker is not the only container


system. Docker has been the industry standard for nearly
fifteen years, but alternative engines and runtimes are
85
growing in popularity, often with their own well-defined
use-cases:

● Podman provides a free and open source container


engine well-suited for individual developers. You can
learn more at https://podman.io/

● Mirantis Container Runtime gives enterprises a


container runtime with advanced cryptographic
functionality to support compliance with security
requirements, as well as native Windows and Linux
support. It can also serve as the container runtime for
Kubernetes. Find more information at
https://www.mirantis.com/software/container-runtim
e/

● containerd is the open source runtime used by


Docker Engine—not so much an alternative as the
open source engine block spun out on its own and
managed under the auspices of the Cloud Native
Computing Foundation (CNCF). Scoped for use as a
component with minimal direct user interaction,
containerd provides an open container runtime for
many cloud platforms and container orchestrators.
Learn more at https://containerd.io/

Fortunately, the rise of alternative runtimes and engines has


not led to dramatic fragmentation in the container market. In
2015, Docker, Inc. founded the Open Container Initiative
(OCI) to help establish open and standardized specifications
for container runtimes and images. That makes
OCI-compliant container images interoperable between
OCI-compliant runtime environments—and it makes your
86
knowledge about one runtime largely transferable to another.
For example, the Podman homepage cheekily recommends
using a command line alias to make “docker” commands run
“podman”—from there, you can use most of the same
commands you already know.

Taken on their own, container platforms provide a way to


accelerate development, providing access to “building block”
images and isolated, quickly provisionable environments. But
the ephemerality and efficiency of containers have given rise
to new software deployment patterns and applications,
including container orchestrators: systems like
Kubernetes and Docker Swarm designed to coordinate
containerized services.

If Docker Compose is like the composer of a complex musical


score, Swarm and Kubernetes are like the conductors of great
orchestras, with containerized applications and services
instead of instruments. By delivering applications in the form
of containers, these systems can quickly replicate
container-based deployments in order to achieve high
availability and resiliency.

Today, we can identify a continuum of container tools with


different (but sometimes overlapping) use-cases:

● Docker (and other user-facing container


engines): designed for creating and running
containers, well-suited to quickly deploying
single-container apps on one host for development
environments

87
● Docker Compose: a Docker tool that simplifies
deployment of multi-container apps, also well-suited
to development environments on one host

● Docker Swarm: a container orchestrator built into


Docker, suitable for deploying applications across
multiple hosts for production use

● Kubernetes: an open source container orchestrator


originally developed by Google, well-suited for very
large scale deployments with many different hosts,
applications, or services—popular among enterprise
users for its ability to scale services across a large
number of nodes

There are many more complexities to unpack when it comes


to Docker Swarm and Kubernetes, and the right tools for your
cloud native projects depend on your context—but
understanding the fundamentals of containerization will give
you a solid foundation for whatever you set out to do. What
you’ve learned in this book will serve as your first steps into a
thriving open source community and ecosystem.

88

You might also like