Kubernetes Multi-Tier Application
Introduction
A multi-tier application divides functionality into separate layers (tiers), each with its own responsibilities. In traditional application development, you might have heard of the three-tier architecture: presentation layer (UI), application layer (business logic), and data layer (database).
Kubernetes excels at orchestrating these complex, multi-component applications. By leveraging Kubernetes' ability to manage containerized workloads, you can deploy, scale, and maintain each tier independently while ensuring they work together seamlessly.
In this tutorial, we'll build a practical multi-tier application on Kubernetes consisting of:
- A frontend web application
- A backend API service
- A database for persistence
Why Use Multi-Tier Architecture in Kubernetes?
Multi-tier applications in Kubernetes offer several advantages:
- Scalability: Scale each tier independently based on its resource needs
- Maintainability: Update components without affecting the entire application
- Resilience: Isolate failures to prevent system-wide outages
- Resource Optimization: Allocate resources according to each tier's requirements
Architecture Overview
Here's a diagram showing the architecture of our multi-tier application:
Prerequisites
Before we start, make sure you have:
- A running Kubernetes cluster (local like Minikube or remote)
kubectl
CLI tool installed and configured- Basic understanding of Kubernetes objects (Pods, Services, Deployments)
- Docker installed (for building container images)
Part 1: Setting Up the Database Tier
Let's start with the database tier. We'll use MongoDB as our database and deploy it using a StatefulSet for data persistence.
Step 1: Create a Persistent Volume Claim
First, we need to create storage for our database:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mongo-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
Save this as mongo-pvc.yaml
and apply it:
kubectl apply -f mongo-pvc.yaml
Step 2: Create a ConfigMap for MongoDB Configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: mongo-config
data:
mongo.conf: |
storage:
dbPath: /data/db
Save as mongo-config.yaml
and apply:
kubectl apply -f mongo-config.yaml
Step 3: Deploy MongoDB using a StatefulSet
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mongo
spec:
serviceName: "mongo"
replicas: 1
selector:
matchLabels:
app: mongo
template:
metadata:
labels:
app: mongo
spec:
containers:
- name: mongo
image: mongo:5.0
ports:
- containerPort: 27017
name: mongo
volumeMounts:
- name: mongo-data
mountPath: /data/db
- name: mongo-config
mountPath: /config
env:
- name: MONGO_INITDB_ROOT_USERNAME
valueFrom:
secretKeyRef:
name: mongo-secret
key: username
- name: MONGO_INITDB_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mongo-secret
key: password
volumes:
- name: mongo-config
configMap:
name: mongo-config
volumeClaimTemplates:
- metadata:
name: mongo-data
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
Step 4: Create a Secret for MongoDB Credentials
apiVersion: v1
kind: Secret
metadata:
name: mongo-secret
type: Opaque
data:
username: YWRtaW4= # 'admin' in base64
password: cGFzc3dvcmQxMjM= # 'password123' in base64
Save as mongo-secret.yaml
and apply:
kubectl apply -f mongo-secret.yaml
Note: In a production environment, never commit secrets to version control. Use a secret management solution like Kubernetes Secrets, HashiCorp Vault, or cloud provider secret managers.
Step 5: Create a Service for MongoDB
apiVersion: v1
kind: Service
metadata:
name: mongo
spec:
selector:
app: mongo
ports:
- port: 27017
targetPort: 27017
clusterIP: None # Headless service for StatefulSet
Save as mongo-service.yaml
and apply:
kubectl apply -f mongo-service.yaml
Part 2: Creating the Backend API Tier
Now let's create our backend API service using Node.js. This service will handle business logic and database interactions.
Step 1: Create a Simple Node.js Backend
Here's a simple Express.js application that connects to MongoDB and provides API endpoints:
// app.js
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const app = express();
const port = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// MongoDB connection
const mongoURI = `mongodb://${process.env.MONGO_USERNAME}:${process.env.MONGO_PASSWORD}@${process.env.MONGO_HOST}:${process.env.MONGO_PORT}/taskdb?authSource=admin`;
mongoose.connect(mongoURI)
.then(() => console.log('Connected to MongoDB'))
.catch(err => console.error('MongoDB connection error:', err));
// Define Task schema
const taskSchema = new mongoose.Schema({
title: String,
description: String,
completed: Boolean,
createdAt: { type: Date, default: Date.now }
});
const Task = mongoose.model('Task', taskSchema);
// API Routes
app.get('/api/tasks', async (req, res) => {
try {
const tasks = await Task.find();
res.json(tasks);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.post('/api/tasks', async (req, res) => {
try {
const task = new Task(req.body);
await task.save();
res.status(201).json(task);
} catch (err) {
res.status(400).json({ error: err.message });
}
});
app.get('/api/health', (req, res) => {
res.status(200).json({ status: 'ok' });
});
// Start server
app.listen(port, () => {
console.log(`Backend API running on port ${port}`);
});
Step 2: Create a Dockerfile for the Backend
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "app.js"]
Step 3: Build and Push the Backend Image
docker build -t your-registry/task-backend:v1 .
docker push your-registry/task-backend:v1
Step 4: Create a Deployment for the Backend
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: your-registry/task-backend:v1
ports:
- containerPort: 3000
env:
- name: MONGO_USERNAME
valueFrom:
secretKeyRef:
name: mongo-secret
key: username
- name: MONGO_PASSWORD
valueFrom:
secretKeyRef:
name: mongo-secret
key: password
- name: MONGO_HOST
value: "mongo"
- name: MONGO_PORT
value: "27017"
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
limits:
cpu: "0.5"
memory: "512Mi"
requests:
cpu: "0.2"
memory: "256Mi"
Save as backend-deployment.yaml
and apply:
kubectl apply -f backend-deployment.yaml
Step 5: Create a Service for the Backend
apiVersion: v1
kind: Service
metadata:
name: backend
spec:
selector:
app: backend
ports:
- port: 80
targetPort: 3000
type: ClusterIP
Save as backend-service.yaml
and apply:
kubectl apply -f backend-service.yaml
Part 3: Building the Frontend Tier
Finally, let's create a React frontend that connects to our backend API.
Step 1: Create a Simple React Frontend
Here's a basic React application that interacts with our backend:
// src/App.js
import React, { useState, useEffect } from 'react';
import './App.css';
function App() {
const [tasks, setTasks] = useState([]);
const [newTask, setNewTask] = useState({ title: '', description: '' });
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:80';
useEffect(() => {
fetchTasks();
}, []);
const fetchTasks = async () => {
try {
const response = await fetch(`${API_URL}/api/tasks`);
const data = await response.json();
setTasks(data);
} catch (error) {
console.error('Error fetching tasks:', error);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await fetch(`${API_URL}/api/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newTask),
});
setNewTask({ title: '', description: '' });
fetchTasks();
} catch (error) {
console.error('Error creating task:', error);
}
};
return (
<div className="App">
<header className="App-header">
<h1>Task Manager</h1>
</header>
<main>
<form onSubmit={handleSubmit}>
<h2>Add New Task</h2>
<div>
<input
type="text"
placeholder="Task title"
value={newTask.title}
onChange={(e) => setNewTask({ ...newTask, title: e.target.value })}
required
/>
</div>
<div>
<textarea
placeholder="Task description"
value={newTask.description}
onChange={(e) => setNewTask({ ...newTask, description: e.target.value })}
/>
</div>
<button type="submit">Add Task</button>
</form>
<div className="tasks">
<h2>Task List</h2>
{tasks.length === 0 ? (
<p>No tasks found</p>
) : (
<ul>
{tasks.map((task) => (
<li key={task._id}>
<h3>{task.title}</h3>
<p>{task.description}</p>
<small>Created: {new Date(task.createdAt).toLocaleString()}</small>
</li>
))}
</ul>
)}
</div>
</main>
</div>
);
}
export default App;
Step 2: Create a Dockerfile for the Frontend
FROM node:16-alpine as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Step 3: Create an nginx.conf for the Frontend
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
Step 4: Build and Push the Frontend Image
docker build -t your-registry/task-frontend:v1 .
docker push your-registry/task-frontend:v1
Step 5: Create a ConfigMap for Environment Variables
apiVersion: v1
kind: ConfigMap
metadata:
name: frontend-config
data:
REACT_APP_API_URL: "http://backend"
Save as frontend-config.yaml
and apply:
kubectl apply -f frontend-config.yaml
Step 6: Create a Deployment for the Frontend
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
spec:
replicas: 2
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: your-registry/task-frontend:v1
ports:
- containerPort: 80
envFrom:
- configMapRef:
name: frontend-config
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 5
resources:
limits:
cpu: "0.3"
memory: "256Mi"
requests:
cpu: "0.1"
memory: "128Mi"
Save as frontend-deployment.yaml
and apply:
kubectl apply -f frontend-deployment.yaml
Step 7: Create a Service for the Frontend
apiVersion: v1
kind: Service
metadata:
name: frontend
spec:
selector:
app: frontend
ports:
- port: 80
targetPort: 80
type: LoadBalancer # Use NodePort for local development
Save as frontend-service.yaml
and apply:
kubectl apply -f frontend-service.yaml
Part 4: Creating an Ingress for External Access
If your cluster has an Ingress controller, you can set up an Ingress resource to manage external access:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: task-app-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: taskapp.example.com # Replace with your domain
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend
port:
number: 80
- path: /api
pathType: Prefix
backend:
service:
name: backend
port:
number: 80
Save as ingress.yaml
and apply:
kubectl apply -f ingress.yaml
Verification and Monitoring
Let's verify our application is running correctly:
Check Pod Status
kubectl get pods
Example output:
NAME READY STATUS RESTARTS AGE
mongo-0 1/1 Running 0 15m
backend-7c7b59f5c9-2hzxw 1/1 Running 0 10m
backend-7c7b59f5c9-p8t6k 1/1 Running 0 10m
frontend-6d9f8c8d77-xg5tq 1/1 Running 0 5m
frontend-6d9f8c8d77-zk7v2 1/1 Running 0 5m
Check Services
kubectl get services
Example output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 1h
mongo ClusterIP None <none> 27017/TCP 15m
backend ClusterIP 10.96.45.12 <none> 80/TCP 10m
frontend LoadBalancer 10.96.101.56 192.168.49.2 80:30001/TCP 5m
Check Logs
kubectl logs deployment/backend
kubectl logs deployment/frontend
Best Practices for Multi-Tier Applications
When designing multi-tier applications in Kubernetes, consider these best practices:
- Use Namespaces: Organize your application tiers into namespaces for better resource management.
kubectl create namespace taskapp
kubectl config set-context --current --namespace=taskapp
-
Resource Limits: Always define resource requests and limits for your containers.
-
Health Checks: Implement readiness and liveness probes for all containers.
-
Horizontal Pod Autoscaler (HPA): Set up autoscaling for dynamic workloads:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: backend-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: backend
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
-
ConfigMaps and Secrets: Externalize configuration and sensitive data.
-
Network Policies: Restrict network traffic between tiers for security:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-network-policy
spec:
podSelector:
matchLabels:
app: backend
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 3000
-
Persistent Storage: Use StatefulSets with PVCs for databases and stateful components.
-
Service Mesh: Consider a service mesh like Istio for complex microservice architectures.
Troubleshooting Common Issues
Connectivity Issues Between Tiers
If services can't communicate:
- Check service DNS resolution:
kubectl exec -it <pod-name> -- nslookup backend
- Verify network policies aren't blocking traffic:
kubectl describe networkpolicy
- Check for service endpoint issues:
kubectl get endpoints
Database Connection Problems
If the backend can't connect to MongoDB:
- Verify MongoDB is running:
kubectl exec -it mongo-0 -- mongo --eval "db.adminCommand('ping')"
- Check backend logs for connection errors:
kubectl logs deployment/backend | grep MongoDB
- Ensure secrets are correctly mounted:
kubectl describe pod <backend-pod-name> | grep Environment
Summary
In this tutorial, we've learned how to:
- Design and implement a multi-tier application architecture in Kubernetes
- Deploy a persistent database using StatefulSets
- Create a scalable backend API service using Deployments
- Build a frontend tier that communicates with the backend
- Configure networking between tiers with Services and Ingress
- Apply best practices for production-ready applications
By separating our application into distinct tiers, we've created a more maintainable, scalable, and resilient system. Each component can be updated, scaled, and managed independently while working together as a cohesive application.
Exercises
- Add a Redis cache tier between the backend and database to improve performance.
- Implement CI/CD pipelines to automate the deployment of each tier.
- Set up monitoring with Prometheus and Grafana to track the health and performance of each tier.
- Add HTTPS support to the Ingress controller using cert-manager.
- Create development, staging, and production namespaces with different resource allocations.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)