Day-14: Deploying a Multi-Microservice Application on GKE
This chapter covers the end-to-end process of containerizing and deploying a multi-microservice application on Google Kubernetes Engine (GKE) using GKE Autopilot. You will learn how to analyze a multi-tier application architecture, write optimized Dockerfiles, verify the services locally using Docker Compose, and deploy them to a production-ready Kubernetes cluster managed entirely by Google Cloud.
1. The Problem Statement: Why Multi-Microservice Architectures?
In modern cloud-native engineering, applications are rarely built as single, monolithic codebases. Monoliths present scaling bottlenecks, deployment coordination challenges, and technology lock-in.
By breaking an application down into microservices, engineering teams gain several advantages:
- Independent Scalability: Services experiencing high traffic (e.g., a public-facing voting portal) can be scaled independently of background processing services.
- Polyglot Development: Developers can choose the optimal programming language and framework for each specific task (e.g., Python for rapid web development, .NET for background workers, and Node.js for real-time dashboards).
- Fault Isolation: If a non-critical service (such as the results dashboard) crashes, the core transaction engine (the voting service) remains fully operational.
To demonstrate these concepts, this guide uses an open-source multi-tier voting application. This application serves as an ideal learning tool because it spans multiple programming languages, utilizes in-memory caching, and relies on a persistent relational database.
2. Core Mental Model: The Voting Application Architecture
The application consists of five distinct microservices. Understanding how data flows between these components is crucial before writing Kubernetes manifests.
The Microservice Components
- Voting Microservice: A public-facing Python Flask web application that allows users to vote between two options (by default, "Cats" vs. "Dogs").
- Redis: An in-memory, high-performance key-value database used to temporarily cache the incoming votes.
- Worker Microservice: A background .NET Core service that continuously polls Redis, processes the raw votes, and persists them into a relational database.
- Database (PostgreSQL): A persistent relational database that stores the finalized voting records.
- Results Microservice: A Node.js web application that reads the voting data from the PostgreSQL database and displays the real-time results to administrators.
Real-Life Analogy: The Restaurant Order Workflow
Think of this architecture like a busy restaurant:
- The Voting Microservice is the front-of-house waiter taking orders from customers.
- Redis is the order ticket spindle where the waiter hangs the orders. It is fast, temporary, and highly accessible.
- The Worker is the kitchen expeditor who grabs tickets off the spindle, processes them, and hands them to the chef.
- The Database (PostgreSQL) is the kitchen's inventory ledger where completed meals are logged for long-term record-keeping.
- The Results Microservice is the manager's dashboard, reading the ledger to see which dishes are selling the most.
Just as the waiter does not directly talk to the ledger, the Voting service does not directly talk to the PostgreSQL database. Redis and the Worker act as the decoupling buffer.
Data Flow Pipeline
The vertical workflow below illustrates how a single vote travels through the system:
3. GKE Autopilot vs. Standard Mode
When deploying to Google Kubernetes Engine (GKE), Google Cloud offers two distinct operational modes: Standard and Autopilot.
4. Step 1: Setting Up the GKE Autopilot Cluster
To deploy our application, we must first enable the necessary Google Cloud APIs and provision a GKE Autopilot cluster.
1. Enable the Container API
Before interacting with GKE, enable the Google Container Service API using the Google Cloud CLI (gcloud):
gcloud services enable container.googleapis.com
2. Create the Autopilot Cluster
Run the following command to provision a GKE Autopilot cluster in your preferred region:
gcloud container clusters create-auto demo-autopilot-cluster \
--region us-central1
Note: Autopilot clusters take approximately 10 to 15 minutes to fully provision.
3. Understanding the Autopilot "Cold Start" Node Behavior
When you first query the cluster using kubectl, you may notice that no worker nodes are visible:
kubectl get nodes
In GKE Autopilot, if there are zero user pods scheduled, the cluster may report zero active worker nodes.
When you deploy your first workload (for example, a dummy Nginx deployment), the pod will initially enter a Pending state:
kubectl create deployment nginx-dummy --image=nginx
kubectl get pods
NAME READY STATUS RESTARTS AGE
nginx-dummy-76bf4969df-xxxxx 0/1 Pending 0 12s
- Why does this happen? GKE Autopilot detects the new unscheduled pod, analyzes its resource requirements, and dynamically provisions a new Compute Engine node to host it.
- Duration: This initial provisioning step can take up to minutes. Once the node is online, the pod transitions to
ContainerCreatingand thenRunning. Subsequent pod deployments on existing nodes will happen instantly.
5. Step 2: Containerizing the Microservices (Dockerfiles)
To run our custom code on Kubernetes, we must containerize the Python, Node.js, and .NET Core applications. Below are the optimized, production-ready Dockerfiles for each service.
1. Voting Microservice (Python Flask)
This Dockerfile uses a multi-stage build to keep the final production image lightweight and secure. It copies dependencies first to leverage Docker's layer caching.
# Stage 1: Build dependencies
FROM python:3.11-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt
# Stage 2: Final lightweight runtime
FROM python:3.11-slim AS runner
WORKDIR /app
# Copy installed dependencies from builder stage
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
EXPOSE 80
CMD ["python", "app.py"]
2. Results Microservice (Node.js)
This service uses a lightweight Node.js base image, installs dependencies, copies the source code, and exposes port 80.
FROM node:18-slim
WORKDIR /app
# Install dependencies first to optimize build caching
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
EXPOSE 80
CMD ["node", "server.js"]
3. Worker Microservice (.NET Core)
The background worker does not have a frontend. It compiles the C# code and publishes a self-contained binary.
# Stage 1: SDK Build environment
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /source
COPY *.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app --no-restore
# Stage 2: Runtime environment
FROM mcr.microsoft.com/dotnet/runtime:7.0 AS runner
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "Worker.dll"]
6. Step 3: Local Verification via Docker Compose
Before deploying to a remote Kubernetes cluster, it is a best practice to verify the multi-container application locally using Docker Compose.
1. Clone the Source Code
Clone the repository containing the application source code and configuration files:
git clone https://github.com/dockersamples/example-voting-app.git
cd example-voting-app
2. Run the Application Locally
Start all five services in the background using Docker Compose:
docker compose up -d
Docker Compose will build the local Dockerfiles, pull public images for Redis and PostgreSQL, and establish a shared network between them.
3. Verify Local Ports
The application maps the following ports to your local machine:
- Voting Web App: Port
8080 - Results Web App: Port
8081
You can test the application in your browser or via Cloud Shell's Web Preview feature by routing traffic to these ports. Once verified, tear down the local environment:
docker compose down
7. Step 4: Kubernetes Manifests & Resource Declarations
To deploy these services to GKE, we must define Kubernetes Deployments and Services.
Microservice Manifest Specifications
1. Database Deployment & Service (db-spec.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: db
labels:
app: db
spec:
replicas: 1
selector:
matchLabels:
app: db
template:
metadata:
labels:
app: db
spec:
containers:
- name: postgres
image: postgres:15-alpine
env:
- name: POSTGRES_USER
value: "postgres"
- name: POSTGRES_PASSWORD
value: "postgres"
ports:
- containerPort: 5432
name: postgres
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
cpu: "500m"
memory: "1Gi"
---
apiVersion: v1
kind: Service
metadata:
name: db
labels:
app: db
spec:
ports:
- port: 5432
targetPort: 5432
selector:
app: db
2. Redis Deployment & Service (redis-spec.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
labels:
app: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:alpine
ports:
- containerPort: 6379
name: redis
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "200m"
memory: "512Mi"
---
apiVersion: v1
kind: Service
metadata:
name: redis
labels:
app: redis
spec:
ports:
- port: 6379
targetPort: 6379
selector:
app: redis
3. Worker Deployment (worker-spec.yaml)
Note: The worker service does not require a Kubernetes Service resource because it does not accept incoming network requests. It only initiates outbound connections to Redis and PostgreSQL.
apiVersion: apps/v1
kind: Deployment
metadata:
name: worker
labels:
app: worker
spec:
replicas: 1
selector:
matchLabels:
app: worker
template:
metadata:
labels:
app: worker
spec:
containers:
- name: worker
image: dockersamples/examplevotingapp_worker:latest
resources:
requests:
cpu: "200m"
memory: "512Mi"
limits:
cpu: "400m"
memory: "1Gi"
4. Voting Deployment & Service (vote-spec.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: vote
labels:
app: vote
spec:
replicas: 1
selector:
matchLabels:
app: vote
template:
metadata:
labels:
app: vote
spec:
containers:
- name: vote
image: dockersamples/examplevotingapp_vote:latest
ports:
- containerPort: 80
name: vote
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "200m"
memory: "512Mi"
---
apiVersion: v1
kind: Service
metadata:
name: vote
labels:
app: vote
spec:
ports:
- port: 5000
targetPort: 80
selector:
app: vote
5. Results Deployment & Service (result-spec.yaml)
apiVersion: apps/v1
kind: Deployment
metadata:
name: result
labels:
app: result
spec:
replicas: 1
selector:
matchLabels:
app: result
template:
metadata:
labels:
app: result
spec:
containers:
- name: result
image: dockersamples/examplevotingapp_result:latest
ports:
- containerPort: 80
name: result
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "200m"
memory: "512Mi"
---
apiVersion: v1
kind: Service
metadata:
name: result
labels:
app: result
spec:
ports:
- port: 5001
targetPort: 80
selector:
app: result
Deploying the Manifests
Apply all specifications to your GKE cluster:
kubectl apply -f k8s-specifications/
8. Step 5: Exposing the Application (Port-Forwarding vs. Ingress)
Once deployed, the services run inside the private virtual network of the GKE cluster. To access them, we must choose an exposure strategy.
Why NodePort is Restricted in GKE Autopilot
In a standard Kubernetes cluster, you might use a NodePort service to expose ports directly on the worker nodes' public IP addresses. However, in GKE Autopilot, you cannot modify node-level firewall rules or access the underlying VM instances directly. Therefore, you must use either Port-Forwarding (for testing) or an Ingress Controller (for production).
Option A: Port-Forwarding (For Quick Testing)
To verify the services without exposing them to the public internet, use kubectl port-forward:
# Forward the Voting Service (Local Port 8080 -> Service Port 5000)
kubectl port-forward svc/vote 8080:5000 &
# Forward the Results Service (Local Port 8081 -> Service Port 5001)
kubectl port-forward svc/result 8081:5001 &
You can now access the services securely through localhost:8080 and localhost:8081.
Option B: Production-Grade Ingress (Using Nginx Ingress Controller)
To expose the application to external users under a single public IP address, we will deploy an Nginx Ingress Controller.
1. Install the Nginx Ingress Controller via Helm
Create a dedicated namespace and install the controller:
# Create Namespace
kubectl create namespace ingress-nginx
# Add Helm Repository
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
# Install Ingress Controller
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx
2. Define the Ingress Routing Rules (ingress.yaml)
Create an ingress resource that routes traffic based on the URL path:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: voting-app-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vote
port:
number: 5000
- path: /result
pathType: Prefix
backend:
service:
name: result
port:
number: 5001
Apply the Ingress manifest:
kubectl apply -f ingress.yaml
3. Retrieve the Public IP Address
Query the ingress resource until GKE provisions a Google Cloud Load Balancer and assigns an external IP address:
kubectl get ingress voting-app-ingress --watch
NAME CLASS HOSTS ADDRESS PORTS AGE
voting-app-ingress nginx * 34.118.229.36 80 45s
You can now access the voting application publicly at http://34.118.229.36/ and the results dashboard at http://34.118.229.36/result.
9. Complete End-to-End Workflow Walkthrough
Once the application is fully deployed on GKE, the interaction between the components behaves as follows:
10. Production Pitfalls, Debugging, and Cleanup
1. Autopilot Resource Limit Warnings
If you deploy manifests to GKE Autopilot without defining resources.requests and resources.limits, you will see warnings like this:
Warning: Autopilot modified limits for container...
- The Cause: GKE Autopilot bills you based on your pod resource declarations. If you do not specify them, Autopilot automatically injects default values (typically
500mCPU and2GiMemory) to ensure the pod runs. - The Solution: Always explicitly define CPU and memory requests and limits in your deployment manifests to control costs and ensure proper scheduling.
2. Debugging Container Logs
If votes are not registering on the results page, check the background worker's logs to ensure it is successfully communicating with both Redis and PostgreSQL:
kubectl logs deployment/worker -f
3. Cleaning Up Resources to Avoid Costs
GKE Autopilot charges for active pod allocations. To prevent unexpected charges on your Google Cloud invoice, delete the cluster immediately after completing your testing:
gcloud container clusters delete demo-autopilot-cluster \
--region us-central1 \
--quiet
11. Summary and Key Takeaways
- Microservices Decoupling: Designing applications with intermediary caching layers (like Redis) and background workers (like .NET) prevents database bottlenecks and isolates service failures.
- GKE Autopilot Efficiency: Autopilot removes the operational overhead of managing Kubernetes worker nodes, automatically scaling the underlying infrastructure up or down based on active workloads.
- Dynamic Node Provisioning: In Autopilot, the initial deployment of a pod triggers a "cold start" node provisioning phase that can take up to minutes. Subsequent deployments run instantly.
- Multi-Stage Docker Builds: Using multi-stage builds for languages like Python and .NET Core minimizes production container image sizes and reduces security vulnerabilities.
- Dependency Caching Best Practice: Always copy dependency definition files (e.g.,
requirements.txt,package.json,*.csproj) and install dependencies before copying the remaining application source code to maximize Docker layer reuse. - Ingress Routing: In GKE Autopilot, external traffic is best managed using an Ingress Controller (such as Nginx) combined with a Google Cloud Load Balancer, rather than relying on NodePorts.
- Resource Declarations: Explicitly declaring CPU and memory requests in your manifests is essential in GKE Autopilot to optimize resource allocation and control cloud spend.