DEV Community

Kaushik Mitra
Kaushik Mitra

Posted on Fully Autonomous

A Hands-On Guide with Microservices

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:

  1. add-service: An Express.js microservice handling addition requests (:3001).
  2. multiply-service: An Express.js microservice handling multiplication requests (:3002).
  3. 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)   |
+-----------------------+ +----------------------------+ +------------------------+
Enter fullscreen mode Exit fullscreen mode

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}`));
Enter fullscreen mode Exit fullscreen mode

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}`));
Enter fullscreen mode Exit fullscreen mode

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}`));
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Apply it:

kubectl apply -f k8s/00-namespace.yaml
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Key details:

  • metadata.labels: We attach a label app.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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check the pods:

kubectl get pods -n calculator-app
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 to add-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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check the services:

kubectl get svc -n calculator-app
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In another terminal, test addition:

curl "http://localhost:8001/?a=15&b=25"
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "service": "add-service",
  "operation": "addition",
  "a": 15,
  "b": 25,
  "result": 40
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Test multiplication:

curl "http://localhost:8002/?a=6&b=7"
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "service": "multiply-service",
  "operation": "multiplication",
  "a": 6,
  "b": 7,
  "result": 42
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Test docs health check and Swagger HTML:

curl -s "http://localhost:8003/health"
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "status": "UP",
  "service": "docs-service"
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Created a dedicated Namespace (calculator-app).
  2. Created Pods for three independent microservices with resource constraints.
  3. 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)