Road To Kubernetes
Road To Kubernetes
Road To Kubernetes
Compliments of
Road to Kubernetes
akamai.com
Justin Mitchel
MANNING MANNING
TRY NOW
Road to Kubernetes
Road to Kubernetes
JUSTIN MITCHEL
MANNING
Shelter Island
For online information and ordering of this and other Manning books, please visit www.manning.com.
The publisher offers discounts on this book when ordered in quantity.
For more information, please contact
Special Sales Department
Manning Publications Co.
20 Baldwin Road
PO Box 761
Shelter Island, NY 11964
Email: [email protected]
No part of this publication may be reproduced, stored in a retrieval system, or transmitted, in any form
or by means electronic, mechanical, photocopying, or otherwise, without prior written permission of the
publisher.
Many of the designations used by manufacturers and sellers to distinguish their products are claimed
as trademarks. Where those designations appear in the book, and Manning Publications was aware of a
trademark claim, the designations have been printed in initial caps or all caps.
∞ Recognizing the importance of preserving what has been written, it is Manning’s policy to have the books
we publish printed on acid-free paper, and we exert our best efforts to that end. Recognizing also our
responsibility to conserve the resources of our planet, Manning books are printed on paper that is at
least 15 percent recycled and processed without the use of elemental chlorine.
The author and publisher have made every effort to ensure that the information in this book was correct
at press time. The author and publisher do not assume and hereby disclaim any liability to any party for
any loss, damage, or disruption caused by errors or omissions, whether such errors or omissions result
from negligence, accident, or any other cause, or from any usage of the information herein.
ISBN: 9781633436770
Printed in the United States of America
To my wife Emilee—thank you for your endless support and encouragement.
I am a very lucky man to have you in my life. Thank you for giving me everything.
To my daughters McKenna, Dakota, and Emerson—thank you for being you.
Each one of you is exactly what our family needs, and I am so proud of the women you are
becoming. Being your Dad has been the greatest and most rewarding gift in the world.
I love each of you more than you can possibly know, and I am very excited for many more
adventures together. Love you all!
brief contents
1 ■ Kubernetes and the path to modern deployment 1
2 ■ Creating the Python and JavaScript web apps 9
3 ■ Manual deployment with virtual machines 37
4 ■ Deploying with GitHub Actions 71
5 ■ Containerizing applications 103
6 ■ Containers in action 126
7 ■ Deploying containerized applications 148
8 ■ Managed Kubernetes Deployment 172
9 ■ Alternative orchestration tools 223
A ■ Installing Python on macOS and Windows 249
B ■ Installing Node.js on macOS and Windows 258
C ■ Setting up SSH keys for password-less server entry 261
D ■ Installing and using ngrok 269
vi
contents
preface xii
acknowledgments xv
about this book xvi
about the author xxi
about the cover illustration xxii
vii
viii
contents
5 Containerizing applications
5.1 Hello World with Docker 104
103
6 Containers in action
6.1
126
Building and pushing containers with GitHub Actions 127
Docker login and third-party GitHub Action tools 128
A GitHub Actions workflow for building containers 129
x
contents
index 271
preface
I believe that more applications are going to be released in the next decade
than in the previous decade to the 10th power. This exponential growth is
going to be driven by modern deployment technologies like containers and
container orchestration, along with the explosion in the use of generative AI
tools. While the tools and technologies might change, I am certain that more
applications will be deployed.
I have seen software go from floppy disks to compact disks (CDs), to car-
tridges (for game consoles), to DVDs, and ultimately to the internet. As the
format of distribution changed, so did the number of applications that were
released; they increased exponentially. Even if each physical format change did
not see this exponential growth, the internet definitely did. I grew up in a time
when I never had to write a single line of code that required a disc or cartridge
for the app to work. My programming and web development skills were devel-
oped on the internet.
When I was 16, I decided I was going to make my fortune selling t-shirts. A
friend agreed this plan was a great idea, so we joined forces and did what any
smart teenager would: borrow money from our parents. While this money was
enough to print shirts, it was not enough to build a website, or so we thought. At
the time, MySpace was the most popular social media site, and eCommerce was
just starting to explode. We wanted to look as legit as possible to potential retail-
ers, so we figured creating a website was a great idea. The problem? Websites
were at least $800 to hire a company to create one. Because we spent all of our
parents’ money, we needed to do this one on our own.
xii
preface xiii
“Just Do It”
—Nike
xv
about this book
With Road to Kubernetes, we start with creating simple Python and Node.js web
applications. These applications will stay with us for our entire journey so we
can learn about all the various tools and techniques to deploy them. From
there, we start using secure shells (SSH) and modern version control by way of
Git with self-managed environments, self-managed repositories, and self-man-
aged deployments.
After we understand the self-managed way of deploying, we move to auto-
mation with GitHub, GitHub Actions, and even Ansible. GitHub is a popular
third-party-managed git repository and code hosting service that also allows
us to run one-off code workflows called GitHub Actions. These workflows are
short-lived computing environments that are useful to build, test, and deploy
our code and help us to continuously integrate and deliver (CI/CD) our apps.
Ansible helps us automatically configure our deployment environments (e.g.,
virtual machines) by declaring what software we need to run our applications.
While Ansible is great at configuring our environments after the fact, we
started adopting a way to preconfigure our environments into portable and
manageable runtime environments called containers. Containers and the
process of containerization were pioneered by Docker and are often known
as Docker Containers. These containers are essentially apps themselves that
include a small operating system to run our code–think of it as a tiny Linux OS
that runs our Python or Node.js app that can be easily moved around from sys-
tem to system with no additional configuration.
xvi
about this book xvii
Chapter 1 lays the foundation for what we will do in this book. This chapter
is for technical and non-technical readers alike to help understand where mod-
ern deployment is and what you can do about it.
Chapter 2 is where we create sample Python and JavaScript via Node.js web
applications. These applications serve as stand-ins to nearly any type of appli-
cation you aim to create. Two different runtimes, Python and Node.js, help us
understand the challenges that we face when deploying various kinds of soft-
ware; these challenges are almost identical but different enough to cause a lot
of issues.
Chapter 3 is the first foray into deploying applications. This chapter is all
about the manual efforts you will take to deploy code to a server using mod-
ern technologies like version control through Git and mature technologies like
secure shells and firewalls. The manual nature of this chapter is a rite of passage
for many developers because this way is often the scrappiest way to get your
application deployed.
Chapter 4 converts a manual deployment into an automated one by leverag-
ing GitHub and GitHub Actions to run various commands on our behalf using
a one-off computing workflow. This computing workflow allows us to use the
third-party software Ansible to automate how we configure our deployment
environments instead of doing them manually.
Chapter 5 is where we start learning about bundling our applications into
containers. Containers help us preconfigure environments for our applications
so we can more easily move them around with very little additional configu-
ration. While this might feel more complex up front, containerized apps can
run wherever there is a container runtime, regardless of whether the applica-
tion uses Python, Node.js, Ruby, Java, and so on. Container runtimes mean we
do not need our deployment systems to have application runtimes installed to
work–as in, we can skip installing Python, Node.js, Ruby, Java, etc., in favor of
just a container and a container runtime.
Chapter 6 is where we use automation to build and push our containers to a
place to store our containers called Docker Hub. From here, we’ll learn about
our first way of multi-container management, called container orchestration,
with a tool called Docker Compose.
Chapter 7 is where we deploy our first containers to a production server,
which will require us to configure the server and run our container images
through Docker Compose, all orchestrated by GitHub Actions.
Chapter 8 is where we deploy our containers to Kubernetes. In this chap-
ter, we’ll learn how to provision a managed Kubernetes cluster across a set of
virtual machines. This cluster of machines, coupled with Kubernetes, gives us
about this book xix
the ability to scale and manage containers, unlike any tool we have used to this
point. Managed Kubernetes unlocks much-needed production features, such
as a static IP address, load balancers, and persistent volumes. We also learn how
to design manifests so we can be deliberate about what containers need to do
on Kubernetes and how.
Chapter 9 is where we deploy containers to two Kubernetes alternatives called
Docker Swarm and HashiCorp Nomad. Both these tools orchestrate containers
like Kubernetes, but the approach and features are different. Docker Swarm is
a natural extension of Docker Compose, while HashiCorp Nomad is a unique
take on managing containers that fits well within the HashiCorp ecosystem of
tools like Terraform and Vault.
As you can see, each chapter builds on the concepts introduced in previous
chapters and ultimately ends with two chapters covering how to manage con-
tainers with modern container orchestration tools. The fundamental building
block of Kubernetes is a container and therefore containers are also a funda-
mental concept for this book. The first few chapters help you understand both
conceptually and practically the need for containers, while the remainder of
the book helps you better understand and leverage containers in deployment.
¡ https://github.com/jmitchel3/roadtok8s-py
¡ https://github.com/jmitchel3/roadtok8s-js
¡ https://github.com/jmitchel3/roadtok8s-kube
The specific chapters will reference these code repositories as needed. The
available code is meant to help supplement the book and be used as learning
material. The code may or may not be production-ready, and the author can-
not guarantee the code will be maintained beyond what is already currently
available.
xxi
about the cover illustration
The figure on the cover of Road to Kuberentes, a “Le Curé Morelos,” or “father
Morelos”, is taken from a book by Claudio Linati published in 1828. Linati’s
book includes hand-colored lithographs depicting a variety of civil, military,
and religious costumes of Mexican society at the time.
In those days, it was easy to identify where people lived and what their trade
or station in life was just by their dress. Manning celebrates the inventiveness
and initiative of the computer business with book covers based on the rich
diversity of regional culture centuries ago, brought back to life by pictures from
collections such as this one.
xxii
This chapter covers
Kubernetes and the path
to modern deployment
Before we can use Kubernetes and its wonderful features, we have to understand
the path that leads up to it. This does not mean the history behind the technology
but rather the skills behind the fundamentals of deploying software from scratch all
the way to Kubernetes.
This book is designed for people with at least a beginner’s background in coding
modern software applications.
If topics such as functions, classes, variables, and string substitution are new to
you, this book might not be for you. In that case, please consider taking a beginner
object-oriented programming course using either Python or JavaScript. This recom-
mendation is for three reasons:
1
2 Chapter 1 Kubernetes and the path to modern deployment
These statements are what I want done as opposed to how it should be done. The how is
handled by the tool itself. We’re concerned with the result, not the process to achieve
that result (figure 1.1).
Writing the contents of your declarative file(s) can be tricky, but once you get it right,
you can use it repeatedly across many of your deployed applications. What’s more, you
can reuse the files to redeploy to entirely new servers and hosts.
In Kubernetes, these declarative files are called manifests. In Docker, they are called
Dockerfiles. Web Servers like Apache (httpd) and NGINX run off declarative files. Popu-
lar infrastructure-as-code tools such as Ansible and Terraform are run using declarative
files also.
Our path to deployment 3
Deployed software is the only kind of software that can eat the world; if it’s not
deployed, how can it bring value to the world?
Software can be deployed on:
There are far too many options to deploy software for this book to cover, so we’ll keep it
focused on cloud-based and internet-facing software. Regardless of where you deploy,
software has the potential to reach all other deployed software thanks to the power of
networking and the internet.
Our deployment path forward is shown in figure 1.2.
Throughout this book, we’ll learn various deployment methods to fully appreciate the
power and simplicity Kubernetes provides.
We start with building two different web applications in Python and JavaScript as
tools that we can deploy and continuously iterate on. These applications will be mostly
rudimentary, but they will help us tangibly understand each one of the deployment
phases.
After our apps are written, we’ll use Git, a form of version control, to track and share
all changes to our code. With Git, we’ll implement a GitHub Repository to store our
code, along with a GitHub Actions integration workflow as the basis of full produc-
tion-ready deployment automation.
Next, we’ll adopt containers, commonly known as Docker containers, to help better
manage application dependencies and to package, share, and run our code on nearly
any machine. We will implement an integration workflow that will automatically build
our containers based on our activities in Git. Once built, we’ll push (i.e., upload) our
containers into a container storage and distribution hub called a container registry.
With our containers stored in a container registry, we’ll be able to move to our next
phase: container-based deployments. We’ll soon learn that these kinds of deployments
can be drastically easier than our non-container deployment process, due to how con-
tainers manage application dependencies and how effective modern container tools
are at pulling (i.e., downloading) and running containerized applications.
Now that we feel like we’ve unlocked a whole new world of deployment, we quickly
realize container-based deployments also have their limits. Deploying more than one
container at a time is possible with tools like Docker Compose and Watchtower, but
managing upgrades and handling additional traffic (load) becomes unsustainable
quickly.
You might think that adding additional compute to handle traffic is a good idea, and
often it is, but a fundamental question becomes, are we efficiently running our contain-
er-based apps? Are we allocating resources well? These questions and many more are
solved by adding automated container orchestration to our workflow. One such option:
Kubernetes.
Implementing a simple deployment of Kubernetes is, as we’ll learn, pretty simple
when using a managed Kubernetes Service (as we will). What gets more complex is add-
ing additional services that may, or may not, require internet traffic.
The purpose of this book is to help you understand and solve many of the challenges
with the road laid out here.
This is to say, I cannot simply attach a Blu-ray player to an iPhone and run an Xbox
One game without serious modification to one or all of these items. Even if the game
itself has a version that is available in the App Store, it has undoubtedly been modified
to work on an iPhone specifically. To drive the point home, you might ask, “But what
iPhone version?”
It’s no surprise that the previous scenario is done on purpose to aid vendor lock-in
to the respective hardware and software platforms. This scenario also highlights how
application dependencies and the respective hardware requirements can have serious
effect on the success of the software itself.
Kubernetes and much of the software we’ll use in this book is open-source and thus
able to be used, modified, and/or changed at will. Full open-source projects do not
have vendor lock-in, because that’s kind of the point.
FROM python:3.10
COPY . /app
WORKDIR /app
RUN python3 -m pip install pip --upgrade && \
python3 -m pip install --no-cache-dir -r requirements.txt
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "main:app"]
In fewer than 10 lines of code, we can define the required isolation for a near produc-
tion-ready Python application. This is the simple power of containers. This simplicity is
true for a large number of applications, regardless of their programming language.
The process that follows creating a configuration file is simple as well. We use a con-
tainer builder to build and bundle application code into a container image from a con-
figuration file called a Dockerfile. Once the image is built, we can either share or run
the container image using a container registry or a container runtime, respectively. Building,
sharing, or running containers is often referred to simply as Docker, but that doesn’t give
us the whole story.
Docker is the software and the company behind the concept and technology for con-
tainers. Docker and many of the underlying technologies are now open-source, which
means Docker is no longer the only way to build, share, and run containers. Here are a
few examples of applications that can, and do, run using containers:
Containers have many properties that we’ll discuss throughout this book, but the gen-
eral idea is that if the software is open source, there’s a really good chance you can
efficiently use containers with that software. If you can use containers, then you can use
Kubernetes.
All of these challenges and many more are often solved by using Kubernetes, and these
are topics we explore throughout this book.
All this friction might cause developers to look elsewhere to deploy applications.
Kubernetes typically runs containers across a cluster, or group, of virtual machines.
By default, Kubernetes will decide for us which virtual machine, or node, our contain-
ers will run on. Installing and updating the Kubernetes software itself is one of the many
reasons that we’re going to opt for managed Kubernetes in this book.
Once we have Kubernetes running, we are now faced with a non-trivial amount of
configuration, even to run just one container-based web application. To update even
one application, we’ll have to learn how to implement continuous integration and con-
tinuous delivery (CI/CD) tools and role-based access control (RBAC) to make changes
to our Kubernetes cluster.
Containers are stateless by design, which means stateful applications (like data-
bases) are another layer of complexity developers might not be ready for. Many stateful
8 Chapter 1 Kubernetes and the path to modern deployment
¡ Manual deployment
¡ Container deployment
¡ Kubernetes Deployment
Each type of deployment stacks skills and tools from the previous deployment.
The benefit of Kubernetes comes full circle once you attempt to manually deploy
applications continuously to virtual machines. Manual deployment is important
because it gives you the exact context of what is behind automated deployments. Auto-
mating something you don’t understand will leave you vulnerable when mistakes or
bugs inevitably happen because everything goes wrong all the time.
The reason we use Python and JavaScript is merely for their popularity among devel-
opers (and the author). If you’re interested in another programming language, please
consider our web application demos on this book’s related GitHub (https://github
.com/roadtokubernetes). Remember, once your application is containerized, you can
almost certainly use two of these three deployment options.
The second half of this book will be dedicated to discussing some of the intricacies of
running and maintaining a Kubernetes Deployment so we can continuously integrate
new features and applications into our Kubernetes Deployment.
Summary
¡ Deployment can be complex, but anyone can do it.
¡ For continuous updates, we need an automated pipeline for deployment.
¡ Python and JavaScript apps are among the easiest web applications to deploy.
¡ App dependencies may or may not be obvious when first deploying.
¡ Containers help isolate our apps for sharing and running.
¡ More servers, not more robust ones, are often the solution for handling traffic
increases.
¡ Adding more containerized applications becomes increasingly difficult.
¡ Kubernetes reduces complexity by managing the deployment of containers while
distributing compute resources effectively.
This chapter covers
Creating the Python and
JavaScript web apps
The goal of this book is to understand how to deploy software ultimately using
Kubernetes. This chapter sets the stage by giving us two different applications to
deploy with. These applications will be continuously referenced throughout this
book and are meant to be a stand-in for the actual applications you intend to deploy.
We’ll use the JavaScript runtime Node.js and the Python programming language
as the foundation for these applications. These two languages are incredibly popular
and easy to work with. They also have a number of overlapping features, but not iden-
tical ones, which require us to adjust how we think about the various nuances that go
into deploying each of them. For as much as I enjoy working with these languages,
this book is by no means meant to be the end-all-be-all for building and deploying
applications in either Node.js or Python.
9
10 Chapter 2 Creating the Python and JavaScript web apps
We will soon learn that deploying applications, regardless of their programming lan-
guage or runtime, can be a complex task regardless of the complexity of the application
itself. Over the course of this book, we’ll learn that containers help simplify this com-
plexity by providing a consistent way to package and deploy applications. Before we can
learn about containers, we must learn how to build basic applications.
¡ Python version 3.8 or higher—If you need help installing Python, please review
appendix A.
¡ venv—A Python module for creating and managing virtual environments for iso-
lating code.
¡ FastAPI—A popular Python web framework that comes with a minimal amount of
features to build a highly functional application.
¡ uvicorn—The web server we will use to handle web traffic with FastAPI.
Each one of these tools is set up and installed in the order presented. To continue, I
will assume that you already have Python installed on your system and you are ready
to create a virtual environment for your Python project. If you are experienced with
Python, you might want to use different tooling than what I listed, which is exactly what
I want you to do. If you are new to Python, I recommend you follow along with my con-
figuration until you learn more about Python.
If you’re on Windows, you can use PowerShell or the Windows Sub-system for Linux
(WSL).
Every time you create a new Python project, I recommend you follow these same steps
to help isolate the code for different projects from one another. To help further isolate
your code, let’s create a virtual environment for our Python project.
Naturally, at this point I assume you know which Python interpreter you will use. If
you’re on macOS or Linux, you will likely use python3 instead of python and if you’re
on Windows you will likely use python instead of python3 (listing 2.2). You can learn
more about this in appendix A for Mac users or appendix B for Windows users.
# Windows users
python -m venv venv
Once this command is complete, you’ll see a new directory called venv, which will hold
all of the Python packages and libraries we will install for this project. To use this new
virtual environment and all the related tools, we will need to activate it. You will activate
the virtual environment every time you want to use it, and some text editors (such as
VSCode) may activate it for you. Let’s activate our virtual environment now, as shown
in the following listing.
# windows users
.\venv\Scripts\activate
Now that it’s activated, we can start installing our project’s third-party dependencies
using the Python Package Manager (pip). Be aware that pip might be installed globally
on your system, but we need to use our virtual environments version of pip. To ensure
this is the case, we use the virtual environment’s python interpreter to run pip. The
following listing shows how it’s done.
Installing each package one at a time is fine for a small project, but as your project
grows, you will need to install many more packages. For this, we’ll use another feature
of pip that allows us to reference a file full of packages to install; this file is called a
requirements file and is most often labeled as requirements.txt. In your Python project’s
source code folder (e.g., ~/Dev/roadtok8s/py/src), add the following to a file called
requirements.txt, as shown in the following listing.
fastapi
jinja2
uvicorn
gunicorn
With this file, we can use the -f flag built into pip to install all the packages listed in
the file. This requirements file can also declare specific versions of code, but we’ll leave
that for another time. See the following listing to install third-party packages.
While this requirements file might seem trivial, it’s important to have one so we can
attempt to recreate the necessary conditions to run our code on other machines.
Requirements files can become a lot more complex and include things like version
Designing a basic FastAPI web app in Python 13
numbers, version ranges, or even Git repositories. Complex requirements files are out-
side the scope of this book, but if you’re interested, I recommend reviewing the open-
source Python package called pip-tools (https://github.com/jazzband/pip-tools).
Now that we have established the requirements in our environment, we have two
options: (1) Destroy the virtual environment and try again so you learn how easy it is or
(2) start writing our FastAPI code. Depending on your experience, I’ll let you decide.
As we see how simple this code is to write, it has a key distinction that many other web
frameworks don’t: the function returns a dictionary value. Most web frameworks are
not this simple by default. This dictionary value is converted into JSON data, which is
a data format that is often used in REST API software development. The Python dic-
tionary itself needs to be JSON serializable, which is typically straightforward but can
complicate things from time to time. To test dictionary serialization with Python, it’s as
simple as python -c 'import json; json.dumps({"your": "dictionary_values"})'.
Now that we have our first Python module, let’s run it as a web server with the aid of
uvicorn. uvicorn is a lightweight gateway server interface that makes it possible to han-
dle asynchronous requests and responses. In later chapters, we will use uvicorn coupled
with the production-ready Web Server Gateway Interface (WSGI) gunicorn to create a
highly performant web server for our FastAPI application. Here’s a standard format in
14 Chapter 2 Creating the Python and JavaScript web apps
the following listing for using uvicorn to run FastAPI applications during the develop-
ment phase.
To adjust this to our project, the <module> corresponds to src.main because the main
.py file is located in src/main.py. The <variable> will correspond to the instance of
FastAPI(), which happens to be app in src/main.py. The --reload flag will restart the
application when we save any files related to it. The --port flag will allow us to specify a
port number for our application to run on. Let’s see a practical example of this syntax
in action in the following listing.
The PORT (e.g. --port 80111) you use for any given application becomes increas-
ingly important as you start running more projects. With uvicorn running our FastAPI
application at port 8011, we can open our web browser to http://127.0.0.1:8011 and
review the results (figure 2.1).
For some, getting to this point is a huge accomplishment. For others, it’s a bit mun-
dane. Wherever you fall on the spectrum, I challenge you to pause for a moment and
add a few new HTTP URL routes to your FastAPI application before moving on.
Assuming you still have uvicorn running, open your web browser and visit
http://127.0.0.1:8011/api/v1/hello-world/ and you should see something similar to
figure 2.2.
Unsurprisingly, this response is identical to listing 2.7 with different data, and that’s
exactly the point. Creating new routes and HTTP method handlers is this easy with
FastAPI.
When it comes to building web applications, a big part of how you design them will
fall in the URL route (e.g., /api/v1/hello-world/) and the HTTP methods (e.g., GET,
POST, PUT, DELETE, etc.). FastAPI makes it easy to handle both features.
Now that we have a basic understanding of how to create a FastAPI application, let’s
move on to creating a Node.js application.
Express.js, like FastAPI, is a server-side web framework that helps us build web applica-
tions with minimal overhead. To use Express.js, we will need to have the following installed:
¡ Node.js version 16.14 or higher—If you need a reference to install Node.js, please
review appendix B.
¡ The Node Package npm—This is a built-in Node.js package for installing and man-
aging third-party packages (much like Python’s pip package).
Assuming you have Node.js on your machine, let’s create a folder in the following list-
ing for our JavaScript project and install Express.js.
mkdir -p ~/Dev/roadtok8s/js/src/
cd ~/Dev/roadtok8s/js/
The Node Package Manager (npm) defaults to isolating your code to the local direc-
tory. In other words, you do not need to activate anything to install packages in your
local directory. What’s more, npm will automatically keep track of the packages you
install in a file called package.json.
To start a new Node.js project, it’s typical to run npm init in the root folder of your
project because it will help with the beginning setup of your project. Fill out the ques-
tions it prompts you with as you see fit. Get started in the following listing.
npm init
After you complete this process, you’ll see the output shown in the following listing.
{
"name": "js",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
In this case, I typically swap "name": "js" for "name": "roadtok8s" because js is far
too generic a name.
You can also see this data in package.json by running:
cat package.json
Creating a JavaScript web application with Node.js and Express.js 17
From here on out, when you run npm install <package-name>, npm will automati-
cally add the package to package.json for you, thus saving you the trouble of manually
adding it. Let’s install Express.js now in the following listing.
Once this completes, we can review package.json and see that npm has added Express.
js to our project, as shown in the following listing.
...
"dependencies": { Your version of Express.js
"express": "^4.18.2" may be different
}
...
As we see, npm has a standardized way to both install and track package dependencies.
This is a bit more rigid than Python’s pip but certainly cuts down the complexity of
managing development environments yourself and/or depending on another third-
party tool to do it. Now, verify the contents of ~/Dev/roadtok8s/js/, as shown in the
following listing.
ls ~/Dev/roadtok8s/js/
Now that our environment is set up for our Node.js project, let’s create our first mod-
ule for our web app.
Unlike FastAPI, Express.js will not use a third-party web server like uvicorn, so we’ll
use a built-in feature of Express.js to listen to a specific port on our machine. The pro-
cess goes like this:
1 Import the necessary modules for Express.js, the file system, and path.
2 Create an instance of the Express.js module.
3 Define a default port for the web app to listen to or tie it to an environment vari-
able for PORT.
4 Create a callback function to handle an HTTP URL route and specific HTTP
Method.
5 Define an App start-up function to listen to a port, output the process ID to a
local file, and output a message to the console.
app.listen(port, () => {
const appPid = path.resolve(__dirname, 'app.pid')
fs.writeFileSync(appPid, `${process.pid}`);
console.log(`Server running on port http://127.0.0.1:${port}`);
})
The format Express.js uses to handle any given HTTP URL route, HTTP Method, and
callback function is as shown in the following listing.
app.<httpMethod>(<pathString>, <callback>)
¡ <httpMethod>—This is the HTTP method that will be handled by the corre-
sponding callback function. In main.js, this is simply the GET method.
¡ <pathString>—This is a string that represents the URL path that will be handled
by the corresponding callback function. In listing 2.17, we only handle the index
route (/).
¡ <callback>—This is another function that will be called when an HTTP request
is made to the corresponding URL path (e.g., /).
Creating a JavaScript web application with Node.js and Express.js 19
Now that we have our main.js module configured and ready to accept a URL request as
well as run on a specific port, let’s run it and see what happens.
cd ~/Dev/roadtok8s/js/src
node main.js
To look at the HTML source code, on a web browser, look for view source for the
page in developer tools to see the output, as in figure 2.4.
The source code shows us two things about our Express.js application:
The PORT we run our apps on becomes increasingly important when we start deploy-
ing applications. With that in mind, let’s change the port our Express.js app runs on.
20 Chapter 2 Creating the Python and JavaScript web apps
Press Control + C and add the PORT environment variable of 3011 to test a new port,
as shown in the following listing.
# macOs or Linux
PORT=3011 node main.js
# Windows
set PORT=3011 && node main.js
The console output should now reflect the new port: Server running on port
http://127.0.0.1:3011. Open your browser to this new URL and see what happens
(figure 2.5).
What if you tried to visit http://127.0.0.1:3000 again? Does anything happen? This is
the early stages of seeing how much of an affect the PORT that you choose can have on
our applications and our ability to access it.
Options to Stop/Cancel the running Node.js server:
Now that we can render HTML responses with Express.js, let’s create a JSON response
by using built-in JavaScript features.
¡ Using res.json()
¡ Using headers
Tracking code changes with Git 21
We’ll leave using headers for another time and opt for the much simpler version of
res.json(). To return JSON data within Express.js, we need to do the following:
Here’s the resulting callback function for our new route in the following listing.
With node main.js still running, navigate to our new route, and you should see the
same output from figure 2.6.
You should now have a working Express.js application that can handle both JSON and
HTML responses, which is a great start for our applications. As with many things, we
could spend book-lengths on Express.js, but we’ll leave it here for now.
With both of our applications, we must start tracking our code changes and pushing
them to a remote repository. The sooner you get in the habit of doing this, the better.
These features will be used at various times throughout the book to continuously aid us
with deployment in general and our journey to Kubernetes.
Be sure to install Git on your local machine before continuing. Visit https://git-scm
.com/downloads because it’s pretty straightforward to install and get started. To verify
that Git is installed, run the following listing’s command in your command line.
git --version
This should respond with a version number (e.g., git version 2.30.1) and not an
error. If you get an error, you’ll need to install Git as previously mentioned.
Over the years, there have been many tools to help with version control. The tool we’ll use
is also the most popular: Git.
Tracking code changes with Git 23
To manage our Git-based projects, we use what’s called a remote repository. In this
book, we’ll use two types of remote repositories: self-managed and third-party man-
aged. We’ll implement a self-managed repository in the next chapter to aid in manual
deployment.
GitHub.com is a third-party managed remote repository tool that allows you to store
your code in the cloud, share your code with others, and trigger various automation
pipelines that we’ll discuss more throughout this book. GitLab is another popular
remote repository that can be third-party managed or self-managed but is outside the
scope of this book. Now, let’s update our projects to start using Git.
cd ~/Dev/roadtok8s/py/
git init
cd ~/Dev/roadtok8s/js/
git init
Both of these will respond with something like Initialized empty Git repository
in . . ., naming the path to your repo.
Easy enough right? We see that git init command creates what’s called a repository
(repo for short) within the directory you run git init inside of.
We now have two repos in our ~/Dev/roadtok8s/ directory: py and js. These repos
are currently only stored on our local machine. Soon, we’ll configure them to be stored
in a remote repository.
If you were to run git init in a directory that already has a repo, you’ll get a message
such as Reinitialized existing Git repository in . . . . I have seen this output a
great many times. I am sure you will, too.
If you do not know if your directory has a repo, you can simply run git status. If
you see fatal: not a git repository, then you have your answer. If you see nearly
anything else, there’s a good chance the folder is already in a Git repo. Let’s expand on
git status a bit to better understand what it does.
24 Chapter 2 Creating the Python and JavaScript web apps
¡ We are working on a Git branch called main or master, depending on your con-
figuration. Branches are a way to manage different versions of your code. Branch-
ing in Git is beyond the scope of this book.
¡ No commits yet means we have not told Git to track any changes to our code yet.
¡ Untracked files provide a list of folders (that include files) along with files that we
could either track or ignore (by adding them to a .gitignore file)
If we repeat this process for our Node.js app, we should see roughly the same result
from git status as seen in figure 2.8.
Some text editors (like VSCode) make using Git much easier with the built-in Git fea-
tures. If, at any time, Git starts to feel too challenging for your current skill level, I
highly recommend downloading VSCode and reviewing how to use Git with VSCode.
In both of our repos, we have a list of untracked files. Before we start tracking any of
these files, we actually want to ignore the vast majority of them, thanks to the third-party
packages stored in venv/ and node_modules/.
¡ Python’s venv/
¡ Node.js’s node_modules/
These directories can be huge because they are third-party packages that may or may
not have a lot of weight (file saves, number of files, etc.) as well as a number of their
own dependencies.
Both of our projects have references to the third-party packages that have been
stored in venv/ and node_modules/. Those references are requirements.txt and pack-
age.json, respectively. These folders can be deleted and recovered at any time simply by
running python -m pip install -r requirements.txt and npm install, respectively.
We hope that the third-party packages we’re using also have Git repositories that
are accessible by the world at large; thus, grabbing copies of them is simple enough. In
other words, we’ll let the third-party packages be responsible for their own code. Yes,
there are caveats to this ethos, but those are outside the context of this book.
To ignore these folders and any other files, we need to create a .gitignore file in the
root of our repo (next to where we ran git init). Notice that the filename starts with a
period (.) and is often referred to as dot-git-ignore.
First, let’s review what is in our Python web app folder/directory:
cd ~/Dev/roadtok8s/py/
ls -aa Windows users can use
dir /a instead of ls -aa.
This should output
.git src venv
If you don’t see .git, you did something wrong during the git init phase. The .git
directory is where all of the Git tracking information is stored.
If we run git status at this time, we’ll see that venv is listed in the untracked files.
We’ll create a .gitignore file specifically for our Python project to prevent venv from
being tracked. Ensure that .gitignore is in the same folder/directory as the .git direc-
tory, as shown in the following listing.
26 Chapter 2 Creating the Python and JavaScript web apps
venv/
.env
.venv
__pycache__/
*.py[cod]
*$py.class
.DS_Store
node_modules/
.env
.venv
logs
*.log
npm-debug.log*
.DS_Store
Once again, run git status and verify the node_modules folder is no longer listed.
If you still see node_modules that means that .gitignore is either not saved or in
the incorrect location. Ignoring files is incredibly important so we don’t accidentally
make our remote repository too large or accidentally upload sensitive information to a
remote repository.
process, let’s start tracking our Python project’s files. First, we’ll navigate to our local
repo, and then we’ll add each file we want to track with the command git add <path>.
git add does a few things:
The following listing shows an example of tracking our Python project’s files.
cd ~/Dev/roadtok8s/py/
git add src/main.py
git add src/requirements.txt
If we think of git add <path> as git prepare <path>, then we’re on the right track.
The reason for this step is to ensure we are tracking the files we want to track and not
tracking files we don’t want to track.
I think of Git as a way to track incremental changes that are interconnected in some
way. git add is a simple way to ensure we only bring the files and folders that are some-
how interconnected. In other words, if I add two features that are unrelated, I should
consider adding them and tracking their changes separately. This is a good practice to
get into because it will help you and others understand the changes you’ve made over
time.
To finalize the tracking process, we need to commit the changes we’ve made. Commit-
ting is a bit like declaring, “Oh yes, those files we want to track, let’s track them now”.
The command is git commit -m "my message about these changes".
git commit does a few things:
¡ Finalizes, solidifies, or saves changes since the last commit based on what was
added with git add
¡ Takes a snapshot of the files or files within the folders we added with git add
¡ Requires a message to describe what happened to these added files since the last
commit
From here on out, every time you make incremental changes to your app, you’ll want
to add and commit those changes to your repo. You can remember the process like
this:
28 Chapter 2 Creating the Python and JavaScript web apps
I purposefully left out a few features of Git (such as git stash or git checkout)
primarily to focus on the fundamentals of tracking files. I recommend reading the
official Git documentation to learn more about these features: https://git-scm.com/
docs. Let’s put our skills to use and do the same for our Node.js project’s files in the
following listing.
cd ~/Dev/roadtok8s/js/
git add src/
git add package.json
git add package-lock.json
As we see here, git add src shows us how to track an entire directory, which will add
all files and sub-folders and the sub-folders’ files within src/ unless it’s in .gitignore.
We can solidify our changes by committing them with the command git commit -m
"Initial node.js project commit".
Great work! We now have our files tracked by Git. If we ever make changes in the
future the process is as simple as
Or, we can do so by
You can add and commit files as often as you want or as infrequently as you want. As a
general rule, I recommend committing as often as possible with a commit message that
describes the changes you made.
For example, let’s say our Python project needs a new URL route. We can create it
in our Python module and then let Git know about the new route with a message like
“Added new URL route to render HTML content.”
Another good example would be if we wanted to handle a new HTTP method for ingest-
ing data on Node.js. We could create that new URL in our JavaScript module and commit
it with a message like “support for ingesting contact form data via a POST request”.
Tracking code changes with Git 29
With the Git folder destroyed, we can now reinitialize our Git repo. Do you remember
how it’s done? Here’s a bullet list to help you remember:
I encourage you to practice this process of destroying and recreating your Git repo as
a way to practice Git and get comfortable with the process of tracking files. Before we
push our code into a remote repo such as GitHub, let’s review another important fea-
ture of Git: git log.
cd ~/Dev/roadtok8s/py/
git log
If we are working in a repo that has a commit history, our output should resemble fig-
ure 2.9.
Figure 2.9 Initial Git commit log for our Python project
Within this single log entry, we see a few important pieces of information:
¡ commit <commit-id> (HEAD → main)—This is the commit id for the branch we are
on (e.g., main or possibly master).
¡ Author—This is the configured author for this commit. This is great for teams.
¡ Date—This is the date and time of the commit itself. This is another feature that is
great for personal and team use so we can verify when a change was made.
¡ Commit message—Finally, we see our commit message (e.g., "Initial Python com-
mit"). Even from the first message, we can see how what we write here can help
inform what happened at this commit event to help us dig deeper.
As we can see, the commit message can play a crucial role in reviewing any given point
in our history. Better commit messages are better for everyone, including a future
Tracking code changes with Git 31
This will give me all of the files I added to the commit with the changes associated, as
seen in figure 2.10.
Even if this is a bit difficult to read, let’s let it sink in for a moment: we see exactly what
has changed with any given file at any given point in time. The + sign indicates a line
that was added, and the - sign indicates a line that was removed (which is not shown in
this particular commit). This is incredibly powerful and can really help us build better
more maintainable code.
If we neede to revert back to this point in time, we can with the git reset
<commit-id> command. git reset (and git reset <commit-id> --force) should be
used sparingly because they can literally erase a lot of your hard work without using other
features of Git (e.g., branches).
If you need to revert to older versions of your code, I always recommend storing your
code on another computer (called a remote repository) so you can always get back to
it. We’ll cover this in the next section. Before we push our code elsewhere, let’s review a
few of the fundamental git commands.
Learning and understanding Git takes time and practice, so I encourage you to review
these commands as frequently as possible. Now that we can track our code, let’s push
it to a remote repository so it can be hosted away from our machine and potentially
shared with others.
This section is really about implementing the git push command into our workflow.
Let’s create a GitHub account if you do not already have one.
After you select Create Repository, you will be taken to the new repository’s page. You
will see a section called Quick Setup that will give you a few options for how to get
34 Chapter 2 Creating the Python and JavaScript web apps
started with your new repository. We’re going to use the . . . or push an existing reposi-
tory from the command line option.
Figure 2.12 A newly minted repository on GitHub for our Python app
Before you get too excited and start copying and pasting code from GitHub, let’s drill
down on what needs to happen to configure our local Git repository to push to this
GitHub repository.
Do you know why this is the key line? It’s because we already ran all the other com-
mands! With the exception of creating a Readme.md file, we ran the following com-
mands in the previous section:
¡ git init
Pushing code to GitHub 35
¡ git add .
¡ git commit -m "Initial commit"
¡ If you run into an error with main not being a valid branch, try git branch
--show-current to see which branch you’re using.
¡ If you run into an error with origin not being a valid remote, try git remote -v
to see which remotes you have configured.
Did you succeed? If so, congratulations! You have just pushed your project’s code to
GitHub! We are now one step closer to pushing our code into production. Now it’s
time to do the same with our other project.
Using Git is crucial for building maintainable applications as well as ones that we can
continuously deploy into production. This book will continue to build on the concepts
of Git and GitHub as we move forward.
36 Chapter 2 Creating the Python and JavaScript web apps
Summary
¡ Python and JavaScript are both excellent choices for learning the basics of build-
ing web applications.
¡ Even simple applications have a lot of dependencies and moving parts to get up
and running.
¡ Using third-party package tracking with requirements.txt for Python and pack-
age.json for JavaScript/Node.js helps to create a repeatable and consistent envi-
ronment for your application.
¡ FastAPI can be used to create a simple web application in Python that can handle
the request/response cycle.
¡ Express.js can be used to create a simple web application in JavaScript/Node.js
that can handle the request-response cycle.
¡ Template engines can be used to render HTML content in Python and Java-
Script/Node.js, thus eliminating repetitive code.
¡ Functions can be used to map to URLs while handling HTTP methods in FastAPI
and Express.js.
¡ Git is a version control system that can be used to track changes to your code and
revert to previous versions of your code.
¡ Using version control is an essential first step in building robust applications that
are deployable, shareable, and maintainable.
This chapter covers
Manual deployment
with virtual machines
In this part of our journey, we will manually deploy our code to a server. This
involves uploading our code from our local computer to our server using just Git.
Once the code is uploaded, custom scripts will be triggered to ensure that our server
is updated with the latest version of our application. The process of sharing code
safely to and from computers is exactly why Git is such an important tool and well
worth mastering.
37
38 Chapter 3 Manual deployment with virtual machines
If this is your first experience with provisioning a VM or remote server, it’s a good time
to let the significance of what you just accomplished set in. With under $10 (or prob-
ably $0), you rented a portion of a computer located in a data center in Dallas, Texas,
or another location of your choice. You now have complete control over this computer
and can run any type of application on it. Additionally, you have an IP address acces-
sible from anywhere in the world, allowing you to positively affect someone’s life on
the opposite side of the globe with your application. Best of all, it only took around 5
minutes to get this server up and running.
This same process is what the big tech companies use every day: provisioning VMs
to run their applications. The biggest difference between our needs in this book and
theirs often comes down to the sheer volume of VMs and the power we may need. For
example, we may only need one VM to run our application, while they may need thou-
sands. The good news is that the process is the same and can be improved using tech-
niques called infrastructure as code (IaC). IaC is outside the scope of this book but is well
worth studying if you are looking for a better way than manually provisioning servers
through a web console (this process is often referred to as ClickOps).
Now that we have a remote server ready, we need a way to start controlling it. For this,
we will use a protocol known as Secure Shell (SSH), which is a secure way to log in and
connect to our VM. Once connected, we can start configuring it and running all kinds
of commands. SSH is how we access the server’s command line to do things like install
server-based software, work with application dependencies, configure a bare Git repos-
itory, and ultimately deploy our application. (IaC can also do wonders for this process
as well.)
¡ A remote host
¡ An IP address or domain name (figure 3.2)
¡ A username
¡ A password or an installed SSH key
¡ A connection to the same network as the remote host (e.g., the internet)
Four of these five things were completed when we provisioned our VM. I’ll go ahead
and assume you are connected to the internet to access our remote host on Akamai
Linode.
Creating and connecting to a remote server 41
The username of root is the default for many Linux distributions and Ubuntu operat-
ing systems. Some cloud providers change this username, and it may change on Aka-
mai Linode in the future as well. In our case, we can find this information within the
Akamai Linode console, as seen in figure 3.3.
Now that we have all the information we need, let’s connect to our remote host.
Depending on your configuration, you may be prompted for your root user password.
With this in mind, open your command line (Terminal or PowerShell) and enter the
command shown in the following listing.
Since this is our first time connecting to this remote host and this particular IP address,
a few standard processes will occur. The first process is a prompt, like in figure 3.4, that
is asking if we want to allow our local computer to trust and connect to the remote
server. This authenticity check should only happen one time unless something has
changed on the remote host or our local machine’s records of remote hosts.
42 Chapter 3 Manual deployment with virtual machines
If you see this, type yes to continue and skip to the next section. After you confirm with
yes, this message will not appear again unless you connect with a new computer or you
remove the host IP address (e.g. 172.104.195.109) from your ~/.ssh/known_hosts
file. If you do not see this response, there are a few possible reasons:
After we verify the authenticity of the remote host, we will be prompted for the root
user password. You will be prompted for this password every time you connect to this
remote host unless you have an SSH key installed on the remote host. Installing an
SSH key on a remote host for password-less connect with SSH is covered in appendix
C. At this point, your local command line should become the remote command line
that resembles figure 3.5.
If you see this output, you have successfully connected to your remote host. For the
second time in this chapter, it’s time to celebrate because your computer is now con-
nected directly to another computer over the internet without using a web browser!
Congratulations!
Serving static websites with NGINX 43
It might not surprise you to learn that your first step will be to update your system. We
are not upgrading the operating system but rather updating our system’s list of available
packages.
Before we run the command to update, let’s learn about what sudo and apt are.
sudo is a command that allows you to run a command as the superuser with special per-
missions to execute or install something. sudo should be used sparingly, but it is often
required when we make changes to the software or configuration of the system. apt is
a package manager for Debian-based Linux distributions. You can consider this com-
mand similar to npm or pip, because it installs all kinds of third-party software on our
behalf but specifically for Linux systems. apt can also be used to install various program-
ming language runtimes like Python and Node.js, as shown in the following listing.
Listing 3.2 Using apt to update the system’s list of available packages
sudo apt update This is a common command we will use when we start
working with containers in future chapters.
We now have the perfect combination of ingredients to create our first static website
on our remote host. It will be incredibly simple and use an amazing tool called NGINX
(pronounced “engine-x”).
Every time you install software on a Linux computer that is distributed through the
APT Package Manager (apt), you should always update the list of available packages.
This is because the list of available packages is updated frequently, and we want to
ensure we are installing the latest version of the software. After we update this list, we
will install NGINX with the following listing’s command.
44 Chapter 3 Manual deployment with virtual machines
When prompted to install NGINX, type y and press Enter. This will install NGINX and
all of its dependencies. A way to automate the apt install <package> command, is
to include the -y flag so we can automatically agree to any prompts that might appear,
ending up with a command like sudo apt install nginx -y.
Once NGINX finishes installing, you open your IP Address in your web browser (e.g.,
http://172.104.195.109). You should see something like figure 3.6.
Congratulations! You have successfully installed NGINX on your remote host and are
now serving a static website to the world. We still need to configure this webpage to
display our own content, but for now, this is exciting.
The second reason I was afraid to make changes was that I did not treat my VMs as if they
were ephemeral. Since I was using FTP, I got into this nasty habit of treating a VM like an
external hard drive that happens to run my software as well. This is flawed logic because
a VM is so easy to destroy and recreate that using it to persist data is a bad idea. Treating
VMs as ephemeral will also help you prepare your code and projects for the correct tools to
ensure that if you do need to destroy a VM, you can do so without losing any data.
With all this in mind, let’s look at a few ways to remove NGINX and start over:
¡ Destroy the VM—This is the easiest way to start over and one I recommend you
practice doing it.
¡ Remove NGINX via the APT package manager—This is a more common approach,
because destroying and setting up VMs can get tedious, especially without auto-
mation and IaC tools:
¡ sudo apt remove nginx --purge and sudo apt autoremove will remove
NGINX and any other packages that are no longer needed.
¡ sudo apt purge nginx and sudo apt autoremove will remove NGINX and
any other packages that are no longer needed.
¡ sudo apt purge --auto-remove nginx will remove NGINX and any other
packages that are no longer needed.
Now that we have a way to install and remove NGINX, it’s time we learn how to modify
the default HTML page that NGINX serves.
sudo rm /var/www/html/index.nginx-debian.html
Let’s create a new HTML file to serve as our new web page. To do this, we’ll use a tech-
nique that uses cat (a command for displaying the contents of a file to the terminal),
the EOF (End Of File) command, and a file path to create a new multiline file. The
following listing shows an example of the syntax for this command.
46 Chapter 3 Manual deployment with virtual machines
Following this same syntax, let’s see a practical example of using it by creating a new
HTML file in the following listing.
To verify the contents of this new file we can use cat once again with the command cat
/var/www/html/index.html.
At this point, our /var/www/html/ directory should just have index.html in it. Let’s
open up our IP address to review the changes on the actual internet, as seen in figure
3.7.
As you may notice in figure 3.7, the IP address has changed from previous examples.
Take a moment and ask yourself why you think that is. Was it a typo? A mistake in the
book? No, it’s simply that I performed the same exercise I asked you to do moments
ago: destroying the VM and starting over. Throughout this book, we’ll see IP addresses
constantly changing because we continuously recycle our VMs.
Self-hosted remote Git repositories 47
It’s important to note that we did not actually touch anything related to NGINX’s
configuration, but we did use the built-in features to serve a new HTML page. If you
know frontend technologies well, you could improve the HTML we added significantly
and make it look much better. For now, we’ll leave it because it’s time to set up a remote
Git repository to store our application code.
With these items in mind, we still want to understand the mechanics of using a self-
hosted Git repository as a means to transport our local code into production. In future
chapters, we will transition this method toward hosting our code solely on GitHub and
deploying through an automation pipeline.
In chapter 2, we configured two web applications that both had a local and private
Git repository as well as a remote third-party Git repository hosted on GitHub (public or
private). For the remainder of this section, we are going to configure one more location
for our code for each project as a remote and private bare Git repository on our VM.
A bare Git repository is a repository that does not have a working directory, which just
means that we have the history of changes but no actual code. This also means that we
will not edit the code that lands on our server, but rather, we will only push and pull
code from the bare repository. After our code exists in a bare repository, we will use a
48 Chapter 3 Manual deployment with virtual machines
Git-based hook to unpack our code into a working directory that will be the basis for our
deployed application.
The end-to-end process of creating a remote and private bare repo is going to go like this:
1 Log into the remote server via SSH and install Git.
2 Create a bare Git repository for our Python and Node.js applications.
3 Update our local Git repository to point to the new remote repository, including
any SSH keys as needed.
4 Push our code to the remote repository.
5 Create a Git hook that will allow us to
6 Check out our code into a working directory.
7 Install or update our app’s dependencies.
8 Restart any web server processes.
ssh root@<your-ip>
Now that we are logged in, we can install Git on our server. In the following listing, we
will use the same command we used in listing 3.8 to install Git on our server.
apt-get update
apt-get install git -y
Now that we have Git installed, we can create our bare Git repositories.
Let’s create the bare Git repos for both Python Web App and our Node.js Web App,
as shown in the following listing.
mkdir -p /var/repos/roadtok8s/py.git
cd /var/repos/roadtok8s/py.git
git init --bare
git symbolic-ref HEAD refs/heads/main
mkdir -p /var/repos/roadtok8s/js.git
cd /var/repos/roadtok8s/js.git
git init --bare
git symbolic-ref HEAD refs/heads/main
Listing 3.9 highlights the same process we went through on GitHub (in chapter 2),
except we used the command line instead of a website to create these new remote
repositories. The format of the URL for these remote repositories will be as follows:
ssh://root@$VM_IP_ADDRESS/var/repos/roadtok8s/py.git
ssh://root@$VM_IP_ADDRESS/var/repos/roadtok8s/js.git
Replace $VM_IP_ADDRESS with the IP address of our current VM. To do this, we can
use curl ifconfig.me or manually look it up in the Akamai Linode console as we did
before. I prefer using curl as it’s built-in with most Linux distributions, and it’s far
more automated.
Since we used the format $VM_IP_ADDRESS we can use built-in string substitution in
bash to replace this value with the IP address of our VM. We can do this by running the
command in the following listing.
Your exact output from these commands will vary based on the IP address of your VM
and thus is omitted from this book. Be sure to use your actual output before continuing.
Alarm bells should be going off in your head based on what we did in chapter 2
because now we can update our local code to point to these remote repositories, and we
can even start pushing code here as well.
50 Chapter 3 Manual deployment with virtual machines
An example of this process for our Python Web App is as follows: . cd ~/Dev/road-
tok8s/py/ . git remote add vm ssh://[email protected]/var/repos/roadtok8s/
py.git (Replace 45.79.47.168 with your IP) . git push vm main.
In this example, we have used the remote named vm instead of origin for two reasons:
¡ Origin—This is already taken up by our GitHub remote repo as the default name
for a remote repo.
¡ vm—Using this name signifies it is out of the norm for Git, and in future chap-
ters, we’ll move towards using just GitHub. This is also a signal that vm is a special
case related to the lessons you are learning in this book.
Repeat this same process again for your Node.js. If you have a successful push to your
VM, your output will resemble figure 3.8.
If these pushes were not successful, you would see an error message. Here are a few
error messages you may have encountered and what they mean:
¡ fatal: ‘vm’ does not appear to be a Git repository—Did you use git remote add vm
ssh://. . .? If not, this is likely the error you will see.
¡ fatal: Could not read from remote repository—Do you have permission to access the
remote repo? If not, this is likely the error you will see.
Self-hosted remote Git repositories 51
¡ error: src refspec main does not match any—This means your local branch is not main
as we created in chapter 2. A quick fix can be as simple as git branch -m master
main assuming that master is the branch you have been using.
If your local push was done correctly, our code should exist on our VM, but if you were
to navigate into the bare repositories, you’ll be met with some confusion as the code
may appear to be nonexistent. Let’s clarify this by checking out the contents of the
bare repositories.
mkdir -p /opt/projects/roadtok8s/py
mkdir -p /opt/projects/roadtok8s/js
The path /opt/ is a common location for storing code on Linux-based systems.
The path /opt/projects/roadtok8s/ is where we will store our code for this book. The
path /opt/projects/roadtok8s/py/ is where we will store our Python code, and the
path /opt/projects/roadtok8s/js/ is where we will store our Node.js code.
Now that we have these two folders created, it’s time to configure our Git hook to do
the following:
¡ Clone the code from the bare repository to the directory where we will run our
code.
¡ Install the dependencies for our project.
¡ Run or restart any running processes that allow our projects to run.
First, let’s create the Git hook for our Python project. To do this, we will create a file
named post-receive in the hooks directory of our bare repository. This file will be exe-
cuted after a push to the remote repository. We will use this file to clone the code from
the bare repository to the directory where we will run our code. After we create the
file, we’ll make it executable so Git will have permission to run the file, as shown in the
following listing.
52 Chapter 3 Manual deployment with virtual machines
Listing 3.12 Creating the Git hook for our Python project
cd /var/repos/roadtok8s/py.git/hooks
touch post-receive
chmod +x post-receive This will make the file executable.
The filename post-receive is a special name for a Git hook so be sure to stick with this
name otherwise the hook will not be executed. This file will contain the following:
¡ git checkout—This is the root command for checking out code with Git.
¡ HEAD—This is a reference specifying the latest commit on the branch of code we
are checking out.
¡ --work-tree=<path/to/working/dir/>—This is a Git-specific flag that specifies
the directory where the code will be checked out to.
¡ --git-dir=<path/to/bare/repo/dir/>—This is a Git-specific flag that speci-
fies the directory where the bare repository is located. Using this flag allows our
post-receive hook to be a bit more flexible.
¡ -f—This is a Git-specific flag that forces the checkout to overwrite any existing
files in the working directory. This is useful if we want to update our code without
having to manually delete the existing code.
With these concepts in mind, let’s create the post-receive file with the command in the
following listing.
export WORK_TREE=/opt/projects/roadtok8s/py
export GIT_DIR=/var/repos/roadtok8s/py.git
chmod +x "$GIT_DIR/hooks/post-receive"
Repeat this process for the Node.js project, replacing py with js wherever necessary.
Since the post-receive hook is just a bash script, we can call this hook at any time to
ensure it’s working properly. Any time you make changes to this hook, it’s recom-
mended that you call it immediately to ensure it’s working properly, and code changes
are being applied, as shown in the following listing.
export PY_GIT_DIR=/var/repos/roadtok8s/py.git
export JS_GIT_DIR=/var/repos/roadtok8s/js.git
bash $PY_GIT_DIR/hooks/post-receive
bash $JS_GIT_DIR/hooks/post-receive
Installing the apps’ dependencies 53
If you see the error error: pathspec 'HEAD' did not match any file(s) known to
git, there’s a good chance you did not push your code from your local computer into
your VM.
If you see an error or not, let’s verify that we can push our code to our server right
now with the code in the following listing.
cd ~/dev/roadtok8s/py
git push vm main
cd ~/dev/roadtok8s/js
git push vm main
Once that’s pushed, you can verify the code on your server by running the following
SSH commands:
ssh root@<your-ip> ls -la /opt/projects/roadtok8s/py
ssh root@<your-ip> ls -la /opt/projects/roadtok8s/js
At this point, we have all of the code on our machine, and we have a method for con-
tinuously installing and updating that code. The next step is to install the various soft-
ware dependencies for our projects so they can actually run.
¡ Python 3 version 3.8 or higher—Verify this in your SSH session with python3
--version.
¡ Node.js version 16.14 or higher—Verify this with node --version.
Since we used Ubuntu 20.04, Python 3.8 should already be installed where Node.js is
not. Regardless of what is currently installed, we are going to run through the process
of installing different versions of Python and Node.js, so you’re better prepared for
long-term usage of these tools.
54 Chapter 3 Manual deployment with virtual machines
Each one of these packages is required to configure our Python application’s environ-
ment and versions in a way that’s suited for production use, as shown in the following
listing. These packages will be installed once again when we start using containers with
our Python application as well.
sudo apt update Always run this before installing new packages.
Using -y will sudo apt install python3 python3-pip python3-venv build-essential -y
automatically
answer yes to While installing, you may see a warning about Daemons using outdated libraries. If
any prompts. you do, hit Enter or Return to continue. Daemons are background processes that run
on your machine that are updated from time to time, and this potential warning is an
example of that.
After the Python installation completes, you should run sudo system reboot to
restart your machine. This will ensure that all of the new packages are loaded and out-
dated daemons are refreshed. Running sudo system reboot will also end the SSH
session, so you’ll have to reconnect to continue. Rebooting after installs like this is
Installing the apps’ dependencies 55
not always necessary, but since we changed global Python 3 settings, it’s a good idea to
reboot. Once our system is restarted, verify that Python 3 with python3 --version is
installed and that the version is 3.10 or greater.
At this point, your mental alarm bells might be ringing because of the line Python
. . . version is 3.10 or greater. This installation process will help us in deploying our basic
Python application, but it leaves us with the question: Why did we not specify the exact
version of Python we need? Installing specific versions of programming languages on
VMs, including Python, is far outside the scope of this book. We actually solve this ver-
sioning installation problem directly when we start using containers later in this book. The
reason that we are not installing a specific version of Python 3 is that there are far too
many possible ways to install Python 3, and our code just needs Python 3.8 or higher to
run in production.
It is now time to create a Python virtual environment to help isolate our Python proj-
ect’s software requirements from the system at large (see listing 3.17). To do this, we
will create a dedicated place in the /opt/venv directory for our virtual environment.
Removing the virtual environment from the project code will help us keep our project
directory clean and organized, but it is ultimately optional, just as it is on your local
machine. We will use the /opt/venv location time and time again throughout this book,
as it is my preferred location for virtual environments on Linux-based systems as well as
in Docker containers.
Going forward, we can use absolute paths to reference the available executables within
our virtual environment. Here are a few examples of commands we will likely use in
the future:
In production, using absolute paths will help mitigate problems that may occur
because of system-wide Python installations as well as Python package version conflicts.
What’s more, if you use consistent paths and locations for your projects, you will end
up writing less configuration code and get your projects running faster and more reli-
ably. Absolute paths are key to a repeatable and successful production environment.
Now, we have the conditions to install our Python project’s dependencies using pip
from the virtual environment’s Python 3 executable. We will use the requirements.txt
file that we pushed and checked out with Git earlier in this chapter, located at /opt/venv/
roadtok8s/py/src/requirements.txt. See the following listing.
56 Chapter 3 Manual deployment with virtual machines
We are almost ready to start running this application on the server, but before we do, I
want to update my post-receive hook for Git so the command in listing 3.18 will run every
time we push our code to our remote repository. This will ensure that our production
environment is always up to date with the latest code and dependencies.
Update the Git hook for the Python app
Let’s have a look at another practical use case for Git hooks: installing application soft-
ware. To do this, we can use the command from listing 3.18 in our post-receive hook spe-
cifically because we used absolute paths to the Python executable and the requirements.
txt file, as shown in the following listing.
Listing 3.19 Updating the Git hook for the Python app
export WORK_TREE=/opt/projects/roadtok8s/py
export GIT_DIR=/var/repos/roadtok8s/py.git
In this case, we overwrite the original post-receive hook with the contents of listing 3.19.
If you want to verify this hook works, just run bash /var/repos/roadtok8s/py.git/
hooks/post-receive, and you should see the output from the pip command. With all
the dependencies installed for our Python application, we are ready to run the code!
Run the Python application
We have reached a great moment in our journey: we have a Python application that we
can run on our server. It’s time to run the application, see if it works, and verify we can
access it from our browser. Both things should be true, but there’s only one way to find
out.
First, let’s discuss the Python packages we need to run a production-ready Python
application:
Let’s now run this baseline configuration and see if it works, as shown in the following
listing.
/opt/venv/bin/gunicorn \
--worker-class uvicorn.workers.UvicornWorker \
--chdir /opt/projects/roadtok8s/py/src/ \
main:app \
--bind "0.0.0.0:8888" \
--pid /var/run/roadtok8s-py.pid
Once you run this command, your application should start and return a domain of
http://0.0.0.0:8888, which matches what we used to bind gunicorn to. If you have
another application running on the specified port, this domain will not appear, but rather
you will get an error. If all went well, you should see the same output as in figure 3.9.
You might be tempted to open your local browser to http://0.0.0.0:8888, but this will
not work because this is running on cloud-based server. What we can do, thanks to the
mapping of 0.0.0.0, is open our browser to http://<your-ip>:8888 and see the applica-
tion running identically to your local version; see figure 3.10 for a live server example.
Now it’s time to move over to our Node.js application. We’ll install Node.js and our
Express.js application on the same server as our Python application. Doing so makes
implementation significantly easier but scaling significantly harder. Gracefully scaling
these applications is one of the core reasons to adopt Kubernetes.
Before we install nvm, your intuition might be to take the Linux-based approach
using the apt package manager and the command apt-get install nodejs. While
a lot of times this can be a suitable approach, just as we saw with Python, apt-get may
install a version that’s just too far out of date. At the time of writing this book, apt-get
install nodejs installs a much older version of Node.js (version 12.22.9) than the one
we need to safely run our application.
With that said, let’s install nvm and the latest LTS version of Node.js using the Linux
package curl along with an official nvm installation script.
Installing the Node Version Manager
The Node Version Manager installation script and related documentation can be
found at http://nvm.sh. Here’s the process for how we’ll install it:
¡ Declare a bash variable NVM_VERSION and set it to the version of nvm we want to
install, and in this case, we’ll use v0.39.3.
¡ curl -o- <your-url>—Using curl -o- will open a URL for us and output the
response to the terminal. This is a bit like echo 'my statement' but for URLs.
Using this will allow us to chain commands together with a pipe |.
¡ bash—We’ll pipe the response from the URL and tell the command line (in this
case, the bash shell) to run the script.
Let’s see how this command looks in practice, as shown in the following listing.
Both of these ways are valid and will work, but I tend to reload my bash profile because
it’s a bit faster. The bash profile is essentially a file with a bunch of user-related configu-
ration settings for commands and shortcuts.
With nvm installed and ready, let’s verify it works and install the latest LTS version of
Node.js (at the time of writing this book, v18.15.0) using nvm install.
nvm --version If this fails, try to exit your SSH session and reconnect.
Option 1 for nvm install --lts
installing the nvm install 18.15.0 Option 2 for installing a specific version of Node.js
latest LTS
version of As nvm completes, you should see the following output (figure 3.12).
Node.js
Assuming the Node.js installation via nvm was successful, your system should now have
the following commands:
¡ node—The Node.js runtime; check the version installed with node –version.
¡ npm—The Node Package Manager; check the version installed with
npm –version.
¡ npx—A utility for running Node.js packages; check the version installed with
npx –version.
Now you have a repeatable way to install Node.js and the associated tools on your
machine. This is a great way to ensure that you can install the same version of Node.js
on any Linux machine you need to. nvm is supported on other operating systems, too;
it’s just not as easy to install as it is on Linux and Ubuntu. We can now finally get our
Express.js dependencies installed and run our application.
Install our application dependencies
As we discussed in chapter 2, we’ll be using npm to install our application dependen-
cies for our Express.js application. We’ll start by navigating to our application directory
Installing the apps’ dependencies 61
cd /opt/projects/roadtok8s/js
npm install -g npm@latest
npm install
npm list
Just as before, this will create a node_modules directory in our application directory.
With Node.js applications, we do not change the location of the node_modules like
we did with the venv location for our Python project because Node.js applications do
not require this step, for better or worse. We can now finally get our Express.js depen-
dencies; let’s update our post-receive hook to install them each time a new commit is
pushed to our repository.
Update the Git hook for the JavaScript app
Once again, we are tasked with updating our post-receive hook to install our applica-
tion dependencies. In the following listing, we’ll use the npm command to install our
dependencies.
Listing 3.24 Updating the Git hook for the Node app
export WORK_TREE=/opt/projects/roadtok8s/js
export GIT_DIR=/var/repos/roadtok8s/js.git
EOF
In this case, we overwrite the original post-receive hook with the contents of listing 3.24.
If you want to verify this hook works, run bash /var/repos/roadtok8s/js.git/hooks/
post-receive, and you should see the output from the npm install command. With
all the dependencies installed for our Express.js application, we are ready to run the
code!
is flexible to the PORT changing, as this will come in handy when we start shutting off
ports in later sections The command is as simple as the following listing.
Congratulations! You have successfully installed Node.js and connected it to the out-
side world.
To stop this application, we have three different options:
At this point, we should have the ability to run two different runtimes for two different
applications. The problem we face is ensuring these runtimes actually run without our
manual input. To do this, we will use a tool called Supervisor.
¡ Update the git post-receive hook for various Supervisor-based commands to ensure
the correct version of the application is running.
Let’s start by installing Supervisor and creating a configuration file for our Python
application.
Let’s start with the Python application by creating a configuration file for it. Supervisor
configuration is as shown in the following listing.
export APP_CMD="/opt/venv/bin/gunicorn \
--worker-class uvicorn.workers.UvicornWorker \
main:app --bind "0.0.0.0:8888" \
--pid /var/run/roadtok8s-py.pid"
cat << EOF > /etc/supervisor/conf.d/roadtok8s-py.conf
[program:roadtok8s-py]
directory=/opt/projects/roadtok8s/py/src
command=$APP_CMD
autostart=true
autorestart=true
startretries=3
stderr_logfile=/var/log/supervisor/roadtok8s/py/stderr.log
stdout_logfile=/var/log/supervisor/roadtok8s/py/stdout.log
EOF
Looking at this configuration should be rather intuitive at this point. Let’s create our
Nodejs configuration file, as shown in the following listing.
With both of these configurations in place, we need to create the directories for our
output log files, as shown in the following listing.
Now we have all the conditions necessary to run these applications via Supervisor. Let’s
run the following commands to update the Supervisor configuration and start the
applications.
To start, stop, restart, or get the status of any Supervisor-managed application, it’s
as simple as: sudo supervisorctl <verb> <app-name> where <app-name> is either
roadtok8s-py or roadtok8s-js. Here are a few examples:
The final step in this process is to update our post-receive hooks to restart either of
these applications after the commit is pushed and the installations are complete, as
shown in the following listings.
export WORK_TREE=/opt/projects/roadtok8s/py
export GIT_DIR=/var/repos/roadtok8s/py.git
export WORK_TREE=/opt/projects/roadtok8s/js
export GIT_DIR=/var/repos/roadtok8s/js.git
Now that we have these applications always running, we need to start thinking about how
they can be accessed. To do this, we’ll implement NGINX as a reverse proxy to direct traffic
to the correct application depending on the URL instead of using a port number. After
we do that, we’ll implement a firewall to limit access to this VM only to the ports we need.
Load balancing is merely the process of forwarding traffic to a server that can handle
the traffic. If the server cannot handle the traffic, the load balancer will forward the traf-
fic to another server if it can. Load balancing can get more complex than this, but that’s
the general idea. NGINX is designed to handle load balancing as well, but it’s a concept
we won’t dive too deep into in this book.
It should be pointed out that these are localhost domain names (DNS), but they could
easily be IP addresses or other public domain names.
We configured both applications to run on this server (localhost) and on the default
ports for each application. We can configure NGINX to forward traffic to these applications
by adding the following to our NGINX configuration file, as shown in the following listing.
sudo ln -s /etc/nginx/sites-available/roadtok8s \
/etc/nginx/sites-enabled/roadtok8s
After we create this symbolic link, we need to remove the default configuration file that
NGINX creates when installed by using sudo rm /etc/nginx/sites-enabled/default,
68 Chapter 3 Manual deployment with virtual machines
and then we’ll restart NGINX with sudo systemctl restart nginx. systemctl is a
built-in process manager, much like Supervisor that NGINX uses by default (systemctl is
a bit more complex than Supervisor to configure, which is why we didn’t use it earlier in
the chapter).
Now open up your browser to the following locations:
We now have a robust way to route traffic to our applications, but there’s one glaring
hole: our PORTs are still accessible, too! Let’s fix this by installing a firewall.
Uncomplicated Firewall (UFW) is a simple and effective way to only allow for the pre-
vious PORTs to be available to the outside internet. In other words, UFW will automat-
ically block access to any ports you do not need to keep open. We will block all ports
except for the ones NGINX uses (80 and 443) and for our SSH (22) connections.
Let’s install UFW:
sudo apt update Whenever installing, update apt packages.
sudo apt install ufw -y
Install UFW if it’s not already installed.
By default, UFW is disabled. Before we enable it, we need to configure it to allow for
SSH and NGINX traffic.
UFW has a lot of great features that one might take advantage of, but for our pur-
poses, we’ll just allow for SSH and NGINX traffic. We want SSH traffic to be allowed so
we can still access our VM via SSH and perform pushes via Git. We also want to allow all
NGINX-based traffic to ensure our web applications are accessible. To do this, here are
the commands we’ll run
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
If you forget to allow SSH traffic, your current SSH session will end, and you may lose
access forever. If this happens, you’ll need to delete your VM and start over.
Nginx Full allows for both HTTP and HTTPS traffic, which maps to ports 80 and 443,
respectively. We won’t run HTTPs at this time but it’s ready and available if we ever need to.
Serve multiple applications with NGINX and a firewall 69
After running each of these commands, you will see the output Rules Updated or
Rules Updated (v6) as a response.
Before we enable these changes, let’s verify the current UFW configuration:
ufw show added
You should respond with y and press Enter with a result of Firewall is active and
enabled on system startup.
If all went well, your firewall is now active, and your security has been improved.
There are certainly other steps you might consider taking to further secure your VM,
but those steps are outside the context of this book.
Now that we have a firewall in place, we can test our NGINX configuration by opening
up a new browser window and navigating to http://<your-ip> and http://<your-ip>/
js. You should see the same results as before.
The first time I ever deployed an application was using a variation of what we did in
this chapter. It served me well for many years and served others well during that same
time. I even had my database running on the same machine.
At this point, you should feel a little uneasy with the brittle nature of this setup. It tech-
nically works, but we have a major problem that needs to be addressed: if this machine
goes down, the entire production stack does, too. In other words, this machine is a single
point of failure, and here’s why. The machine is
Here’s a few things you might think of to help mitigate this problem:
While these ideas might help, they do not solve the underlying problem. The best solu-
tion is to break up each component of our production stack into dedicated machines
or services that can run independently of each other.
What’s interesting is that we essentially configured a remote development environment
that happens to have production-like qualities that the public can interact with. If we
removed public access (via UFW) and just allowed SSH access, we would now have a
powerful way to write our code from nearly any device using just SSH, a username, and
a password.
Summary
¡ SSH is a powerful way to access and manage remote machines nearly anywhere
in the world.
¡ NGINX is a tool that enables static website hosting, as well as more robust fea-
tures like reverse proxies and load balancers.
¡ Installing and configuring software manually on a remote machine gives us a
deeper insight into how to use the software and what the expected results are.
¡ Python and Node.js web applications are typically not run directly but rather
through a web server like NGINX.
¡ Web applications should run as background processes to ensure they are always
running with tools like Supervisor.
¡ Configuring a private host for our code is a great way to have redundant backups
as well as a way to deploy code into production.
¡ Firewalls are an easy way to block specific ports from having unwanted access to
our machine.
Deploying with GitHub Actions
When you first learn to drive a car, everything is new, so every step must be carefully
considered. After a few months or years, you start driving without thinking about
every little detail. I believe this is the natural state for all humans—we learn and
focus intently until it becomes automatic and effortless. Software is much the same.
Our built-in human need to remove attention from any given task translates to how
we deploy our software into production.
In chapter 3, we implemented elements of automation using Git hooks and Super-
visor. Those elements are still important, but they’re missing something: automated
repeatability. In other words, if we want to spin up a new server with the exact same
configuration, we have to manually run through all of the same steps once again. To
perform automated repeatability later in this chapter, we’ll use a popular Python-
based command line tool called Ansible. Ansible uses a document-based approach
to automation, which means we declare our desired state, and Ansible will ensure
that state is met. This desired state would include things like NGINX is installed,
71
72 Chapter 4 Deploying with GitHub Actions
configured, and running; Supervisor is installed, configured, and running; and so on.
Ansible is a very powerful tool that we’ll only scratch the surface of in this chapter to
discuss how it pertains to the tasks we’re going to automate, continuing the work we
began in chapter 3.
In addition to configuration automation through Ansible, we are also going to
remove Git hooks from our workflow in favor of CI/CD pipelines. CI/CD pipelines can
handle a few critical pieces for us, including running our configuration automation
and testing our code to ensure it’s even ready for live production. Further, we can use
CI/CD pipelines to house our runtime environment variables and secrets (e.g., applica-
tion-specific passwords, API keys, SSH keys, and so on) to be a single source of truth for
our projects. This can be true regardless of how those variables and secrets are used (or
obtained) in our code.
The value unlocked in this kind of automation comes down to the key phrase porta-
bility and scalability. Creating software that can move as you need is essential to maintain
an adaptable project and keep all kinds of costs in check while still serving your users,
customers, or whomever or whatever needs your software. We’ll start by diving into our
first CI/CD pipeline as a Hello World of sorts to show how simple it can be to manage
software through Git-based CI/CD workflows.
as “live.” Continuous deployment is much like continuous delivery except that the code is
automatically deployed into production without manual approval; it’s end-to-end auto-
mation with building, testing, and deploying to production.
For this book, we will perform CI/CD using a popular tool called GitHub Actions.
GitHub Actions is a free service by GitHub.com that allows us to run code on their serv-
ers when we make changes to our stored repositories. GitHub Actions can be used to
execute all kinds of code and run all kinds of automations, but we’ll use it primarily to
deploy our applications and configure our environments. There are many CI/CD pipe-
line tools that exist to perform the exact actions we’ll do in GitHub Actions, including
open source options like GitLab and nektos/act (see appendix D for how to use act).
This process is the same for many CI/CD tools out there; GitHub Actions is just one of
many.
The workflows you define are up to you and can be as simple or as complex as you
need them to be. They will run on GitHub’s servers and can run any code you tell them
to run just as long as they can. How long you run code and how frequently are up to you.
It’s good to keep in mind that although GitHub Actions has a generous free tier, the
more you use it, the more it costs you.
For this book and many other projects, I have used GitHub Actions thousands of
times and have yet to pay a cent; the same should be true for you. If using GitHub
Actions is a concern, as it might be for some, we will also use the self-hosted and open-
source alternative to GitHub Actions called nektos/act, which is almost a one-to-one
drop-in replacement for GitHub Actions.
Now let’s build a Hello World workflow to get a sense of how to define and run work-
flows. In chapter 2, we created two GitHub repositories; for this section, I’ll use my road-
tok8s-py repo (https://github.com/jmitchel3/roadtok8s-py).
In the root of your Python project, create the path .github/workflows/. This path
is required for GitHub Actions to recognize your workflow definition files. For more
advanced Git users, the workflows will only work on your default branch (e.g., main or
master), so be sure to create these folders on your default branch.
74 Chapter 4 Deploying with GitHub Actions
Now that we have the base GitHub Actions Workflow folder created, we’ll add a file
named hello-world.yaml as our first workflow with the resulting path .github/work-
flows/hello-world.yaml. The workflow is going to define the following items:
We need to convert these items into YAML format because that is what GitHub Actions
workflows require. YAML format is a human-readable data serialization format com-
monly used for configuration and declarative programming. YAML is often great for at-a-
glance reviews because it’s very easy to read, as you can see in the following listing.
While listing 4.1 is just a simple example, you can see the format in action.
YAML is commonly used for declarative programming, which is concerned with the
output and not the steps to get there. Imperative programming is concerned with the steps
to get to the output. Python and JavaScript are both examples of imperative programming
because we design how the data flows and what the application must do. YAML can be
used for both kinds of programming paradigms.
Listing 4.2 is a YAML-formatted file that GitHub Actions workflows can understand.
GitHub Actions is an example of declarative programming because we declare what we
want to be done regardless of how exactly it gets done.
jobs:
build:
runs-on: ubuntu-latest
steps:
Getting started with CI/CD pipelines with GitHub Actions 75
- uses: actions/checkout@v3
- name: Hello World
run: echo "Hello World"
After you create this file locally, be sure to commit and push the file to GitHub with the
following:
git add .github/workflows/hello-world.yaml
git commit -m "Create hello-world.yaml workflow"
git push origin main
¡ on:push—This tells GitHub Actions to run this workflow every time we push
code to our repository. We can narrow the scope of this configuration, but for
now, we’ll keep it simple.
¡ on:workflow_dispatch—This tells GitHub Actions that this workflow can be
manually triggered from the user interface on GitHub.com. I recommend using
this manual trigger frequently when learning GitHub Actions.
¡ runs-on: ubuntu-latest—Ubuntu is one of the most flexible options to use
for GitHub Actions so we will stick with this option. There are other options for
other operating systems but using them is beyond the scope of this book. Con-
sider reading more about GitHub Action Runners in the official docs (https://
docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-
runners) and review the available images from GitHub (https://github.com/
actions/runner-images#available-images).
¡ steps—This is where we define the steps, or code, we want to run. In our case, we
only have one step, which is to run the command echo "Hello World". This com-
mand will print the text “Hello World” to the console. Steps have several options
including
¡ uses—This is the name of the action we want to use (if it’s not built into
GitHub Actions). In our case, we’re using the built-in action actions/
checkout@v3, which is used to check out our code from GitHub. This is a
required step for nearly all workflows.
¡ name—We can optionally name each step; in this case, we just used “Hello
World”.
¡ run—This is the barebones command that allows us to execute any other com-
mand, such as echo "my text" or even python3 my_script.py.
Even in this simple workflow, we can see that GitHub Actions workflows can start to
get rather complex rather quickly because they are so flexible. The flexibility is a great
thing, but it can also be a bit overwhelming at first. The best way to learn GitHub
Actions is to start with a simple workflow and then build on it as you need to.
Let’s review this workflow on GitHub.com by visiting the Actions tab of any given
GitHub repository, as seen in figure 4.1.
76 Chapter 4 Deploying with GitHub Actions
Once at this tab, you should see our GitHub Action listed in the sidebar, as seen in figure
4.2. If you do not see our workflow listed, you did not commit or push your code to the
GitHub repository correctly, so you will have to go back and review the previous steps.
The name listed here matches directly to the declaration of name: Hello World in the
workflow file in listing << #github-actions-hello-world>>. To maintain the ordering of
these names, it’s often a good idea to put a number in front of the name (e.g., name: 01
Hello World). Now click Hello World and then click Run Workflow, as seen in figure 4.3.
The reason we can even see Run Workflow is directly tied to the definition of on:
workflow_dispatch in the workflow file.
Given that we included on:push, there’s a good chance that this workflow has already
run as denoted by Create hello-world.yaml in the history of this workflow, as seen in fig-
ure 4.3. Click any of the history items here to review the output of the workflow, as seen
in figure 4.4.
Congratulations! You just ran your first GitHub Actions workflow. This is a great first
step into the world of CI/CD pipelines and GitHub Actions. Whether or not your
workflow was successful is not the point. The point is you just used a serverless CI/CD
pipeline to run code on your behalf. This is a very powerful concept we’ll use through-
out this book.
GitHub Actions workflows are powerful indeed, but they become even more pow-
erful when we give them permission to do a wider variety of things on our behalf. In
the next section, we’ll look at how to give GitHub Actions workflows permission to do
things like install software on a remote server by storing passwords, API keys, SSH keys,
and other secrets.
78 Chapter 4 Deploying with GitHub Actions
# windows/mac/linux
ssh-keygen -t rsa -b 4096 -f github_actions_key -N ""
This command results in the following files in the directory you ran the command in
Once again, you should never expose or share your personal, private SSH keys (e.g.,
~/.ssh/id_rsa) because it poses a major security concern, which is exactly why we cre-
ated a new SSH key pair for this purpose.
At this point, we will add the private key (e.g., github_actions_key without the
.pub) to the GitHub repos we created in chapter 2. Here are the steps:
At this point, click New Repository Secret and enter SSH_PRIVATE_KEY. Then paste
the contents of your newly created private key (e.g., github_actions_key) into the
value field. Click Add Secret to save the new secret. Figure 4.6 shows what this should
look like.
Repeat this same process for any new secret you want to add. Figure 4.6 only high-
lights my Python application (https://github.com/jmitchel3/roadtok8s-py), but
I recommend you repeat this exact same key on your Node.js application’s repo as
well (https://github.com/jmitchel3/roadtok8s-js). With the private key installed on
GitHub Actions secrets, it’s time to take the public key to the Akamai Connected Cloud
Console.
A more in-depth guide on SSH keys can be found in appendix D. With our SSH keys
installed, let’s provision a new virtual machine on Akamai Connected Cloud.
Getting started with CI/CD pipelines with GitHub Actions 81
Figure 4.9 Akamai Linode: create VM GitHub Actions with SSH keys.
After you configure these settings, click Create Linode and wait for the IP address to be
assigned, as seen in figure 4.10. Once the IP address is assigned, we will move on to the
next section, where we use GitHub Actions to install NGINX on this virtual machine.
Figure 4.10 shows the IP address of the newly provisioned virtual machine as
45.79.56.109. This IP address will be different for you, so be sure to use the IP address
assigned to your virtual machine.
Back in your GitHub Repo’s Actions secrets, you need to add this IP address just like
we did the SSH private key in section 4.1.3. Use the secret name of AKAMAI_INSTANCE_
IP_ADDRESS and the secret value with your IP address as seen in figure 4.11.
With all of the conditions set correctly, we are now going to create a GitHub Actions
workflow that will install NGINX on our newly created virtual machine. The workflow
and process are as simple as it gets, but it’s an important next step to using automation
pipelines.
wanted to run sudo apt update and sudo apt install nginx -y on our newly provi-
sioned virtual machine, we would run the following commands:
ssh root@my-ip-address sudo apt get update:
ssh root@my-ip-address sudo apt get install nginx -y
Remember, the -y will automatically approve any prompts during this installation, thus
making it fully automated, assuming the nginx package exists.
With these two commands in mind, our new workflow will use two of our GitHub
Actions secrets:
We must never expose secrets in GitHub Actions or Git repositories. Secrets are meant
to be secret and should be treated as such. Storing secrets in GitHub Actions is a great
way to keep them secure, out of your codebase, and out of the hands of others.
Using stored secrets within a GitHub Actions workflow requires special syntax that
looks like this: ${{ secrets.SECRET_NAME }} so we’ll use the following:
¡ The private key—Available locally (e.g., cat ~/.ssh/id_rsa) and executable with
chmod 600 ~/.ssh/id_rsa
¡ The public key—Installed remotely (e.g., in ~/.ssh/authorized_keys)
¡ An IP address (or hostname)—Accessible via the internet (or simply to the machine
attempting to connect)
We have the conditions set for passwordless SSH sessions, but we still need to install the
private key during the execution of a GitHub Actions workflow. This means we will inject
this key during the workflow so future steps can use this configuration. Luckily for us,
this is a straightforward process, as we can see in listing 4.4. Create a new workflow file at
the path .github/workflows/install-nginx.yaml with the contents of the following listing.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Configure the SSH Private Key Secret
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Set Strict Host Key Checking
run: echo "StrictHostKeyChecking=no" > ~/.ssh/config
- name: Install NGINX
run: |
export MY_HOST="${{ secrets.AKAMAI_INSTANCE_IP_ADDRESS }}"
ssh root@$MY_HOST sudo apt update
ssh root@$MY_HOST sudo apt install nginx -y
As we can see, this workflow introduced two new steps that we did not have in our Hello
World workflow, while everything else remained virtually untouched. Let’s review the
new steps:
1 Configure the SSH private key secret—Once again, we see that we use run: to execute
a command. In this case, we use a multiline command by leveraging the pipe (|)
to enable multiple commands within this single step. We could break these com-
mands into separate steps for more granular clarity on each step (e.g., if one of
the commands fails, often having multiple steps can help).
2 Set strict host key checking—Typically, when you connect via SSH on your local
machine, you are prompted to verify the authenticity of the host key. This step
skips that verification process because GitHub Actions workflows do not allow for
user input. You could also consider using
echo "${{ secrets.AKAMAI_INSTANCE_IP_ADDRESS }} $(ssh-keyscan -H
${secrets.AKAMAI_INSTANCE_IP_ADDRESS})" > ~/.ssh/known_hosts
3 Install NGINX—This step is very similar to the previous step, except we do not
need to configure the SSH private key secret again because the entire build job
has access to the results of previous steps. We simply run the two commands to
install NGINX on our remote server.
Now you can open your web browser to your IP address and see the NGINX Hello
World page, as we have seen before. Seeing this page might not be too exciting because
we’ve seen it before, and there’s not much going on here, but the fact that we did this
with GitHub Actions is a big deal. We can now use GitHub Actions to install software on
remote servers, and we can do it in a repeatable and automated fashion. This is a big
step toward a fully automated CI/CD pipeline.
Virtual machine automation with Ansible 85
At this point, we could dive into the wonderful world of ssh and scp (a command to
copy files) to install and configure our remote host, but instead, we’re going to imple-
ment a more robust solution, Ansible, that will allow us to install and configure our
remote host. From a developer’s perspective, Ansible and GitHub Actions look a lot
alike because they both use YAML-formatted files, but Ansible is designed to automate
the configuration of remote hosts.
¡ Is NGINX installed?
¡ Is NGINX running?
¡ Do we need to add or update our NGINX configuration?
¡ Will we need to restart or reload NGINX after we configure it, if we do need to
configure it?
¡ Do we need to perform this same exact process on another machine? How about 10
others? 1,000 others?
Since this command, sudo apt install nginx -y, raises several really good questions,
we must consider what they are all about: desired state. Desired state is all about ensur-
ing any given machine is configured exactly as needed so our applications will run as
intended. I picked NGINX because it’s an incredibly versatile application that has very
little overhead to get working well. NGINX is also a great stand-in for when we need to
install and configure more complex applications like our Python and Node.js applica-
tions. NGINX also gives us features that we can use on both of our applications, such as
load balancing and path-based routing.
Ansible, like other infrastructure-as-code (IaC) software, is a tool that helps us achieve
a desired state on our machines regardless of the current state of those machines so we
can reach a specific outcome through YAML-based files called Ansible Playbooks. Ansible
is an example of declarative programming. We will use Ansible to do the following:
Ansible works through a secure shell connection (SSH), so it remains critical that we
have the proper SSH keys on the machine to run Ansible and the machine or machines
we aim to configure.
Ansible is a powerful tool with many options, so I recommend keeping the Official
Documentation (https://docs.ansible.com/ansible/latest/) handy to review all kinds
86 Chapter 4 Deploying with GitHub Actions
of configuration options. Let’s create our first Ansible Playbook along with a GitHub
Actions workflow to run it.
1 Set up Python 3.
2 Install Ansible.
3 Implement the private SSH key.
4 Create an Ansible inventory file for our remote host.
5 Create a Ansible default configuration file.
6 Run any Ansible Playbook.
Before we look at the GitHub Actions Workflow, let’s review the new terms:
¡ Inventory file—Ansible has the flexibility to configure a lot of remote hosts via
an IP address or even a domain name. This file is how we can define and group
the remote hosts as we see fit. In our case, we’ll use one remote host within one
group.
¡ Default configuration file—Configuring Ansible to use the custom inventory file we
create, along with a few SSH-related options, is all we need to get started. We’ll
use the default configuration file to do this.
¡ Ansible Playbook—Playbooks are the primary feature of Ansible we’ll be using.
These are YAML-formatted files that define the desired state of a remote host or
group of remote hosts. For larger projects, playbooks can get rather complex.
In listing 4.5, we see the workflow file that will set up Ansible and our remote host.
Create a new workflow file at the path .github/workflows/run-ansible.yaml with the
contents of the following listing.
jobs:
run-playbooks:
Virtual machine automation with Ansible 87
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Python 3
uses: actions/setup-python@v4
with:
python-version: "3.8"
- name: Upgrade Pip & Install Ansible
run: |
python -m pip install --upgrade pip
python -m pip install ansible
- name: Implement the Private SSH Key
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Ansible Inventory File for Remote host
run: |
mkdir -p ./devops/ansible/
export INVENTORY_FILE=./devops/ansible/inventory.ini
echo "[my_host_group]" > $INVENTORY_FILE
echo "${{ secrets.AKAMAI_INSTANCE_IP_ADDRESS }}" >> $INVENTORY_FILE
- name: Ansible Default Configuration File
run: |
mkdir -p ./devops/ansible/
cat <<EOF > ./devops/ansible/ansible.cfg
[defaults]
ansible_python_interpreter = '/usr/bin/python3'
ansible_ssh_private_key_file = ~/.ssh/id_rsa
remote_user = root
inventory = ./inventory.ini
host_key_checking = False
EOF
- name: Ping Ansible Hosts
working-directory: ./devops/ansible/
run: |
ansible all -m ping
- name: Run Ansible Playbooks
working-directory: ./devops/ansible/
run: |
ansible-playbook install-nginx.yaml
- name: Deploy Python via Ansible
working-directory: ./devops/ansible/
run: |
ansible-playbook deploy-python.yaml
The two key files created in this workflow are inventory.ini and ansible.cfg. This work-
flow is an end-to-end example of what needs to be done to run an Ansible workflow. The
command ansible all -m ping simply ensures that our previous configuration was
done correctly and in the correct location. We see the declaration working-directory
for each of the ansible commands (ansible and ansible-playbook) simply because
ansible.cfg is located in and references the inventory.ini file in the working-directory.
88 Chapter 4 Deploying with GitHub Actions
After you commit this workflow to Git, push it to GitHub, and run it, you will see the
result in figure 4.12.
Ansible can verify that it’s connecting to our remote host and that it fails to run the
install-nginx.yaml workflow (because it hasn’t been created yet). If you see the
same result, then you’re ready to create your first Ansible Playbook. If you see anything
related to connecting to localhost, there’s a good chance that your inventory.ini file was
incorrectly created or the IP address is no longer set correctly in your GitHub Actions
secrets. If you see anything related to permission denied or ssh key errors, there’s a good
chance that your SSH private key is not set up correctly.
Assuming you got the result shown in figure 4.12, we’re now ready to create our first
Ansible Playbook. Get ready because this is where the magic happens.
Listing 4.6 shows the contents of install-nginx.yaml, which we will create at the path
devops/ansible/install-nginx.yaml.
Virtual machine automation with Ansible 89
Are you floored by how simple this is? Probably not. After all, it’s a lot more lines than
just sudo apt update && sudo apt install nginx -y. The major difference is that
we can run this same workflow on thousands of hosts if needed, with the same ease as
running on one host (assuming the correct SSH keys are installed, of course). We can
make this even more robust by ensuring that the NGINX service is actually running on
this host by appending a new task to this playbook, as shown in the following listing.
With this file created, let’s commit it to Git, push it, and then run our GitHub Actions
Ansible workflow. You should see the output shown in figure 4.13.
With Ansible, we can see the status of each task being run in the order it was declared.
If you had more than one host in your inventory file, you would see the same output
for each host. This is a great way to see the status of each task and each host. Once
again, we see the desired state (e.g., the declared task), along with the current state, and
the updated state if it changed. This is a great way to see what Ansible is doing and why
it’s doing it. Of course, adding more remote hosts is as easy as the following:
With this new Ansible Playbook, we can now install NGINX on any number of remote
hosts with a single command. This is a great step toward a fully automated CI/CD
pipeline. Now we need to take the next step toward automating the deployment of our
Python and Node.js applications.
The source code and the requirements.txt file are the items that will likely change the
most, while the configurations for Supervisor and NGINX are likely to change very lit-
tle. We’ll start by adding a purpose-designed configuration for each tool by adding the
following files to our local repo:
¡ conf/nginx.conf
¡ conf/supervisor.conf
gunicorn) along with a working directory and a location for the log output, which is all
encapsulated in the following listing.
[program:roadtok8s-py]
directory=/opt/projects/roadtok8s/py/src
command=/opt/venv/bin/gunicorn
--worker-class uvicorn.workers.UvicornWorker main:app
--bind "0.0.0.0:8888" --pid /var/run/roadtok8s-py.pid
autostart=true
autorestart=true
startretries=3
stderr_logfile=/var/log/supervisor/roadtok8s/py/stderr.log
stdout_logfile=/var/log/supervisor/roadtok8s/py/stdout.log
NOTE For those familiar with writing multiline bash commands, you will notice
that command= does not have the new line escape pattern (\) on each line. This
is done on purpose because of how Supervisor parses this file.
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:8888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Let’s create our Ansible file piece by piece to understand what’s going on. First, we’ll
create a new Ansible Playbook at the path devops/ansible/deploy-python.yaml with
the contents of listing 4.10.
Now let’s move on to the first task of updating the system’s package cache. We do this first
so that future tasks can use this if needed. Listing 4.11 shows the first task. Define pack-
age cache and explain briefly under what conditions future tasks will need to use it.
92 Chapter 4 Deploying with GitHub Actions
tasks:
- name: Update and upgrade system
apt:
upgrade: yes
update_cache: yes
cache_valid_time: 3600
This will update the system’s package cache for about an hour. In the next listing,
we’ll install the specific apt packages our system needs to run Python, Supervisor, and
NGINX.
The apt declaration is built-in to Ansible and allows us to have the state: present
line as an option on this task for any given package we list. We have other options for
state (e.g., latest, absent). What’s powerful about this state: present line is how
repeatable it is, assuming apt install is your OS-level install package. This is a lot
like what requirements.txt enables for us within our Python project but for our entire
system.
Since python3-venv is a core component of the installations done in listing 4.12, we
can now create our Python 3.8+ virtual environment. Listing 4.13 shows the task to cre-
ate the virtual environment.
The built-in Ansible command module (see command module in the Ansible documen-
tation for more details) runs a command without shell variables (like bash variables
$HOME, $PWD, $USER). If you are familiar with these bash variables and need them,
you can use ansible.builtin.shell instead. In this task, it will run the command
python3 -m venv /opt/venv only if the directory /opt/venv does not exist due to the
Virtual machine automation with Ansible 93
creates: declaration with the folder the virtual environment creates. This is a great
way to ensure that this task is only run once to guard against running it multiple times,
which can cause problems with other commands.
Now we want to create the directories for both the source code (e.g., the Python
project) and the log output folder(s) as specified in Supervisor. If you need to run a
task multiple times for multiple directories, you can use the with_items declaration, as
shown in the following listing.
As we see here, with_items takes a list of items and iterates over the remainder of
the task. In this case, the task is to create a file or directory. This task introduces us to
Jinja2-like syntax with the variable {{ item }}, which corresponds directly to the list
of items in with_items, and the {{ ansible_user }} variable, which is the root user,
as provided directly by Ansible because of the become: yes declaration at the top of
the playbook. The rest of the file declaration is just to ensure that each directory is
created with the correct permissions and ownership so that our user (and other appli-
cations) can properly use these directories.
Now we have a choice to make: How do we get our code to our remote machine(s)?
On the one hand, we could use Git on the remote machine(s) to clone or pull the code,
or we could synchronize the code from the local machine (e.g., the GitHub Actions
workflow). We will opt to synchronize the code for the following reasons:
¡ Our GitHub Actions workflow already has a Git checkout copy of the most cur-
rent code.
¡ Ansible has a clean built-in way to synchronize files and directories (without
needing to copy each one each time).
¡ Within GitHub Actions workflows, our Ansible synchronize command is less
prone to accidentally synchronizing files and folders that should not be synchro-
nized (e.g., any file that is not yet managed by Git and is not yet in .gitignore).
¡ GitHub Actions workflows can be run on private code repositories. Attempting
to clone or pull a private repo on a remote machine requires additional configu-
ration and is not as clean as using Ansible’s synchronize command.
94 Chapter 4 Deploying with GitHub Actions
Whenever I need to copy a number of files to a remote host with Ansible, I tend to use
the built-in synchronize module (see synchronize module in the Ansible documentation
for more details) because it handles recursively copying directories very well. The fol-
lowing listing shows the task to synchronize the code from the local machine to the
remote machine.
Python pip will not install or update a package that is already installed, so it’s perfectly
reasonable to attempt to run this task on every Ansible run. This is a great way to ensure
that the Python packages are always up to date. There are more advanced configura-
tions that can be done with pip, but this is the approach we’ll take.
At this point, our remote host(s) would have our Python project installed and ready
to run. All we have to do now is copy our NGINX and Supervisor configuration. When
Virtual machine automation with Ansible 95
they are copied, we’ll also notify an Ansible feature called a handler to run a task based
on these configuration changes. Ansible handlers are basically callback blocks we can
trigger from anywhere in our playbooks using the notify: argument. Listing 4.17
shows the task to copy these configurations.
When I am copying a single file, I tend to use the built-in copy module (see copy module
in the Ansible documentation for more details) as seen in listing 4.17. This module is
very similar to the file module we used in listing 4.16, but it’s more specific to copying
files. If the supervisor configuration file changes, use the notify declaration to trigger
the reload supervisor handler. If the nginx configuration changes, we trigger the
restart nginx handler. Each handler is defined outside of the tasks: block and is
run after all tasks have completed. Almost any task or handler can trigger a handler
to execute. In this case, it makes sense that if our NGINX or Supervisor configuration
changes, we should reload or restart each respective service.
With our new NGINX configuration added, we will remove the default NGINX con-
figuration and link our new configuration to the sites-enabled directory. The follow-
ing listing shows the task to do this.
Removing the default NGINX configuration is optional, but it’s often a good idea, as
it ensures that only your NGINX configuration is being used. We use the file module
96 Chapter 4 Deploying with GitHub Actions
to remove the default configuration and then use the command module again to create
a symbolic link from our new configuration to the sites-enabled directory. This is a
common way to configure NGINX and is a great way to ensure that our NGINX config-
uration is always up to date.
Now it’s time to implement the handler for each service. Notifying handlers typically
occurs when a task with notify: runs successfully and the handler referenced exists. In
our case, we used notify: restart nginx and notify: reload supervisor. We must
remember that the handler name must match the notify: declaration exactly (e.g.,
restart nginx and reload supervisor). Listing 4.19 shows the handler for reloading
Supervisor and restarting NGINX.
handlers:
- name: reload supervisor
command: "{{ item }}"
with_items:
- supervisorctl reread
- supervisorctl update
- supervisorctl restart roadtok8s-py
notify: restart nginx
- name: restart nginx
systemd:
name: nginx
state: restarted
Handlers are callback functions that are only run if they are notified. Handlers are
only notified if a block (task or another handler) successfully executes. To notify a han-
dler, we use the notify: <handler name> declaration, assuming the <handler name> is
defined and exists in the playbook.
In our case, the handler reload supervisor has a notify: restart nginx definition.
This means that if the reload supervisor handler is called and is successful then the
restart nginx handler will be triggered. If the restart nginx handler had a notify:
reload supervisor, we would see an infinite loop between these handlers. This infinite
loop should be avoided, but it is something to be aware of.
With the playbook nearly complete, let’s commit it to Git and push it to GitHub.
Once you do, you should have the following files related to running Ansible:
¡ devops/ansible/install-nginx.yaml
¡ devops/ansible/deploy-python.yaml
¡ .github/workflows/run-ansible.yaml
As you may recall, run-ansible.yaml has a step that calls the install-nginx.yaml
file with ansible-playbook install-nginx.yaml. We must update this line to read
ansible-playbook deploy-python.yaml instead. The following listing shows the
updated step in the workflow file.
Virtual machine automation with Ansible 97
Listing 4.20 Deploying the Python app with Ansible with GitHub Actions
Before we commit this playbook and updated workflow to our GitHub Repo, be sure to
compare yours with the full reference playbook at my Python project’s official repo at
https://github.com/jmitchel3/roadtok8s-py. With the full playbook and the updated
GitHub Actions workflow, it is time to run the workflow and see what happens. When
you run the workflow, you should see the output in figure 4.14.
If you ran into errors with this workflow, you may have to use a manual SSH session to
diagnose all the problems that may have occurred, just like we did in chapter 3. If you
did not run into any errors, you should be able to open your web browser to your IP
address and see the Python application running. If you see the Python application run-
ning, you have successfully deployed your Python application with Ansible and GitHub
Actions. Congratulations!
If you were successful, I recommend changing your Python source code and run-
ning it all over again. Ensure that the desired outcome(s) are happening correctly.The
next step is to implement this Ansible and GitHub Actions automation on the Node.js
application.
Let’s recap what we need to do in order for our Node.js application to run on GitHub
Actions correctly:
1 Create new SSH keys: Having new keys will help ensure additional security for each
isolated web application.
¡ Add the new SSH private key as a GitHub Actions secret on our Node.js repo.
¡ Install the new SSH public key on Akamai Linode.
¡ Provision a new virtual machine and add the new IP address as a GitHub
Actions secret.
2 Copy the NGINX and Supervisor configuration files for Node.js from chapter 3.
3 Create a new Ansible Playbook for our Node.js application.
4 Copy and modify the GitHub Actions workflow for running Ansible, created in
section 4.2.3.
You should be able to do steps 1-2 with ease now, so I will leave that to you. Now let’s go
ahead and create our Ansible Playbook for our Node.js application.
Creating a new Ansible Playbook for our Node.js application
We already have most of the foundation necessary to create a Node.js-based Ansible
Playbook for our project. The main difference between using Ansible for installing
Node and what we did in chapter 3 is that we are going to update the apt package
directly instead of using the Node Version Manager.
In your Node.js application at ~/dev/roadtok8s/js, create a folder devops and add
the file deploy-js.yaml, and declare the first few steps as outlined in listing 4.21. Since
nvm and Ansible do not play well together, we added a new block called vars (for
variables) that we can reuse throughout this playbook. In this case, we set the variable
nodejs_v to the value 18 to be used to install Node.js v18, as shown in the following listing.
---
- name: Deploy Node.js app
hosts: all
become: yes
vars:
nodejs_v: 18
tasks:
- name: Update and upgrade system
apt:
upgrade: yes
update_cache: yes
cache_valid_time: 3600
- name: Install dependencies
apt:
pkg:
- curl
- rsync
- nginx
Virtual machine automation with Ansible 99
- supervisor
state: present
tags: install
Once again, we start will updating apt and various system requirements to set the foun-
dation for installing Node.js.
Unfortunately, we cannot just install Node.js with apt install nodejs, so we need
to add a new key and apt repository with Ansible by using the blocks that add a new
apt_key and apt_repository, respectively. Once this is done, we can install Node.js using
the apt block directly, as seen in the following listing.
---
- name: Deploy Node.js app
hosts: all
become: yes
vars:
nodejs_v: 18
tasks:
- name: Update and upgrade system
apt:
upgrade: yes
update_cache: yes
cache_valid_time: 3600
- name: Install dependencies
apt:
pkg:
- curl
- rsync
- nginx
- supervisor
state: present
tags: install
Once this is complete, we’ll use the npm block to update npm globally, as seen in the
following listing.
The npm block is built in to Ansible, but our host machine must have it installed; oth-
erwise, it will not succeed.
100 Chapter 4 Deploying with GitHub Actions
This process is nearly identical with what we did in listing 4.14 and 4.15, just modified
slightly to account for the Node.js application.
With the code synchronized, we can now install the Node.js application dependen-
cies with the npm block, as seen in in the following listing.
The remainder of this file is the same as the one for the Python application before it, as
you can see in the following listing.
src: ../../conf/supervisor.conf
dest: /etc/supervisor/conf.d/roadtok8s-js.conf
notify: reload supervisor
- name: Configure nginx
copy:
src: ../../conf/nginx.conf
dest: /etc/nginx/sites-available/roadtok8s-js
notify: restart nginx
- name: Enable nginx site
command: ln -s /etc/nginx/sites-available/roadtok8s-js /etc/nginx/
sites-enabled
args:
creates: /etc/nginx/sites-enabled/roadtok8s-js
- name: Remove default nginx site
file:
path: "{{ item }}"
state: absent
notify: restart nginx
with_items:
- /etc/nginx/sites-enabled/default
- /etc/nginx/sites-available/default
handlers:
- name: reload supervisor
command: "{{ item }}"
with_items:
- supervisorctl reread
- supervisorctl update
- supervisorctl restart roadtok8s-js
notify: restart nginx
- name: restart nginx
systemd:
name: nginx
state: restarted
Now that we have our Ansible playbook complete, let’s update our GitHub Actions
workflow.
GitHub Actions workflow for Node.js and Ansible Playbook
Copy the entire GitHub Actions workflow file from the Python project at ~/dev/
roadtok8s/py/.github/workflows/run-ansible.yaml to the Node.js project at
~/dev/roadtok8s/js/.github/workflows/run-ansible.yaml. After you do this,
you’ll need to change one line to make our new Ansible playbook work: ansible-play-
book deploy-python.yaml to ansible-playbook deploy-js.yaml. Everything else
should work exactly the same.
With this change, we can now commit our changes to Git and push them to GitHub.
Before you can run the workflow, you need to verify the following GitHub Actions
secrets:
¡ SSH_PRIVATE_KEY
¡ AKAMAI_INSTANCE_IP_ADDRESS
102 Chapter 4 Deploying with GitHub Actions
As a reminder, if you haven’t done it already, you will need to create a new SSH_
PRIVATE_KEY with ssh-keygen, install it on Akamai Linode, and then add the private
key to GitHub Actions secrets. You will also need to create a new virtual machine on
Akamai Linode with the new SSH key to obtain a new and valid IP address that you will
add to GitHub Actions secrets as AKAMAI_INSTANCE_IP_ADDRESS.
Once you do that, you can run your GitHub Actions workflow and verify that your
Node.js application is running by visiting the AKAMAI_INSTANCE_IP_ADDRESS after the
workflow completes. I will leave it to you to go through this process and verify it works.
Summary
¡ Continuous integration is the process of constantly integrating code changes
into a single source of truth, namely a Git repo.
¡ Continuous delivery is the process of constantly delivering code changes to a Git
repo in preparation for deployment.
¡ Continuous deployment is the process of constantly deploying code changes to a
production environment.
¡ Deploying consistent environments for your applications is essential to ensure
your applications will work as intended.
¡ GitHub Actions workflows are designed to be easily understood and easy to use
while still being incredibly flexible to the various tools we may need to use and
automate.
¡ Ansible is a powerful tool that can be used to automate the installation and con-
figuration of software on remote machines.
¡ NGINX is a powerful web server that sits in front of web applications to provide
additional security and performance.
¡ Supervisor is a powerful process manager that can be used to ensure that our
applications are always running.
¡ The YAML format is a staple of automation and deployment regardless of what
programming language you write your applications in.
¡ Running applications on remote machines requires the systems to be continually
updated and monitored to ensure the correct dependencies are installed and
configured.
¡ Even with simple application design, the process to deploy applications can be
complex and error prone.
¡ Node.js and Python runtimes can share a number of production dependencies,
such as NGINX and Supervisor, but are different enough that they require spe-
cific configurations, causing more complexity and more potential for errors.
Containerizing applications
Back in the late 1900s, we had to insert disks and cartridges into our devices to run
software or play games. Continuously updating these applications was mostly impos-
sible, so developers worked tirelessly to ensure these applications were locked and
as bug-free as possible before their release. As you might imagine, this was a pain-
fully slow process, especially compared to today’s standards.
These disks and cartridges could only be played or run on the devices they were
designed for; Mario was played on Nintendo devices, and Windows computers ran
Microsoft Word. In other words, the storage devices holding the software were tied
directly to the operating system that could run the software. We still have the digital
version of this today with app stores and digital downloads, with one major differ-
ence: the internet.
103
104 Chapter 5 Containerizing applications
As you know, there are very few applications out today that do not use the internet
in some capacity, regardless of the device they run on. This means that the applications
we use every day are rarely tied to the operating system they run on. In other words, the
hardware and OS matters less than it ever has before simply because applications use
the internet and apps run on servers elsewhere. The first few chapters of this book high-
lighted this by the mere fact that I omitted rigorous system requirements for you to run
Python, Node.js, Git, and so on. With late 1990s software, I would have had to give you a
substantial list of hardware and system requirements to run the applications and maybe
even write different book versions based on the OS you had. Now, I can just vaguely give
you an OS, and there is a great chance the software will run on your machine.
That is, until it doesn’t. Open source software tends to do very well with the initial
installation process, but as soon as we start needing third-party dependencies, our appli-
cations and CI/CD pipelines can start to crumble, requiring a lot of attention to bring
them back to a stable state. This is where containers come in.
Third-party packages are often essential to modern application development. For
one, you might need to connect your app to a database of some kind. While there is
likely a native Python or Node.js package you could use, those packages often assume
system-level installations as well. You know what they say about assumptions, right?
Containers bundle your code and third-party system-level dependencies together so
that you can have a portable application. This bundle is known as a container image and
was pioneered and popularized by Docker. The magical thing about containers is that if
a container runtime, also known as a Docker runtime, is installed on any given system,
there’s a great chance that your application will run without additional installations.
Containers still require a system-level dependency to run, a container runtime, but
what’s in the container could be Python, Node.js, Java, Ruby, and virtually any software
that runs on Linux. Containers are the best modern solution for ensuring your app is
portable and can run on any system with a container runtime installed. Containers have
a number of limitations that we’ll continue to explore for the remainder of the book.
(Spoiler alert: One of these limitations is why Kubernetes exists.) Let’s get started by
installing Docker Desktop on your machine.
Once you have Docker installed, we’ll see how simple it is to run third-party applications.
Listing 5.1 Hello World with Docker: Running the NGINX container image
Figure 5.1 shows the output of running the NGINX container image.
Before we visit the running instance of this container image, we see the output that
contains Unable to find image . . . and Pulling from, which signifies that Docker automat-
ically attempts to download an image because it’s missing from our local system. This
download attempts to use Docker Hub, which is the official Docker container hosting,
also known as a container registry. Docker will use this registry by default unless you
have a good reason to change it. Docker registries are also open source, so you could,
if you are super adventurous, host your own registry, but that’s outside the context of
this book.
For official Docker images, the command is docker run <imageName>, and third-
party commands (including our own), the command is docker run <userRegis-
tryName>/<imageName>, such as docker run codingforentrepreneurs/try-iac. We’ll
cover this in more detail later in this chapter.
So, where can we visit our NGINX instance? Well, we can’t. Not yet, at least. We need
to provide Docker with additional arguments, called flags, to be able to visit the applica-
tion that the container is running.
106 Chapter 5 Containerizing applications
Assuming you have docker run nginx still running in your terminal, press Control
+ C to stop that running container and start a new one at docker run -p 8080:80
nginx. Once you do that, visit http://localhost:8080 on your local machine, and you
should see the standard NGINX Hello World, just like in figure 5.2.
Exposing ports as one of our first steps helps highlight that containers are mostly
isolated from the surrounding operating system environment until we add addi-
tional parameters to change how that isolation works. In other words, containers are
designed to be isolated from the host computer by default. This is a good thing because
it means we can run virtually any software in a container without touching our system’s
Hello World with Docker 107
configuration other than to run Docker itself. The container has its own world within it
as well. Let’s see what I mean by entering the container’s bash shell.
As we see from this simple example, the container runtime has a world of its own that
may or may not exist on our localhost. This is why containers are so powerful. We can
run virtually any software in a container, and it will run the same way on any system with
a container runtime installed. You might be tempted to treat a container like a virtual
machine, and you can, but I’ll offer a simple word of warning: containers are designed to
be destroyed.
Our Python and Node.js applications are currently examples of stateless applications
because we can always recreate the same environment regardless of whether the running
machine is destroyed or not. As you are hopefully aware, recreating the environment is
possible and mostly easy thanks to the requirements.txt and package.json files.
Before we dive into the stateless nature of containers, let’s work through a practical
example by running a Linux-based container image and installing NGINX. The pur-
pose of this example is to help illustrate what we should not do with containers but should
do with virtual machines.
For this example, we’ll add the flag --rm to our docker run command to immedi-
ately remove the container after it’s stopped. Here’s what we’ll do:
1 docker run --rm -it ubuntu /bin/bash—Downloads ubuntu and enters the
bash shell environment
2 apt-get update—Updates the package manager within the container
3 apt-get install nginx—Installs NGINX
4 cat /etc/nginx/sites-available/default—Shows the NGINX configuration
file
5 nginx -v—Shows the NGINX version
6 exit—Exits the container
This installation process feels so similar to what you might expect within a virtual
machine environment because this container is still Ubuntu! So, at this point, we
should be good to go with NGINX, right? Well, let’s just use bash shell on this container
again and see what happens:
docker run --rm -it ubuntu /bin/bash
cat /etc/nginx/sites-available/default
nginx -v
exit
Wait what? Errors! Why?! Doing this process again without installing NGINX gives us the
errors No such file or directory and command not found. These errors tangibly show
us that we cannot long-term modify an already existing container image from within the
container environment. This is the stateless nature of containers. So, how do we modify
containers? This is the purpose of a Dockerfile and what we’ll do in the next section.
FROM ubuntu
RUN apt-get update && apt-get install -y nginx
CMD ["nginx", "-g", "daemon off;"]
Before we use this Docker, let’s name this container simply rk8s-nginx. We’ll use this
name when we build the container through the tag flag: -t <your-tag name>. Now we
need to navigate to the directory of your Dockerfile and build and tag our container
image as seen in listing 5.3.
Figure 5.4 shows the output of running the build command. The time to build this
container image will vary from machine to machine, but it should be relatively quick
because our Dockerfile makes very few changes to the prebuilt Ubuntu container
image we’re inheriting from.
If this is your first time building a container image, congratulations! Now let’s run it
and see what happens.
Once you run this, you should be able open http://localhost:8080 to view the standard
NGINX Hello World page once again. If the port is in use, you can change the port
number to anything you’d like or stop the application/container running on that port.
Once again, we’ll verify that NGINX was installed on our container through the bash
shell. Run the following commands:
1 docker run --rm -it rk8s-nginx /bin/bash
2 cat /etc/nginx/sites-available/default
3 nginx -v
4 exit
All of these commands (we swapped ubuntu for rk8s-nginx) should now be available
in our modified version of Ubuntu thanks to our Dockerfile and built container! Now
that we have the basics down, let’s create our Python and Node.js container images.
Figure 5.5 shows the results of the previous steps with a few versions cropped out.
Figure 5.6 shows the exact command to download the image to your local machine. This
process, known as pulling an image, is done automatically when we run or build an image.
Docker Hub maintains nearly all versions of stable code that has been released for
major open-source projects. A huge benefit to this is that, in 2035 and beyond, version
3.11.4 will probably still be available exactly as it is here. If it’s not, that is likely due to
some major security risk or a massive change to Docker Hub.
112 Chapter 5 Containerizing applications
In this case, the docker pull python:3.11.4 command shows us the exact container
tag we need to use this image. The format is docker pull <containerName>:<tag>.
We can use this format in our Dockerfile in the FROM <image> line, as we’ll see in the
next section.
Before we carry on, let’s run this container image locally to see what happens. The
following listing shows the command to run this container image.
When you run this, there’s a good chance the container will need to download (in my
case, I saw Unable to find image 'python:3.11.4' locally again). Once it finishes
downloading, I have the Python shell at my fingertips running through Docker! Figure
5.7 shows the output of running this command.
Take a moment to appreciate how fast this works and how easy it is to use nearly any
version of Python within a few minutes (if not seconds). This is the power of contain-
ers. Using nearly any version of nearly any open-source programming language from
the past decade can run within minutes of you choosing to do so. Do you remember
how long it took to install just Python? Then Node.js? What if you need to use Ruby?
Or Java? Or Go? Or Rust? Or C++? Or C? Or PHP? Or Perl? Or . . . you get the point. As
long as Docker is installed, nearly any version of nearly any open-source programming
language can be installed within minutes.
Containerizing Python applications 113
With this power, we can also run nearly any version of any iteration of our own appli-
cations within minutes (if not seconds). This paradigm is what excites me about con-
tainers. Let’s see it in action by containerizing our Python application.
Two major packages are purposefully absent: NGINX and Supervisor. We’ll see why
these packages are no longer needed very soon.
Before we run our code, we’re going to make a Dockerfile that is almost complete
so we can see what is available to us by default. This means we will use the following
commands:
¡ Change our working directory to /app (this will become clear within the
Dockerfile).
¡ Copy our src/ folder to the /app directory.
¡ Run apt-get update && apt-get install -y python3-venv python3-dev
python3-pip. This will ensure the OS-level dependencies are installed within our
container.
¡ Add python3 -m venv /opt/venv. We will store our virtual environment in the /
opt/ location, which is a recommended place to store add-on software like virtual
environments.
¡ Add /opt/venv/python -m pip install --upgrade pip. It’s always a good idea
to update our virtual environment pip installation before we install our project
requirements.
¡ Run /opt/venv/python -m pip install -r /app/src/requirements.txt to
install our project requirements.
¡ Run /opt/venv/python -m http.server 8080. This will run a built-in Python
web server that will either render an index.html file, if one exists, or display the
contents of any given folder; we’ll have the latter.
114 Chapter 5 Containerizing applications
We have used each of these commands a few times throughout this book, with the
exception of python -m http.server. The goal now is not to introduce too many new
commands but just reframe how we use commands we are starting to become familiar
with.
With this in mind, navigate to the root of your Road to Kubernetes Python project.
If you have been following along, the project will be located at ~/dev/roadtok8s/py. In
this project we’ll create a Dockerfile with the contents in the following listing.
Now let’s build our container with the commands in the following listing.
cd ~/dev/roadtok8s/py
cat Dockerfile # should yield correct values
With this built and running, let’s open our browser to http://localhost:8080, and we
should see the same results as figure 5.8.
Containerizing Python applications 115
Before we complete this Dockerfile, we must address the problem of extra files we do
not want to include. This is where a .dockerignore file comes in handy.
Once our build phase is moved to GitHub Actions, we will likely have less of a need
for a .dockerignore simply because then we’ll be using both Git and a .gitignore file to
push the code. In other words, the files and folders you would typically want Docker
to ignore should already be absent. That said, I always recommend using a .dockeri-
gnore to make your final built container image lighter (e.g., fewer files and folders)
to decrease the build time, decrease the deploy time, and decrease the likelihood of
exposing a file you should not expose (e.g., any .env files or .secret files). The follow-
ing listing shows the output of our container image after adding a .dockerignore file.
Now that we have a .dockerignore file, let’s move to the final step of our Dockerfile:
creating a script to boot our application.
As a reminder, environment variables are where you want to store sensitive runtime
information, such as passwords and API keys. Environment variables can also be used to
modify the configuration of the application runtime (e.g., PORT), which is why our entry-
point has a fallback to a PORT environment variable. Sensitive environment variables
should never be stored in the codebase, added to a Dockerfile, or included in a container
build (e.g., using .dockerignore to ignore common environment variable files like .env
or .secrets). Most container runtimes, like Docker or Kubernetes, make it easy to inject
environment variables when we run the container. With this in mind, let’s create a new
file in our src/ folder called conf/entrypoint.sh with the contents in the following listing.
#!/bin/bash
export RUNTIME_PORT=${PORT:-8080}
/opt/venv/bin/gunicorn \
--worker-class uvicorn.workers.UvicornWorker \
main:app \
--bind "0.0.0.0:$RUNTIME_PORT"
A key trait of the location of this entrypoint is that it is not stored in src/, but rather in
conf/, right next to nginx.conf and supervisor.conf. I prefer to keep all of my configu-
ration files in a single location; this is no different, but it also gives us the opportunity
to once again use the COPY command for a specific file.
This entrypoint script will be the final command we run, which means the script itself
needs to have the correct permissions to be executed (e.g., chmod +x); otherwise, we’ll
get a permission error after we build this container when we attempt to run it. The fol-
lowing listing shows the updated lines for our Dockerfile.
...
COPY ./src/ /app
Let’s go ahead and test our new Dockerfile by building and running it:
Depending on the production hosting choice for our container images, the PORT vari-
able is often set for us, so we need to ensure our application is flexible enough to han-
dle this. Now that we have our Python container image built, let’s move to our Node.js
container image.
1 Find the Node.js version tag we need in the Node.js image repo on Docker Hub
(http://hub.docker.com/_/node).
2 Use the FROM directive to use this image and version tag.
3 COPY our code into our WORKDIR.
4 RUN the npm install command to update npm and install our package.json
project dependencies. (As a reminder, there are other package managers for
Node.js, but we’ll stick to npm for this book.)
5 Write a entrypoint script to manage our application’s runtime and default PORT.
6 Create the CMD directive to run our entrypoint script.
Figure 5.10 is a screenshot of the command direction from the Node.js repo to pull the
image and specific tag from Docker Hub. It is important to recognize what to look for
to ensure you are using the correct tag for any given container image.
Once again, docker pull node:18.17.0 shows us the exact container tag we need to
use for this image.
Now that we have the container image and image tag we need, let’s create the entry-
point script we need prior to creating our Dockerfile.
The following listing shows the contents of our entrypoint script located at conf/entry-
point.sh in our Node.js application (located at ~/Dev/roadtok8s/js if you have been
following along).
120 Chapter 5 Containerizing applications
#!/bin/bash
node /app/src/main.js
As we can see, the Node.js entrypoint script is incredibly simple but also very consistent
with how we containerized our Python application.
Our final pre-Dockerfile step is to create a .dockerignore file that includes all the
files and folders we want to ignore. The following listing shows the contents of our
.dockerignore file located at ~/Dev/roadtok8s/js/.dockerignore.
Now that we have all the code we need, let’s create our Dockerfile.
¡ Create a working directory (WORKDIR) for our project; we’ll use the commonly
used /app.
¡ Copy all code from src/ to app/src/. With our Node.js app, we want to maintain
paths relative to the root of our project due to the fact that the node_modules/
will be installed in the root of our project. Moving node_modules is not as easy as
moving a Python virtual environment and thus is not recommended.
¡ Copy both package.json and package-lock.json files. package-lock.json will help
ensure consistent builds from local to container.
¡ Copy conf/entrypoint.sh to app/entrypoint.sh.
¡ Run npm install to install our project dependencies.
¡ Run chmod +x entrypoint.sh to ensure our entrypoint script can be executed.
¡ Write the CMD directive to run our entrypoint script.
All of these steps should feel repetitive for a good reason: we are repeating almost
the same process. Honing in on a consistent environment often means repeating our-
selves consistently. The following listing shows the final Dockerfile for our Node.js
application.
Pushing container images to Docker Hub 121
# install dependencies
RUN npm install
Now that we have this Dockerfile, let’s run and build it with the following steps:
The output of these commands will be nearly identical to the Python containerization
process, so I’ll leave it to you to verify. Now that we know how to pull, build, and run
container images, let’s learn how to upload them to Docker Hub using the built-in
push command.
book. Docker Hub is the official Docker registry and is what the docker command line
tool uses by default. Here are the steps for using Docker Hub:
With this in mind, let’s proceed to a build-to-push process for our Python application.
cd ~/dev/roadtok8s/py
Before we review the output, I want to point out that I built the image once but tagged
it two times using the flag -t. I did this because of a standard Docker feature. If you
recall when we ran docker run nginx, we were actually running docker run nginx-
:latest; the latest tag is used by default if no tag is specified. Tagging my container
build with latest and a specific version gives me the flexibility to use either tag while I
am also storing iterations of any given version. This scenario is meant to highlight the
Pushing container images to Docker Hub 123
version-control nature of tagging, building, and pushing images to Docker Hub. Fig-
ure 5.11 shows the output of our push command.
This output shows all the layers that make up the built container being pushed to
Docker Hub. These same layers should resemble the docker run or docker pull com-
mands when we are using an image that isn’t on our local machine.
The layers we see in figure 5.11 correspond roughly to each instruction (e.g., FROM,
COPY, RUN, etc.) from our Dockerfile. Each instruction is cached and reused when we
build our container image. This caching is also why our first build may take a while,
but subsequent builds are sped up. This is also why Docker registries can host massive
container images without taking up a lot of space or taking a long time to upload or
download. With the build complete and pushed to Docker Hub, let’s take a look at our
repository on Docker Hub. Figure 5.12 shows the repository page for our Python con-
tainer image.
There is another, newer Docker tool called buildx, which enables multi-platform, multi-ar-
chitecture builds; this is outside the scope of this book because it’s a bit more complex
than what we need.
Instead of using multi-platform builds, we will build our container images on GitHub
Actions. This will allow us to build our container images on a Linux-based virtual machine
and push them to Docker Hub. This will also allow us to build our container images on
every commit to our main branch, which will ensure our container images are always
up-to-date.
At this point, we have done everything we need to do to build and run container
images on our local machine. It’s a good idea to continue to experiment with these
local containers before we start automating the process of building and pushing them
to Docker Hub. I want to emphasize that there is a lot more to Docker than we cov-
ered here, but understanding this chapter will be crucial for deploying a containerized
application to production.
Summary 125
Summary
¡ Containers are mini-Linux machines, and we have a lot of flexibility in how we
can use them.
¡ Containers unlock portability for nearly any kind of application, especially open-
source ones.
¡ The pattern of instructions for containers uses a logical method of copying and
installing the software any given application might need.
¡ Containers are stored in a container registry like Docker Hub so they can then be
distributed to production systems.
¡ Building containers is essentially identical regardless of the application, except
for what goes into the Dockerfile.
¡ The container is responsible for running the containerized application or appli-
cations through what’s declared in the Dockerfile (or as an additional argument).
¡ Containers force consistency in how we package our final application.
¡ A containerized application can run differently based on the environment vari-
ables passed at runtime.
This chapter covers
Containers in action
Imagine sitting in an audience where CodeOprah comes out and says, “You get a
container and you get a container and you get a container—everyone gets a con-
tainer!” The crowd goes wild. But you don’t—not yet, anyway. You’re sitting there
asking yourself a few fundamental questions. How do I run a database within a con-
tainer? How do my two containers talk to each other? How do I manage multiple
containers at once? Before we start passing out containers like they’re free cars, we
need to configure our end-to-end CI/CD pipeline to allow GitHub Actions to be
responsible for building and pushing our containers to Docker Hub.
126
Building and pushing containers with GitHub Actions 127
Once our build process is automated, we need to understand how to inject state into
containers by using volumes. Volumes are a way to persist data directly in a container
regardless of the current status or state of the container. They can exist even if the con-
tainer is not running or completely destroyed. Using volumes is how we get stateful, or
the opposite of stateless, containers.
Volumes are not the only way to enable state in containers. Environment variables
can have API keys or secret credentials to external database services, thus enabling a
form of application state.
Attaching volumes and using environment variables with the docker command
can be simple to use but difficult to remember and manage. Let’s look at a couple of
examples:
Imagine trying to run this for more than one project? It’s going to be a nightmare to
remember. Enter Docker Compose. With Docker Compose, we define all the neces-
sary container runtime options in a YAML file and then use a simple command to run
everything. Docker Compose can easily build, run, and turn off containers; attach vol-
umes; inject environment variables; and more. Docker Compose is the first and easiest
container orchestration tool we’ll use. Working with Docker Compose is an important
step on our way to using Kubernetes for container orchestration.Let’s get started auto-
mating our container builds and pushes with GitHub Actions.
What these steps enable us to do is, first, log in to Docker Hub safely and securely
and, second, tag our images based on secrets (i.e., the tag is not exposed) in a GitHub
Actions workflow using docker build -t ${{ secrets.DOCKERHUB_USERNAME }}/${{
secrets.DOCKERHUB_REPO }}:0.0.1 or whatever tag we choose. Let’s build the work-
flow for both of our applications now (it’s about the same for both).
The following listing shows exactly how simple it can be to containerize an application
this way.
jobs:
build-push:
runs-on: ubuntu-latest
env:
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
REPO: ${{ secrets.DOCKERHUB_REPO }}
steps:
- uses: actions/checkout@v3
- uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build the container
run: |
docker build -f Dockerfile \
-t "$DH_USER/$REPO:latest" \
-t "$DH_USER/$REPO:${{ github.sha }}" \
.
- name: Push the container images
run: |
docker push "$DH_USER/$REPO" --all-tags
130 Chapter 6 Containers in action
For readability, we used the env variables for DH_USER and REPO to represent our store
secret for USER and REPO, respectively. This means that "$DH_USER/$REPO" translates
to ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }}. and
codingforentrepreneurs/rk8s-js depending on the repo.
Commit this file to your Git repo, push it to GitHub, and give it a whirl to see if you
can successfully build both applications. If you run into errors, be sure to use the ref-
erence Python Repo (https://github.com/jmitchel3/roadtok8s-py) and Node.js Repo
(https://github.com/jmitchel3/roadtok8s-js) for this book to compare your code to
the working code. Now that we have our container images built and hosted on Docker
Hub, let’s start learning about another great Docker feature called Docker Compose.
If you are having trouble with these two commands, please review chapter 5. While
knowing how these commands work should be memorized, the parameters to these com-
mands do not need to be. In other words, each project is different, and you’ll likely
have different parameters for each one. This is where Docker Compose comes in.
Docker Compose will help us remember the parameters we need to build and run
our containers correctly. Even if you never use Docker Compose directly, the Docker
Compose configuration file, known as compose.yaml (also docker-compose.yaml), can
be a great place to store all the parameters you need to build and run your containers.
might actually be the configuration you need to run your application. Now try to do
the same thing for three other container types, and you’ll find it’s a nightmare to
remember.
Enter Docker Compose. Docker Compose can automate all of the commands we
need to build and run our containers with just one: docker compose up --build.
Before we explore the commands to use Docker Compose, let’s review a sample con-
figuration file known as compose.yaml or docker-compose.yaml (both filenames are
supported). The following listing is a sample that might be used with a web application
with an existing Dockerfile.
services:
web:
build: .
ports:
- "3001:3001"
Do these look familiar to you? I should hope so. This compose.yaml file is just scratch-
ing the surface at this point, and it’s even missing the declaration that replaces the
-f Dockerfile argument we used previously. The point of seeing this now is to help
us get a glimpse into what Docker Compose can do for us. Now that we have a taste of
Docker Compose, let’s put it into practice with our Python application.
This process is nearly identical to Docker Compose, with a key aspect missing: runtime
arguments. At this stage of using Docker Compose, we will focus on removing all the
runtime arguments. To do this, let’s first understand the key elements of a configura-
tion for Docker Compose:
132 Chapter 6 Containers in action
Docker Compose has a lot more configuration options that are available to us, but
these are among the most common and, in our case, the only ones necessary for our
Python application.
Navigate to the root of your Python project (~/dev/roadtok8s/py) and create com-
pose.yaml with the contents in the following listing.
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
To build, run, and stop our container, we will use the following commands:
Give these commands a try. Figure 6.1 shows a clipping of the build command (docker
compose build).
Managing a container with Docker Compose 133
Figure 6.2 shows a clipping of the run command (docker compose up).
With the Docker Compose running (after using docker compose up), open a new
terminal window, navigate to your project, and run docker compose down to stop the
container. Figure 6.4 shows a clipping of the stop command (docker compose down).
If that fails, you may have to use an older version of Docker Compose. You can install this ver-
sion via the Docker Compose Python package (https://pypi.org/project/docker-compose/)
with python3 -m pip install docker-compose --upgrade.
Once that’s installed, you will use docker-compose (notice the use of a dash -) instead
of docker compose whenever you need to use Docker Compose.
This Python-based version of Docker Compose may require additional changes to your
configuration. Refer to the official Docker Compose documentation (https://docs.docker.
com/compose/install/) for more information.
Now that we have our first look at Docker Compose with our Python application, let’s
shift gears for our Node.js application; spoiler alert: it’s going to be almost identical.
The build-and-run process, aside from various arguments, is identical no matter what
container you are using. This is the beauty of Docker Compose; it puts those argu-
ments into a format that’s easily repeated and very portable. Now navigate to the root
of your Node.js project (e.g., ~/dev/roadtok8s/js) and create compose.yaml with the
contents from the following listing.
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- PORT=3000
Stateful containers with volumes 135
Once again, we can build, run, and stop our container, respectively, with the following:
I will let you explore this on your own project to verify it’s working correctly. Now that
we understand the fundamentals of managing containers with Docker Compose, let’s
start to explore other kinds of apps your projects might need: stateful apps.
You can attach a volume to any container. Detaching the volume is as simple as stop-
ping the container and running it again without the -v flag.
Before we use volumes, let’s recap the various arguments our docker run command
needs:
¡ --name is the flag for naming our running container (this makes it easier to stop
or enter the shell).
¡ <image-name> is the final argument we’ll use to specify our previously built image
(e.g., with docker build -t <image-name> -f Dockerfile).
Using the --name <my-custom-name> flag allows us to run commands like docker stop
<my-custom-name> to stop a container and docker exec -it my-custom-name /bin/
bash to enter the bash shell. We’ll do both in a moment. The following listing shows
how we can use a volume with our Python application.
The name of this runtime is my-rk8s-py, while the image name is rk8s-py. These two
names tripped me up for a long time, so it’s important for you to understand the dif-
ference: using --name just gives an arbitrary name to your container runtime, while the
<image-name> must be a valid image. In other words, the runtime name is a variable;
the image name is fixed.
With this container running, let’s test our volume with the following commands:
echo "hello world" >> ~/dev/roadtok8s/py/data/sample.txt
docker exec my-rk8s-py cat /app/data/sample.txt
If done correctly, you should see the output of hello world from the cat command.
This means that the volume is working correctly.
Let’s log a view dates via the date command to a file and see if we can read them
back. Run the following commands:
date >> ~/dev/roadtok8s/py/data/sample.txt
date >> ~/dev/roadtok8s/py/data/sample.txt
date >> ~/dev/roadtok8s/py/data/sample.txt
docker exec my-rk8s-py cat /app/data/sample.txt
NOTE The date command outputs the current date and time in the terminal.
You can use any command you want here, but date is a simple one to use and
track how this all works.
If done correctly, you should see the output shown in figure 6.4.
Figure 6.4. Volume output samples with Docker and a local machine
Stateful containers with volumes 137
We can also add content to the file from within the container. Run the following
commands:
docker exec my-rk8s-py bash -c "echo \"\" >> /app/data/sample.txt"
docker exec my-rk8s-py bash -c "echo \"from docker\" >> /app/data/sample.txt"
docker exec my-rk8s-py bash -c "date >> /app/data/sample.txt"
docker exec my-rk8s-py bash -c "date >> /app/data/sample.txt"
cat ~/dev/roadtok8s/py/data/sample.txt
Figure 6.5 Volume output samples with Docker and a local machine
This simple example shows us a few key items about Docker volumes:
Now that we have a basic understanding of volumes, let’s use them with Docker
Compose.
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
volumes:
- ~/dev/roadtok8s/py/data:/app/data
¡ docker compose up—To bring up our web application docker compose service.
Be sure to stop any other containers running at PORT 8080.
¡ docker compose exec web ls /app/data—To list out all of the files in our vol-
ume mounted on our container.
¡ docker compose exec web bash -c "date >> /app/data/sample.txt"—To
append a new date to our sample.
¡ docker compose exec web cat /app/data/sample.txt—To see the output of
our sample file.
As we see with these commands, Docker Compose has shortcut our ability to use vol-
umes with our containers. We can now use the docker compose exec command to run
commands inside of our container instead of needing to know the --name of a con-
tainer or for the more advanced Docker usage the container ID.
To stop our Docker Compose services and attempt to remove volumes we can run
docker compose down -v or docker compose down --volumes. The reason this is an
attempt is because Docker Compose intelligently knows that the volume ~/dev/road-
tok8s/py/dev is not managed by Docker Compose. This means that Docker Compose
will not attempt to remove this volume.
Let’s allow Docker Compose to manage volumes on our behalf. Doing this gives
Docker Compose more power over the environment but also provides us with simplicity
when it comes to managing the dependencies for our containers.
Compose will manage mywebdata because it is declared in the volumes section; it does
not exist within my local project and never will. While there is a way to access this vol-
ume directly on our host machine, it is unnecessary for us to do, and thus allowing
Docker Compose to manage mywebdata is the best option in this case. What’s more, this
volume will be removed when we run docker compose down -v because it is managed by
Docker Compose; your self-managed directory will not be removed with this command.
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
volumes:
- mywebdata:/app/data
volumes:
mywebdata:
Now if we run docker compose up and docker compose down -v, we will see the out-
put in figure 6.6.
The figure shows us that Docker Compose will remove a volume that destroys all files
and data within that volume. I showed you the other way first because destroying vol-
umes that should not be destroyed is terrible and something that we want to avoid.
The final way we’ll discuss managing containers is by declaring an external volume
within the compose.yaml directly. External volumes are not managed by Docker Com-
pose and thus will not be deleted when docker compose down -v is run, making them
ideal candidates for data we need to persist regardless of Docker or Docker Compose’s
status. Using the external: true configuration allows you to use a folder relative to
compose.yaml as an external volume that Docker Compose will not attempt to manage.
The following listing shows us how to do this.
140 Chapter 6 Containers in action
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
volumes:
- mywebdata:/app/data
- myprojectdata:/app/other-data
volumes:
mywebdata:
myprojectdata:
external: true
If you run docker compose up without creating the folder myprojectdata, you will get
an error stating external volume "myprojectdata" not found. This is a good indica-
tion that Docker Compose is not managing this folder for you, and you must make it.
Another key aspect to note here is you can mix and match the various volume types
as well as have many different volumes attached to your container. This means you
can have a volume that is managed by Docker Compose, a volume that is managed by
Docker Compose but is external, and a volume that is not managed by Docker Com-
pose at all. If the data in the volume is important and needs to persist, I recommend not
allowing Docker Compose to manage it but instead attaching directories you manage
yourself. Letting Docker Compose manage a volume means that it can be destroyed at
any time, even by accident via docker compose down -v; this is not something you want
to happen to your critical data.
Now that we have a grasp of how to use volumes, let’s take a look at a practical reason
to use them: databases. To use databases effectively with Docker Compose, we have to
understand one more key concept: networking.
In this section, we’ll look at a few fundamentals of networking with Docker Compose
as they pertain to container-to-container communication. Using this kind of communi-
cation is how we can use stateful containerized applications like databases to connect
directly to stateless containerized applications like our Python or Node.js applications.
Once we understand these fundamentals, we will have laid the foundation for what’s to
come with Kubernetes.
services:
my-nginx-service:
image: nginx
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
volumes:
- mywebdata:/app/data
volumes:
mywebdata:
Now that we have this new service, perform the following steps:
After running the curl command, we will see the HTML that results from the standard
NGINX page, as seen in figure 6.7.
Over at our docker compose up terminal window, we will see that the my-nginx
-service will indicate the request occurred, as seen in figure 6.8.
As we can see, the web service was able to communicate with the my-nginx-service
service using the service name as the hostname. We can also see that our compose.
yaml file never exposes the my-nginx-service service to the host machine (our local
machine) through a port. This means that the my-nginx-service service is only acces-
sible from within the Docker Compose network. A quick way to get practice with this
concept is to do the following:
¡ Run docker compose down to take the services and networks down.
¡ Change the service names in compose.yaml to something else.
¡ Run docker compose up to bring the services and networks back up.
¡ Remove any and all declared ports.
¡ Attempt to run curl from within a bash shell (docker compose exec my-service
/bin/bash) and see if you can access the other service.
Using the NGINX container made it easy for us since the default port is 80. Some
containerized applications, like our Python and Node.js apps, do not use port 80 by
default. It’s important that when you perform communication within the network, you
know what the correct port is.
This type of communication is great because it allows our Docker Compose services
to use common ports without conflicting with ports on the host machine. While this is a
nice feature, it can cause confusion while you are learning. So, when in doubt, add the
ports arguments to your compose.yaml file to expose the ports to your host machine.
Now we are going to combine our knowledge of volumes and networking to create a
database container that our Python application can use.
¡ When we mount volumes, our data will persist. If we don’t mount a volume, the
data won’t persist.
¡ Databases have standard ports that we need to be aware of. If you have too many
projects on your host machine attempting to use the same port, you will run into
a lot of unnecessary errors.
¡ docker compose down will persist data in Docker Compose-managed mounted
volumes, while docker compose down -v will destroy data in those same volumes.
This means your database data can be completely wiped just by using the -v flag
with certain volume mounting types.
¡ Using the service name instead of the hostname is how your other services can
connect.
144 Chapter 6 Containers in action
With these items in mind, we will install Redis for our Python Application to use. Redis
is among the most useful key-value data stores available, and it’s incredibly easy to set
up.
Before we add our Redis service to our compose.yaml file, we need to update our
Python application’s Dockerfile to install redis-tools so we can use the Redis command
line client. The following listing shows us how to do this.
...
# Run os-level updates
RUN apt-get update && \
apt-get install -y python3-venv python3-dev python3-pip
# Install redis-tools
RUN apt-get install -y redis-tools
...
It’s important to note that most open-source databases require additional system-wide
drivers for client applications (e.g., in Docker) and language-specific packages (e.g.,
requirements.txt or package.json) to fully connect. This is true for containerized appli-
cations and non-container but still Linux-installed applications. In other words, if you
want to use PostgreSQL or MySQL there are additional steps that are outside the scope
of this book, but the process is nearly the same. With our Dockerfile updated, let’s
add a new service to our compose.yaml called my-redis-service, as in the following
listing.
services:
my-nginx-service:
image: nginx
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
volumes:
- mywebdata:/app/data
my-redis-service:
image: redis
volumes:
- myredisdata:/data
volumes:
mywebdata:
myredisdata:
Networking fundamentals in Docker Compose 145
Now we have all the conditions needed to update our web service to communicate with
our Redis service. Follow these steps to see this in action:
1 Run docker compose down --remove-orphans to take the services and networks
down. The --remove-orphans flag is useful if you ever change service names or
remove services from your compose.yaml file.
2 Run docker compose up --build. In this case, the --build command is required
because we changed our web service’s Dockerfile.
3 Run docker compose exec web /bin/bash to enter the bash shell for the web
service.
4 Within the bash session, run redis-cli ping. You should see Could not con-
nect to Redis at 127.0.0.1:6379: Connection refused.
5 Now run redis-cli -h my-redis-service ping. You should see PONG as the
response.
Aside from adjusting the environment, you will see once again that the hostname for
Docker Compose services matches directly to what you declare in compose.yaml.
We can now iterate on our Python code to use Redis as a data store. We can also use
the redis-cli to interact with our Redis service. While this is nice, the important bit is
we now know how to do cross-container communication within Docker Compose using
stateful and stateless containers that either have or do not have volumes.
The last key step for improving our containers is to use an external environment
variable file called dotenv. This external file can be used with both Docker and Docker
Compose; let’s look at both of them.
dotenv files are used throughout the development process and, in some cases, are
used in production systems. I tend not to recommend using dotenv in production, but
doing so is certainly better than having sensitive data in your code. For production, I
recommend tools like HashiCorp Vault or Google Secret Manager to manage sensitive
data. Configuring both of these tools is outside the scope of this book.
To use dotenv files, we use the following syntax:
KEY=value
KEY="value"
KEY='value'
You can read more on this syntax at the official Docker reference (https://docs.docker
.com/compose/environment-variables/env-file/).
We want to create a dotenv file for the REDIS_HOST variable so that our redis-cli -h
my-redis-service ping can become redis-cli ping. A REDIS_HOST variable is a good
example of an environment variable you would not expose in your code.
In the root of your Python project (~/dev/roadtok8s/py), create a file named .env.
It’s important that you use the dot prefix in the file name. This file will be ignored by Git
and will not be committed to your repository (the .gitignore files we use already have
.env in them). The following listing shows us how to add the REDIS_HOST variable to our
.env file along with a few other examples that we won’t use.
REDIS_HOST=my-redis-service
DEBUG=1
RK8S_API_KEY="this-is-fake"
To use this .env file with Docker, it’s as simple as docker run --env-file path/to/.
env -p 8080:8080 rk8s-py or from the root of our project docker run --env-file
.env -p 8080:8080 rk8s-py. Do you think running this container outside of Docker
Compose will be able to communicate with the my-redis-service within Docker
Compose? Give it a try.
To use this dotenv file within our compose.yaml, we can update any service configu-
ration, much like in the following listing.
services:
...
web:
...
env_file:
- .env
environment:
- PORT=8080
volumes:
...
...
Summary 147
With this new configuration, we can now run docker compose up and docker compose
exec web /bin/bash to enter the bash shell for the web service. Within the bash shell,
we can run redis-cli ping and see the response: PONG. In the short term, this con-
figuration is more complicated. In the long term, this configuration is simpler, more
flexible, more portable, and ultimately more secure.
As you might imagine, there are many other ways to customize how to use both
Docker and Docker Compose. This book is not designed for all the ways to customize
those tools. Instead, we now have a practical model of using Docker Compose locally
for developing our containerized applications. Now it’s time to take this knowledge and
deploy applications using containers with Docker and Docker Compose.
Summary
¡ Building production-ready containers should happen in a CI/CD pipeline to
automate the process and ensure consistency and quality.
¡ Docker Compose is an effective way to manage various parameters needed to run
a single container.
¡ To make a container remember data, we mount volumes to it, giving it a stateful
nature instead of the default ephemeral nature.
¡ Docker Compose helps reduce the complexity of managing multiple containers
at once while providing a number of useful features for managing volumes and
container-to-container communication with built-in networking.
¡ It is never a good idea to hard-code sensitive information; always use environ-
ment variables when possible.
¡ Dotenv (.env) files are a common format to inject various environment variables
into a container runtime through the Docker CLI or directly in Docker Compose.
¡ Docker Compose can manage a container’s lifecycle from build to run to stop
and can manage volumes for you if you declare them in the compose.yaml or the
docker-compose.yaml file.
This chapter covers
Deploying containerized
applications
148
Hello prod, it’s Docker 149
Once you have this virtual machine and IP address, verify that you can access it via
SSH by running ssh root@<ip-address>. If you can connect, you are ready to install
Docker.
NOTE The term Docker means a lot of things, as we have come to learn. You may
hear that Kubernetes no longer supports Docker, but that isn’t true. Kuberne-
tes uses a different runtime for running containers than Docker Engine. With
additional configuration, Kubernetes can use Docker Engine as well as other
runtimes, but it’s not done by default. When Kubernetes moved away from
Docker Engine, it caused confusion, as if Kubernetes no longer used Docker,
which isn’t true. Kubernetes uses containerd, which is an even lighter runtime
than Docker Engine.
Like with our local system, there are a number of ways to install Docker on a vir-
tual machine. My preferred way is by using the Docker installation script available
at https://get.docker.com. If you want a more in-depth approach, review the offi-
cial Docker Engine documentation (https://docs.docker.com/engine/). To install
Docker Engine, be sure to start an SSH session on your virtual machine and then run
the commands in the following listing.
For those of you who are new to using these commands in Linux or Bash, here’s how
they break down:
¡ Curl is a way to download files from the internet via the command line.
¡ Using -fsSL with Curl is a combination of flags to follow redirects (-f), in silent
mode (-s), suppress a progress bar (-S), and follow location-header redirects
(-L). This flag ensures if any redirects happen, Curl will follow them and down-
load the file without any command-line outputs.
¡ The command sudo ensures the proceeding command has full privileges to run
this script. With many applications or installations, running it as the root user
might not be recommended because it can have dramatic security implications.
In this case, we are using a script from Docker that we trust.
¡ The command sh is how we can run this script as shell script. The command bash
can be used as well.
Hello prod, it’s Docker 151
After you run these commands, if you run docker --version you should see some-
thing resembling figure 7.2. The actual version that responds is not important as long
as you see a version number and not a command not found error.
While this install script is great, it does not install Docker Compose, so let’s do that
now.
NOTE Older versions of Docker Compose were installed via Python3 and used
the command docker-compose. While the older version might work, I do not
recommend it.
Now that we have both Docker and Docker Compose installed on our virtual machine,
it’s time to deploy our first containerized application directly with Docker. We are
going to leave Docker Compose for a bit later.
Docker has a built-in process manager that you can access using docker ps, which
will yield all of the running containers on your system. This works everywhere but is
especially important now. Figure 7.3 shows the output of docker ps after running
docker run -d nginx.
This command gives us a few key pieces of information we need for any given container:
We use the CONTAINER ID to do stop, view, and execute the bash shell:
When you run any Docker container image, it will automatically assign a name to the
container unless we pass one with the flag --name <your-container-name> (e.g., the
name boring_jepsen was autogenerated in this case). We can use this assigned name
(e.g., the NAME columns of docker ps give us the name or names of our running con-
tainers) instead of the CONTAINER_ID to stop, review, and execute the boring_jepsen
container:
If you are an observant reader, you will notice that there is a PORT associated with this
container, and according to figure 7.3, the port is 80, which is the standard HTTP port.
Now, you might be tempted to just visit http://<ip-address> in your browser, but that
won’t work. Do you remember why? The internal Docker port has not been exposed, or
published, to the external or system port. Let’s do that now.
Before we fix our running container, we should know about the command docker
ps -aq because it will return a list of all running containers in a format that is easy to use
with other commands. This is useful for stopping all running containers or restarting
all running containers.
Staging containers 153
The final flag we need to discuss is the --restart always flag. This flag will ensure
this application runs if Docker is restarted, the container fails (for some reason), or the
virtual machine is restarted. This is a great flag to use for any container that you want to
run continuously. Before we run the correct version of our NGINX container, let’s stop
all containers by running docker stop $(docker ps -aq).
The following listing shows a complete example of running a background service,
restarting the container if it fails, naming our container image, exposing a port, and
running the NGINX container.
If you need to manually stop and restart this running NGINX container, run docker
stop my-nginx, docker rm my-nginx to remove the named container image and then
repeat the command in listing 7.3.
You can now visit http://<ip-address> in your browser and see the NGINX welcome
page. Congratulations, you have deployed your first containerized application in pro-
duction! Now that we can deploy a production-ready application, let’s discuss how to
stage our applications using Docker and Docker Compose before we lock a production
version.
Staging environments often means changing internal container runtimes (e.g., Python
3.10 vs. Python 3.11), configurations (e.g., environment variables), the code itself (e.g.,
bug fixes, new features, etc.), and more. A few ways to do this are by using a different
¡ Dockerfile
¡ Docker Compose file
¡ Docker Compose file and a different Dockerfile
¡ Environment variables
¡ Entrypoint commands or scripts
Each is a valid way to modify how your application runs in or for different stages.
Changing or using different Dockerfiles or Docker Compose files is one of the most
common ways to prepare your application for production.
To use this Dockerfiles, we might also tag them specifically for each phase or environ-
ment with
How much you vary your build phases or stages will depend on the complexity of
your applications, the requirements of the team or business, how many platforms you
need to build for (e.g., ARM or x86), and how many staging environments you may need
to support. For many smaller teams, you might only need two environments: develop-
ment and production. Larger teams or applications often use many environments,
many versions using semantic versioning (https://semver.org/), and many platforms.
The ability to use different Dockerfiles is the key takeaway as you start to move con-
tainers into production, while tags signify the change in the state of your containerized
application. You will often have a different Dockerfile for your production stage and
your development stage. Docker Compose can also help with staging multiple environ-
ments in a very familiar way.
services:
web:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
volumes:
- mywebdata:/app/data
volumes:
mywebdata:
156 Chapter 7 Deploying containerized applications
The ingredient we left out of this Docker Compose file is the ability to specify an image
and a tag for that image. If the image or tags are unspecified, Docker Compose will set
them for you based on the directory of the compose.yaml file. To update this, we spec-
ify the tags: within the build block and add an image: block. Copy the compose.yaml
file and update it to compose.prod.yaml with the image and tag as seen in listing 7.5.
...
web:
image: codingforentrepreneurs/rk8s-py:${TAG:-latest}
build:
context: .
dockerfile: Dockerfile
tags:
- "codingforentrepreneurs/rk8s-py:${TAG:-latest}"
...
The ${TAG:-latest} is a way to use the environment variable TAG to specify the tag of
the image. If the TAG environment variable is not set, it will default to latest. This is
a common pattern for specifying tags in Docker Compose files, and we have seen this
syntax before with the PORT environment variable in our entrypoint scripts. To build a
container image with Docker Compose, we can now run:
¡ docker-compose -f compose.prod.yaml build: This will tag the web image as
codingforentrepreneurs/rk8s-py:latest.
¡ TAG=v1.0.0 docker-compose -f compose.prod.yaml build: This will tag the
web image as codingforentrepreneurs/rk8s-py:v1.0.0.
¡ TAG=v1.0.1 docker-compose -f compose.prod.yaml up --build: This will tag
the web image as codingforentrepreneurs/rk8s-py:v1.0.1.
Before you commit this to your GitHub repository, be sure to update your repository
and replace codingforentrepreneurs with your Docker Hub username.
To ensure you have tagged your container images correctly, you can run docker
image ls <image-name> or docker image ls codingforentrepreneurs/rk8s-py in
our case. This will output something like figure 7.4.
Once you tag an image, you can push it to Docker Hub with docker push <imagename>
--all-tags. Before you push all of your tags, you might want to remove a few. To
remove image tags, you can run docker rmi <image-name>:<tag> or docker rmi
GitHub Actions to deploy production containers 157
<image-id>. In my case and according to figure 7.4, that would be docker rmi cod-
ingforentrepreneurs/rk8s-py:latest or docker rmi 4dddf18b630e.
At this point, you should ensure that your local application directories (e.g., ~/dev/
roadtok8s/py and ~/dev/roadtok8s/js) have the following Docker-related files in the
root folder:
If you do not have these files, make them now, commit them to your repository, and
push them to GitHub. For a quick recap that will be
1 git add Dockerfile*: The * is a wildcard that will add both Dockerfile and
Dockerfile.prod.
2 git add compose*.yaml: The * is a wildcard that will add both compose.yaml
and compose.prod.yaml.
3 git commit -m "Added Docker Support"
4 git push origin main
Now that our Docker-ready applications are on GitHub, let’s create a GitHub Action
workflow to build, push, and deploy our containers to a production environment.
The actual steps our GitHub Actions workflow is going to take are the following:
Each one of this steps will be translated into GitHub Actions workflow steps. With this
in mind, let’s get started on our workflow.
Listing 7.6 GitHub Actions workflow to build and push containers, part 1
on:
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
As a reminder, the runs-on block shows us that we will be using ubuntu-latest to run
this step’s jobs, which will end up building our container image. Of course, we use
Ubuntu on our production virtual machine as well.
Before we start declaring the steps, we can configure the job’s environment variables
with the env: declaration. As mentioned before, this can help make our workflow more
concise and easier to maintain. The following listing shows the env: declarations we
need for this job in this workflow.
Listing 7.7 GitHub Actions workflow to build and push containers, part 2
runs-on: ubuntu-latest
env:
DH_USER: $glossterm::[ secrets.DOCKERHUB_USERNAME ]
REPO: $glossterm::[ secrets.DOCKERHUB_REPO ]
SSH_OPTS: '-o StrictHostKeyChecking=no'
REMOTE: 'root@$glossterm::[ secrets.AKAMAI_INSTANCE_IP_ADDRESS ]'
REMOTE_APP_DIR: '/opt/app'
With these items in mind, let’s configure the steps to build and push our container
image to Docker Hub with the values in the following listing.
Listing 7.8 GitHub Actions workflow to build and push containers, part 3
...
jobs:
deploy:
...
env:
...
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build the Docker image
run: |
docker build -f Dockerfile \
-t "$DH_USER/$REPO:latest" \
-t "$DH_USER/$REPO:${{ github.sha }}" \
.
- name: Push the Docker image to Docker Hub
run: |
docker push "$DH_USER/$REPO" --all-tags
With this code available, commit and push to your GitHub repository (e.g., git add .
and git push origin main). After you do, confirm this workflow runs successfully in
GitHub Actions. While it should run correctly based on our previous actions, we need
to verify that before we move to the next step.
After you verify you can once again build and push container images to Docker Hub,
let’s start the process of configuring our production virtual machine.
GitHub Actions to deploy production containers 161
7.3.2 Installing Docker and Docker Compose on a virtual machine with GitHub Actions
Earlier in this chapter, we saw how easy it is to install Docker and Docker Compose on
a virtual machine with only a few commands. We are going to do the same thing here
but with GitHub Actions.
You might be wondering if we should use Ansible to configure our virtual machine,
just as we did in chapter 4. While that is a valid option and one you may consider adopt-
ing in the future, the simplicity of installing Docker with the shortcut script means Ansi-
ble is not necessary at this time. Here’s what we’ll do instead of using Ansible:
We can use these commands to create a condition statement that can be run with an
SSH connection. The following listing configures our SSH key and shows how these
commands are used.
Listing 7.9 Installing Docker on virtual machine with GitHub Actions workflow and SSH
...
jobs:
deploy:
...
env:
...
SSH_OPTS: '-o StrictHostKeyChecking=no'
REMOTE: 'root@${{ secrets.AKAMAI_INSTANCE_IP_ADDRESS }}'
steps:
....
- name: Implement the Private SSH Key
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Ensure Docker and Docker Compose is installed on VM
run: |
ssh $SSH_OPTS $REMOTE << EOF
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
rm get-docker.sh
# install Docker Compose
sudo apt-get update
sudo apt-get install docker-compose-plugin
fi
EOF
162 Chapter 7 Deploying containerized applications
Now if you run this workflow, Docker and Docker Compose should be installed on your
new virtual machine. Verify this with a new SSH session on your local machine with
ssh root@<ip-address> docker --version and ssh root@<ip-address> docker
compose version. Running these commands should result in an output like in figure 7.5.
NOTE If you have any SSH errors on your local machine, you might need to
revisit how to install your SSH keys from section 4.1 or see appendix C for more
information on SSH keys.
Now that we have Docker and Docker Compose installed on our virtual machine, run
the workflow two more times. What you will see is this new installation step should run
very quickly since Docker is already installed. You can also consider provisioning a new
virtual machine to see how long it takes to install Docker and Docker Compose from
scratch. Let’s continue to the next step of our workflow, using Docker Compose to run
our production application.
¡ A production-ready Dockerfile
¡ A production-ready Docker Compose file (e.g., compose.prod.yaml)
¡ A production-ready container image
¡ A cloud-based production-ready host virtual machine
¡ Docker Hub hosted container images (either public or private)
¡ Docker and Docker Compose are installed on the host machine
¡ Production secret values stored on GitHub Actions secrets for our application
repositories
Our GitHub Actions workflow already has a number of these conditions fulfilled, so
let’s start with using privately hosted Docker Hub container images. Private images
take just one extra step to start using in production: logging in to Docker Hub via
docker login. The following listing shows how to log in to Docker Hub with GitHub
Actions and SSH.
GitHub Actions to deploy production containers 163
...
jobs:
deploy:
...
env:
...
SSH_OPTS: '-o StrictHostKeyChecking=no'
REMOTE: 'root@${{ secrets.AKAMAI_INSTANCE_IP_ADDRESS }}'
steps:
...
- name: Ensure Docker and Docker Compose is installed on VM
...
- name: Login to Docker Hub on VM
run: |
ssh $SSH_OPTS $REMOTE << EOF
docker login -u $DH_USER -p ${{ secrets.DOCKERHUB_TOKEN }}
EOF
The important part of this step is that you ensure that it is done through an SSH con-
nection and not a GitHub Action like the docker/login-action@v2 step.
Now let’s add our environment variables to our REMOTE_APP_DIR location. The envi-
ronment variables file, or dotenv (.env) file, will be created within the GitHub Actions
workflow from stored GitHub Actions secret and then copied to our production virtual
machine. After this file is created, we’ll also copy compose.prod.yaml as compose.yaml
to our production virtual machine to enable using docker compose up -d without spec-
ifying a compose file. The following listing shows how to do this.
Listing 7.11 Using SSH and SCP to add Dotenv secrets to a virtual machine
...
jobs:
deploy:
...
env:
...
SSH_OPTS: '-o StrictHostKeyChecking=no'
REMOTE: 'root@${{ secrets.AKAMAI_INSTANCE_IP_ADDRESS }}'
REMOTE_APP_DIR: '/opt/app'
steps:
...
- name: Create .env file
run: |
echo "MY_SECRET_KEY=${{ secrets.MY_SECRET_KEY }}" >> .env
echo "API_ENDPOINT=${{ secrets.API_ENDPOINT }}" >> .env
- name: Ensure remote directory exists
run: |
ssh $SSH_OPTS $REMOTE mkdir -p $REMOTE_APP_DIR
- name: Copy GitHub Actions .env file to Remote VM
run: |
scp $SSH_OPTS .env $REMOTE:$REMOTE_APP_DIR/.env
164 Chapter 7 Deploying containerized applications
The .env file is created right in the GitHub Actions workflow, so we can copy it to
our virtual machine, and eventually, Docker Compose can use it when running docker
compose up.
Keep in mind that it is necessary to create the app folder (e.g., the REMOTE_APP_
DIR) on our machine before copying the .env file to that folder:
Now that our .env has been created on our virtual machine, we need to bring our
Docker Compose configuration file over. To ensure you and I have the same configu-
ration, let’s create the compose.prod.yaml files for each application (e.g., ~/dev/road-
tok8s/py/compose.prod.yaml and ~/dev/roadtok8s/js/compose.prod.yaml).
First, we’ll look at our Python application’s compose.prod.yaml file. There are a few
key attributes to notice in this file:
services:
web:
restart: always
image: codingforentrepreneurs/rk8s-py:${TAG:-latest}
ports:
- "80:8080"
environment:
- PORT=8080
env_file:
- .env
services:
web:
restart: always
image: codingforentrepreneurs/rk8s-js:${TAG:-latest}
ports:
- "80:3000"
environment:
- PORT=3000
env_file:
- .env
The main difference is the default PORT value the container runs on; other than that,
the files are nearly identical.
Now that we have our production Docker Compose configuration files, let’s update
our GitHub Actions workflow to copy this file, pull images, and run our containers. The
keys to keep in mind are the following:
¡ Renaming to compose.yaml means we do not need to specify the file we will use.
¡ compose.yaml needs to exist next to .env on our virtual machine (e.g., /opt/
app/.env and opt/app/compose.yaml).
¡ Running docker compose pull in our REMOTE_APP_DIR will pull the latest
image(s) from Docker Hub based on our compose.yaml file.
¡ Running docker compose up -d in our REMOTE_APP_DIR will run our contain-
er(s) in the background at the correct ports.
The following listing shows how to do this for either application’s compose.prod.yaml
file.
166 Chapter 7 Deploying containerized applications
Listing 7.14 Copying configuration, pulling Images, and running Docker Compose
...
jobs:
deploy:
...
env:
...
SSH_OPTS: '-o StrictHostKeyChecking=no'
REMOTE: 'root@${{ secrets.AKAMAI_INSTANCE_IP_ADDRESS }}'
REMOTE_APP_DIR: '/opt/app'
steps:
...
- name: Copy compose.prod.yaml to VM
run: |
scp $SSH_OPTS compose.prod.yaml $REMOTE:$REMOTE_APP_DIR/compose.yaml
- name: Pull updated images
run: |
ssh $SSH_OPTS $REMOTE << EOF
cd $REMOTE_APP_DIR
docker compose pull
EOF
- name: Run Docker Compose
run: |
ssh $SSH_OPTS $REMOTE << EOF
cd $REMOTE_APP_DIR
# run containers
docker compose up -d
EOF
Before we commit this file, it’s often a good idea to clean up our SSH connection and
remove the .env file from the GitHub Actions workflow. While doing so may not be
strictly necessary as these workflows are destroyed once completed, I consider it a good
idea to ensure the workflow limits any potential secret leak or security concerns. The
following listing shows how to do this.
Listing 7.15 Cleaning up SSH and the dotenv file in GitHub Actions
...
jobs:
deploy:
...
env:
...
steps:
...
- name: Clean up .env file
run: rm .env
- name: Clean up SSH private key
run: rm ~/.ssh/id_rsa
The limitations of using Docker Compose for production 167
¡ Scaling container instances—While technically you can add services, you can’t
easily scale multiple instances of the same running container.
¡ Health checks—Docker Compose does not have a built-in health check mecha-
nism other than whether the container is running or not.
¡ Remote access—Docker Compose does not have a built-in way to remotely access a
container’s runtime (e.g., docker compose run <service-name> bash without SSH).
¡ Multi-virtual machine support—Docker Compose does not have a built-in way to
run containers on multiple virtual machines by default.
¡ Ingress management—While technically possible with an NGINX service,
there is no native way to manage incoming traffic with Docker Compose (e.g.,
ip-address/api goes to the api service, and ip-address goes to the web service).
¡ Public IP address provisioning—Docker Compose does not have a built-in way to
provision a public IP address for a container or service. As we’ll see in the next chap-
ter, managed Kubernetes provides this ability. This means you can provision a public
IP address for a container or service without needing to provision a virtual machine.
While there are a number of other limitations we won’t discuss here, these might con-
vince you to pursue leveraging Kubernetes, as we’ll discuss in the next chapter. Before
we do, let’s review a sample of how you could scale containers with Docker Compose,
regardless of whether it’s in production or development.
168 Chapter 7 Deploying containerized applications
upstream myapp {
server localhost:8080;
server localhost:8081;
server my-ip-address:80;
server my-other-ip-address:80;
}
server {
listen 80;
server_name localhost;
The limitations of using Docker Compose for production 169
location / {
proxy_pass http://myapp;
}
location /api {
proxy_pass http://localhost:3000;
}
}
The upstream <your-name> definition is how we can specify the various destinations
we want to use.
Now we can modify this configuration to work with Docker Compose services. While
we are not going to implement this in our current applications, the following listings
can serve as examples you can try on your own. Here are the steps we need to do to con-
figure NGINX to load-balance across multiple Docker Compose services:
1 Define the compose.yaml with multiple service names. For this example, we’ll
use bruce, tony, and thor as our service names.
2 With the service names as our hostnames (that is, replacing the IP address and
localhost), we’ll configure our upstream definition in an NGINX configuration
file.
3 Update the compose.yaml configuration with an NGINX service that uses the
new NGINX configuration file.
Listing 7.17 shows a minimized Docker Compose file with only the application ser-
vices listed without the ports definition. As a reminder, Docker Compose can do
cross-service communication; thus no ports are needed to expose these services to the
host machine.
Listing 7.17 Docker Compose with multiple services of the same container
services:
bruce:
image: codingforentrepreneurs/rk8s-py
restart: always
tony:
image: codingforentrepreneurs/rk8s-py
restart: always
thor:
image: codingforentrepreneurs/rk8s-py
restart: always
scott:
image: codingforentrepreneurs/rk8s-js
restart: always
With these services declared, let’s look at a NGINX configuration file (e.g., custom
-nginx.conf) that we could use to implement these different services, in listing 7.18.
170 Chapter 7 Deploying containerized applications
upstream myapp {
server bruce:8080;
server tony:8080;
server thor:8080;
server scott:3000;
}
server {
listen 80;
server_name localhost;
location / {
proxy_pass http://myapp;
}
}
services:
nginx:
image: nginx
ports:
- 8000:80
volumes:
- ./custom-nginx.conf:/etc/nginx/conf.d/default.conf
bruce:
...
tony:
...
thor:
...
scott:
...
With this configuration, you could run docker compose up, navigate to localhost:8000,
and you should see NGINX load-balancing in action. If you reload the page enough
times, you should see the different container instances being used. Modify this exam-
ple as you see fit to better understand how this is functioning.
While using NGINX to load-balance services in Docker Compose is very nice to know,
it still leaves us with a number of challenges that fall outside the scope of what Docker
Compose can realistically do. In the next chapter, we’ll look at how Kubernetes can
help us solve many of the challenges with scaling and managing multiple containers.
Summary 171
Summary
¡ Installing Docker on virtual machines is generally easier than most programming
languages and frameworks.
¡ Docker Compose is an effective way to run, manage, and update containers in
development and production.
¡ Docker Compose managed volumes can potentially cause major issues, includ-
ing data deletion, if not configured correctly.
¡ GitHub Actions provides an effective way to build production-ready containers
that work on x86-based virtual machines by default.
¡ Docker Compose can manage a handful of containers and services, but it starts
to fail as we scale.
¡ Load-balancing with NGINX is an effective way to scale applications, container-
ized or not, without needing to use more robust orchestration methods.
¡ Using documents, such as compose.yaml or a GitHub Actions workflow, to man-
age configurations is an effective way to ensure consistency and repeatability
while providing a single source of truth for an application’s configuration.
This chapter covers
Managed Kubernetes
Deployment
We have now reached our primary destination: the land of Kubernetes. Put sim-
ply, Kubernetes runs and manages containers across a cluster of virtual machines.
Kubernetes can start, stop, and restart containers; run 0 to N number of container
instances; switch or roll back versions; unlock container-to-container communica-
tion at scale; manage volumes and storage; inject environment variables; and so
much more. K8s, a shorthand for Kubernetes (pronounced “kay eights”), also has
a massive third-party ecosystem that extends its capabilities even further. The sheer
number of features and third-party tools can make learning Kubernetes seem down-
right daunting. This chapter focuses on a few key features of Kubernetes that will
accomplish two primary objectives: deploy our applications and build a practical
foundation for learning more advanced Kubernetes topics.
Starting in chapter 5, we learned that containers open a whole new world of por-
tability while also reducing the overhead in production-system configuration. This
172
173
reduction happens because with containerized applications our virtual machines mostly
need just a container runtime (e.g., Docker Engine) to run our apps with very little other
configuration. Put another way, using containers streamlines the deployment process by
spending significantly less time configuring virtual machines for various runtimes (e.g.,
Python, Node.js, or Java) while also reducing the time needed for additional app-spe-
cific configuration on the virtual machine. While containerized applications still need
app-specific configurations to run, this type of app-specific configuration can occur long
before the app reaches a production virtual machine or production environment.
If you have a container and a container runtime, your app can be deployed nearly
anywhere. While there are some caveats to this statement, such as the need to build
separate images for x86 and ARM platforms, containerized applications can move
across hosting locations almost as easily as the hosting companies can charge your wal-
let. Need an on-premise production system? Great, you can use containers. Need to
move those containers to achieve more scale by using the cloud and without spending
anything on new hardware? Great, you can use the same containers. Need to distribute
the application to other parts of the world with a load balancer? Great, you can use the
same containers. Need to move the application to a different cloud provider? Great, you
can use the same containers. The list goes on while the point is made: containers are the
most portable form of application deployment.
While containers solve a massive challenge in deploying applications, the technology
was not the first to do so. Popularized by Heroku and other platform-as-a-service (PaaS)
businesses, buildpacks enable application developers to push their code via Git, and then
the buildpack tool automatically configures the server or virtual machine. With build-
packs, developers would create a simple configuration file, called a Procfile, that contains
a single line to run the app itself (e.g., web: gunicorn main:app). Beyond the Procfile,
buildpacks use the code itself to infer how to configure the server. This often means that if
requirements.txt is found in the code, Python would be used. If it is package.json, Node.js
would be used. Notice that I purposefully omitted the version of the application runtime
because not all buildpacks support all versions of application runtimes. This is why we
need a better solution to help with portability and deploying our applications.
While buildpacks are powerful, the OS-level runtime configuration is largely inferred
instead of declared as we do with Dockerfiles or infrastructure as code (IaC) tools like
Ansible, SaltStack, or Puppet. What’s more, buildpacks do not always support modern
runtimes (e.g., Python 3.11 or Node.js 18.17 LTS, etc.), which, as we now know, contain-
ers do very well. That said, buildpacks paved the way for containers to become the most
portable form of application deployment. Luckily for us, we can also use buildpacks to
create container images using an open-source tool called pack (https://buildpacks.io/).
Using buildpacks is outside the scope of this book, but it’s important to note that the
challenge of portability has been around for a long time.
Buildpacks and containers ushered in a new era of software development that
enabled the field of DevOps to flourish. With these tools and cloud-based hosting ser-
vices, developers have been able to create and deploy more production-ready appli-
cations than ever before, often as solo developers or very small teams. Adding the fact
174 Chapter 8 Managed Kubernetes Deployment
that more businesses have adopted all kinds of software (cloud-based or otherwise), we
continue to see a rising need for managing and deploying more public-facing and pri-
vate-facing applications. This is where Kubernetes comes in.
Once we have built container images and access to a Kubernetes cluster, deploying an
application is as simple as writing a YAML configuration file known as a Kubernetes manifest.
we will be spending a lot of this chapter on: Kubernetes manifests. With this in mind,
let’s provision a Kubernetes cluster using Akamai Connected Cloud.
Before you click Create Cluster, verify your screen resembles figure 8.1.
The Linode Kubernetes Engine (LKE) will automatically provision the virtual machine
instances based on our previous configuration. These particular instances do not need
our SSH keys because we will not be logging directly into them. The virtual machine
instances will appear in your Linode dashboard just like any other instances. LKE will
manage all related virtual machines and the Kubernetes Control Plane for us.
Click Create Cluster, and after a few moments, we’ll see the LKE detail view for this
cluster (located at http://cloud.linode.com/kubernetes). The result you will see is in
figure 8.2.
¡ Cluster—A group of virtual machines that run Kubernetes and our containers.
While clusters can be run on other kinds of compute devices, we will focus on
virtual machines in this book.
¡ Node—A virtual machine that is a member of our Kubernetes cluster.
¡ Container—A portable application bundle that runs in isolation via a container
runtime. Kubernetes uses a lightweight container runtime called containerd. As
a reminder, Docker Engine is a container runtime that Docker Compose uses.
¡ Pod—This is the smallest unit of running a container, including the configura-
tion the container needs. Pods can also define multiple containers that share
resources (e.g., CPU, memory, etc.). While we can create Pods directly, it’s more
common to use a Deployment to create Pods.
¡ Deployment—Deployments define the conditions to run containers via Pods. In a
Deployment, we can specify the container image, the number of Pod replicas we
want to run, and more. Deployments are the most common way to run contain-
ers in Kubernetes.
¡ Service—Services expose our Pods (and thus our containers) to either our
Kubernetes network (internal) or to the internet (external). We’ll use Services
in conjunction with Deployments to expose our containers to the internet with
cloud-based load balancing.
¡ Namespaces—Namespaces are a way to organize our Kubernetes resources. A
Namespace can be assigned to any Kubernetes resource (e.g., a Deployment, a
Service, a Pod, etc.), allowing us to filter our resources by Namespace. You can
use namespaces for helping to keep projects separate, for app environment stag-
ing (e.g., production, preproduction, dev, rnd, etc.), for different organizations,
or for internal teams.
¡ Secrets and ConfigMaps—These are the mechanisms built into Kubernetes for
storing key-value pairs (much like the dotenv files we know and love). Secrets
are really just base64-encoded values, while ConfigMaps are key-value pairs. Both
are used to inject environment variables into containers. I tend to think of these
together because Kubernetes Secrets, while convenient, are not very secure.
We’ll cover both in this chapter.
¡ StatefulSets—Deployments, like containers, are designed to be ephemeral. State-
fulSets are designed to be more permanent and are often used for databases
(e.g., PostgreSQL, Redis, MySQL, etc.) and other stateful applications.
¡ Volumes—When we need data to persist beyond a container’s lifecycle, we can
attach persistent volumes managed directly from our cloud provider.
¡ Service account—Kubernetes does not have users in the traditional sense; instead,
it has service accounts. Service accounts are used to authenticate with the Kuberne-
tes API and manage or access various Kubernetes resources. When you provision
Connecting to Kubernetes 179
a new managed cluster, you typically get a root service account with unrestricted
permissions to your Kubernetes cluster. This root service account also comes with
sensitive access keys through an automatically generated kubeconfig file. While
creating service accounts is outside the scope of this book, it can be important as
your team or Kubernetes project grows in contributors.
¡ Kubeconfig—With managed clusters, the root service account is provisioned with
a kubeconfig file (e.g., rk8s-cluster-kubeconfig.yaml) that contains the sensitive
access keys to your Kubernetes cluster. Treat this file like any other secret (e.g.,
only share with trusted individuals, reset the keys if you think it’s been compro-
mised, do not check it in to Git, etc.). The kubeconfig file is used to authenticate
with the Kubernetes API to access a specific Service Account. We will create a new
kubeconfig file in this chapter.
¡ kubectl—The Kubernetes Command Line interface is called kubectl and is var-
iously pronounced “cube CTL,” “cube control,” and “cube cuddle.” In conjunc-
tion with a service account via a kubeconfig file, we’ll use kubectl to manage our
Kubernetes cluster, deploy containers, manage Services, create Secrets, restart
Deployments, and more. kubectl is a powerful tool, and we’ll use it a lot in this
chapter.
With this in mind, let’s learn a few ways to connect and interact with our new Kuberne-
tes Cluster.
These steps will open a new tab in your browser and prompt you to log in with your
kubeconfig file. To do so, select Kubeconfig (not token), choose your rk8s-cluster
-kubeconfig.yaml download file, and click Sign In, and the result should resemble fig-
ure 8.4.
180 Chapter 8 Managed Kubernetes Deployment
This Dashboard is painfully empty and has no wizard-like forms to help us configure
our cluster or K8s resources. This is by design. To configure Kubernetes we must use
specific YAML files known as Kubernetes manifests. If you hit the plus sign (+) in the
top right corner, you can paste in manifests to create resources. We will learn more
about manifests soon. Even with no resources provisioned, we can view the current
nodes available in your cluster as you can review in the Nodes tab, which will look much
like figure 8.5.
While some users might prefer using the dashboard to better visualize what is hap-
pening in their cluster, I prefer using kubectl because it’s a tool I can use in my termi-
nal and automate with custom scripts and in my CI/CD pipelines. Now that we have
reviewed the dashboard, let’s install kubectl on our local machine.
Figure 8.6 shows that I have kubectl installed and the connection to server
localhost:8080 failed. This error is expected as we do not have kubectl configured,
and there is likely not a local version of Kubernetes running (for sure, not at port
8080). I show you this output up front so you know what to expect when you install
kubectl on your machine.
To install kubectl, there are three primary options we’ll use based on your operating
system:
The methods I provide in the following sections are the ones I prefer because they
tend to be the easiest to get up and running.
Install kubectl with Homebrew on macOS
Homebrew is an open-source package manager for macOS and some Linux distribu-
tions. Homebrew makes installing a lot of third-party software much easier, including
kubectl. Installing kubectl with Homebrew is as simple as the following:
1 Run brew update in Terminal to update Homebrew. If the brew command fails,
install homebrew from https://brew.sh/.
2 Run brew install kubectl.
3 Verify kubectl with kubectl version --client --output=yaml.
If you are having problems with Homebrew, consider using the kubectl binary instal-
lation as seen in the section Install the Kubectl Binary on Linux. If you’re using Apple
Silicon, be sure to use the ARM version (e.g., export PLATFORM="arm64"). If you’re not
on Apple Silicon, use the x86 version (e.g., export PLATFORM="amd64").
Install kubectl with Chocolatey on Windows
Chocolatey is an open source package manager for Windows that makes installing a lot
of third-party software easy. We can use Chocolatey to install kubectl and many other
tools. Here’s how to install kubectl with Chocolatey:
If you are having problems with Chocolatey, consider using the Windows subsystem for
Linux (WSL) and follow the instructions in the next section.
Install the kubectl binary on Linux
Installing the kubectl binary on Linux requires a few more steps than using package
managers. The steps in listing 8.1 come directly from the Official kubectl Documen-
tation (https://kubernetes.io/docs/tasks/tools/install-kubectl-linux/) but are modi-
fied to easily switch platforms (e.g., amd64 or arm64) for different system architectures.
# install kubectl
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
# verify installation
kubectl version --client --output=yaml
¡ Use ~/.kube/config as the path for our kubeconfig file. This means we would name
the file config (without .yaml) and store it in the .kube directory for our user.
¡ Use the environment variable KUBECONFIG with the absolute path to our kubecon-
fig file. This means we can name the file anything we want and store it anywhere
we want.
We will use the KUBECONFIG variable for local development to make it easier to use mul-
tiple kubeconfig files across multiple Kubernetes projects.
Using KUBECONFIG is as simple as the following:
Writing the entire environment variable each time can be tedious, so we can set the
environment variable for each command-line session with
Once again, we’ll have to remember to set the KUBECONFIG environment variable each
time we open a new Terminal or PowerShell session. While we could set the KUBECONFIG
environment variable globally, it defeats the purpose of using multiple kubeconfig files
for different projects.
If you’re anything like me, you’ll want to set up your local development environment
to use multiple clusters right away. While this does take more configuration, it really sets
you up for success as you learn Kubernetes and adapt it for your projects.
One of the reasons I use Visual Studio Code (VSCode) as my text editor is how sim-
ple it is to configure the VSCode terminal to use pre-defined environment variables. As
a reminder, text editor terminals (including PowerShell-based ones) are not the same
as the operating system’s Terminal or PowerShell. While other text editors may have
similar features, I’ll show you how to configure VSCode to use multiple kubeconfig files
for different projects.
Within VSCode, you can update your .code-workspace file to include the terminal.
integrated.env setting for each platform with specific key/value pairs. For example, you
can use terminal.integrated.env.osx to set PORT to 8080 or KUBECONFIG to some/
path/rk8s-cluster-kubeconfig.yaml. With these values set and your VSCode work-
space open, you can open a new VSCode terminal session, and these values will be
automatically injected into your command line, which you can verify by running echo
$YOUR_VARIABLE like echo $KUBECONFIG. Listing 8.2 shows us cross-platform examples
in a .code-workspace file that sets multiple environment variables (i.e., KUBECONFIG, PY_
PORT, JS_PORT) for each platform.
{
...
"settings": {
...
"terminal.integrated.env.osx": {
"KUBECONFIG": "${workspaceFolder}/.kube/kubeconfig.yaml",
"PY_PORT": "8080",
"JS_PORT": "3000"
},
"terminal.integrated.env.windows": {
"KUBECONFIG": "${workspaceFolder}\\.kube\\kubeconfig.yaml",
"PY_PORT": "8080",
"JS_PORT": "3000"
},
"terminal.integrated.env.linux": {
"KUBECONFIG": "${workspaceFolder}/.kube/kubeconfig.yaml",
"PY_PORT": "8080",
"JS_PORT": "3000"
},
}
}
Deploy containers to Kubernetes 185
The variable ${workspaceFolder} is special for VSCode and will be replaced by the
absolute path to the workspace. Using the line $workspaceFolder}/.kube/kubecon-
fig.yaml results in the path relative to the workspace’s root (often the same folder that
holds the .code-workspace file), which is also true for the Windows version (terminal.
integrated.env.windows). If you prefer absolute paths, you can do the following:
¡ On macOS or Linux—${workspaceFolder}/.kube/kubeconfig.yaml to /path/
to/my/kube/config.yaml
¡ On Windows—Swap "${workspaceFolder}\\.kube\\kubeconfig.yaml" to
"C:\\path\\to\\my\\kube\\config.yaml". Notice the double backslashes.
Regardless of how you set the KUBECONFIG environment variable, you should be able to
verify kubectl can connect with kubectl get nodes and see the nodes in your cluster.
The result should resemble figure 8.7.
Now that we have kubectl configured, let’s deploy our first container.
With our roadtok8s-kube directory ready, let’s create your first Pod manifest to run your
first container image.
186 Chapter 8 Managed Kubernetes Deployment
With this in mind, let’s create your first manifest file running a single NGINX-based
Pod. If you haven’t done so already, create the file my-first-pod.yaml and add the
contents from the following listing, which has a minimal manifest for a Pod that runs
the Docker-managed public NGINX container image.
apiVersion: v1
kind: Pod
metadata:
name: first-pod-nginx
spec:
containers:
- name: nginx
image: nginx
Now that we have this file, we can do a few things with it:
If you are using the Kubernetes Dashboard, you could use this file to create a new Pod
directly in the dashboard. Doing so is outside the scope of this book, but I encourage
you to explore if you are interested.
After running the kubectl apply command, we can verify the resource was created
by running kubectl get pods, which should result in figure 8.8.
The status of any Pod, known as a Pod’s phase, can be one of the following:
¡ Pending—The Pod is being created and is not yet running. This often means the
container is being pulled from a container registry or is still booting.
¡ Running—The Pod is running, and thus the Pod’s container(s) is running as well.
¡ Succeeded—For Pods that do not run continuously, this status indicates the Pod
has completed successfully.
¡ Failed—Not surprisingly, this status indicates the Pod has failed.
¡ Unknown—For some reason, the state of the Pod could not be obtained.
You should expect that the NGINX container image will be running successfully after
a couple of minutes. If it’s not running, you can investigate the Pod by using kubectl
describe pod <pod-name> or kubectl logs <pod-name>. The NGINX container
image is very mature, and thus, errors are unlikely to happen because of the container
itself (this may or may not be true of the containers we built for our Python and Node.
js projects).
Now that the container is running because of the Pod resource, how do we actually
visit the internet-facing NGINX default page from this Pod? Enter your first Service
resource.
¡ NodePort (external traffic)—Our cluster has three nodes (virtual machines), and
each node has a static public IP address provided by the ISP and Akamai Con-
nected Cloud. Using the NodePort Service resource allows you to expose a Pod to
a specific port value, such as 30007, on every available node in your K8s cluster.
¡ LoadBalancer (external traffic)—This is one of my favorite Service types because
it automatically provisions a cloud-based load balancer with a static public IP
address for a K8s Service resource. In the case of Akamai Connected Cloud, the
Linode Kubernetes Engine (i.e., the managed Kubernetes Service we’re using)
will provide us with a public static IP address through the cloud-based load bal-
ancing service called Linode NodeBalancers.
¡ ClusterIP (internal cluster-only traffic)—Using this Service type allows other con-
tainers in our Kubernetes Cluster to communicate with our Pods (or Deploy-
ments), enabling container-to-container communication within our cluster.
More accurately, ClusterIPs are for Pod-to-Pod or Deployment-to-Deployment
communication within our cluster. ClusterIPs are great for Pods or Deployments
that do not need to be exposed to the internet, such as databases, internal APIs,
and others.
We will start with NodePort for our Pod because it’s the easiest to test quickly without
adding anything else to our cluster or our cloud provider.
Before you create a service resource, you need to update your pod manifest to make
it easier for a Service to find and attach to it. To do this, add a label to the manifest of
your Pod (your-first-pod.yaml) within the metadata block, just like listing 8.4.
Listing 8.4 K8s YAML manifest for an NGINX Pod with a Label
apiVersion: v1
kind: Pod
metadata:
name: first-pod-nginx
label:
app: my-nginx
...
While we typically add metadata labels when we first create resources like Pods, I
wanted to add additional emphasis to the importance now: if your Pod or Deployment
does not have a label, the service resource will not work correctly.
With this in mind, update your pod by running kubectl apply on the updated man-
ifest with kubectl apply -f my-first-pod.yaml. Now run kubectl get pods, and
your pod should still be running. Run kubectl get pods my-nginx -o yaml to add
even more details about your pod (including verifying that your label is present in the
output).
Within any Kubernetes Manifest, regardless of its type (Pod, Service, Deployment,
etc.), we can apply a metadata label to the resource. Metadata labels are key-value pairs
that can be used to filter any Kubernetes Resources. In the case of your updated Pod
Deploy containers to Kubernetes 189
resource and the app: my-nginx label, you can see the filtering in action with the com-
mand kubectl get pods -l app=my-nginx. Running this command will either result in
a list of Pods matching this selector or no Pods at all.
The selector, or filter, for narrowing the list of results of any given Kubernetes
Resource is the -l or --selector flag as in kubectl get <resource> -l <key>=<value>
or kubectl get <resource> --selector <key>=<value>. Here are a few examples of
the selector filter in action:
As these examples imply, the metadata label can be anything you want; that goes for
both the key and its related value. In other words a resource’s metadata label key value
(e.g., app: in our Pod manifest) has no special meaning to Kubernetes; it’s just a con-
ventional name. While some metadata can have special meaning, this one does not.
Just remember the key-value pair you use (e.g., buddy: holly, hello: world, peanut:
butter), will be known as the resource’s selector value.
Now that we have seen selectors in action based on metadata key-value pairs, let’s
define a few key declarations we need for our Services within the spec: block:
¡ The port we’ll use on our nodes is 30007 (e.g., nodePort: 30007).
¡ The port we’ll use for internal cluster communication is 5050 (e.g., port: 5050).
We’ll use internal cluster communication later in this chapter.
¡ The port the container expects is the port we’ll use for our targetPort (e.g.,
targetPort: 80).
¡ Our Pod has a label with app: my-nginx, so our Service can use the selector app:
my-nginx to find our Pod.
apiVersion: v1
kind: Service
metadata:
name: first-service-nginx
spec:
type: NodePort
ports:
- port: 5050
targetPort: 80
nodePort: 30007
selector:
app: my-nginx
The listed Services should match our first-service-nginx and the default Kuberne-
tes Service. The default Kubernetes Service is how our cluster communicates with the
managed Kubernetes API; this API, known as the control plane, is how resources work.
The control plane is what schedules and runs resources across the cluster. With man-
aged Kubernetes, the control plane is managed by the service provider but is still essen-
tially the glue that keeps it all together. The kubectl tool communicates directly to the
Kubernetes API with this Service. For our Service, here’s what’s important to note:
Deploy containers to Kubernetes 191
Now, let’s verify that NodePort actually works by getting each public static IP address
for each node. To do this, you can
With this in mind, let’s create a new file called my-first-deployment.yaml in your
roadtok8s-kube directory with the contents of the following listing.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 15
selector:
matchLabels:
app: my-nginx
template:
metadata:
labels:
app: my-nginx
spec:
containers:
- name: nginx
image: nginx:latest
With this manifest, apply the changes with kubectl apply -f my-first-deployment
.yaml. Once you do, you can verify the Deployment with kubectl get deployments
or the shortcut version kubectl get deploy. The result should resemble figure 8.10.
In this example, I put 15 replicas to illustrate how simple it is to scale up the number
of Pods running because of the Deployment resource and once again highlight how
the selector filter works. If you run kubectl get pods --selector app=my-nginx, you
should see at least 15 Pods running (and possibly 16 with our previous Pod resource
manifest).
The replica count is the desired state for Kubernetes Deployment resources. This
means that Kubernetes will continuously deploy Pods until 15 of them are running and
healthy, as defined in our manifest. If any Pods fail at any time, Kubernetes will automat-
ically roll out new Pods until the number of Pods reaches the replica count. The rollout
process is tiered by default, so if you were to update a Deployment manifest, only a few
Pods would be updated at a time. This is a great feature because it allows us to deploy
new versions of our containers without any downtime, and it helps to ensure that failing
containers have little to no effect on our Service.
Deploy containers to Kubernetes 193
The Pod resource does not have a replica count, so the behavior is not the same as
the Deployment resource. This means that if a Pod manifest (e.g., kind: Pod) has a fail-
ing container, you, as the developer, will have to intervene manually.
With the provisioned Deployment manifest, you might notice that we do not need
to change our Service manifest at all. Do you remember why? It's because we used the
exact same selector for the Deployment as we did for the Pod. This means that our
Service will automatically load balance traffic across all 15 Pods from the Deployment
resource (e.g., my-first-deployment.yaml) and the one Pod from the Pod resource
(my-first-pod.yaml), totaling 16 Pods available for our Service resource. To clean up,
delete all three resources with the following commands:
kubectl delete -f my-first-pod.yaml
kubectl delete -f my-first-deployment.yaml
kubectl delete -f my-first-service.yaml
With all of these resources gone, let’s see how we can make some modifications to our
NGINX container by using a new resource called ConfigMaps.
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-configmap
data:
index.html: |
<html>
<head>
<title>Hello World</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>
The ConfigMap resource uses data: instead of spec: to define the key-value pairs that
we choose to use. In other words, the data: blocks are incredibly flexible. What you
194 Chapter 8 Managed Kubernetes Deployment
see here is we are using the key index.html with a multi-line value, denoted by the pipe
|, that has very rudimentary HTML.
To override the default index.html value that comes with the NGINX container
image, the ConfigMap key value of index.html: in the data: will be used in our Deploy-
ment. To do this, we will mount the ConfigMap as a volume in our Deployment manifest.
When we mount a volume on a Deployment, we are mounting a read-only volume
since the ConfigMap is not meant to be modified by the container. If we need to mount
read-write volumes, Deployments or Pods can use persistent volumes, which we’ll talk
about more later in this chapter.
Declaring volumes in a Deployment’s template block (or in a pod manifest) is a lot
like using volumes with Docker Compose: we must declare the volume and mount the
volume. We can have multiple volumes and multiple mounts for each volume. In our
case, we will have one volume and one mount.
Create a file called 2-deployment.yaml within the custom-nginx folder in the road-
tok8s-kube directory with the contents. This Deployment is almost identical to the pre-
vious one, with two new additions for declaring the volume (the volumes: block) and
mounting the volume (the volumeMounts within the containers definition). The follow-
ing listing shows this in action.
Listing 8.8 K8s YAML manifest for an NGINX Deployment with a ConfigMap
apiVersion: apps/v1
kind: Deployment
metadata:
name: custom-nginx-deploy
spec:
replicas: 15
selector:
matchLabels:
app: custom-nginx
template:
metadata:
labels:
app: custom-nginx
spec:
containers:
- name: nginx
image: nginx:latest
volumeMounts:
- name: my-configmap-volume
mountPath: /usr/share/nginx/html
readOnly: true
volumes:
- name: my-configmap-volume
configMap:
name: nginx-configmap
Deploy containers to Kubernetes 195
Now that we have a Deployment, let’s create a new Service for this Deployment. Create
a file called 3-service.yaml within the custom-nginx folder in the roadtok8s-kube direc-
tory with the contents in listing 8.9.
Listing 8.9 K8s YAML manifest for an NGINX Service with a ConfigMap
apiVersion: v1
kind: Service
metadata:
name: custom-nginx-svc
spec:
type: NodePort
ports:
- port: 8080
targetPort: 80
nodePort: 30008
selector:
app: custom-nginx
Notice that the port and nodePort have changed from our previous Service; this is just
to ensure we have no conflicts due to these values. Now our custom-nginx folder has
the following files:
¡ 1-configmap.yaml
¡ 2-deployment.yaml
¡ 3-service.yaml
The display order within our computer should match the order above. In this case, the
provision order is necessary because the Deployment depends on the ConfigMap, and
the Service depends on the Deployment. If you have the order done incorrectly, some
or all of these Services may not be provisioned correctly or may require them to be pro-
visioned multiple times before it actually does work. We want to have it work on the first
go; thus, the order is important. We have the following options to provision these files:
Or simply:
The latter will provision all of the files in the folder in the order they are displayed on
your computer. This is why I prefixed the files with 1-, 2-, and 3- to ensure the order is
correct. Pretty neat feature, huh?
Now if you open the nodePort (e.g., 30008) on any of your node’s public IP addresses,
you should see the output shown in figure 8.11.
196 Chapter 8 Managed Kubernetes Deployment
Success! You should see the new HTML output instead of the standard NGINX wel-
come page. If you ran into problems, here are a few things to verify:
We can use ConfigMaps for more than just mounting volumes. We can also use Con-
figMaps to set environment variables, as we’ll see shortly. Remember, ConfigMaps are
not abstracted in any way so you should treat them like you treat any code: leave out all
sensitive data.
Before we move on, let’s clean up our ConfigMap-based NGINX Deployment with
the command kubectl delete -f custom-nginx/. Once again, we can shortcut calling
all manifests in a folder, in this case for the purpose of deleting the Deployment.
Now that we have seen the volume-mounted use case for ConfigMaps, let’s see how
we can use ConfigMaps to set environment variables.
The first option is the least flexible, the least reusable, and probably the least secure.
ConfigMaps and Secrets can be attached to an unlimited number of resources, thus
making them by far the most reusable. With this in mind, changing configuration can
be as easy as changing the ConfigMap or Secret instead of changing each individual
Deploy containers to Kubernetes 197
resource directly. What’s more is that some environment variables can be exposed pub-
licly (even if the public is just teammates) while others cannot be; using a ConfigMap
or Secret helps ensure this visibility is minimized. The following listing shows an exam-
ple of setting environment variables directly in a Deployment manifest.
kind: Deployment
...
spec:
...
template:
...
spec:
containers:
- name: nginx
image: nginx:latest
env:
- name: MY_ENV_VAR
value: "This is simple"
- name: PY_PORT
value: "8080"
- name: JS_PORT
value: "3000"
As we can see, this type of setting environment variables is easy, but they are not exactly
reusable. What’s more, if you accidentally commit this manifest to a public repository
with a sensitive environment variable, you could be in a world of trouble. Let’s move
the PY_PORT and JS_PORT environment variables to a ConfigMap resource, as seen in
the following listing.
apiVersion: v1
kind: ConfigMap
metadata:
name: project-ports-cm
data:
PY_PORT: "8080"
JS_PORT: "3000"
SPECIAL: "delivery"
Now that we have these ConfigMap variables, we can update our Deployment env block
like in the following listing.
kind: Deployment
...
spec:
...
198 Chapter 8 Managed Kubernetes Deployment
template:
...
spec:
containers:
- name: nginx
image: nginx:latest
env:
- name: MY_ENV_VAR
value: "This is simple"
- name: PY_PORT
valueFrom:
configMapKeyRef:
name: project-ports-cm
key: PY_PORT
- name: JS_PORT
valueFrom:
configMapKeyRef:
name: project-ports-cm
key: JS_PORT
envFrom:
- configMapRef:
name: project-ports-cm
What we see here is there are two ways we can use ConfigMaps to set environment
variables:
While ConfigMaps are not secure, their cousin resource, Secrets, are more secure.
Secrets are meant to hold sensitive data like passwords and API keys, but they are just
Base64-encoded values. In other words, they are only slightly more secure than Config-
Maps. That said, let’s see how we can use Secrets to set environment variables. To cre-
ate our Secret resource, create a file called dont-commit-this-secrets.yaml in your
roadtok8s-kube directory with the contents in the following listing.
apiVersion: v1
kind: Secret
metadata:
name: somewhat-secret
type: Opaque
stringData:
SECRET_YT_VIDEO: "dQw4w9WgXcQ"
Deploy containers to Kubernetes 199
When defining Secrets, we can use two different configurations data or stringData.
The data block requires you to submit Base64-encoded values and the stringData
block requires just raw text. In this case, we are using stringData to keep things sim-
ple. Now let’s verify these Secrets with
After you perform these steps, you should see the same output as in figure 8.12.
As you can see, the key SECRET_YT_VIDEO is now set to the value ZFF3NHc5V2dYY1E=.
This is a base64 encoded version of the plain text we submitted. Do a quick search
online, and you’ll find an easy way to decode this data.
For this reason, I recommend using third-party tools for better securing your true
Secret values. A few options are HashiCorp Vault (which has managed and open-source
versions), AWS Secrets Manager (a managed service), and Google Secrets Manager
(also a managed service).
The best part of Secret and ConfigMap resources is that we can reuse them on any
Deployment or Pod within our cluster. While this is yet another potential security risk,
it’s a great way to remove a lot of redundant information our Kubernetes resources may
need.
Now that we have the foundations of a few core Kubernetes resources, let’s see how
to deploy stateful containers like databases to Kubernetes.
200 Chapter 8 Managed Kubernetes Deployment
The Deployment resource’s Pod naming scheme means using specific Pods by name is
a bit finicky. For example, if we wanted to use the bash shell of a running Pod within a
Deployment, we have two options:
This strange naming scheme was not just a sneaky way to introduce a few new com-
mands; it was also to help us ask the question: Can we have logical Pod names? Some-
thing like custom-nginx-01 or custom-nginx-02 would be nice. While we could create
a Pod manifest with these auto-incrementing names (e.g., kind: Pod), that would
be wasteful and downright silly when we have a Kubernetes resource that does this
already: the StatefulSet resource.
Are StatefulSets just Deployments that name Pods differently? Yes and no. While
StatefulSets do name Pods in order (e.g., custom-nginx-01 or custom-nginx-02), they
also have a major other benefit: volume claim templates.
Before we provision our first StatefulSet, let’s look at volume claims within a Deploy-
ment. Volume claims are requests to the cloud provider for access to a managed storage
volume that can be attached to a Pod.
Create a file called 1-pvc.yaml in the deploy-pvc directory with the contents in the
following listing.
202 Chapter 8 Managed Kubernetes Deployment
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: first-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: linode-block-storage-retain
With this PVC resource, we can now attach this volume to a Deployment resource. Cre-
ate a file called 2-deployment.yaml in the deploy-pvc directory with the contents in
the following listing.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 1
...
template:
...
spec:
...
containers:
- name: nginx
...
volumeMounts:
- mountPath: "/usr/share/nginx/html"
name: first-pv-storage
volumes:
- name: first-pv-storage
persistentVolumeClaim:
claimName: first-pvc
Apply these resources with kubectl apply -f deploy-pvc/. After a short wait, the
following should happen:
Now that we have seen our first volume generated, let’s discuss limitations to the Read-
WriteOnce access mode: the replica limit. By default, this particular access mode means
the volume can only be attached to one Pod at any given time. In other words, this volume
cannot be shared across multiple Pods. If we scale our Deployment to two or more repli-
cas, the new Pods will not provision because the volume is already attached to the first Pod.
This is a limitation of the ReadWriteOnce access mode and not a limitation of Kubernetes.
As you may know, multiple computers or apps or Pods sharing the same persistent
volume can often lead to data corruption (i.e., may different processes writing to the
exact same file at the same time). What’s more, there are better storage options (e.g.,
databases, message queues, s3-compatible object storage, etc.) for sharing data across
multiple Pods. I believe it’s for this reason that many cloud providers, Akamai Con-
nected Cloud included, opt for only supporting ReadWriteOnce access mode at the
time of writing this book.
Before we move on, destroy these resources with kubectl delete -f deploy-pvc/;
this will delete both the PVC, the volume in your Akamai account, and the Deployment
resources.
While you may still need multiple containers running with access to storage volumes,
you can do so by using another Kubernetes resource: the StatefulSet. Let’s see how we
can attach a volume to each Pod in a StatefulSet.
8.4.2 StatefulSets
Throughout this book, we have seen the use of “Hello World” examples with our web
applications. While these apps have been useful in explaining a lot of concepts, they
lack a key layer of practicality. While web apps can run independently of data stores
(e.g., databases, message queues, and so on), as we have done many times in this book,
the real valuable web apps rarely do. Data stores are a combination of an application
running and a storage volume that application uses to read and write data. This means
that the data store application itself is stateless and can be a container, while the data
itself is stateful and needs to be in a storage volume. StatefulSets provide a simple way
to attach a storage volume to each Pod replica declared by the manifest, thus ensuring
the Pods are stateful and perfect candidates for running data stores.
StatefulSets unlock the ability to run stateful Pods (containers) by providing a few
key features:
¡ Volume template—Every new Pod in a StatefulSet will have a new volume attached
to it.
¡ Pod naming scheme—Every new Pod in a StatefulSet will have a new name that is
predictable and ordered.
¡ Pod ordering—Every new Pod in a StatefulSet will be created in order. This means
that custom-nginx-01 will be created before custom-nginx-02 and so on.
¡ Pod deletion—Every new Pod in a StatefulSet will be deleted in reverse order. This
means that custom-nginx-03 will be deleted before custom-nginx-02 and so on.
204 Chapter 8 Managed Kubernetes Deployment
These features make StatefulSets an excellent way to deploy stateful containers like
databases, message queues, and other applications that require a persistent volume.
Keep in mind that deploying multiple instances of any given container does not auto-
matically connect the instances to each other. In other words, deploying a Postgres
database with a StatefulSet does not automatically create a Postgres cluster. Creating
clustered databases is a topic for another book.
To create a StatefulSet, we need to define a few key configurations:
¡ initContainers:—When we mount a new volume to a container, it comes with
a directory called lost+found. This directory must be removed for the Postgres
container to work properly. The initContainers: block allows us to run a con-
tainer before the main container is started. In our case, we’ll run a minimal con-
tainer image that mounts our volume, removes the lost+found directory with the
command sh -c "rm -rf lost+found", and then exits. While the lost+found
folder can be used for file recovery, we have no files to recover at this time. This
is a common pattern for initializing volumes and is a great way to ensure that our
Postgres container will work properly without accidentally deleting the data we
actually want.
¡ replicas:—The number of Pods to run; defaults to 1.
¡ selector:—Just as with Deployments, our StatefulSet needs to know which Pods
to manage through the selector.
¡ template:—The configuration we use here is identical to a standalone Pod man-
ifest without the kind and apiVersion declaration. It’s called a template because
it contains the default values for the group of Pods the StatefulSet will manage.
¡ volumeClaimTemplates:—When creating new Pods, this configuration includes
the volume claim template that will be used for each Pod. The format is identi-
cal to the PVC resource we used in the previous section without the kind and
apiVersion declarations.
With this in mind, let’s create a new folder called postgres-db with the following files:
¡ 1-secrets.yaml—Be sure to add this to .gitignore as it contains sensitive data. This
file will be used to configure the default Postgres database name, user, and password.
¡ 2-statefulset.yaml—This is the StatefulSet resource that will create our Postgres
Pods and PVCs for each Pod.
Create a file called 1-secrets.yaml in the postgres-db directory with the contents in the
following listing.
apiVersion: v1
kind: Secret
metadata:
name: postgres-db-secret
type: Opaque
Volumes and stateful containers 205
stringData:
DB_NAME: "postgres-db"
DB_USER: "postgres"
DB_PASSWORD: "dQw4w9WgXcQ"
Next, create a file called 2-statefulset.yaml in the postgres-db directory and start with
the contents in the following listing.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
At this point, this is identical to what we might see in a Deployment resource, except
we are using kind: Service. Before we add the Pod template configuration, let’s add
the volumeClaimTemplates: block. The spec.template and spec.volumeClaimTem-
plates blocks need to be on the same level as seen in the following listing.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
...
spec:
...
volumeClaimTemplates:
- metadata:
name: db-vol
spec:
storageClassName: linode-block-storage-retain
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 20Gi
206 Chapter 8 Managed Kubernetes Deployment
Now let’s start the process of defining our Pod template within the StatefulSet. First,
we need the metadata label to match the selector, and then we need to define the
initContainers block, as seen in the following listing.
...
spec:
...
template:
metadata:
labels:
app: postgres
spec:
initContainers:
- name: delete-lost-found
image: alpine:latest
command: ["sh", "-c", "rm -rf /mnt/lost+found"]
volumeMounts:
- name: db-vol
mountPath: /mnt
volumeClaimTemplates:
- metadata:
name: db-vol
spec:
...
...
spec:
...
template:
metadata:
containers:
- name: postgres-container
image: postgres:12.16
imagePullPolicy: IfNotPresent
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: postgres-db-secret
key: DB_NAME
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: postgres-db-secret
key: DB_USER
- name: POSTGRES_PASSWORD
Volumes and stateful containers 207
valueFrom:
secretKeyRef:
name: postgres-db-secret
key: DB_PASSWORD
volumeMounts:
- name: db-vol
mountPath: /var/lib/postgresql
volumeClaimTemplates:
- metadata:
name: db-vol
spec:
...
This StatefulSet is a very practical example that combines a lot of what we have learned
so far. Here’s a quick summary of what we have defined:
Before we verify this StatefulSet, let’s create a Service to allow access to the StatefulSet’s
provision Pods via the defined selector. This time, we’ll use ClusterIP so we don’t
expose our database to the internet. Create a file called 3-service.yaml in the post-
gres-db directory with the contents in the following listing.
apiVersion: v1
kind: Service
metadata:
name: postgres-svc
spec:
type: ClusterIP
ports:
- port: 5432
targetPort: 5432
selector:
app: postgres
With these manifests defined, run kubectl apply -f postgres-db/. After a short
duration, the database should be provisioned and running at the Pod postgres-0.
208 Chapter 8 Managed Kubernetes Deployment
To verify the PostgreSQL database is working, we can use the port forwarding feature
within kubectl. This feature enables our local computer, as in localhost, to connect to a
cluster’s resource. Here are a few ways:
If you have the psql PostgreSQL client installed on your local machine, you can run
PG_PASSWORD="<password-set>" psql -h localhost -U postgres -d <db-set> -p
<port-set> like PG_PASSWORD="dQw4w9WgXcQ" psql -h localhost -U postgres -d
postgres-db -p 3312.
If you do not have the psql client installed, just run kubectl get pods (to get a list of
running Pods) and kubectl get svc (to get a list of Services). Both of these commands
should verify the Pods are working. If they are not running, use kubectl describe pod
<pod-name> to see what is going on.
While using a local version of the psql client works well, it doesn’t show us how to do
container-to-container communication from within our cluster. Let’s do that now.
of the container, and -p 8080:8080 is the port mapping. The deletable part is the --rm
flag, which tells Docker to delete the container when it exits.
To run a delete-at-anytime Pod with kubectl, we will use the following:
At this point, you have a manually created Pod running a container that has psql
installed. Let’s connect this psql client to our PostgreSQL database Pod via the
postgres-svc. To do so, we have two options:
Before we see the built-in DNS Service, let’s get the value of the cluster IP for this Ser-
vice. It’s as simple as running kubectl get service <service-name> like kubectl get
service postgres-svc. The output should resemble figure 8.13.
In the Cluster-IP column in figure 8.13, the value is 10.128.189.63. This value is the
internal IP address of this particular Service. I would be willing to bet you have a dif-
ferent value, but even if you didn’t, you absolutely could. The Cluster IP value is the
host value we can use to connect to this Service from within our cluster. As a reminder,
Kubernetes Service resources do not care what they are attached to; the cluster IP will
attempt to forward the requests to the correct Pod as you have it configured.
While the cluster IP is a valid approach, it’s flawed for a major reason: the given IP
address is unpredictable. An unpredictable IP means how our Pods communicate with
each other becomes a game of constantly checking the provisioned internal dynamically
provisioned IP address values. What’s better is we can use Kubernetes’ built-in DNS Ser-
vice to connect to our database using very predictable values. The format is as follows:
<resource-name>.<namespace>.<resource-type>.cluster.local.
Here are a few examples of DNS endpoints for various Services in various Namespaces
(more on Namespaces shortly):
¡ postgres-svc.default.svc.cluster.local is the DNS endpoint for the
postgres-svc Service in the default Namespace.
¡ redis-svc.default.svc.cluster.local would be the DNS endpoint for a Ser-
vice with the name redis-scv in the default Namespace.
¡ mysql.doubleounit.svc.cluster.local would be the DNS endpoint for a Ser-
vice with the name mysql in the doubleounit Namespace.
¡ mariadb.imf.svc.cluster.local would be the DNS endpoint for a Service with
the name mariadb in the imf Namespace.
I’ll let you decide which is easier for you, the DNS endpoint or the cluster IP. Now let’s
put both commands to the test, starting with the correct DNS endpoint. Be sure to enter
the bash shell with kubectl exec -it my-psql — /bin/sh. Then run the following:
¡ psql -h postgres-svc.default.svc.cluster.local -p 5432 -U post-
gres -d postgres-db—This should prompt you for a password. The username
(-U), the database name (-d), and the password (prompted) were all set in the
postgres-secrets resource. The port (-p) is the default port for PostgreSQL as
well as the port we set in the postgres-svc Service.
Volumes and stateful containers 211
While this is about as basic as it gets for SQL commands with psql and a PostgreSQL
database, it’s a great example of how to connect to a database from within a Kuberne-
tes cluster and, more importantly, do container-to-container communication.
If a Namespace is not used or declared, all resources will be provisioned in the auto-
matically added Kubernetes Namespace called default. You can verify that the default
Namespace exists by running kubectl get namespaces to review all available Name-
spaces. In other words, when we ran kubectl apply -f . . ., we were actually running
something like kubectl apply -f . . . -n default. The -n flag is used to specify a
Namespace (unless it’s declared in the manifest, as we’ll learn about shortly).
Since we just saw how Namespaces can affect Kubernetes-managed DNS endpoints
from within a cluster, let’s create another example data store using Redis. Redis is
another stateful application that requires a StateFulSet, much like PostgreSQL before.
In this section, we use the Namespace resource to highlight how we can further isolate
and distinguish various Pods and Services within our cluster. Of course, there are some
technical differences between Redis and PostgreSQL that are outside the scope of this
book, but I think it’s important to see how Namespaces can help further divide these
two pieces of software.
Create a new folder called redis-db with the following files:
While Redis can be protected through a password, we will focus more on the Namespace
aspect of this example. Create a file called 1-namespace.yaml in the redis-db directory.
apiVersion: v1
kind: Namespace
metadata:
name: rk8s-redis
Before we create anything else, provision the Namespace with kubectl apply -f
redis-db/1-namespace.yaml. Once complete, run kubectl get pods -n rk8s-redis
and then kubectl get pods -n default. If you still have your Postgres StatefulSet
running, you should see Pods in the second get pods command but not the first. This
is because the -n flag is used to filter resources by Namespace.
While this feels similar to our selector filter, it’s different. While Namespaces can be
used to filter results with a selector, we can also create specific user accounts to limit
access to specific Namespaces and resources within those Namespaces. We can also
configure policies to prevent internal communication across Namespaces. This kind of
configuration is outside the scope of this book but good to know about.
So how do we add resources to a Namespace? Two ways:
¡ Add the metadata.namespace value to the resource manifest. This is the most
common way to add a resource to a Namespace.
Volumes and stateful containers 213
With this in mind, create the file 2-statefulset.yaml in the redis-db folder for our Redis
StatefulSet.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
namespace: rk8s-redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:latest
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: redis-data
spec:
storageClassName: linode-block-storage
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 10Gi
As we can see, Redis does not require an InitContainers declaration like PostgreSQL
did. Now let’s create a Service for this StatefulSet. Create a file called 3-service.yaml in
the redis-db directory.
apiVersion: v1
kind: Service
metadata:
name: redis-svc
namespace: rk8s-redis
214 Chapter 8 Managed Kubernetes Deployment
spec:
type: ClusterIP
ports:
- port: 6379
targetPort: 6379
selector:
app: redis
Apply the changes with kubectl apply -f redis-db/. After a short duration, the data-
base should be provisioned and running at the Pod redis-0. Now ask yourself, what is
the DNS endpoint for this Service? If you said redis-svc.rk8s-redis.svc.cluster.
local, you are correct. Let’s verify this by following a similar pattern to the one we
used to verify the PostgreSQL database, StatefulSet, and Service:
1 Enter the temporary Pod’s shell with kubectl exec -it my-psql — /bin/sh.
2 Install the Redis client library with apt update && apt install -y redis-tools.
3 Verify the Redis client exists with the command -v redis-cli, which should
return /usr/bin/redis-cli.
4 Use the redis-cli ping command with the host -h flag as: redis-cli -h
redis-svc.rk8s-redis.svc.cluster.local ping. This should return PONG,
which means the connection was successful.
We now have a working Redis database that we can connect to from within our clus-
ter. What’s more, we can communicate via internal DNS from Pod-to-Pod via a Service
resource regardless of the Namespace.
If you are done with the temporary Pod, feel free to delete it now with kubectl
delete pod my-psql. You also might consider removing your PostgreSQL and Redis
resources with kubectl delete -f postgres-db/ and kubectl delete -f redis-db/,
respectively. As with many cloud-managed resources, volumes are going to be charged
extra; while this number is not going to be high, it’s good to know and delete resources
you are not going to use.
Now that we thoroughly understand how to deploy containers, both stateless and
stateful, let’s put our Python and Node.js applications to work on Kubernetes along
with a custom domain name.
Routing external, or internet-based, traffic to our applications is critical for any pro-
duction application. To route incoming traffic, also known as ingress traffic, we have
two built-in options with the Kubernetes Service resource: NodePort or LoadBalancer.
As we discussed previously, using a NodePort Service will use a port on each node,
and if the configuration is left blank, this port will be automatically assigned to a non-
standard HTTP port value to make room for other Services that use NodePort. If you
intend to have a custom domain, you need a standard HTTP port of 80 or 443 for
internet traffic, as web browsers default to Port 80 for HTTP traffic and Port 443 for
HTTPS traffic. We will use Port 80 since Port 443 is for secure traffic but requires addi-
tional configuration and certificates that fall outside the scope of this book. Using a
standard HTTP port (80 or 443) means we can use a custom domain (e.g., roadtok8s
.com) without specifying a port number (e.g., roadtok8s.com:30000) like you might
with a NodePort Service.
Since we want to use Port 80, we still could use NodePort configured to Port 80, but
that would mean that only one Service can be on Port 80 since only one Service can run
on a single port. This is not a problem if you only have one service, but if you have two or
more Services, you need to use a LoadBalancer Service. Let’s take a look.
Before we create our Services, create the folder apps in the roadtok8s-kube direc-
tory for both of our applications (Python and Node.js) and add the following as empty
placeholder files:
¡ 1-configmap.yaml
¡ 2-deployment-py.yaml
¡ 3-deployment-js.yaml
¡ 4-service-py.yaml
¡ 5-service-js.yaml
Before we look at the ConfigMap, let’s think about the internal domain name we want
for each application’s Service. Here’s what I think would be good:
¡ Python—py-book.default.svc.cluster.local
¡ Node.js—js-book.default.svc.cluster.local
The reason I am defining these now is because they will be our north star for the Con-
figMap and the Service for each app. With this in mind, let’s create a ConfigMap for
each application. Create a file called 1-configmap.yaml in the apps directory.
apiVersion: v1
kind: ConfigMap
metadata:
name: rk8s-apps
data:
PY_ENDPOINT: "py-book.default.svc.cluster.local"
PY_PORT: "8080"
JS_ENDPOINT: "js-book.default.svc.cluster.local"
JS_PORT: "3000"
Each of these values will be used in the Deployment resources for each application,
which means we need this ConfigMap on our cluster before the Deployment resources
are created (thus the 1- prefix).
While PY_ENDPOINT and JS_ENDPOINT are not going to be used in this book, they
exist to show more examples of DNS endpoints in case you decide to modify your web
applications to communicate with each other in the cluster.
With this in mind, let’s create the Deployment resources for each application. Create
a file called 2-deployment-py.yaml in the apps directory.
apiVersion: apps/v1
kind: Deployment
metadata:
name: py-deploy
spec:
Deploy apps to production with Kubernetes 217
replicas: 3
selector:
matchLabels:
app: py-deploy
template:
metadata:
labels:
app: py-deploy
spec:
containers:
- name: rk8s-py
image: codingforentrepreneurs/rk8s-py:latest
ports:
- name: http-port
containerPort: 8080
env:
- name: PORT
valueFrom:
configMapKeyRef:
name: rk8s-apps
key: PY_PORT
envFrom:
- configMapRef:
name: rk8s-apps
The only new addition is that we named the container port http-port. Naming the
port makes it more reusable when we provision the service. While this is an optional
step, it’s very convenient when you are doing multiple Deployments with multiple con-
tainer ports the way we are.
To create the Node.js version of this Deployment, copy this file, rename it to
3-deployment-js.yaml, and update the following values:
At this point, you can run kubectl apply -f apps/ and verify that the Pods are run-
ning for both container images. If there are errors, consider using nginx as the con-
tainer image to verify the container you’re using is not the problem. As a reminder, you
can use the following commands to verify the Pods are running:
¡ kubectl get pods -l app=py-deploy or kubectl get pods -l app=js-deploy
¡ kubectl describe deployments/py-deploy or kubectl describe deployments/
➥js-deploy
¡ kubectl logs deployments/py-deploy or kubectl logs deployments/js-deploy
218 Chapter 8 Managed Kubernetes Deployment
With the Deployments in place, let’s create the Service resources for each application.
Create a file called 4-service-py.yaml in the apps directory.
apiVersion: v1
kind: Service
metadata:
name: py-book
spec:
type: LoadBalancer
ports:
- port: 80
targetPort: http-port
selector:
app: py-deploy
We want to set the port to 80 because this is the default HTTP port value, which means
our application will be available at http://<loadblanancer-static-ip>:80 and
http://<loadblanancer-static-ip>;.
Once again, copy this file, rename it to 5-service-js.yaml, and update the following
values:
¡ Change the Service name from py-book to js-book (remember the DNS names?).
¡ Change the selector from py-deploy to js-deploy.
Now run kubectl apply -f apps/ and verify the Pods and Services were created with
kubectl get pods and kubectl get svc (or kubectl get services), which should
result much like figure 8.15.
Both of your Services should have external IP addresses that will direct traffic to your
Deployments via the LoadBalancer Service. If you do not see an external IP address,
wait a few minutes, as sometimes the LoadBalancer takes time to provision a new static
public IP address. If you still do not see an external IP address, use kubectl describe
service py-book or kubectl describe service js-book to see what is going on.
Deploy apps to production with Kubernetes 219
Now that you have a public static IP address, you can point your domain name to it.
To do so, you must use an A Record with the value being the IP address of the LoadBal-
ancer Service and where the subdomain can be anything you choose.
Creating and managing service accounts is a more advanced security topic that is out-
side the scope of this book. If you are interested, you can read my blog post (https://
www.codingforentrepreneurs.com/blog/kubernetes-rbac-service-account-github
-actions/) because it relates to GitHub Actions and Kubernetes with Akamai Con-
nected Cloud’s Linode Kubernetes Engine.
While this process can happen in the same GitHub repositories as our Python and
Node.js applications, I recommend separating the infrastructure (Kubernetes) from
the application. Our applications can still build the containers, but they do not need to
be responsible for configuring the Kubernetes cluster.
Our workflow will do the following:
¡ Historical record—Easier auditing and rollback right in your git repo history.
¡ Debugging—Quickly find problems with any given container.
¡ Deployment failures—If the deployment fails altogether in an automated release, it
will be much easier to identify and roll back faulty releases.
¡ Immutability—If done correctly, each tag will be a unique, unchanging version.
¡ Testing Deployments—Simplifies testing multiple versions of the same container
image.
While there are other reasons, these are the most common considerations for using
a specific container image tag, especially in production. Implementing explicit con-
tainer tags is outside the scope of this book, but I do recommend it for production
deployments.
For development and learning purposes, I think using :latest is often a great
option because it allows you to quickly update your running containers without having
to change the manifest or container-building GitHub Actions workflow. This is why we
run kubectl rollout restart deployment <deployment-name> to force a rollout with
the same manifest. The kubectl rollout restart is also a practical command if you
ever need to “reset” a Deployment after you make updates related to configuration (e.g.,
Secrets or ConfigMaps changes do not automatically trigger Deployment rollouts).
While you can manually set a container’s image with the command kubectl
set image deployment/<deployment-name> <container-name>=<image-name>
:<image-tag>, I do not recommend doing so in a GitHub Actions workflow because
if your manifest had :latest and you used kubectl set to use the :v1, your manifest
would be out of sync. The main reason is that the repo’s manifest yaml file would not
match the actual Deployment’s image value, which might cause an unintended rollout
or rollouts when running kubectl apply -f apps/ or something similar.
With this in mind, let’s create our workflow within the roadtok8s-kube directory at
./.github/workflows/k8s-apps-deploy.yaml.
jobs:
verify_service_account:
name: Verify K8s Service Account
runs-on: ubuntu-latest
steps:
- name: Checkout Repo Code
uses: actions/checkout@v4
- uses: azure/setup-kubectl@v3
- name: Create/Verify `.kube` directory
run: mkdir -p ~/.kube/
- name: Create kubectl config
Summary 221
run: |
cat << EOF >> ~/.kube/config
${{ secrets.KUBECONFIG_SA }}
EOF
- name: Echo pods
run: |
kubectl get pods
- name: Echo deployments
run: |
kubectl get deployments
- name: Apply App Changes
run: |
kubectl apply -f apps/
- name: Rollout Apps
run: |
kubectl rollout restart deployment -n default py-deploy
kubectl rollout restart deployment -n default js-deploy
Be sure to commit this workflow along with the rest of your Kubernetes manifests.
Once you have it committed, add your kubeconfig file to your repository’s Secrets with
the name KUBECONFIG_SA and the value being the contents of your kubeconfig file.
This will allow GitHub Actions to use your kubeconfig file to connect to your Kuberne-
tes cluster.
I’ll leave it to you to test and run this workflow. If the workflow fails, be sure to try
each of these commands locally to ensure they are working there.
Congratulations! You have now successfully deployed a number of applications to a
Kubernetes cluster. While this is a great accomplishment, it’s meant to be a launching
point into the wider world of Kubernetes beyond the scope of this book. I hope you con-
tinue your learning path and build, deploy, and release more applications into the wild.
In the next and final chapter, we will look at two alternatives to Kubernetes for deploy-
ing containers: Docker Swarm and HashiCorp Nomad. While I believe Kubernetes is the
best option, it’s good to know what else is out there and how it compares to Kubernetes.
Summary
¡ Kubeconfig files are used to connect to a Kubernetes Cluster with the Kuberne-
tes CLI, kubectl.
¡ The KUBECONFIG environment variable can be used to set the location of the
kubeconfig file for use with kubectl.
¡ Service Accounts contain permission to access various resources within a clus-
ter. The default service account is unlimited in what it can do and access in the
Kubeconfig file.
¡ Pods are the smallest Kubernetes resource and can be defined and provisioned
for fast and efficient container deployment.
¡ Deployments are used to manage and scale stateless Pods across a cluster of
Kubernetes nodes.
222 Chapter 8 Managed Kubernetes Deployment
¡ StatefulSets are used to manage a scale stateful Pods and include built-in support
for scaling and attaching volumes with scaled Pods.
¡ Deployments and StatefulSets are designed to ensure a specific number of Pods
are running based on the replica count, and if a Pod is deleted, failing, or other-
wise not running, a new Pod will be provisioned to meet the replica count.
¡ Updating or changing a container image within a Deployment or StatefulSet will
trigger a Pod rollout, which is a no-downtime replacement of Pods.
¡ ConfigMaps are reusable resources that store static key-value stores that can be
attached to other resources like Pods, Deployments, and StatefulSets as environ-
ment variables values and read-only volumes.
¡ Secrets are exactly like ConfigMaps but obscure the value data from the key-value
pairs using Base64 encoding. Secrets are designed to be readable when attached
to Pods, Deployments, and StatefulSets as environment variable values and read-
only volumes.
¡ Using third-party services such as HashiCorp Vault, AWS Secrets Manager, or
Google Secrets Manager is a good alternative to Kubernetes Secrets to obstruct
values beyond Base64 encoding.
¡ Selectors can be used to filter Pods and other resources by key-value pairs.
¡ Namespaces can be used to group resources together and can be used to filter
resources.
¡ Services manage internal and external communication to Pods, Deployments,
and StatefulSets.
¡ ClusterIP-type Service resources only allow internal traffic and are not accessible
from outside the cluster.
¡ NodePort Service resources expose a port on each node to route external traffic
to Pods.
¡ The LoadBalancer Service resource uses a cloud-managed load balancer service
to route external traffic to Kubernetes nodes and Pods.
¡ The Service types of ClusterIP, NodePort, and LoadBalancer can direct and load
balance internal traffic through an internal DNS endpoint or dynamically provi-
sioned IP address called a Cluster IP.
¡ Container-to-container communication is possible through the use of Service
resources.
¡ A records, or address records, are used to configure a custom domain to a static
IP address.
¡ GitHub Actions can automate and provision Kubernetes resources with manifest
files by using a valid kubeconfig file with a service account with the correct per-
missions and kubectl.
This chapter covers
¡ Running multiple containers with Docker and
Docker Swarm
Alternative
orchestration tools
9
¡ Modifying Docker Compose files to run on
Docker Swarm
¡ Running multiple containers with HashiCorp
Nomad
¡ Using Nomad’s built-in user interface to
manage container jobs
A key goal of this book is to help you adopt using containers as your go-to-deploy-
ment strategy with any given application. We have seen that Kubernetes is an effec-
tive way to run and manage containers in production. While I tend to think that
Kubernetes is valuable for projects of all sizes, it can be useful to understand a few
outstanding alternatives: Docker Swarm and HashiCorp Nomad.
Docker Swarm is essentially Docker Compose distributed across a cluster of vir-
tual machines. We’ll see how the technical implementation works, but it is essen-
tially a slightly modified Docker Compose file that connects to worker machines
running Docker. Docker Swarm is a good option for those who are already familiar
with Docker Compose and want to scale their applications across multiple virtual
machines.
223
224 Chapter 9 Alternative orchestration tools
¡ Manager node—The virtual machine or machines that manage the worker nodes.
The manager node essentially tells the worker nodes what to run. By default, a
manager node is also a worker node.
¡ Worker node—The virtual machine or machines that run the tasks (e.g., contain-
ers) that the manager node tells it to run. A worker node cannot run without a
manager node.
¡ Task—This is Docker Swarm’s term for a running container. The manager node
tells the worker node which task (container) to run. Tasks do not move nodes, so
once they are defined, they stay put.
¡ Service—Much like with Docker Compose, a service is the definition of a task to
execute on the manager or worker nodes.
¡ Stack—A stack is a collection of services deployed to the cluster. A stack is defined
in a Docker Compose file.
¡ Docker Swarm Manager is like the Kubernetes Control Plane. Just as we used the
Linode Kubernetes Engine (as the K8s Control Plane) to manage Kubernetes
tasks like scheduling Pods and configuring nodes, the Docker Swarm Manager
Container orchestration with Docker Swarm 225
performs similar roles, such as job scheduling and cluster management, within
the Docker Swarm environment.
¡ Docker Swarm Worker is like the Kubernetes node. The Docker Swarm Worker
runs the tasks (or containers) the Docker Swarm Manager tells it to run. The
Kubernetes node runs the Pods (or containers) that the Kubernetes Control
Plane tells it to run.
¡ Docker Swarm Task is like a Kubernetes Pod. The Docker Swarm Task is a run-
ning container. The Kubernetes Pod is a running container.
¡ Docker Swarm service is like a Kubernetes Deployment. The Docker Swarm ser-
vice is a collection of tasks (or containers) deployed to the cluster. The Kuberne-
tes Deployment is a collection of Pods (or containers) deployed to the cluster.
¡ Docker Swarm Stack is like a Kubernetes Namespace. The Docker Swarm Stack is
a collection of services (or containers) deployed to the cluster. The Kubernetes
Namespace is a collection of Pods (or containers) deployed to the cluster.
Now that we have some definitions in place, let’s provision a virtual machine for a man-
ager node. These steps should look familiar to you, as we have done this before with
one exception (private IP addresses):
If you forget to select Private IP, you can add it later by doing the following:
226 Chapter 9 Alternative orchestration tools
Before we SSH into this virtual machine, let’s make note of the private IP address that
has been provisioned. In the detail page of your instance, navigate to the Network tab
and scroll to the bottom of the page. Figure 9.1 shows the private IP address for our
instance.
With these attributes in mind, let’s prepare our virtual machine as a Docker Swarm
manager node.
Once these items are complete, verify Docker is working by running docker ps, and
you should see that no containers are currently active. If this portion fails, please revisit
previous chapters to review.
With Docker installed, we are going to configure three services: an NGINX load bal-
ancer, our containerized Python app (codingforentrepreneurs/rk8s-py), and our
containerized Node.js app (codingforentrepreneurs/rk8s-js). Each of these ser-
vices will correspond to the following names:
In Docker Compose, we will declare these names as the service names, as we have
before. Docker Compose’s built-in DNS resolver allows service discovery via the service
name as the hostname. Put another way, the service web can be accessed via http://
web:8080 (assuming port 8080 is used) from within any given Docker Compose-man-
aged container.
Since we can use service names as DNS host names, we will configure NGINX to dis-
tribute traffic to each service and load balance traffic as designed. If you use different
service names, be sure that both the NGINX configuration and the Docker Compose
configuration match.
From within your SSH session and directly on the virtual machine, create a file
named nginx.conf with the contents of listing 9.2. Normally, I would advise that we use
Git and a CI/CD pipeline to deploy this configuration file, but for the sake of simplicity,
we’re going to do it directly on the virtual machine.
Listing 9.2 NGINX configuration file for load-balancing Docker Compose services
server {
listen 80; The default NGINX port is 80.
web_backend will forward traffic to all
location / { servers defined in the upstream block
proxy_pass http://web_backend; of the same name.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api {
228 Chapter 9 Alternative orchestration tools
While this NGINX configuration will be effective, it is one of the simpler configura-
tions by design to ensure our focus remains on Docker Swarm. Docker Swarm also has
built-in mechanisms for load balancing that can work hand in hand with the NGINX
container image.
Now create the file compose.yaml on your server with Docker Compose installed.
The following listing shows us exactly that with a configuration we saw earlier in this
book.
services:
nginx:
image: nginx
ports:
- 80:80
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- web
- api
web:
image: codingforentrepreneurs/rk8s-py
environment:
- PORT=8080
api:
image: codingforentrepreneurs/rk8s-js
Docker Swarm uses a legacy version of Docker Compose’s configuration file, so we’ll need
to make a few modifications to our compose.yaml file to make it work with Docker Swarm.
This modification may work with Docker Compose because it’s a legacy syntax for
Compose file version 3. Review the Docker Compose documentation (https://docs
.docker.com/compose/compose-file/compose-file-v3/) for more information on ver-
sion 3’s syntax.
Since Docker Swarm is a container orchestration tool, we can define the number of
replicas (or instances) of each service we want to run in the deploy: directive. The more
instances you run, the more compute power your cluster will need, which is exactly why
we can continue to add node(s) to our swarm.
On your Docker Swarm Manager instance, create the file compose-swarm.yaml,
which includes the deploy: directive blocks and replica counts you want for each
service.
environment:
- PORT=8080
deploy:
replicas: 5
api:
image: codingforentrepreneurs/cfe-nginx
deploy:
replicas: 2
As you can see, this swarm-ready legacy Docker Compose file is not too different from
what we have seen before. In fact, you could run docker compose -f compose-swarm
.yaml up, and it would work just fine, although the replicas in the deploy: directive
would simply be ignored.
Now that we have our Docker Compose file ready for Docker Swarm, let’s start the
Swarm cluster.
That’s it. After you run this command, you should see a message like figure 9.2 shows.
This output indicates how other nodes can join the swarm with docker swarm join
--token <some-token> <private-ip-address>:2377. You can also get this token by
running docker swarm join-token worker on the Docker Swarm Manager instance.
Now that we have a swarm initialized, let’s add a few nodes to the cluster. To do this, we
need to do the following:
1 Create two new virtual machines in the same region (required for private IP com-
munication) with private IP addresses and labeled rk8s-swarm-worker-1 and
rk8s-swarm-worker-2.
2 SSH into each of the new virtual machines.
3 Install Docker Engine on each of the new virtual machines with curl -fsSL
https://get.docker.com | sudo bash.
4 Use docker swarm join --token <some-token> <private-ip-address>:2377
to join the swarm. Running this command will result in a statement like: This node
joined a swarm as a worker. Repeat this step for each of the new virtual machines.
The number of worker nodes is up to you. I think a good starting point for your cluster
is three nodes (including the manager). The previous steps are the same for each node
you want to add to the cluster.
Now that we have our manager and worker nodes set up and our swarm-ready com-
pose file, it’s time to deploy our Docker Swarm Stack.
Now, in your SSH session on the Docker Swarm Manager instance, run docker
stack ls, and you should see no stacks running. Now run docker stack deploy -c
compose-swarm.yaml rk8s, and the output should be as you see in figure 9.3.
With this stack deployed, you can now run docker stack ls and see the rk8s stack
listed with three services. You can also run docker stack ps rk8s to see the tasks (or
containers) running in the stack.
If you visit the public IP address of any of the virtual machines in your Docker Swarm
cluster, you should see the same results as you did with just Docker Compose, only this
time you have many more instances of each service running.
When you need to make changes to this Docker Swarm Stack, you can adjust the
compose-swarm.yaml file and run docker stack deploy -c compose-swarm.yaml rk8s
again. Docker Swarm will only update the services that have changed.
Docker Swarm is a great way to quickly spin up containers across many different vir-
tual machines. Docker Swarm does build on the knowledge of Docker Compose, so it’s
a good idea to be familiar with Docker Compose before using Docker Swarm.
The big advantage Docker Swarm has over Kubernetes is how simple it is to move
from Docker Compose to Docker Swarm since they use a lot of the exact same config-
uration. This configuration is also simple and does not require a steep learning curve.
That said, Kubernetes, and especially managed Kubernetes, offers the huge benefit of
quickly provision static IP addresses, load balancers, persistent storage volumes, and
other cloud resources. Docker Swarm does not have this capability built-in. Kubernetes
also has a massive ecosystem of tooling that is Kubernetes-specific and not available to
Docker Swarm.
The challenge I will leave you with is how to automate configuring new instances in
your swarm. Here’s a hint: it has something to do with GitHub Actions. Now let’s look at
another approach of orchestrating containers with HashiCorp Nomad.
For our purposes, we’ll look at a basic example of using Nomad after we configure a
cluster of virtual machines.
¡ Server—The server is the primary agent that manages the cluster. The server is
responsible for maintaining the state of the cluster and scheduling tasks (or con-
tainers) to run on the client nodes.
¡ Client—The client is the virtual machine that runs the tasks (or containers) the
server tells it to run. A client cannot run without a server.
¡ Task—This is Nomad’s term for a running container. The server tells the client
which task (container) to run. Once defined, a task remains on the same node
and does not move node to node.
¡ Job—A job is a collection of tasks deployed to the cluster. A job is defined in a
Nomad configuration file.
¡ Nomad Server is like the Kubernetes Control Plane. Just as we used the Linode
Kubernetes Engine (as the K8s Control Plane) to manage Kubernetes tasks
like scheduling Pods and configuring nodes, the Nomad Server performs sim-
ilar roles, such as job scheduling and cluster management, within the Nomad
environment.
¡ Nomad Client is like the Kubernetes Node. The Nomad Client runs the tasks (or
containers) that the Nomad Server tells it to run. The Kubernetes Node runs the
Pods (or containers) that the Kubernetes Control Plane tells it to run.
¡ Nomad Task is like a Kubernetes Pod. The Nomad Task is a running container.
The Kubernetes Pod is a running container.
¡ Nomad job is like a Kubernetes Deployment. The Nomad job is a collection of
tasks (or containers) deployed to the cluster. The Kubernetes Deployment is a
collection of Pods (or containers) deployed to the cluster.
To prepare our Nomad cluster, we need to create three virtual machines: one to act as
the Nomad server and two to act as Nomad clients. Here are the steps you should take
to do so:
After a few minutes, you should have three virtual machines with the following:
Repeat this process for each of your virtual machines. Once you have Nomad and
Docker Engine installed on each of your virtual machines, you’re ready to configure
Nomad.
HashiCorp Nomad 235
With this in mind, let’s start creating our first HCL configuration file for Nomad. Here
are the steps to do so:
# Located at /etc/nomad.d/nomad.hcl
data_dir = "/opt/nomad/data"
bind_addr = "0.0.0.0"
name = "nomad-server-1"
advertise {
http = "192.168.204.96"
rpc = "192.168.204.96"
serf = "192.168.204.96"
}
server {
enabled = true
bootstrap_expect = 1 # number of servers
}
client {
enabled = false
}
If you are familiar with JSON (JavaScript Object Notation), you can see some of the
inspiration for HCL. HCL uses curly braces ({}) to denote a block of configuration,
square brackets ([]) to denote a list of values, and the = sign to denote a key-value pair.
YAML, on the other hand, uses indentation to denote a block of configuration, a dash
(-) to denote a list of values and a colon (:) to denote a key-value pair.
With this file created, we can run Nomad in two different ways:
When comparing the Nomad CLI to the Kubernetes CLI (kubectl), it’s important
to note that the Nomad CLI is not nearly as full-featured kubectl. The Nomad CLI
focuses on job scheduling, task management, and basic monitoring. kubectl, on the
other hand, has a variety of complex operations that can happen as well as a variety of
plugins that can be used to extend its functionality.
Running Nomad as a foreground service is a great way to test your configuration, but
it’s not recommended for production. Running Nomad as a background service is the
recommended way to run Nomad in production. Luckily for us, the configuration for
the background service is already included:
1 Enable the background service with sudo systemctl enable nomad. Enabling is
required one time.
2 Start it with sudo systemctl start nomad.
3 If you make changes to Nomad, run sudo systemctl restart nomad. (If the
changes do not propagate, run sudo systemctl daemon-reload before restarting.)
HashiCorp Nomad 237
With Nomad running in the background, run nomad server members, and you should
see output similar to figure 9.4.
¡ Name—This should be the name you declared in the configuration with .global
appended to it.
¡ Address—This needs to be your Nomad server’s private IP address (likely not
192.168.204.96 as shown).
¡ Status—This value should be alive; otherwise, we have a problem with our
configuration.
¡ Leader—This value will be set to true since it is the only Nomad server we have.
If we had more than one, each server member would automatically elect a leader,
and this value would be false for all but one server member.
¡ Datacenter—We can modify this value, but dc1 is the default value and what you
should see here.
Before we configure our Nomad Clients, let’s access the Nomad server UI to verify it is
also working. To do so, we need the Nomad server’s Public IP Address and visit port 4646
because this is the default port. Open your browser to http://<public-ip-address>:4646/,
and you will be redirected to /ui/jobs and see the Nomad UI, as shown in figure 9.5.
The Nomad user interface is a great way to manage our Nomad cluster. Soon we will
use the UI to create our first job and run our first containers with Nomad. We could
also use the command line, and in some scenarios, we definitely would, because the
Nomad UI is publicly accessible by default. For the purposes of this simple example,
using the UI is sufficient. Before creating jobs, we need to configure our Nomad Cli-
ents. Let’s do that now.
# Located at /etc/nomad.d/nomad.hcl
data_dir = "/opt/nomad/data"
bind_addr = "0.0.0.0"
name = "nomad-client"
server {
enabled = false
}
client {
enabled = true
options = {
"driver.raw_exec.enable" = "1"
"docker.privileged.enabled" = "true"
}
servers = ["192.168.204.96:4647"]
server_join {
retry_join = ["192.168.204.96:4647"]
}
}
ui {
enabled = false
}
With these concepts in place, let’s configure our Nomad Client. Here are the steps to
do so:
After you complete this step with both clients, SSH back into your Nomad server and
run nomad node status. Figure 9.6 shows the output of this command.
You can also verify this data in the Nomad UI (e.g., http://<nomad-server
-public-ip-address>:4646/ui/) and navigate to the Clients tab as seen in figure 9.7.
240 Chapter 9 Alternative orchestration tools
Now that we have the Nomad server and the Nomad Clients, it’s time to create our first
Nomad job.
job "a_unique_job_name" {
group "unique_group_per_job" {
task "unique_task_per_group" {
driver = "docker"
config {
image = "codingforentrepreneurs/rk8s-py"
}
}
}
}
The definitions in listing 9.8 are a valid job, but it’s missing a few key configurations
(more on this soon). Unsurprisingly, we need a Docker runtime (like Docker Engine)
to run the container, and we need a valid and publicly accessible container image (e.g.,
codingforentrepreneurs/rk8s-py); both of these items are declared in the task defi-
nition. Private container images can run, too, but they require additional configura-
tion that is outside the scope of this book.
To test this job configuration let’s create a new job on our Nomad server. Here are
the first few steps to do so:
HashiCorp Nomad 241
At this point, you can paste in the code from listing 9.8, as you can see in figure 9.8.
After you click Plan, you will see the output of what the job intends to do. HashiCorp
tools like Terraform are great at letting you know what will happen before you agree to
make it happen. This simple plan is shown in figure 9.9.
242 Chapter 9 Alternative orchestration tools
Now click Run and watch as Nomad kicks of the process of attempting to run this con-
tainer image. If done correctly, you will see the same output as in figure 9.10.
Learning the ins and outs of the Nomad server UI is out of the scope of this book, but
I do encourage you to explore it. The Nomad server UI is a great way to rapidly iterate
over jobs and learn more about the Nomad cluster.
Now that we have a job running, let’s remove it altogether in the Nomad UI. The
steps are as follows:
HashiCorp Nomad 243
If you are interested in the command-line equivalent of this process, it’s the following
steps:
Creating and removing jobs is simple and a good way to get practice doing so.
Now let’s create another job with an additional container and two replicas of each
container. The following listing shows the new job definition.
Listing 9.9 Job definition with two tasks for two containers
job "web" {
group "apps" {
count = 1
task "unique_task_per_group" {
driver = "docker"
config {
image = "codingforentrepreneurs/rk8s-py"
}
}
task "js-app" {
driver = "docker"
config {
image = "codingforentrepreneurs/rk8s-js"
}
}
}
}
244 Chapter 9 Alternative orchestration tools
As we can see, we have two tasks within one group. This group also has count = 1,
which is the default value. I put this here so we can practice modifying a pre-existing
job. Let’s change the value to count = 2 and see what happens. Here are the steps to
do so:
After a few moments, the modified plan should execute successfully in full. Now our
two containers should be running two times each across our Nomad client instances.
To verify this, I SSHed into both of my Nomad Clients, ran docker ps, and found the
output shown in figure 9.11.
Adding count = 2 to our group told Nomad to run each task (container) twice across
our cluster so that there are four total container instances running. In this case,
Nomad found that the most effective way to do this was to distribute one of each con-
tainer image to both Nomad Clients. This is not always the case, but it is the case for
this example.
HashiCorp Nomad 245
An important piece that is missing from our tasks is the PORT mapping we want our
containers to have. For this, we can introduce a new block within our group definition,
network, as shown in the following listing.
job "web" {
group "apps" {
...
network {
port "http-py" {
to = 8080
}
port "http-js" {
to = 3000
}
}
}
}
Using the network block within our group gives each task within that group access to
those values. Let’s see this in action by updating our tasks with the correct port map-
ping as seen in the following listing.
Listing 9.11 Nomad job definition with two tasks for two containers with network port
mapping
job "web" {
group "apps" {
...
task "py-app" {
...
config {
image = "codingforentrepreneurs/rk8s-py"
ports = ["http-py"]
}
}
task "js-app" {
...
config {
image = "codingforentrepreneurs/rk8s-js"
ports = ["http-js"]
}
}
}
}
246 Chapter 9 Alternative orchestration tools
As usual, mapping the internal container ports correctly is an important step for run-
ning them effectively, but this brings up a new question: How can we access these
containers? Yes, we could run SSH sessions into each Nomad client to access the con-
tainers, but we want to be able to publicly access them.
To expose our tasks (containers) to the internet with Nomad and our current config-
uration, we have two options: a group-level service or a task-level service. For our tasks,
we must use the task-level service because of our port values. If our port values were the
same for each service, we could then use a group-level service.
A service will automatically assign a port (at random) that is mapped to the container
port (e.g., http-py and http-js) assigned to each running instance of the task. This
means that if we have two instances of the unique_task_per_group task, we will have
two ports assigned to that task. The same is true for the js-app task. To create a service,
we need to add a new block to our task definition, service, as shown in listing 9.12.
job "web" {
group "apps" {
...
task "py-app" {
...
service {
port = "http-py"
provider = "nomad"
}
}
task "js-app" {
...
service {
port = "http-js"
provider = "nomad"
}
}
}
}
Once you update the job definition, navigate to the Services tab for this job, and you
should see two new services with two allocations. Click on either service, and you will
see the exposed port and IP address of each service and task, as seen in figure 9.12.
HashiCorp Nomad 247
¡ 194.195.209.152:23995
¡ 45.79.217.198:24297
These are the public IP addresses of our Nomad Clients and the ports Nomad assigned
to each task’s service. These ports are dynamically allocated to ensure our job defini-
tions do not conflict with each other or with our group counts (e.g., count = 2). If you
increase the count in a group or add additional groups or additional jobs, Nomad is
intelligent enough to allocate port addresses that are available to be used for any given
service.
If you use HashiCorp’s other tools, like Terraform or Vault, using Nomad is very logi-
cal before jumping into using Kubernetes. Nomad is a much simpler tool to use overall
and has better cross-region and cross-datacenter support over the default Kubernetes
configuration. Nomad’s UI is, in my opinion, far easier to use than the Kubernetes
Dashboard. Using managed Kubernetes, like mentioned with Docker Swarm, allows
you to quickly provision cloud resources like load balancers, persistent storage volumes,
and static IP addresses. Nomad does not have this capability built-in, but if you were to
couple it with HashiCorp’s Terraform tool, it would be more than possible to do so.
At this point, we have covered the basics of Nomad for managing and deploying
a number of containers across a number of machines. To unleash the full power of
Nomad, we would need to tightly couple it with another service by HashiCorp called
Consul. Consul gives Nomad the ability to do service discovery similar to the built-in
features of Kubernetes and Docker Swarm. Configuring Consul is outside the scope of
this book, but I encourage you to explore it on your own.
248 Chapter 9 Alternative orchestration tools
Summary
¡ Docker Swarm is a lot like Docker Compose, but it runs on multiple virtual
machines.
¡ Docker Swarm and Docker Compose are almost interchangeable, but Docker
Swarm requires more configuration.
¡ Docker Swarm has built-in service discovery, making it easier to run multiple con-
tainers across multiple virtual machines.
¡ HashiCorp Nomad has an intuitive user interface that makes managing the clus-
ter, the jobs, and the clients easier.
¡ HashiCorp has a suite of tools that use HCL, making Nomad a great choice for
those already using HashiCorp tools.
¡ Nomad’s dynamic IP allocation allows each running container instance to be
publicly accessible in seconds.
appendix A
Installing Python on
macOS and Windows
In chapter 2, we create a Python web application. This application will be trans-
formed throughout the book to be deployed to Kubernetes.
Before we can start building our application, we need to install Python on our
machine. This appendix will help us do that on macOS and Windows. In chapter 2,
we cover how to install Python on Linux. For my most up-to-date guide on installing
Python, please consider visiting my blog at https://cfe.sh/blog/. If you are inter-
ested in the official documentation for installing Python, please visit https://docs
.python.org/.
¡ Python version
¡ Processor—Intel or Apple
249
250 appendix A Installing Python on macOS and Windows
If you have an Apple processor (e.g., M1, M2, M3, etc.), you must use the macOS 64-bit
universal2 installer selection. If you have an Intel Processor, the macOS 64-bit univer-
sal2 installer or the macOS 64-bit Intel-only installer are the selections you can use.
Older Python versions may or may not support your current machine.
With the installer complete, we can verify the installation worked in our command line
application called Terminal (located in Applications / Utilities), as shown in the
following listing. Open the terminal and type the following commands,
python3.10 -V
Since we installed Python 3.10,
python3 -V Because we just installed a new this command should work. If we
version of Python, python3 will installed Python 3.9, we would
likely default to that version. If we type python3.9 -V.
installed Python 3.9, we would
type python3 -V, which would
return Python 3.9.7.
Python installation for Windows 251
Now that we have Python installed, we can move on to creating a virtual environment
in section 3.5.2.
1 Go to https://www.python.org/downloads/windows.
2 Pick Python 3.10.X (replace X with the highest number available).
3 For the selected Python version, verify the listing for Windows Installer that
matches your System Type (e.g., 64-bit or 32-bit).
4 Select to download the version 64-bit or 32-bit to match your system (see figure
A.2.).
5 Click the link to download the installer.
After the installer downloads, open and run it and do the following:
1 Select Add Python 3.10 to PATH only if this is the first version of Python you have
installed.
2 Select Customize Installation and then Next.
3 Select to Install pip and then Next.
4 In Advanced Options, use at least the following configuration (see figure A.3):
¡ Install for All Users
¡ Add Python to Environment Variables
¡ Create Shortcuts for Installed Applications
¡ Precompile Standard Library
Python installation for Windows 253
Selecting Add Python 3.10 to PATH is also optional but will allow you to shortcut run-
ning Python from the command line without specifying the full path to the executable.
Open the command prompt and type the following commands in the following listing.
Using the custom installation for C:\Python310 is optional but recommended because
then you can easily have multiple versions of Python on your machine. For example, if
you have Python 3.10 and Python 3.9 installed, you can execute either by opening the
command prompt and typing the following commands.
C:\Python39\python.exe -V
C:\Python310\python.exe -V
Now that you have Python installed on your Windows machine, it’s time to create a vir-
tual environment in the section 3.5.2.
254 appendix A Installing Python on macOS and Windows
python3 -V
If you see Python 3.8 or newer, you should be good to skip to the section 3.5.2. If you
see Python 3.8 or older (including Python 3.6 or Python 2.7), you will need to install
Python 3.8 or newer. If you need a more in-depth guide on installing Python on Linux,
go to chapter 6 because we install Python 3 on Linux for our early production environ-
ment for our Python application.
cd ~\Dev\k8s-appendix-test
python3 -m venv venv
python -m (or python3 -m) is how you can call built-in modules to Python; another pop-
ular one is using the pip module with python -m pip (or python3 -m pip, depending
on your system). This is the preferred way to call venv or pip, as it will ensure you’re
using the correct version of Python, as shown in the following listing for Windows.
cd ~\Dev\k8s-appendix-test
python -m venv venv
In this case, using python -m should work if you installed Python 3 correctly on Win-
dows and Python has been added to your PATH (as mentioned previously). In some
cases, you may need to use the absolute path to the specific Python version’s execut-
able on Windows machines (e.g., C:\Python310\python.exe -m venv).
cd ~/Dev/k8s-appendix-test
source venv/bin/activate
The command source venv/bin/activate might be new to you. This is how it breaks down:
¡ source is built into macOS and Linux. It’s used to execute a script or program.
¡ venv is the name of the directory that holds all of our virtual environment files.
¡ bin is the directory that holds all of the executable files for our virtual environ-
ment. You will also find a version of Python here, which you can verify with venv/
bin/python -V
¡ activate is the executable file that activates our virtual environment.
256 appendix A Installing Python on macOS and Windows
cd ~\Dev\k8s-appendix-test
.\venv\Scripts\activate
¡ Run a script on the command line using period (.) at the beginning of the
command.
¡ venv is the name of the directory that holds all of our virtual environment files.
¡ Scripts is the directory that holds all of the executable files for our virtual envi-
ronment. You will also find a version of Python here, which you can verify with
venv\Scripts\python -V.
¡ activate is the executable file that activates our virtual environment.
Activating the virtual environment is likely the primary nuance you’ll find when using
Python on macOS/Linux and Windows. The rest of the commands are about the same.
$(venv) python -V
$(venv) python -m pip install fastapi
$(venv) pip install uvicorn
¡ $(venv) denotes that our virtual environment is active. You might see #(venv) or
(venv) or (venv) > depending on your command line application.
¡ Notice that the 3 is missing from python? That’s because our activated virtual
environment leverages the virtual environment’s Python by default. On macOS/
Linux, python3 should still reference the activated virtual environment’s Python.
¡ python -m pip install is the preferred way to use pip to install third-party
Python packages from http://pypi.org/.
¡ pip install technically works but is not the preferred way to install third-party
Python packages because the command pip might be pointing to an incorrect
version of Python.
Virtual environments and Python 257
Deactivating a virtual environment is often necessary when you need to switch Python
projects and/or Python versions.
When you need to reactivate, it’s as simple as using one of the commands in the fol-
lowing listings.
There are other tools that can install Node.js, but it often makes it more difficult in
the long run than the previous two options.
Unlike Python, we do not need to know which processor our Apple computer is
running since the latest LTS version of Node.js already supports Apple Silicon (M1,
M2, etc.) and Intel processors.
To install nvm, use the following command:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | bash
258
Node.js installation for Windows 259
This command comes directly from the official nvm documentation at https://github
.com/nvm-sh/nvm.
After nvm is installed, open a new terminal session and run the command nvm
install --lts. This will install the latest LTS version of Node.js. Once this completes,
you should be able to run node --version and see the version of Node.js installed.
Now that we know what kind of system we are on, 64-bit or 32-bit, we can install Node.js.
If you have a 64-bit machine, you can install Node.js with the following:
If you have a 32-bit machine, you can install Node.js with the following:
If you need to install multiple versions of Node.js on your Windows machine, consider
using the tool nvm-windows with instructions at https://github.com/coreybutler/
nvm-windows. Since nvm only supports macOS/Linux, nvm-windows is a well-made alter-
native to give you a number of the same features. Installing nvm-windows is outside the
context of this book.
appendix C
Setting up SSH keys for
password-less server entry
In this appendix, we are going to learn how to use a connection protocol known as
Secure Shell (SSH) so that our local machine can connect to a remote computer. In
this case, a remote computer can be anything from another computer in your house
to a virtual machine that you’re renting from a cloud provider.
SSH gives us command line access to another computer, which will give us the
ability to treat another computer’s command line much like we treat our local com-
puter’s command line. We very often use SSH to enter Linux-based operating sys-
tems, which means your local computer’s commands might be slightly different than
the Linux-based ones. This is especially true for Windows users because Linux and
macOS share a lot of commonality in their operating systems.
To connect to a remote computer with SSH, you often need one of the following:
Given the nature of this appendix, we’re going to prepare for using SSH keys as they
can allow for password-less entry to a remote computer.
SSH keys are comprised of two components:
We can install our public key manually on a large number of servers, we can use
ssh-copy-id (more on this shortly), or we can have a cloud provider do it for us. In
chapter 3, we show an example of how the cloud provider Akamai Connected Cloud
(formally Linode) will automatically install our public SSH key for us on any virtual
machine we create.
261
262 appendix C Setting up SSH keys for password-less server entry
If your SSH key ever fails, you will often have a username and password (just like a
lot of software) to log in to the remote device. This username and password can be used
in lieu of or in conjunction with valid SSH keys. If you have a username and password
but your public SSH key isn’t installed, then you can use the command ssh-copy-id to
install your public SSH key on the remote device. You’ll find more on ssh-copy-id in
the section 3.4.2.
If at any time you need to stop this creation process, just run Control+C. Enter the
name id_rsa_rk8s and press Enter. You should see:
Enter file in which to save the key (/Users/justin/.ssh/id_rsa): id_rsa_rk8s
Enter passphrase (empty for no passphrase):
For added security, you can add a passphrase to the SSH key. Think of this like a pass-
word for the private SSH key itself.
For simplicity, we’re going to leave this blank. Press Enter/Return, and you’ll see
Enter same passphrase again:
It’s important to note that you can run ssh-keygen anywhere on your computer, and it
will create and store the key/pair in the working directory unless you specify an exact
path to a storage location. We used ~/.ssh to help mitigate problems when generating
our keys.
Now that we have created the keys, let’s verify they were created in the correct spot.
ls ~/.ssh
These are the two keys that make up your SSH key pair. The public key We will give
our public key (id_rsa_rk8s.pub) to our remote hosts, Linode, GitHub, or any other
place we might need to use SSH for faster authentication. We need to keep the private
key (id_rsa_rk8s) safe to prevent unwanted access.
Again, enter the name id_rsa_rk8s and press Enter. You should see
Enter file in which to save the key (/Users/justin/.ssh/id_rsa): id_rsa_rk8s
id_rsa_rk8s already exists.
Overwrite (y/n)?
Feel free to answer y or n to experiment with what happens. There’s no wrong answer
here. You can also delete the SSH key pair with
rm id_rsa_rk8s
rm id_rsa_rk8s.pub
If you start thinking of SSH keys as actually two keys, you’re on the right track.
Installing the SSH public key on a remote host 265
A shortcut to copy the public key is to run the command in the following listings,
depending on your system.
Now that we can copy the public key, we can share it wherever needed or install it directly.
Just like when creating these keys on our local machine, you can add or remove them
from Linode at will. You can also add multiple keys to your Linode account. This is a
great way to ensure you have access to your Linode account from multiple machines.
When you need to install this key on any given virtual machine, just be sure to select
to add the RK.
¡ If your SSH key had a passphrase, you will be prompted for it.
¡ We are using -i ~/.ssh/id_rsa_rk8s and not -i ~/.ssh/id_rsa_rk8s.pub
with the SSH command. Private keys are used to prove a public key is valid.
¡ We must pass -i ~/.ssh/id_rsa_rk8s until we update SSH config in the next
section.
Host rk8s-book-vm
Host can be a nickname
Hostname <your_linode_ip_address> or a specific IP address.
User root
IdentityFile ~/.ssh/id_rsa_rk8s
HostName must be the IP Address
or domain name that’s mapped to
the IP address.
Host <your_linode_ip_address>
Host can be a nickname
Hostname <your_linode_ip_address> or a specific IP address.
User root
IdentityFile ~/.ssh/id_rsa_rk8s
HostName must be the IP Address
or domain name that’s mapped to
the IP address.
Once ~/.ssh/config is updated, we can now connect to our remote host using the
nickname we created or the IP address without the -i flag for our private key:
ssh root@rk8s-book-vm
ssh root@<your_linode_ip_address>
appendix D
Installing and using ngrok
We build web applications so that we can share them with others. To share our web
applications, we need to make them accessible to the internet.
Going from the applications we have right now into a real production environ-
ment will take a few more chapters to fully understand the process, but that doesn’t
mean we can’t put our applications on the internet right now. We can do this by using
a tool called ngrok (pronounced en-grok). ngrok is a tool that allows us to expose our
local web applications to the internet. It’s a great tool for testing and development as
well as sharing with friends or coworkers on our progress.
269
270 appendix D Installing and using ngrok
271
272 index
docker compose build command 132 environment variables 117, 118, 127
docker compose down command 132, 141 abstracted away from application code 145
Docker Compose services 169 ConfigMap for 197
docker compose up command 132, 141 defining 196
Docker Desktop dotenv files 145
installing 104 PORT value 145
official installation guide 104 with ConfigMaps and Secrets 196–199
Docker Engine 173 EOF (End Of File) command 45
described 150 Express.js
installing on Ubuntu 150 as server-side web framework 16
Docker Hub 105 creating application 17–19
as official Docker registry 122 creating JSON response with 20–21
logging in via docker login 162 described 15
Node.js version tags 118 handling routes with 18
prebuilt and official container images 110 installing 17
Python version tags 110 requirements for using 16
repository page 123 returning JSON data with 20
Docker registry 121 running web server 19
Docker Swarm 223 used as microservice 20
advantage over Kubernetes 232
built-in service discovery 227, 248 F
configuration 224 failed, Pod status 187
container orchestration with 224–232 FastAPI 10–15
Docker Compose 229 creating FastAPI application 13–14
starting 230 described 10
terminology 224 handling multiple routes with 14–15
Docker Swarm Manager 226–229 port 8011 14
Docker Swarm Stack, deploying 231 using uvicorn to run 14
Dockerfile 2 files
building container image from 109 committing changes 27
creating 109–110 creating post-receive file 52
designing new container images 108 File Transfer Protocol (FTP) 44
different Dockerfiles for multiple git add 27
environments 154 Git status and 24
example of creating configuration file for ignoring 25
container 5 tracking 26
syntax 109 untracked 25
DOCKERHUB_REPO 159 firewalls 68
DOCKERHUB_TOKEN 159
.dockerignore file 115, 119 G
dotenv, environment variable file 145, 163
Git 4
as open-source tool 21
E
commit history 30
entrypoint script 116–118, 119 committing changes with git commit
environment command 27
different Dockerfiles for multiple downside 30
environments 154 features of 21
staging environment 72
index 275
git add and tracking Python files 27 managing configurations 164, 165, 171
overview of fundamental git commands 31–32 reviewing the output 77
rebooting Git repos 29 syntax for using stored secrets 83
sharing codes to and from computers 37 vs. Ansible Playbook 88
status 24 GitHub Repository 4
tracking code changes 21–32 git init command 23
using in projects 23–25 GitLab 23
verifying that it is installed 22 Git repository
version control 22 bare repository
git checkout command 52 creating 48
git commit command 27–29 described 47
GitHub project metadata 51
configuring local git repo for 34 few benefits compared to third-party-services 47
creating account 32 initializing 23
creating repository on 33 pushing local code to remote repository 50
pushing code to 32–35 self-hosted remote 47–53
GitHub Actions logging in to server with SSH 48
Actions tab 76 git status command 24
building and pushing containers 127–130, Google Secret Manager 146
159–160 as alternative to Kubernetes Secrets 199
building containers with 129 gunicorn 13
CI/CD pipelines 72–85 baseline configuration for 57
deploying production containers 157–167 described 56
declarative programming 74
described 73 H
Kubernetes resources 222 HashiCorp 146
running Docker Compose in production 162 HashiCorp Nomad 223
Run workflow drop-down list 76 comparing Nomad CLI to Kubernetes CLI 236
security concern and storing secrets in 83 configuring
sidebar 76 HCL, configuration language 224, 232
third-party plugins 128 installing Nomad 234
GitHub Actions secret intuitive user interface 248
adding IP address 82 Nomad clients 238
adding Personal Access Token to 128 Nomad job definition 240
creating 78–79 Nomad server 235
New Repository Secret 79 overview 232
purpose-built SSH keys 78 preparing Nomad cluster 233
login_action plugin 128 running containers with Nomad jobs 240–247
GitHub Actions workflow 158 terminology 233
Ansible 86–88 testing job configuration, example 240
setup result 88 HashiCorp Vault, as alternative to Kubernetes
example 73–77 Secrets 199
flexibility of 75 HEAD, creating bare Git repositories 48
for building containers 129 headers, returning JSON data with Express.js 20
for Node.js and Ansible Playbook 101 health checks 167
installing NGINX on remote server with 82 Heroku 38
items to be defined 74 hooks directory 51
276 index
navigating to root SSH directory 262 configuration for Python and Ansible 91
requirements for performing SSH 40 configuration update 65
SSH keys configuring for apps 63–66
ACC and installing public SSH key 79 creating log directories 64
and GitHub Actions 78 described 62
components 261 installing 63
connecting to remote host with specific SSH useful commands 63
key 267 systemctl 68
copying public key 265
creating new 78 T
cross-platform SSH key generation 262 targetPort 189
installing SSH public key on remote task, Docker Swarm terminology 224
host 265 template 204
overwriting 264 Terraform, declarative files and 2
password-less server entry 261 third-party GitHub Action plugins 128
permission denied 88
purpose-built 78 U
security concern 78
Ubuntu
ssh key errors 88
Docker Compose 151
ssh-keygen 262
Docker Engine 150
verifying 263
installing Docker 150
updating or creating SSH config 267
Uncomplicated Firewall (UFW) 68
security, self-hosting Git repositories and 47
unknown, Pod status 187
selectors 189, 204, 222
uvicorn 10, 57
selector value 189
described 13
service accounts 178
running FastAPI applications 14
software
deployed 3–4
V
deployment 2, 9
cloud computing 38 venv
ssh command 83 as built-in virtual environment manager 10
stack, Docker Swarm terminology 224 described 10
staging environments 154 version control 22, 36
StatefulSets 201 continuous integration and 72
defined 178 view source, HTML source code 19
defining key configurations for creating virtual environment
StatefulSet 204 activating 11
for Postgres 205 creating 11
key features 203 creating and activating 255
overview 203 deactivating and reactivating 257
static IP addresses 38 described 10
succeeded, Pod status 187 Python and 254
sudo apt install nginx command 82 installing Python packages 256
sudo apt update command 82 recreating 257
sudo command 43, 150 server-side, creating 55
Supervisor virtual machines
background processes 62 installing Docker with GitHub Actions 161
checking status 65 provisioning new 149–150
280 index