Using Kubernetes to deploy a 3-tier containerized application infrastructure
Intro
In my last article, we discussed containerized application orchestration using Docker Swarm. Now we’re going to deploy that same architecture with another leading orchestration platform: Kubernetes.
If you need a refresher, review the previous article.
What we’re building
A Kubernetes environment with one (1) master/control plane node and three (3) worker nodes, using minikube.
A Kubernetes application deployment that will deploy a 3-tier application architecture (Web/Apache, Node.js, and Postgres DB).
Note: We won’t have any application source code in this demo. We’ll focus more on the architecture and infrastructure of the application.
Prerequisites
Review of the last article about Docker Swarm.
Familiarity with Linux systems and commands.
Familiarity with Docker containers and orchestration methods, such as Docker Compose and Swarm.
Docker installed on your system.
Access to a command line tool.
What is Kubernetes?
Similar to Docker Swarm, Kubernetes is one of the leading container orchestration tools, but for larger and more complex application workloads. It offers much more flexibility, scalability, reliability, and specificity on how to deploy workloads.
So…Kubernetes is better than Docker Swarm, right?
Not necessarily! Although Docker Swarm is more lightweight, it’s often considered to be easier to use and scale. Like anything in the tech world, it really depends on your use case and what you're trying to build.
Some key differences
Kubernetes adds a layer of abstraction on top of Docker, so it has its own set of APIs to create networks, containers, and volumes. Like docker, we can access the API engine through the kubectl CLI.
Pods:
Pods are the basic unit of deployment. They are pretty much like services/containers in Swarm, but with a layer of abstraction on top (you’ll hear a lot of this). You can have multiple pods within a node and multiple containers within a pod. It can be a little confusing, but just know, when we mention ‘pods,’ think ‘containers.’
Services:
In Swarm, a service referred to container/task definitions and deployments. In Kubernetes, a service refers to a network and stable endpoint/address for pods to either communicate internally or with outside services. There are three main types of services: ClusterIP, NodePort, and LoadBalancer.
Let’s get started!
Install minikube and Hyperkit
Before start building our containerized application, we need to create a local dev environment. minikube is a great tool that allows us to implement multi-node Kubernetes clusters locally on our machines and comes with kubectl already installed.
Install minikube on your system however you see fit, but for me (macOS), the Homebrew package manager was the most straightforward method and also allows us to install the virtual machine environment we’ll need.
brew install minikube
To run a multi-node cluster locally, minikube needs a virtual machine driver, so we need to install a VM tool, such as Hyperkit, VirtualBox, or Parallels. I’ll be using Hyperkit, but feel free to choose your own.
brew install hyperkit
Cluster setup
If you remember from Docker Swarm, our container workloads run on ‘nodes,’ which can be physical or virtual machines/servers that provide high availability and fault tolerance. In Swarm, nodes are assigned roles of manager or worker. The principle is the same in Kubernetes, but nodes are split into roles of the control plane — which contain all the master nodes — and workers. Let’s set up our cluster in minikube:
One (1) master/control plane node.
Three (3) worker nodes.
By default, minikube will assign our local machine as a master/control plane node and all others as workers.
minikube start --driver hyperkit --nodes 4

Now, we can use kubectl commands to get our nodes
kubectl get nodes

Restrict pod assignments
We only want pods to be scheduled on worker nodes, so we need to add special instructions to the control plane in the form of a taint. A Taint allows a node to repel a set of pods by telling the scheduler not to schedule any pods unless they have a matching toleration.
kubectl taint nodes node_name key1=value1:NoSchedule

Deploy the frontend web tier
Similar to Docker Compose, an application’s entire infrastructure can be deployed using a single YAML file; however, Kubernetes YAML files can be a little more involved and detailed. Although a single YAML file can be used, the deployment configurations might be easier to manage if we break them out into a few organized files.
We’ll start with the frontend web tier and deploy our pods that contain our Apache web servers.

I’ve organized my ‘kube_app’ application directory into ‘frontend,’ ‘backend,’ and ‘database’ tiers.
Frontend web Service
We’ve been working with Docker Swarm so, you might be familiar with the term, ‘services.’ In Docker, a service is the image or image type run on a container, such as Apache, NGINX, Node, Postgres, etc… It is essentially a description and role of a container/task that will be deployed in a cluster.
A Kubernetes service refers to a network and stable endpoint/address for pods to either communicate internally or with outside services. There are three main types of services: ClusterIP, NodePort, and LoadBalancer.
A ClusterIP creates virtual IPs to allow pods to communicate with each other. A default ClusterIP is given to our cluster, so the control plane and worker nodes can communicate with each other.
kubectl get services

A NodePort allows the control plane to expose and allocate specific ports for outside access. This is the type of service we’ll need to access our website on port 80.
Create a new YAML file named frontend-kube-app.yml.
apiVersion: v1
kind: Service
metadata:
name: web-httpd-service
spec:
type: NodePort
ports:
- protocol: TCP
port: 80
targetPort: 80
selector:
app: web-httpd
---
Each Kubernetes object, or kind, refers to a certain API group, so it’s important to know which apiVersion we should point to. An easy way to find this is to look at the api-resources and find the version of the object.
kubectl api-resources

Under metadata, we can give our service a name (required).
We specify the type as Nodeport and expose port 80.
The concept of selector and labels are very important. The service uses the selector to allow traffic to pods with matching labels.
The --- indicates a break and the end of the object.
Tip: Instead of trying to go back and forth between documentation, you can get details about each section of an object from the
explaincommand. For example, you can use dot-notation to drill down on specific areas, such askubectl explain service.specand it will give you an explanation of each option that falls under that category.
Frontend web deployment
Now we’re ready to create a Deployment! Deployments are more akin to Docker services. It’s the set of instructions and specifications for the pods including the image, number of replicas, ports, restart policies, etc…
In the same file, under the service object:
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-httpd-deployment
spec:
selector:
matchLabels:
app: web-httpd
replicas: 10
template:
metadata:
labels:
app: web-httpd
spec:
containers:
- name: web-httpd
image: httpd:2.4.55
ports:
- containerPort: 80
We’re keeping this pretty simple, but you can see how more detailed specs can make this a little more complicated.
As we can see, pod deployments are under the kind: Deployment, with its own API Group. We should also notice the selector section where we've given it a matchlabel of the same name as the web-httpd-service selector, so they know to connect to each other.
Under template, we can provide the container information, such as the name, image, and port.
Deploy the pods and service
We’re ready to deploy our first pods! This is the easy part. All we have to do is apply the configuration and point to our YAML file.
kubectl apply -f ./frontend/frontend-kube-app.yml

We can run kubectl get all -o wide to get all the service and pod details for our cluster.

Alright! All ten of our pods are up and running, and we can see that none of them have been distributed on the master/control plane node.
We also see our NodePort service and its port mapping. Let’s see if we can access the web server by going to any of our node's IP addresses with the NodePort port (the number on the right). We can get a node's IP by running kubectl get nodes -o wide.

It works! We’ve just deployed our first Kubernetes pods to our cluster!
Deploy the backend application tier
Now that we have our first tier, out of the way, it’s pretty much rinse-and-repeat — but with varying specs to fit your needs.
Since we don’t have any application source code or need to connect anything at this point, we’ll just create a simple Deployment for the app backend. In the backend directory, create a new YAML file called backend-kube-app.yml.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nodejs-app-deployment
spec:
selector:
matchLabels:
app: nodejs-app
replicas: 4
template:
metadata:
labels:
app: nodejs-app
spec:
containers:
- name: nodejs-app
image: node:19-alpine3.16
command: ["sleep", "100000"]
We’ll enter a command of sleep for testing purposes to keep the container open and running.
Run the apply command again and get the results.
kubectl apply -f ./backend/backend-kube-app.yml
kubectl get all -o wide

Great! 4/4 pods we provisioned are running successfully.
Deploy the backend database tier
We’re ready for the final phase, and deploy the database (Postgres) for our app. The database can be a little more involved so let’s break down what we’ll need:
A
ConfigMapto store secrets such as usernames and passwords.A
PersistantVolumeandPersistantVolumeClaimto define and allocate storage for the A database.A
Serviceto connect the databaseA
Deploymentto define and deploy the database pod to our cluster.
Create a ConfigMap
It’s always best practice to separate data from code. A good way to do this in development is through a ConfigMap. Kubernetes has its own API group for this purpose.
In the database directory, create a new YAML file called postgres-config.yml
apiVersion: v1
kind: ConfigMap
metadata:
name: postgres-config
labels:
app: postgres-db
data:
POSTGRES_DB: postgresdb
POSTGRES_USER: admin
POSTGRES_PASSWORD: mypass
Here, we’ll store environment variables such as the DB name, user, and password. Again, take special notice of the labels, since this is what our Deployment will look for to connect to the config.
Apply the ConfigMap to the cluster.
kubectl apply -f ./database/postgres-config.yml
Create a PersistantVolume & PersistantVolumeClaim
Since pods are ephemeral — meaning once they’re gone, so is all the data they carried — we’ll need persistent storage in place for our database. Here, we’ll define the capacity, access, and the hostPath of the volume.
Once we create the volume, we need a PersistantVolumeClaim, which defines how the users request and consume PV resources.
Create a new YAML file named postgres-pvc-pv.yml.
kind: PersistentVolume
apiVersion: v1
metadata:
name: postgres-pv-volume # Sets PV's name
labels:
type: local # Sets PV's type to local
app: postgres-db
spec:
storageClassName: manual
capacity:
storage: 5Gi # Sets PV Volume
accessModes:
- ReadWriteMany
hostPath:
path: "/mnt/data"
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: postgres-pv-claim # Sets name of PVC
labels:
app: postgres-db
spec:
storageClassName: manual
accessModes:
- ReadWriteMany # Sets read and write access
resources:
requests:
storage: 5Gi # Sets volume size
Apply the PV and PVC to the cluster.
kubectl apply -f ./database/postgres-pvc-pv.yml
Create the Service
For now, we’ll just create a simple NodePort service and expose port 5432.
Create a new YAML file named database-kube-app.yml.
apiVersion: v1
kind: Service
metadata:
name: postgres # Sets service name
labels:
app: postgres-db # Labels and Selectors
spec:
type: NodePort # Sets service type
ports:
- port: 5432 # Sets port to run the postgres application
selector:
app: postgres-db
---
Create the Deployment
Finally, we’ll create the deployment options for our Postgres database (in the same file).
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres-db-deployment # Sets Deployment name
spec:
replicas: 1
selector:
matchLabels:
app: postgres-db
template:
metadata:
labels:
app: postgres-db
spec:
containers:
- name: postgres-db
image: postgres:10.1 # Sets Image
imagePullPolicy: "IfNotPresent"
ports:
- containerPort: 5432 # Exposes container port
envFrom:
- configMapRef: # Maps env variable from ConfigMap
name: postgres-config
volumeMounts:
- mountPath: /var/lib/postgresql/data
name: postgres-volume
volumes:
- name: postgres-volume
persistentVolumeClaim:
claimName: postgres-pv-claim # Maps claim from PersistantVolumeClaim
We can map our environment variables envFrom and volumes from our config and PVC (this is why names and labels are important!).
Apply the service and Deployment to the cluster.
kubectl apply -f ./database/postgres-pvc-pv.yml

Test database connection
For good measure, let’s test and see if we can connect to the database.
kubectl exec -it [pod-name] -- psql -h localhost -U admin --password -p 5432 postgresdb
Enter the DB password and type \l to list all the databases.

And we’re in!


