In this series, I’ll demonstrate how to get started with infrastructure as code (IaC). My goal is to help developers build a strong understanding of this concept through tutorials and code examples. Here are the topics this series will cover:

In this post, I will demonstrate how to how to create a Docker image for an application, then push that image to Docker Hub. I’ll also discuss how to create and deploy the Docker image to a Google Kubernetes Engine (GKE) cluster using HashiCorp’s Terraform. Here is a quick list of things we’ll accomplish in this post:

Before we begin, you must complete all of the actions in the prerequisites section of part 1 in this series. Once you have completed the prerequisites section, we’ll start by learning how to build a Docker image based on the example Node.js application included in this code repo.

Building a Docker image

In the previous post, we used Terraform to create a new GKE cluster, but that cluster was basically useless because no application or service was deployed. Since Kubernetes (K8s) is a container orchestrator, apps and services must be packaged into Docker images, which can then spawn Docker containers that execute our applications or services.

Docker images are created using the docker build command and you will need a Dockerfile to specify how to build your Docker images. Before we discuss Dockerfiles, let’s take a moment to address .dockerignore files.

The .dockerignore file

The .dockerignore file excludes the files and directories that match patterns declared in it. This helps to avoid unnecessarily sending large or sensitive files and directories to the daemon, and potentially adding them to public images. In this project, the .dockerignore file excludes unnecessary files related to Terraform and Node.js local dependencies.

The Dockerfile

The Dockerfile is critical in building Docker images. It specifies how to build and configure the image, in addition to what files to import into the image. Dockerfile files are very dynamic. This means that you can accomplish many objectives in many ways and it’s important that you have a solid understanding of its capabilities in order to build functional images. Let’s breakdown the Dockerfile contained in this project’s code repo.

FROM node:12

# Create app directory
WORKDIR /usr/src/app

# Install app dependencies
COPY package*.json ./

RUN npm install --only=production

# Bundle app source
COPY . .

EXPOSE 5000
CMD [ "npm", "start" ]

The FROM node:12 line defines an image to inherit from. When building images, Docker inherits from a parent image. In this case it is the node:12 image, which is pulled from Docker Hub, if it doesn’t exist locally.

# Create app directory
WORKDIR /usr/src/app

# Install app dependencies
COPY package*.json ./

RUN npm install --only=production

In the code block above, we define the WORKDIR parameter, which specifies the working directory in the Docker image. The COPY package*.json ./ line copies over any package-related files to the Docker image. The RUN npm install line installs the application dependencies listed in the package.json file.

COPY . .

EXPOSE 5000
CMD [ "npm", "start" ]

The code block above copies all the files into the the Docker image with the exception of the files and directories listed in the .dockerignore file. The EXPOSE 5000 line specifies the port to expose for this Docker image. Finally the CMD [ "npm", "start" ] line defines how to start this image. In this case, it’s executing the start section specified in the package.json file for this project. This CMD parameter is the default execution command. Now that we’ve covered the Dockerfile, let’s use it to build an image locally.

The docker build command

The Dockerfile is used with the docker build command to build a new imaged based on the directives in the Dockerfile. When building Docker images, there are some naming conventions that should be understood. This is very important if you plan on sharing the images.

Before we build an image, let’s discuss naming our images. Docker images use the concept of tags, which are composed of slash-separated name components. In this tutorial, we’re pushing the image to Docker Hub. We need to prefix the image name with our Docker Hub username. In my case, that is ariv3ra/. I usually follow that with the name of the project, or a useful description of the image. The full name of this Docker image will be ariv3ra/learniac:0.0.1 the :0.0.1 is a version tag for the application, but you could also use that to describe other details about the image.

Now that we’ve covered image names, let’s build an image. The following command must be executed from within the root of the project repo (be sure to replace ariv3ra with your Docker Hub name):

docker build -t ariv3ra/learniac  -t ariv3ra/learniac:0.0.1 .

Next, run this command to see a list a of Docker images on your machine:

docker images

This was my output.

REPOSITORY              TAG                 IMAGE ID            CREATED             SIZE
ariv3ra/learniac        0.0.1               ba7a22c461ee        24 seconds ago      994MB
ariv3ra/learniac        latest              ba7a22c461ee        24 seconds ago      994MB

The docker push command

Now we are ready to push this image to Docker Hub and make it available publicly. Since Docker Hub requires authorization to access the service, we need to use the login command to authenticate. Run this command to login:

docker login

Enter your Docker Hub credentials in the prompts to authorize your account. This login only needs to be completed once per machine. Now we’re ready to push our image.

Run the following command to push the new image to Docker Hub. Remember to use the image name listed in your docker images command:

docker push ariv3ra/learniac

This was my output.

The push refers to repository [docker.io/ariv3ra/learniac]
2109cf96cc5e: Pushed 
94ce89a4d236: Pushed 
e16b71ca42ab: Pushed 
8271ac5bc1ac: Pushed 
a0dec5cb284e: Mounted from library/node 
03d91b28d371: Mounted from library/node 
4d8e964e233a: Mounted from library/node

You now have a Docker image living in Docker Hub and ready to be deployed to a GKE cluster. Now we have all the pieces in place to deploy our application to a new Kubernetes cluster and the next step is to build the Kubernetes Deployment using Terraform.

In [part 1 of this series, we learned how to create a new Google Kubernetes Engine (GKE) cluster using Terraform and as I mentioned earlier, that cluster was not serving any applications or services because we didn’t deploy any to it. Next I’ll discuss what it takes to deploy a Kubernetes Deployment using Terraform.

Terraform Kubernetes Deployment

Terraform has a Kubernetes Deployment resource that enables us to define a and execute a Kubernetes deployment to our GKE cluster. In part 1 we created a new GKE cluster using the Terraform code in the part01/iac_gke_cluster/ directory. In this post, we’ll be using the part02/iac_gke_cluster/ and part02/iac_kubernetes_app/ directories, respectively. The iac_gke_cluster/ is the same code we used in part 1, and we’ll be using it again here in conjunction with the iac_kubernetes_app/ directory.

Terraform Kubernetes provider

I want to point out that we previously used the Terraform Google Cloud Platform provider to create a new GKE cluster, which is specific to the Google Cloud Platform, but is still Kubernetes under the hood. Since GKE is essentially a Kubernetes cluster, we need to use the Terraform Kubernetes provider and Kubernetes Deployment resource to configure and deploy our application to the GKE cluster.

Terraform code

The part02/iac_kubernetes_app/ directory contains these files:

  • providers.tf
  • variables.tf
  • main.tf
  • deployments.tf
  • services.tf
  • output.tf

These files maintain all the code that we’re using to define, create, and configure our application deployment to a Kubernetes cluster. Next, let’s breakdown these files to gain a better understanding of what they do.

Breakdown: providers.tf

The provider.tf file is where we define the Terraform provider we’ll be using which is the Terraform Kubernetes provider. The provider.tf is shown below:

provider "kubernetes" {

}

The code block above is used to define the providers that will be used in this Terraform project. The { } blocks are empty because we’ll be handling the authentication requirements with a different process.

Breakdown: variables.tf

This file should seem familiar and is similar to the part 1 variables.tf file, but this particular file only specifies the input variables that this Terraform Kubernetes project uses.

variable "cluster" {
  default = "cicd-workshops"
}
variable "app" {
  type        = string
  description = "Name of application"
  default     = "cicd-101"
}
variable "zone" {
  default = "us-east1-d"
}
variable "docker-image" {
  type        = string
  description = "name of the docker image to deploy"
  default     = "ariv3ra/learniac:latest"
}

The variables defined in this file will be used throughout this Terraform project in code blocks in the various files. All of these variables have default values, but these values can be changed by defining them in the CLI when executing the code. These variables add much needed flexibility to the Terraform code and enables valuable code reusability. One thing to note here is that the variable "docker-image" default parameter is set to my Docker image. Replace that value with your Docker image’s name.

Breakdown: main.tf

Let’s breakdown the elements of the main.tf file starting with the terraform block. It specifies the type of Terraform Backend. A “backend” in Terraform determines how state is loaded and how an operation such as apply is executed. This abstraction enables non-local file state storage, remote execution, etc. In this code block, we’re using the remote backend. It uses the Terraform Cloud, and is connected to the iac_kubernetes_app workspace you created in the Prerequisites section of the part 1 post.

terraform {
  required_version = "~>0.12"
  backend "remote" {
    organization = "datapunks"
    workspaces {
      name = "iac_kubernetes_app"
    }
  }
}

Breakdown: deployments.tf

Now we’ll discuss the syntax in the deployments.tf file. This file uses the Terraform Kubernetes Deployment resource to define, configure, and create all the Kubernetes resources required to release our application to the GKE cluster.

resource "kubernetes_deployment" "app" {
  metadata {
    name = var.app
    labels = {
      app = var.app
    }
  }
  spec {
    replicas = 3

    selector {
      match_labels = {
        app = var.app
      }
    }
    template {
      metadata {
        labels = {
          app = var.app
        }
      }
      spec {
        container {
          image = var.docker-image
          name  = var.app
          port {
            name           = "port-5000"
            container_port = 5000
          }
        }
      }
    }
  }
}

Let’s review the code elements to gain a better understanding of what’s going on.

resource "kubernetes_deployment" "app" {
  metadata {
    name = var.app
    labels = {
      app = var.app
    }
  }

The code block above specifies the use of the the Terraform Kubernetes Deployment resources which defines our deployment object for Kubernetes. The metadata block is used to assign values for parameters that are used within the Kubernetes services.

  spec {
    replicas = 3

    selector {
      match_labels = {
        app = var.app
      }
    }

    template {
      metadata {
        labels = {
          app = var.app
        }
      }

      spec {
        container {
          image = var.docker-image
          name  = var.app
          port {
            name           = "port-5000"
            container_port = 5000
          }
        }
      }
    }
  }

In the resources spec{...} block, we’re specifying that we want three Kubernetes pods running for our application in the cluster. The selector{...} block represents label selectors. These are a core grouping primitive in Kubernetes. They are used by the users to select a set of objects.

The resource template{...} block has a spec{...} block in it which has a container{...} properties block. In this are parameters that define and configure the container used in the deployment. As you can read from the code, this is where we will be defining the pod’s Docker image (image we want to use), the container’s name as it should appear in Kubernetes, and the port to expose on the container that will allow ingress access to the application running. The values are coming from the variables.tf file found in the same folder. The Terraform Kubernetes Deployment resource is capable of performing very robust configurations and I encourage you to experiment with some of the other properties to gain broader familiarity with the tooling.

Breakdown: services.tf

We’ve created a Terraform Kubernetes Deployment resource file and we’ve defined our Kubernetes deployment for this application. That leaves one detail left to complete the deployment of our app. The application we’re deploying is a basic web site. As with all web sites, it needs to be accessible for it to be useful. At this point, our deployments.tf file specifies the directives for deploying a Kubernetes pod with our Docker image and the number of pods required. We are missing a critical element for our deployment and that is a Kubernetes service. This is an abstract way to expose an application running on a set of pods as a network service. With Kubernetes, you don’t need to modify your application to use an unfamiliar service discovery mechanism. Kubernetes gives pods their own IP addresses and a single DNS name for a set of pods, and can load-balance across them.

The services.tf file is where we define a Terraform Kubernetes service. It will wire up the Kubernetes elements to provide ingress access to our application running on pods in the cluster. Let’s take look at the services.tf file.

resource "kubernetes_service" "app" {
  metadata {
    name = var.app
  }
  spec {
    selector = {
      app = kubernetes_deployment.app.metadata.0.labels.app
    }
    port {
      port        = 80
      target_port = 5000
    }
    type = "LoadBalancer"
  }
} 

Lets focus on the spec{...} block and the elements within it. The selector{ app...} block specifies a name that was defined in the deploymnents.tf file and represents the app value in the label property of the metadata block in the deployments resource. This is an example of reusing values that have already been assigned in related resources. It also provides a mechanism that streamlines important values and establishes a form of referential integrity for important data such as this.

The port{...} block has two properties port and target_port and these parameters define the the external port that the service will be listening on for request to the application. In this example, it is port 80. The target_port is the internal port our pods are listening on, which is port 5000. This service will route all traffic from port 80 to port 5000.

The last element we’ll address here is the type parameter which specifies the type of service we’re creating. Kubernetes has three types of services. In this example, we’re using the LoadBalancer type, which exposes the service externally using a cloud provider’s load-balancer. NodePort and ClusterIP Services, to which the external load balancer routes, are automatically created. In this case, GCP will create and configure a LoadBalancer that will control and route traffic to our GKE cluster.

Breakdown: output.tf

Terraform has a concept of Output Values which return values of a Terraform module and provides a child module with outputs to expose a subset of its resource attributes to a parent module or print certain values in the CLI output after running terraform apply. The output.tf blocks below we’re using output values to readout values like Cluster name and the ingress IP address of our newly created LoadBalancer service. This address is where we can access our application hosted in Pods on the GKE cluster.

  output "gke_cluster" {
    value = var.cluster
  }

  output "endpoint" {
    value = kubernetes_service.app.load_balancer_ingress.0.ip
  }

Initializing the Terraform part02/iac_gke_cluster

Now that you have a better understanding of our Terraform project and syntax, it’s time to start provisioning our GKE cluster using Terraform. Change directory into the part02/iac_gke_cluster directory:

cd part02/iac_gke_cluster

While in part02/iac_gke_cluster, run this command:

terrform init

This was my output.

Initializing the backend...

Successfully configured the backend "remote"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "google" (hashicorp/google) 3.31.0...

The following providers do not have any version constraints in configuration,
so the latest version was installed.

To prevent automatic upgrades to new major versions that may contain breaking
changes, it is recommended to add version = "..." constraints to the
corresponding provider blocks in configuration, with the constraint strings
suggested below.

* provider.google: version = "~> 3.31"

Terraform has been successfully initialized!

This is great! Now we can create the GKE cluster.

Terraform apply part02/iac_gke_cluster

Terraform has a command that allows you to dry run and validate your Terraform code without actually executing anything. The command is called terraform plan which also graphs all the actions and changes that Terraform will execute against your existing infrastructure. In the terminal, run:

terraform plan

This was my output.

Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.
------------------------------------------------------------------------
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # google_container_cluster.primary will be created
  + resource "google_container_cluster" "primary" {
      + additional_zones            = (known after apply)
      + cluster_ipv4_cidr           = (known after apply)
      + default_max_pods_per_node   = (known after apply)
      + enable_binary_authorization = false
  ...

Terraform is going to create new GCP resources for you based on the code in the main.tf file. Now you’re ready to create the new infrastructure and deploy the application. Run this command in the terminal:

terraform apply

Terraform will prompt you to confirm your command. Type yes and hit enter.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

Terraform will build your new Google Kubernetes Engine cluster on GCP.

Note: It will take 3-5 minutes for the cluster to complete. It’s not an instant process because the backend systems are provisioning and bringing things online.

After my cluster was completed, this was my output.

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

cluster = cicd-workshops
cluster_ca_certificate = <sensitive>
host = <sensitive>
password = <sensitive>
username = <sensitive>

The new GKE cluster has been created and the Outputs results are displayed. Notice the output values that were marked sensitive are masked in the results with <sensitive> tags. This ensures sensitive data is protected by available when needed.

Next, we’re going to use the code in the part02/iac_kubernetes_app/ directory to create a Kubernetes deployment and accompanying LoadBalancer service.

Terraform Initialize part02/iac_kubernetes_app/

We can now deploy our application to this GKE cluster using the code in the part02/iac_kubernetes_app/ directory. Change directory into the directory with this command:

cd part02/iac_kubernetes_app/

While in part02/iac_kubernetes_app/, run this command to initialize the Terraform project.

terrform init

This was my output.

Initializing the backend...

Successfully configured the backend "remote"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "kubernetes" (hashicorp/kubernetes) 1.11.3...

The following providers do not have any version constraints in configuration,
so the latest version was installed.

To prevent automatic upgrades to new major versions that may contain breaking
changes, it is recommended to add version = "..." constraints to the
corresponding provider blocks in configuration, with the constraint strings
suggested below.

* provider.kubernetes: version = "~> 1.11"

Terraform has been successfully initialized!

GKE cluster credentials

After creating a google_container_cluster with Terraform, authentication to the cluster is required. You can use the Google Cloud CLI to configure cluster access, and generate a kubeconfig file. Execute the following command to gain access to the Kubernetes cluster.

gcloud container clusters get-credentials cicd-workshops --zone="us-east1-d"

Using this command, gcloud will generate a kubeconfig entry that uses gcloud as an authentication mechanism. The above command uses the cicd-workshops value as the cluster name which is also specified in the variables.tf. Now we can deploy our app to GKE.

Terraform apply part02/iac_kubernetes_app/

We’re finally ready to deploy our application to the GKE cluster using Terraform. Execute the following command:

terraform plan

This was my output.

Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.
------------------------------------------------------------------------
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create
Terraform will perform the following actions:
  # kubernetes_deployment.app will be created
  + resource "kubernetes_deployment" "app" {
      + id = (known after apply)
      + metadata {
          + generation       = (known after apply)
          + labels           = {
              + "app" = "cicd-101"
            }
          + name             = "cicd-101"
          + namespace        = "default"
          + resource_version = (known after apply)
          + self_link        = (known after apply)
          + uid              = (known after apply)
        }
  ...

Terraform is going to create new GCP resources for you based on the code in the deployment.tf and services.tffiles. Now you’re ready to create the new infrastructure and deploy the application. Run this command in the terminal:

terraform apply

Terraform will prompt you to confirm your command. Type yes and hit enter.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

Terraform will build your new Kubernetes application deployment and related LoadBalancer.

Note: It will take 3-5 minutes for the cluster to complete. It’s not an instant process because the backend systems are provisioning and bringing things online.

After my cluster was completed, this was my output.

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

endpoint = 104.196.222.238
gke_cluster = cicd-workshops

The application has now been deployed and the endpoint value, and the output is the IP Address to the public ingress of the cluster LoadBalancer. It also represents the address where you can access the application. Open a web browsers and use the output value to access the application. You should see a web page with the text “Welcome to CI/CD 101 using CircleCI!”.

Terraform destroy

Now that you have proof that your Kubernetes deployment works and that deploying the application to a GKE cluster has been successfully tested, run the terraform destroy command to destroy the assets that you created in this tutorial. You can leave it up and running, but be aware that there is a cost associated with any assets running on the Google Cloud Platform and you will be liable for those costs. Google gives a generous $300 credit for its free trial sign-up, but you could easily eat through that if you leave assets running. It’s up to you, but running terraform destroy will terminate any running assets.

Run this command to destroy the GKE cluster.

terraform destroy

Remember that the above command will only destroy the part02/iac_kubeernetes_app/ deployment and you need to run the following to destroy all the resources created in this tutorial.

cd ../iac_gke-cluster/

terraform destroy

The above will destroy the GKE cluster we created earlier.

Summary

Congratulations! You’ve just completed part 2 of this series and leveled up your experience by building and publishing a new Docker image along with provisioning and deploying an application to a Kubernetes cluster using infrastructure as code and Terraform.

Continue to part 3 of the tutorial where you’ll learn how to automate all of this awesome knowledge into CI/CD pipelines using CircleCI.

The following resources will help you expand your knowledge from here: