Module 2: Pods Deployments And Services

7. Hands-on: exposing `status-api-service`

Description

This is the lesson where, for the first time in this entire guide, you talk to andes-cargo-status-api through an address that doesn't depend on any specific Pod. You're going to declare status-api-service — the same name, literally, aws-serverless-and-containers-guide left documented on ECS and never ran — confirm it finds your two Pods, and really curl it with kubectl port-forward. You're also going to run into, honestly and documented, this point in the guide's real limit: /health works perfectly, but /shipments/<id> doesn't have any data to connect to yet — and this lesson explains exactly why, without faking a result that doesn't exist yet.

Connection to the module

This lesson confirms, with real evidence, everything lesson 6 explained in theory. It's also the first time you see, in real production within this guide, the distinction between "the Pod runs" and "the service is complete" — a distinction that becomes central in Module 3, when ConfigMap/Secret give andes-cargo-status-api the configuration it's missing, and that stays open for the rest of this guide: the data layer (DynamoDB, via LocalStack) stays outside its $0 scope, so /shipments/<id> remains a representative path, not an executed one.


Step 1 — The Service: service.yaml

# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: status-api-service
  namespace: andes-cargo
spec:
  type: ClusterIP
  selector:
    app: andes-cargo-status-api
  ports:
    - port: 80
      targetPort: 8080
      protocol: TCP

Three decisions, each explained in depth in lesson 6, now applied to the real case: type: ClusterIP (internal to the cluster, the type this guide uses consistently); selector: app: andes-cargo-status-api (exactly the same label you declared in deployment.yaml, lesson 5 — without this exact match, the Service wouldn't find any Pod); and port: 80 with targetPort: 8080 (the Service listens on the standard HTTP port, while the real container — inherited from aws-serverless-and-containers-guide's Dockerfile — keeps listening on 8080, with no change).

kubectl apply -f service.yaml

What to expect:

service/status-api-service created

Step 2 — Verify: the Service and its Endpoints

kubectl get service -n andes-cargo

What to expect (CLUSTER-IP is your variable value — assigned by Kubernetes from an internal range every time the Service is created; the rest is literal):

NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
status-api-service   ClusterIP   10.96.78.1   <none>        80/TCP    0s

Now confirm the piece lesson 6 named but hadn't shown yet: the Endpoints list Kubernetes automatically maintains, with the real IPs of the Pods matching the selector:

kubectl get endpoints -n andes-cargo

What to expect (the two listed IPs are your variable values — they correspond to the two Pods lesson 5 left; port 8080 is literal, it matches targetPort):

Warning: v1 Endpoints is deprecated in v1.33+; use discovery.k8s.io/v1 EndpointSlice
NAME                 ENDPOINTS                         AGE
status-api-service   10.244.1.3:8080,10.244.2.4:8080   0s

The warning about v1 Endpoints is informational, not an error: it's Kubernetes flagging that the classic Endpoints object is being replaced by EndpointSlice, a more scalable version of the same concept — for what this guide needs, kubectl get endpoints still works perfectly and is simpler to read at a glance. Two IPs, two 8080 ports — exactly the two Pods you confirmed in lesson 5. This is direct proof that the Service's selector really is finding your Pods; if this list came back empty, you'd know immediately there's a label-matching problem, the common mistake lesson 6 already anticipated.


Step 3 — port-forward: a temporary tunnel to the Service

kubectl port-forward opens a tunnel between a port on your machine and an object inside the cluster — in this case, the Service, not a specific Pod — with no need to expose anything permanently. It's the right tool to quickly test something during development; Module 4 is going to replace this need with a real, permanently exposed Ingress.

kubectl port-forward svc/status-api-service -n andes-cargo 8080:80

What to expect (literal — the command stays running in the foreground, not returning terminal control; leave it as is and open a new terminal for the next steps, or run it in the background with & if you prefer a single terminal):

Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080

Notice the command's syntax: 8080:80 means "port 8080 on my machine, to port 80 on the Service" — the same port you declared in service.yaml, not the container's targetPort. kubectl port-forward talks to the Service, and it's the Service that decides, underneath, which of its Pods to forward the connection to.


Step 4 — Verify: /health really responds

With port-forward running, open a second terminal (or use the & flag to leave it in the background) and confirm the first endpoint:

curl -i -s http://localhost:8080/health

What to expect (literal, executed — Date is your variable value):

HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.13.15
Date: Fri, 14 Aug 2026 19:34:54 GMT
Content-Type: application/json
Content-Length: 51
Connection: close

{"service":"andes-cargo-status-api","status":"ok"}

200 OK, the exact same body you already saw run with docker run in aws-serverless-and-containers-guide, now being served from inside a Kubernetes Pod, through a Service, through a port-forward — four layers of indirection, zero changes in the result. This confirms something important: the Pod started correctly, the Flask process is healthy, and the complete network path (your machine → kube-apiserverkubelet → Pod) works end to end.


Step 5 — This point in the guide's honest limit: /shipments/<id>

Here's where this lesson stops to explain something with all the honesty this guide has promised since its design. Try the second endpoint, the one that queries the Shipments table:

curl -i -s http://localhost:8080/shipments/4471

What to expect (literal, executed — this result is not an error in your lab, it's the correct, expected behavior at this exact point in the guide):

HTTP/1.1 500 INTERNAL SERVER ERROR
Server: Werkzeug/3.1.8 Python/3.13.15
Date: Fri, 14 Aug 2026 19:34:54 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 265
Connection: close

<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>

500, with no detail in the HTTP response — Flask's standard behavior outside debug mode, exactly as aws-serverless-and-containers-guide warned about the development server. To understand the real cause, without guessing, check the logs of the Pod that handled the request:

kubectl get pods -n andes-cargo -l app=andes-cargo-status-api

What to expect (variable names — use yours):

NAME                                      READY   STATUS    RESTARTS   AGE
andes-cargo-status-api-56856576d4-7ztk9   1/1     Running   0          81s
andes-cargo-status-api-56856576d4-vjc9k   1/1     Running   0          94s
kubectl logs andes-cargo-status-api-56856576d4-vjc9k -n andes-cargo --tail=40

What to expect (literal — the exact Pod name is your variable value, but the full traceback, if your Pod is the one that handled the request, is identical):

127.0.0.1 - - [14/Aug/2026 19:34:54] "GET /health HTTP/1.1" 200 -
[2026-08-14 19:34:54,789] ERROR in app: Exception on /shipments/4471 [GET]
Traceback (most recent call last):
  File "/usr/local/lib/python3.13/site-packages/flask/app.py", line 1511, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.13/site-packages/flask/app.py", line 902, in dispatch_request
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
  File "/app/app.py", line 27, in get_shipment
    response = dynamodb.get_item(
        TableName=TABLE_NAME,
        Key={"shipmentId": {"S": shipment_id}},
    )
  File "/usr/local/lib/python3.13/site-packages/botocore/client.py", line 569, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/usr/local/lib/python3.13/site-packages/botocore/auth.py", line 423, in add_auth
    raise NoCredentialsError()
botocore.exceptions.NoCredentialsError: Unable to locate credentials
127.0.0.1 - - [14/Aug/2026 19:34:54] "GET /shipments/4471 HTTP/1.1" 500 -

The exact cause, no detours: app.py — the same code inherited, with no changes, from aws-serverless-and-containers-guide, Module 6 — creates its boto3 client by reading DYNAMODB_ENDPOINT_URL, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY from environment variables. The Deployment you declared in lesson 5 of this module defines none of those variables — on purpose, because configuring them wasn't this module's topic yet. Without AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, boto3 doesn't even try to reach the network: it fails earlier, at the moment of signing the request, with NoCredentialsError. And even if it had them, there's no LocalStack running inside andes-cargo-cluster for those credentials to connect to, nor is there going to be in any later module of this guide — the data layer (DynamoDB) stays outside this Kubernetes lab's $0 scope; /shipments/<id> is documented as the representative path a real backing store would complete, not as something this guide is going to install.

This isn't a failure in this lesson — it's exactly where this guide stands today, and it stays that way through the capstone. The Pod runs. The Flask application started with no errors (docker logs/kubectl logs confirm it). The /health endpoint, which doesn't depend on any external data, responds perfectly. All that's missing is the data layer: neither the configuration (ConfigMap/Secret, Module 3) nor a real DynamoDB backend exist in this lab — and they're not going to exist, because installing LocalStack (or connecting a real AWS account) is outside this Kubernetes guide's $0 scope. Note this result — NoCredentialsError, 500 — because Module 3 is going to partially resolve it (adding the configuration, though there still won't be any backend to connect to), and because this same condition stays, documented, through Module 8.

              WHAT WORKS TODAY, AND WHAT STAYS THAT WAY (M2's honesty)

  curl /health            ──▶  200 OK   (doesn't depend on any external data)
  curl /shipments/4471    ──▶  500      (depends on DynamoDB, which doesn't
                                          exist in the cluster and stays
                                          outside this guide's $0 scope)

  What does arrive, and when:
    - ConfigMap/Secret with the endpoint and credentials  ──▶  Module 3

  What stays representative (not executed in this guide):
    - Real DynamoDB / LocalStack backend in the cluster

Stop the port-forward

Before closing this lesson, stop the tunnel with Ctrl+C in the terminal where you left it running (or kill the process, if you put it in the background). The Service and Pods keep running with no change — port-forward is only a temporary tunnel from your machine, not part of the cluster's state.


Common mistakes

Interpreting /shipments/<id>'s 500 as a lab problem, and trying to "fix" it before its time (expectation, the most important mistake to prevent in this specific lesson). What happens: someone sees the 500 and starts looking for what's wrong in deployment.yaml or the Service, not realizing the behavior is exactly what's expected at this exact point in the guide. Why it happens: a 5xx error code almost always means "something's broken and needs fixing" — a correct reflex in most contexts, but not this one, where this same lesson documents it as expected. How to spot it: if you try adding AWS environment variables to lesson 5's Deployment on your own, before reaching Module 3. How to fix it: there's nothing to fix at this point — this lesson's "This point in the guide's honest limit" section explains the exact cause (NoCredentialsError) and which modules resolve each part of the problem. Configuring the full connection now would get ahead of a topic Module 3 (ConfigMap/Secret) teaches with the depth it deserves.

Confusing port-forward's port (8080:80) with the container's targetPort, and using the wrong number on the left side (configuration). What happens: someone writes kubectl port-forward svc/status-api-service -n andes-cargo 8080:8080, instead of 8080:80, and gets an error saying port 8080 doesn't exist on the Service. Why it happens: it's easy to assume the number on the right side of port-forward should match the container's real port (8080), instead of the port the Service listens on (80, the port value in service.yaml). How to spot it: kubectl port-forward's error message explicitly mentions it couldn't find the requested port on the Service. How to fix it: remember lesson 6's distinction — kubectl port-forward svc/<name> <local-port>:<service-port> always uses the Service's port (the number to the left of the colon in service.yaml), never the container's targetPort, on the right side of that :80 in this case.

Leaving port-forward running in the background and forgetting it, causing port conflicts in future lessons (workflow). What happens: someone backgrounds this lesson's port-forward with &, keeps working, and in a later lesson in this module (or a future module) tries to run another port-forward on the same port 8080, and gets an "address already in use" error. Why it happens: kubectl port-forward doesn't stop on its own — it keeps running until you explicitly interrupt it or close the terminal that started it. How to spot it: a bind: address already in use error when trying a new port-forward. How to fix it: lsof -i :8080 (macOS/Linux) shows which process has that port occupied; kill that process (kill <PID>) before opening a new port-forward, or simply remember to close every port-forward with Ctrl+C as soon as you're done using it, as this lesson's closing recommends.


Exercises

Exercise 1 — Reconstruct the whole flow from memory. Without going back to the lesson, list the four steps, in order, from declaring service.yaml to confirming /health responds 200.

See solution
  1. Declare service.yaml with type: ClusterIP, selector: app: andes-cargo-status-api, port: 80/targetPort: 8080, and apply it with kubectl apply -f.
  2. Verify with kubectl get service -n andes-cargo (the assigned CLUSTER-IP address) and kubectl get endpoints -n andes-cargo (confirm the selector did find the Pods).
  3. Open a tunnel with kubectl port-forward svc/status-api-service -n andes-cargo 8080:80.
  4. curl -i http://localhost:8080/health, confirming 200 OK with the expected JSON body.

Exercise 2 — Explain NoCredentialsError to a colleague without using the word "error." A colleague who didn't read this lesson asks you why /shipments/4471 doesn't work yet. Explain it to them in two or three sentences, focusing on what the system is missing at this exact point, not on "something being broken."

See solution

A reasonable explanation: "The Pod is running perfectly, and the /health endpoint confirms it — the problem isn't that something's broken, it's that this service is missing two things to query real data: credentials to talk to a database (we add that in Module 3, with ConfigMap and Secret), and a real database to connect to — and that second piece is outside this guide's scope: installing LocalStack or connecting a real AWS account is data-infrastructure work, not Kubernetes work, so /shipments/<id> is documented as representative throughout the guide. The rest of the system already works."

Exercise 3 — Predict what would happen with credentials, but no LocalStack. If Module 3 added the AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY variables to this Deployment (with dummy values, like test/test, the pattern you already know from aws-serverless-and-containers-guide), but with no LocalStack instance running anywhere yet, would you expect /shipments/4471 to work? Justify your answer with what you learned from this lesson's traceback.

See solution

It wouldn't work — the error type would change, but it would still fail. With credentials present (even dummy ones), boto3 would no longer fail at the request-signing step (NoCredentialsError would disappear), but since DYNAMODB_ENDPOINT_URL still wouldn't point to any real, responding service, the request would fail at the next step: a connection error (timeout, or connection refused, depending on which endpoint it tried to connect to by default without that variable configured). This lesson's traceback shows the failure happens before attempting the network connection — at request signing; adding only credentials would move the failure point one step further down the same chain, but wouldn't resolve it, because the piece no configuration alone can resolve is still missing: a real DynamoDB backend running somewhere. That piece stays outside this Kubernetes guide's $0 scope — Module 3 resolves the configuration, not the backend.


Summary and next step

In this lesson andes-cargo-status-api received, for the first time in this guide, real traffic through a stable address: you declared status-api-service (ClusterIP), confirmed its selector correctly found lesson 5's two Pods (with kubectl get endpoints), and really curled it with kubectl port-forward. /health responded 200 OK, confirming the entire network path works end to end. /shipments/4471 responded 500, and instead of hiding that result, this lesson documented it with the full traceback: the credentials configuration is missing (which Module 3 does add) and, above all, a real DynamoDB backend is missing — something no module in this guide installs, because the data layer stays outside its $0 scope. That's the correct behavior, not a mistake on your part, and it stays that way through the capstone.

Before moving on you should be able to: explain the difference between port and targetPort with this lesson's real example; read a Python traceback inside kubectl logs to identify a 500's exact cause; and describe, without guessing, exactly what andes-cargo-status-api is missing for /shipments/<id> to work, and which of those pieces this guide does resolve (Module 3) and which stays outside its scope by design.

Next lesson: this module's project, andes-cargo-status-api with N replicas. There you scale the Deployment to three replicas, confirm all three respond through the same Service (the load balancing lesson 6 explained in theory), and repeat the experiment of deleting a Pod on purpose — this time with this module's complete system working together.

Resources

  1. Kubernetes — Service — the same reference from lesson 6, now confirmed with real evidence.
  2. Kubernetes — Use Port Forwarding to Access Applications in a Cluster — the official kubectl port-forward guide, this lesson's central command.
  3. Boto3 — Credentials — official reference for how boto3 requires credentials before signing any request, this lesson's NoCredentialsError root cause.
  4. aws-serverless-and-containers-guide (NIEVA), Module 6, lesson 5 — the exact origin of app.py, with the same environment-variable-reading behavior this lesson confirms without configuring yet.