When starting out with Kubernetes, terms like Pods, Deployments, Services, and Namespaces get thrown around constantly. Reading definitions in the documentation is one thing, but seeing how they actually connect and route traffic between multiple services is where the concepts truly click.
Today, we're going to build and deploy a practical multi-service application: a Calculator App composed of three independent microservices:
-
add-service: An Express.js microservice handling addition requests (:3001). -
multiply-service: An Express.js microservice handling multiplication requests (:3002). -
docs-service: An Express.js microservice serving interactive Swagger/OpenAPI documentation (:3003).
We will package them with Docker, create Kubernetes manifests, and understand how Pods and ClusterIP Services work together under the hood.
📦 Source Code: You can find the full source code, Dockerfiles, and Kubernetes manifests for this project on GitHub: MitraKumar/kube-prac-calculator-app.
The Application Architecture
Before touching any Kubernetes YAML, let's understand what we are deploying:
[Client Request]
|
+------------------------+------------------------+
| | |
v v v
+-----------------------+ +----------------------------+ +------------------------+
| add-service-cluster-ip| | multiply-service-cluster-ip| | docs-service-cluster-ip|
| Service (Port 3000) | | Service (Port 3000) | | Service (Port 3000) |
+-----------------------+ +----------------------------+ +------------------------+
| | |
v v v
+-----------------------+ +----------------------------+ +------------------------+
| Pod: add-service | | Pod: multiply-service | | Pod: docs-service |
| (:3001) | | (:3002) | | (:3003 - Swagger UI) |
+-----------------------+ +----------------------------+ +------------------------+
Each service is a lightweight Node.js Express server.
1. add-service (add-service/index.js)
Listens on port 3001 and handles addition via query parameters ?a=10&b=20 or JSON body:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3001;
app.use(express.json());
function handleAddition(req, res) {
const a = parseFloat(req.query.a ?? req.body?.a);
const b = parseFloat(req.query.b ?? req.body?.b);
if (isNaN(a) || isNaN(b)) {
return res.status(400).json({
error: 'Please provide valid numbers for "a" and "b" via query params or JSON body.'
});
}
return res.json({
service: 'add-service',
operation: 'addition',
a,
b,
result: a + b
});
}
app.get('/health', (req, res) => res.json({ status: 'UP', service: 'add-service' }));
app.get('/', handleAddition);
app.listen(PORT, () => console.log(`Add Service is running on http://localhost:${PORT}`));
2. multiply-service (multiply-service/index.js)
Identical structure, but performs multiplication and listens on port 3002:
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3002;
app.use(express.json());
function handleMultiplication(req, res) {
const a = parseFloat(req.query.a ?? req.body?.a);
const b = parseFloat(req.query.b ?? req.body?.b);
if (isNaN(a) || isNaN(b)) {
return res.status(400).json({
error: 'Please provide valid numbers for "a" and "b" via query params or JSON body.'
});
}
return res.json({
service: 'multiply-service',
operation: 'multiplication',
a,
b,
result: a * b
});
}
app.get('/health', (req, res) => res.json({ status: 'UP', service: 'multiply-service' }));
app.get('/', handleMultiplication);
app.listen(PORT, () => console.log(`Multiply Service is running on http://localhost:${PORT}`));
3. docs-service (docs-service/index.js)
Listens on port 3003 and serves an interactive Swagger UI documentation at / and OpenAPI specification at /openapi.json:
const express = require('express');
const swaggerUi = require('swagger-ui-express');
const swaggerDocument = require('./swagger.json');
const app = express();
const PORT = process.env.PORT || 3003;
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => res.json({ status: 'UP', service: 'docs-service' }));
// Raw OpenAPI JSON spec
app.get('/openapi.json', (req, res) => res.json(swaggerDocument));
// Swagger UI at root route /
app.use('/', swaggerUi.serve);
app.get('/', swaggerUi.setup(swaggerDocument));
app.listen(PORT, () => console.log(`Docs Service is running on http://localhost:${PORT}`));
4. Dockerizing the Services
All three services use a clean, lightweight Dockerfile based on Node Alpine:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3001 # 3002 for multiply, 3003 for docs
CMD ["node", "index.js"]
Build the local images with:
docker build -t add-service:1.0 ./add-service
docker build -t multiply-service:1.0 ./multiply-service
docker build -t docs-service:1.0 ./docs-service
Diving into Kubernetes Concepts
Now, let's deploy these microservices into our Kubernetes cluster. We organize all our manifests inside a k8s/ folder:
k8s/
├── 00-namespace.yaml
├── 01-add-service-pod.yaml
├── 02-add-service-cluster-ip.yaml
├── 03-multiply-service-pod.yaml
├── 04-multiply-service-cluster-ip.yaml
├── 06-docs-service-pod.yaml
└── 07-docs-service-cluster-ip.yaml
Step 1: Isolating with Namespaces (00-namespace.yaml)
Namespaces help you group related resources logically and prevent naming collisions across teams or environments.
apiVersion: v1
kind: Namespace
metadata:
name: calculator-app
Apply it:
kubectl apply -f k8s/00-namespace.yaml
Step 2: What is a Pod? (01-add-service-pod.yaml)
In Docker, the atomic unit is a container. In Kubernetes, the smallest deployable unit is a Pod.
A Pod is a wrapper around one or more tightly coupled containers that share the same network namespace (IP address and port space) and storage volumes.
Here is our Pod manifest for add-service:
apiVersion: v1
kind: Pod
metadata:
name: add-service
namespace: calculator-app
labels:
app.kubernetes.io/name: add-service
spec:
containers:
- name: add-service
image: add-service:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
memory: "128Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "250m"
ports:
- containerPort: 3001
Key details:
metadata.labels: We attach a labelapp.kubernetes.io/name: add-service. This label acts as a metadata tag that our Service will look for.resources.requests & limits: Specifies the minimum and maximum CPU/RAM the pod can consume.containerPort: 3001: The port on which our Node.js app inside the container listens.
Likewise, 03-multiply-service-pod.yaml defines the multiply Pod (containerPort 3002) and 06-docs-service-pod.yaml defines the Swagger UI docs Pod (containerPort 3003):
apiVersion: v1
kind: Pod
metadata:
name: docs-service
namespace: calculator-app
labels:
app.kubernetes.io/name: docs-service
spec:
containers:
- name: docs-service
image: docs-service:1.0
imagePullPolicy: IfNotPresent
resources:
requests:
memory: "128Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "250m"
ports:
- containerPort: 3003
Apply all three pods:
kubectl apply -f k8s/01-add-service-pod.yaml
kubectl apply -f k8s/03-multiply-service-pod.yaml
kubectl apply -f k8s/06-docs-service-pod.yaml
Check the pods:
kubectl get pods -n calculator-app
Output:
NAME READY STATUS RESTARTS AGE
add-service 1/1 Running 0 25s
docs-service 1/1 Running 0 18s
multiply-service 1/1 Running 0 20s
Step 3: Why Do We Need Services?
Here is the fundamental challenge with Pods in Kubernetes: Pods are ephemeral.
Pods get created, crash, get restarted, or get scheduled onto different nodes. Every time a Pod restarts, it receives a new internal IP address. If multiply-service needs to talk to add-service, it cannot rely on a hardcoded Pod IP.
Enter the Kubernetes Service.
A Service provides a stable virtual IP (ClusterIP) and a stable DNS name that never changes, even if underlying pods die and get replaced.
Let's look at k8s/02-add-service-cluster-ip.yaml:
apiVersion: v1
kind: Service
metadata:
name: add-service-cluster-ip
namespace: calculator-app
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: add-service
ports:
- port: 3000
targetPort: 3001
The Secret Sauce: Labels and Selectors
How does the Service know which Pod to send traffic to?
Look at:
- Service
spec.selector:app.kubernetes.io/name: add-service - Pod
metadata.labels:app.kubernetes.io/name: add-service
The Service continuously queries the Kubernetes API for any pods bearing that exact label and maintains an internal list of endpoints.
Port vs Target Port
This often confuses beginners:
-
port: 3000: The port that the Service itself exposes inside the cluster. Other services will talk toadd-service-cluster-ip:3000. -
targetPort: 3001: The port that the underlying Pod container is actually listening on.
Kubernetes automatically routes incoming requests on port 3000 to port 3001 on the container!
Now let's check k8s/04-multiply-service-cluster-ip.yaml:
apiVersion: v1
kind: Service
metadata:
name: multiply-service-cluster-ip
namespace: calculator-app
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: multiply-service
ports:
- port: 3000
targetPort: 3002
Notice how all three services expose port 3000 to the internal cluster network, even though their underlying containers listen on 3001, 3002, and 3003. That is the elegance of Service abstraction.
Here is k8s/07-docs-service-cluster-ip.yaml:
apiVersion: v1
kind: Service
metadata:
name: docs-service-cluster-ip
namespace: calculator-app
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: docs-service
ports:
- port: 3000
targetPort: 3003
Apply all three services:
kubectl apply -f k8s/02-add-service-cluster-ip.yaml
kubectl apply -f k8s/04-multiply-service-cluster-ip.yaml
kubectl apply -f k8s/07-docs-service-cluster-ip.yaml
Check the services:
kubectl get svc -n calculator-app
Output:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
add-service-cluster-ip ClusterIP 10.96.220.14 <none> 3000/TCP 40s
docs-service-cluster-ip ClusterIP 10.96.180.25 <none> 3000/TCP 15s
multiply-service-cluster-ip ClusterIP 10.96.115.82 <none> 3000/TCP 35s
Step 4: Testing Internal Connectivity
Because ClusterIP services are strictly internal, they do not have an external IP address (EXTERNAL-IP: <none>). They cannot be directly reached from your browser yet.
To verify that our Services are properly routing to our Pods, we can use kubectl port-forward to tunnel traffic from our host machine into each Service:
# In Terminal 1: Forward add-service
kubectl port-forward svc/add-service-cluster-ip 8001:3000 -n calculator-app
In another terminal, test addition:
curl "http://localhost:8001/?a=15&b=25"
Response:
{
"service": "add-service",
"operation": "addition",
"a": 15,
"b": 25,
"result": 40
}
Now let's test the multiplication service:
# In Terminal 2: Forward multiply-service
kubectl port-forward svc/multiply-service-cluster-ip 8002:3000 -n calculator-app
Test multiplication:
curl "http://localhost:8002/?a=6&b=7"
Response:
{
"service": "multiply-service",
"operation": "multiplication",
"a": 6,
"b": 7,
"result": 42
}
And finally, test the documentation service:
# In Terminal 3: Forward docs-service
kubectl port-forward svc/docs-service-cluster-ip 8003:3000 -n calculator-app
Test docs health check and Swagger HTML:
curl -s "http://localhost:8003/health"
Response:
{
"status": "UP",
"service": "docs-service"
}
It works! Each Service successfully accepts traffic on port 3000 and maps it to the respective container ports (3001, 3002, and 3003).
Summary & What's Missing?
Here is what we achieved:
- Created a dedicated Namespace (
calculator-app). - Created Pods for three independent microservices with resource constraints.
- Created ClusterIP Services that decouple network routing from ephemeral pod lifecycles using Labels & Selectors.
The Big Question:
In a production app, we cannot ask users to run kubectl port-forward to access our API, nor do we want to open multiple random ports for each service. We want a single entry point (e.g., http://localhost/api/add and http://localhost/api/multiply) on standard HTTP port 80.
In the next post, we will install and configure the NGINX Ingress Controller in Docker Desktop to achieve intelligent path-based routing.
Have any questions about Labels, Selectors, or ClusterIP? Drop a comment below!
Top comments (0)