Day 6 - Deploying a Web App on Google Kubernetes Engine (GKE) using Terraform & Docker

Day 6 - Deploying a Web App on Google Kubernetes Engine (GKE) using Terraform & Docker

In this article, we will be deploying a simple Flask web application to Google Kubernetes Engine (GKE) using Terraform and Docker. We will leverage the power of containerization and Infrastructure as Code (IaC) to build a scalable and robust deployment pipeline.
The code for this article can be found in this GitHub repo under the Day_6 folder.

Prerequisites

  1. Python 3.x

  2. Google Cloud Project with the Compute Admin, Compute Network Admin, Kubernetes Engine Admin & Service Account User roles granted to a service account.

  3. The Service Account Key JSON file downloaded locally.

  4. gcloud CLI installed locally on your computer (Instructions)

  5. Terraform (Instructions)

  6. Docker (Instructions)

Setting up

Writing and building the app

On your computer, create a directory with the name Deploy-a-web-app or a name of your choosing.

Inside the folder, create and activate the virtual environment that we’ll use to install our dependencies:

$ python -m venv venv # Create the virtual environment

# Activate the virtual environment
$ venv\Scripts\activate # For Windows

$ source venv/bin/activate # For MacOS/Linux

Install the dependencies that we’ll need & add them to the requirements.txt file

$ pip install Flask requests gunicorn Werkzeug

# Add them to a requirements.txt file
$ pip freeze > requirements.txt

Proceed to create two folders in the Deploy-a-web-app directory: templates & static
In the templates folder, create a file called index.html and fill it with the following code:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Flask Demo Random Quote</title>
    <style>
        .theroot{
            height: 100vh;
            width: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
        }
        .container {
            text-align: center;
        }
    </style>
</head>
<body>
    <div class="theroot">
        <div class="container">
            <img src="{{ url_for('static', filename='images/dog.webp') }}"/>
            <h2><b>Random Quote Generator</b></h2>
            <p>✍️ <b>Author</b>: {{quote.author}}</p>
            <p>📃 <b>Quote</b>: {{quote.quote}}</p>
        </div>
    </div>
</body>
</html>

Inside the static folder, create a folder called images. Inside the images folder, you can add an image of your choice. I added an image of a dog. Remember to edit the index.html to ensure the image name matches yours. i.e replace dog.webp with your image name.

Proceed to create a file called main.py in the Deploy-a-web-app directory and fill it with the following code:

import requests
from flask import Flask, render_template

app = Flask(__name__)

@app.route("/")
def get_random_quote():
    """Fetch a random quote from the API"""
    response = requests.get('https://dummyjson.com/quotes/random')
    quote = response.json()
    return render_template('index.html', quote=quote)

if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=8080)

Script explanation: The code above will be using the requests module to fetch a random quote from the dummyjson API and render that quote in a HTML template; this app will also run on port 8080 when executed.

Structure

Your directory structure should ideally look like this:

Deploy-a-web-app
|- static/
|   |- images/
|        |- dog.webp (or another image)
|
|- templates/
|     |- index.html
|
|- main.py

Run the web app

In your terminal, navigate to the location of your main.py file and run the following command to launch the web app. Visit http://127.0.0.1:8080 on your browser to see your web app

$ python main.py

Creating our Dockerfile and .dockerignore files

In the same directory as our main.py file, create a file called Dockerfile and fill it with the following:

FROM python:3.13-alpine

WORKDIR /usr/src/app

COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD [ "python", "./main.py" ]

Proceed to create a file called .dockerignore and add the following inside it. Remember to include the period in the dockerignore filename

venv/ # or the name of the folder of your virtual environment

Explanation:
When building our Docker image, Docker will ignore the files/folders listed in the .dockerignore file. We will ideally be ignoring the virtual environment folder from the image building process.

What is Docker?
Docker is a software platform that allows you to build, test, and deploy applications as containers quickly. You can read more about it here.

Our Dockerfile explanation:
This Dockerfile builds a container image for a Python 3.13 application using a lightweight version called Alpine. It installs dependencies from requirements.txt, copies the application code and its dependencies into the container, and sets the default command to run the main.py script within the container. This approach ensures a consistent and reproducible environment for your Python application across different systems.

Updated Structure:
Your structure should now look like this with the Dockerfile and .dockerignore added:

Deploy-a-web-app
|- static/
|   |- images/
|        |- dog.webp (or another image)
|
|- templates/
|     |- index.html
|
|- main.py
|- Dockerfile #------> NEW
|- .dockerignore #------> NEW

Building our Docker image

Go back to your terminal and navigate to the same directory as main.py or Dockerfile and run the following. The below command will build a docker image called my-web-app. You can replace my-web-app with the image name of your choice:

$ docker build -t my-web-app .

After it’s done building, you can view it in the present images in your system by running the following command:

$ docker image list

Storing our Docker container image in Artifact Registry

Assuming you already setup a Google Cloud Project, a Service Account with the necessary roles and installed & set up gcloud CLI locally as earlier advised, we will proceed to create a Docker repository in our Google Cloud Project with the following command below.

Remember to replace:

  1. my-docker-repo - replace with the name of your repo.

  2. us-east1 - replace with the location of your choice.

  3. “My Docker repository” - replace with the description of your choice

  4. PROJECT - replace with your Google Cloud Project ID

$ gcloud artifacts repositories create my-docker-repo --repository-format=docker \
  --location=us-east1 --description="My Docker repository" --project=PROJECT

You can confirm that your repository was created by listing out the repositories in the Google Cloud project. Replace PROJECT with the GCP Project ID:

$ gcloud artifacts repositories list --project=PROJECT

Configure authentication

Before we push our image to the Artifact Registry, we need to configure Docker to use the gcloud CLI to authenticate our requests. Since I used us-east1 as my location, my command would look as shown below. Make sure to match yours accordingly.

$ gcloud auth configure-docker us-east1-docker.pkg.dev

When prompted to save the credentials in the Docker configuration, type Y and hit Enter.

Adding the image to our repository

Tag the image with the registry name

Tagging the Docker image with the repository name configures the docker push command to push the image to a specific location. Here is the command you can use to tag the image. Replace the missing bits with the ones you configured earlier on:

$ docker tag <YOUR-LOCAL-IMAGE-NAME>:latest \
  <LOCATION>-docker.pkg.dev/<PROJECT ID>/<REPOSITORY-NAME>/<YOUR-LOCAL-IMAGE-NAME>:tag1

For example, mine looked like this:

$ docker tag my-web-app:latest \
  us-east1-docker.pkg.dev/my_project_id_1234/my-docker-repo/my-web-app:tag1

Push the image to Artifact Registry

$ docker push <LOCATION>-docker.pkg.dev/<PROJECT ID>/<REPOSITORY-NAME>/<YOUR-LOCAL-IMAGE-NAME>:tag1

Confirm the existence of your repository and pushed image when you visit Google Cloud Console and search for Artifact Registry. Click on Repositories

Creating our Terraform Configuration

Go back to your code editor inside your Deploy-a-web-app folder, and create a file called app.tf and fill it with the following. Remember to replace the missing bits with your values as configured:

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "6.8.0"
    }
  }
}

provider "google" {
  # Configure authentication using a service account key file
  credentials = file("./<CREDENTIALS-FILE>.json")   # Point this to where your credentials are locally
  region      = "us-east1"                          # Replace with desired region
  project     = "PROJECT_ID"                        # Replace with your project id
}

data "google_client_config" "default" {}

provider "kubernetes" {
  host                   = "https://${google_container_cluster.default.endpoint}"
  token                  = data.google_client_config.default.access_token
  cluster_ca_certificate = base64decode(google_container_cluster.default.master_auth[0].cluster_ca_certificate)

  ignore_annotations = [
    "^autopilot\\.gke\\.io\\/.*",
    "^cloud\\.google\\.com\\/.*"
  ]
}

resource "kubernetes_deployment_v1" "default" {
  metadata {
    name = "my-web-app-deployment"
  }

  spec {
    selector {
      match_labels = {
        app = "my-web-app"
      }
    }

    template {
      metadata {
        labels = {
          app = "my-web-app"
        }
      }

      spec {
        container {
          image = "<LOCATION>-docker.pkg.dev/<PROJECT_ID>/<REPOSITORY_NAME>/<IMAGE_NAME>:tag1" # Replace with the actual image name
          name  = "my-web-app-container"

          port {
            container_port = 8080
            name           = "my-web-app-svc"
          }

          security_context {
            allow_privilege_escalation = false
            privileged                 = false
            read_only_root_filesystem  = false

            capabilities {
              add  = []
              drop = ["NET_RAW"]
            }
          }

          liveness_probe {
            http_get {
              path = "/"
              port = "my-web-app-svc"

              http_header {
                name  = "X-Custom-Header"
                value = "Awesome"
              }
            }

            initial_delay_seconds = 3
            period_seconds        = 3
          }
        }

        security_context {
          run_as_non_root = true

          seccomp_profile {
            type = "RuntimeDefault"
          }
        }

        # Toleration is currently required to prevent perpetual diff:
        # https://github.com/hashicorp/terraform-provider-kubernetes/pull/2380
        toleration {
          effect   = "NoSchedule"
          key      = "kubernetes.io/arch"
          operator = "Equal"
          value    = "amd64"
        }
      }
    }
  }
}

resource "kubernetes_service_v1" "default" {
  metadata {
    name = "my-web-app-loadbalancer"
    annotations = {

    }
  }

  spec {
    selector = {
      app = kubernetes_deployment_v1.default.spec[0].selector[0].match_labels.app
    }

    ip_family_policy = "RequireDualStack"

    port {
      port        = 80
      target_port = kubernetes_deployment_v1.default.spec[0].template[0].spec[0].container[0].port[0].name
    }

    type = "LoadBalancer"
  }

  depends_on = [time_sleep.wait_service_cleanup]
}

# Provide time for Service cleanup
resource "time_sleep" "wait_service_cleanup" {
  depends_on = [google_container_cluster.default]

  destroy_duration = "180s"
}

Script explanation:

This Terraform script sets up a Kubernetes deployment and service on GCP using Google Kubernetes Engine (GKE). The google provider is configured with authentication via a service account key file, a specified region i.e us-east1, and a project ID. The Kubernetes provider uses the GKE cluster’s endpoint, access token, and CA certificate to manage the resources.

The script creates a Kubernetes deployment named my-web-app-deployment, which runs a container based on the Docker image we stored in Artifact Registry earlier on. It configures the container with security settings and a liveness probe for health checks. Additionally, a toleration is added to handle architecture-specific scheduling. The deployment is exposed via a Kubernetes service named my-web-app-loadbalancer, which is configured as a load balancer to distribute traffic to the app.

The service supports dual-stack IPs and maps external traffic on port 80 to the container’s port 8080. To ensure smooth resource cleanup, a time_sleep resource introduces a delay before service destruction.

Proceed to create another file in the same directory and name it cluster.tf Remember to replace the values to match your configurations.

resource "google_compute_network" "default" {
  name = "myclusternetwork"

  auto_create_subnetworks  = false
  enable_ula_internal_ipv6 = true
}

resource "google_compute_subnetwork" "default" {
  name = "myclustersubnetwork"

  ip_cidr_range = "10.0.0.0/16"
  region        = "us-east1" # Select your desired region

  stack_type       = "IPV4_IPV6"
  ipv6_access_type = "EXTERNAL" # This is "EXTERNAL" since we're creating an external loadbalancer

  network = google_compute_network.default.id
  secondary_ip_range {
    range_name    = "services-range"
    ip_cidr_range = "192.168.0.0/24"
  }

  secondary_ip_range {
    range_name    = "pod-ranges"
    ip_cidr_range = "192.168.1.0/24"
  }
}

resource "google_container_cluster" "default" {
  name = "my-web-app-cluster"

  location                 = "us-east1" # Select your desired region
  enable_autopilot         = true
  enable_l4_ilb_subsetting = true

  network    = google_compute_network.default.id
  subnetwork = google_compute_subnetwork.default.id

  ip_allocation_policy {
    stack_type                    = "IPV4_IPV6"
    services_secondary_range_name = google_compute_subnetwork.default.secondary_ip_range[0].range_name
    cluster_secondary_range_name  = google_compute_subnetwork.default.secondary_ip_range[1].range_name
  }

  # Set `deletion_protection` to `true` will ensure that one cannot
  # accidentally delete this instance by use of Terraform.
  deletion_protection = false
}

Script explanation:

This Terraform script provisions a Google Kubernetes Engine (GKE) cluster with a custom network and subnetwork configuration in your GCP project. The script begins by creating a VPC network i.e myclusternetwork with auto_create_subnetworks set to false, ensuring custom subnetworks are used instead of automatically created ones. It enables IPv6 for internal communication.

Next, it creates a custom subnetwork i.e myclustersubnetwork in the region you selected i.e us-east1, with an IPv4 CIDR range of 10.0.0.0/16 and dual-stack support for both IPv4 and external IPv6 traffic. The subnetwork includes two secondary IP ranges: one for Kubernetes services (services-range) and another for pods (pod-ranges).

The GKE cluster my-web-app-cluster is created in the same region with Autopilot mode enabled for automated cluster management. It is configured to use the custom network and subnetwork, with IP allocation policies that associate the secondary IP ranges with Kubernetes services and pods. The enable_l4_ilb_subsetting feature is enabled to optimize internal load balancer routing. The script also includes deletion_protection, set to false, allowing the cluster to be deleted using Terraform if needed.

In your terminal, navigate to the same directory as the app.tf and cluster.tf files and initialize Terraform:

$ terraform init

Next, run terraform plan to plan and review the resources/changes that will be applied to your GCP project

$ terraform plan

Run terraform apply to apply the changes

$ terraform apply

The command will take some time to deploy the resources on your GCP Project and get everything working.

Once the deployment is done, you can navigate to Kubernetes Engine on your Google Cloud Console and click on Clusters to confirm your newly created cluster

On the left panel in Kubernetes Engine page, click on Workloads to confirm your new deployment:

View your running web app

Click on the deployment name; this will take you to the deployment details page. Scroll to the bottom to the section called Exposing Services.
Here is where you’ll find the endpoints for your Load Balancer that will direct traffic to your web app.

Click on the endpoint IP address. This will open your web app on a new tab

Conclusion

Deploying a web application to GKE using Docker, Terraform, gcloud CLI to GCP demonstrates the power of modern cloud-native tools to streamline and automate the deployment process. Docker ensures consistent and portable containerized applications, while Terraform enables infrastructure as code (IaC) for reliable and repeatable deployments. The gcloud CLI simplifies interaction with Google Cloud services, making it easier to manage Kubernetes clusters and resources. By combining these tools, we not only achieve a scalable and resilient deployment but also lay a solid foundation for implementing DevOps best practices.