Expose Python Apps with ClusterIP

Expose Python apps with a ClusterIP service — Kubernetes for Python Developers.

Focus: expose python apps with a clusterip service

Sponsored

You've built a Python Flask app, containerized it, and deployed it as a Kubernetes Deployment. But now the real question hits: can other Pods inside your cluster talk to your app? By default, each Pod gets its own internal IP, but those IPs are ephemeral — they change on restarts and scale events. Exposing Python apps with a ClusterIP service solves this by giving your app a stable virtual IP and DNS name inside the cluster. Without it, your microservices would have to chase moving IPs, making any form of service-to-service communication fragile and brittle. In this lesson, you'll learn to create a ClusterIP service, test it from within the cluster, and understand exactly when this internal networking pattern is the right choice.

The problem this lesson solves

Think about the last time you called a Python API from another Python service. You probably used requests.get("http://my-service:5000/health"). The URL contains a hostname — my-service — that you expect to resolve to a stable IP. Kubernetes makes that promise possible with a Service. But why is it a problem without one?

When you create a Deployment, Kubernetes launches Pods. Each Pod receives an internal IP address from the cluster's network range. These IPs are ephemeral: they change whenever the Pod is recreated (after a crash, a rollout, or a scaling event). If your other components hardcode these IPs, they'll break as soon as anything restarts.

Here's another wrinkle: your Deployment might have multiple replicas. Which Pod should the caller hit? Without a Service, you'd have to pick one of several IPs, and you'd have no load balancing. The result is a mess of brittle configuration and manual IP management.

So the pain is real: unstable internal addressing and no built-in load balancing. Both are solved cleanly by creating a Kubernetes Service of type ClusterIP.

By the end of this lesson, you'll be able to: - Explain how a ClusterIP service provides a stable network endpoint. - Create a Service object for your Python app using kubectl and YAML. - Test the service from inside a Pod using curl. - Choose between ClusterIP, NodePort, and LoadBalancer based on your use case.

Core concept / mental model

A ClusterIP service is a stable virtual IP and DNS name inside your Kubernetes cluster. It acts as a front door to your Pods, receiving traffic and forwarding it to one of the selected Pods. The name is a bit misleading: the IP is not assigned to a specific Pod — it's a virtual IP managed by the cluster's control plane.

Here's the mental model: imagine your Pods are apartment units in a building. Each unit has an internal room number (Pod IP). The ClusterIP service is the building's front desk. Outsiders (other services) know the building's address and phone number (the service's cluster IP and DNS name). They call, and the front desk directs the call to an available unit. If a tenant moves out (Pod restarts), the front desk still knows the building's address — no one outside needs to know the individual room numbers.

Conceptually, a Service object has three critical parts:

  1. Selector: a set of labels that match your Pods (e.g., app: my-python-app). The service only sends traffic to Pods that have those labels.
  2. Ports: the mapping between the service's listening port and the target port on the Pod. For example, your Flask app might listen on port 5000 inside the container.
  3. Type: ClusterIP is the default; it exposes the service only inside the cluster network.

Kubernetes automatically creates a DNS record for the service using the format <service-name>.<namespace>.svc.cluster.local. Within the same namespace, you can shorten that to just <service-name>. So a Pod can call http://my-flask-svc:5000 without any configuration.

Key definitions

  • Endpoint: an IP:port pair that the service routes to. These are updated automatically when Pods change.
  • kube-proxy: the component on each node that implements the virtual IP forwarding rules.
  • Selector: the label query that links the service to its backing Pods.

How it works step by step

When you create a Service, here's what happens under the hood (simplified but accurate):

  1. You apply a Service manifest — you define apiVersion: v1, kind: Service, metadata, and a spec block with selector, ports, and type: ClusterIP.
  2. Control plane assigns a virtual IP — Kubernetes picks an IP from your cluster's service CIDR range (e.g., 10.96.0.0/12). This IP is stable for the life of the Service.
  3. Endpoints are created — the control plane watches Pods that match your selector and populates an Endpoints object (or EndpointSlices in newer versions) with the current Pod IPs.
  4. kube-proxy installs rules — on every node, kube-proxy programs iptables (or IPVS) rules that capture traffic to the service IP and forward it to one of the endpoints.
  5. Load balancing happens automatically — Kubernetes ships with a default round-robin strategy (the actual algorithm depends on kube-proxy mode).
  6. DNS is registered — CoreDNS creates a record for the service name, so DNS queries resolve to the virtual IP.

This all happens within seconds and is fully automated — you don't need to manage IPs or proxies yourself.

Why not just use Pod IPs?

If you're skeptical, here's a quick comparison: - Pod IPs: tied to a Pod lifecycle — a Pod restart changes IP. - ClusterIP: stable across Pod restarts, updates, and scaling events. - Pod IPs: not automatically load balanced. - ClusterIP: balances across all healthy Pods matching the selector. - Pod IPs: not reachable via DNS by default. - ClusterIP: has a built-in DNS name.

That's why Services are fundamental to Kubernetes networking: they abstract away the volatility of Pods.

Hands-on walkthrough

Time to get your hands dirty. In this section, you'll deploy a minimal Flask app, expose it with a ClusterIP service, and then verify it works from a test Pod. We'll use a simple HTTP endpoint that returns JSON.

Step 1: Create a Python app

Create a file named app.py:

from flask import Flask, jsonify

app = Flask(__name__)

@app.route("/")
def home():
    return jsonify({"message": "Hello from Python in Kubernetes!"})

@app.route("/health")
def health():
    return jsonify({"status": "ok"})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

Step 2: Containerize the app

Add a Dockerfile:

FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]

And a requirements.txt:

flask==2.3.3

Build and push the image:

# Build the image (replace your-dockerhub with your Docker Hub username)
docker build -t your-dockerhub/my-python-api:1.0 .
docker push your-dockerhub/my-python-api:1.0

Note: If you're using Minikube or a local cluster, you can skip the push and load the image directly into the cluster with minikube image load my-python-api:1.0.

Step 3: Deploy the app

Create deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-python-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: my-python-api
  template:
    metadata:
      labels:
        app: my-python-api
    spec:
      containers:
      - name: api
        image: your-dockerhub/my-python-api:1.0
        ports:
        - containerPort: 5000

Apply it:

kubectl apply -f deployment.yaml

Your app is now running with two replicas.

Step 4: Create the ClusterIP service

Create service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: my-python-api-svc
spec:
  selector:
    app: my-python-api
  ports:
  - port: 80
    targetPort: 5000
  type: ClusterIP

Here's the breakdown: - selector matches Pods with app: my-python-api. - port: 80 is the port that other services will use to reach the service. - targetPort: 5000 is the port the container listens on. - type: ClusterIP is the default, but we're being explicit.

Apply it:

kubectl apply -f service.yaml

Step 5: Verify the service

Get the service details:

kubectl get svc my-python-api-svc

Output:

NAME                TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
my-python-api-svc   ClusterIP   10.96.123.45    <none>        80/TCP    10s

You'll see a cluster IP like 10.96.123.45 and no external IP. Now, to test the service from inside the cluster, run a temporary curl Pod:

kubectl run curlpod --image=curlimages/curl -it --rm -- sh

Inside that shell, hit the service:

curl http://my-python-api-svc:80/health

Expected output:

{"status":"ok"}

Type exit to leave the pod. That's it — your Python app is now exposed to other components inside the cluster under a stable DNS name.

Bonus: Check the Endpoints

kubectl get endpoints my-python-api-svc

You'll see both Pod IPs listed, proving the service is load balancing across replicas.

Compare options / when to choose what

You now have a solid grasp of ClusterIP, but Kubernetes offers three main Service types. Here's a quick comparison to help you choose correctly:

Service Type Accessibility Use Case Typical Python Example
ClusterIP Only inside the cluster Internal service-to-service communication A Flask backend calling a Django or FastAPI microservice
NodePort On each node's IP via a high port (e.g., 30000+) Quick external access for development or debugging Exposing your API on http://<node-ip>:30080 for a quick test
LoadBalancer On a cloud load balancer with a public IP Production internet-facing services that need a stable public IP Serving a user-facing API behind a cloud LB (like AWS ELB)

When to choose ClusterIP

  • When your Python app is meant to be consumed only by other services in the cluster — e.g., a backend API called by a frontend service.
  • When you want to load balance across multiple replicas without exposing any public IP.
  • When you're inside a microservices architecture and want a clean, internal dependency graph.

Alternatives and variations

  • Headless Service (setting clusterIP: None) if you need the DNS to return Pod IPs directly — handy for stateful apps like a database cluster.
  • ExternalName Service to point to an external hostname — useful when you intentionally want to proxy to an outside service.
  • Network Policies (not a Service type, but related) — if you want to restrict which Pods can even talk to your ClusterIP service, combine it with Kubernetes NetworkPolicies.

Pro tip: Don't use a NodePort or LoadBalancer for internal services — it's a security risk and violates least-privilege. Stick with ClusterIP unless you have a clear need.

Troubleshooting & edge cases

Even with such a simple concept, things can go wrong. Here are the most common issues and how to fix them:

1. Service has no endpoints

If kubectl get endpoints my-svc shows <none>, it means your selector doesn't match any Pods.

kubectl get svc my-svc
kubectl get pods --show-labels

Compare the labels of your Pods with the selector in your Service YAML. Fix the selector and re-apply.

2. curl times out from inside a Pod

This often happens because the target port is wrong. Check that targetPort matches the port your container actually listens on (e.g., 5000 for Flask). A common mistake is pointing targetPort to 80 when your Flask app runs on 5000.

3. DNS name doesn't resolve

If you can't reach http://my-svc:80, verify that CoreDNS is running:

kubectl get pods -n kube-system -l k8s-app=kube-dns

If it's not, check your cluster's DNS add-on (e.g., on Minikube, minikube addons enable coredns).

4. Your app is unreachable because you're using a NodePort already

If you see NodePort as the type and you can't access it, check the port range (default 30000-32767). You may also need to use the node's IP, not localhost.

5. Load balancing seems uneven

Kubernetes uses round-robin by default with kube-proxy in iptables mode. If you have sticky sessions enabled, you may see skewed distribution. For most cases, this is fine. If you need true load distribution metrics, consider istio or linkerd.

Pro tip: Always check kubectl describe svc my-svc for events — it will often tell you exactly what's wrong (e.g., "no endpoints").

What you learned & what's next

Awesome — let's recap what you've mastered in this lesson:

  • You can now explain the core idea behind a ClusterIP service: it's a stable, virtual network endpoint that load balances and provides DNS for internal access to your Python app.
  • You completed a practical exercise: you deployed a Flask app, exposed it with a ClusterIP service, verified endpoints, and tested it using curl from a temporary pod.
  • You understand when to choose ClusterIP vs. NodePort vs. LoadBalancer, based on accessibility and use case.
  • You've practiced troubleshooting common issues like mismatched selectors, wrong target ports, and DNS resolution problems.

Now that your app is internally accessible, the next logical step is to expose it to the outside world. You're ready to move on to learning about NodePort services — how to reach your Python app from outside the cluster using a node's IP and a high port, and why you'd only use that for quick testing. After that, you'll dive into Ingress controllers as the production-grade way to route both internal and external traffic to your services.

Keep building — every Kubernetes abstraction you master brings you closer to running Python microservices confidently in production.

Practice recap

Test your understanding by creating a second Python service (e.g., a FastAPI app) with two replicas and expose it with a ClusterIP service. Then, from a curl pod, call the /health endpoint of both services using their DNS names. Finally, deliberately break the selector and watch the endpoints disappear, then fix it — this reinforces the troubleshooting skills you just learned.

Common mistakes

  • Typo in the selector: a single wrong label key/value silently means zero endpoints — always run kubectl get endpoints to confirm.
  • Setting targetPort to the service port instead of the container's actual listening port (e.g., 5000 for Flask) causes connection refused.
  • Forgetting that ClusterIP is only reachable inside the cluster — trying to curl it from your laptop will time out; use kubectl port-forward or a NodePort for external access.
  • Not specifying type: ClusterIP explicitly and assuming a NodePort was created — the default is ClusterIP, which is often what you want, but be aware.
  • Scaling to zero replicas: a ClusterIP service with no backing Pods returns connection errors — keep at least one replica for availability.

Variations

  1. Use a headless service (clusterIP: None) for stateful workloads where DNS should return Pod IPs directly — ideal for a Cassandra or Kafka cluster.
  2. Use an ExternalName service to alias an external hostname within your cluster — handy for migrating off external dependencies later.
  3. Pair your ClusterIP service with a NetworkPolicy to restrict which Pods can talk to it, enforcing zero-trust networking inside the cluster.

Real-world use cases

  • A Python FastAPI backend serves as an internal API for a Django frontend running in the same cluster — both talk over a ClusterIP service.
  • A Python Celery worker needs to reach a Redis cache and a database — both exposed as ClusterIP services for stable internal endpoints.
  • A machine learning service (e.g., a Flask model server) is called only by other Python microservices inside the cluster, never directly from outside.

Key takeaways

  • ClusterIP is the default and most common Service type — it provides a stable virtual IP and DNS name for internal traffic.
  • The service's selector must match the Pod labels exactly; otherwise no endpoints are created.
  • The port field is the service's listening port; targetPort must match the container's actual application port.
  • Always test a ClusterIP service from inside the cluster using a temporary pod with kubectl run.
  • Choose NodePort or LoadBalancer only when external access is needed; ClusterIP keeps your services secure and internal.
  • Troubleshoot systematically: check endpoints, service description, and CoreDNS logs in that order.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.