K8s Basics #3: kubectl and Your First Pod

13 min read

#2 Local environments put a cluster on your laptop. This post puts the first Pod onto that cluster, looks inside it, reads its logs, and tears it down cleanly. Along the way we pick up the basic kubectl commands and what a Pod actually is.

This series is K8s Basics, 7 posts.

By the end of this post the everyday kubectl commands will feel natural, and you’ll have created and inspected a Pod yourself at least once. From the next post on, we stop creating Pods directly and let controllers do it for us.

What is a Pod? #

The one-line definition from the resource table in #1a Pod is K8s’s smallest execution unit, holding one container or a few that run together. Let’s unpack that a little.

Readers coming from the Docker Basics series already have container as a unit in their head. K8s adds one more layer on top — the unit that’s scheduled and run together, holding one or more containers, is the Pod. Containers in the same Pod share:

  • A network namespace — same IP, same port space. Containers in one Pod call each other on localhost.
  • Some volumes — multiple containers can mount volumes defined on the Pod.
  • A lifecycle — they come up together and die together (on the same node).

This is where it’s easiest to confuse Pods with Docker containers. In Docker, “one container = one execution unit.” In K8s, “one Pod = one execution unit,” and that Pod might hold one container or two. A handy mental shortcut: Pod ≈ “a group of containers sharing one IP.”

In practice, 99% of the time it’s one Pod, one container. Putting two containers in one Pod makes sense for sidecars (log shippers, proxies) — cases where the second container has to ride along with the main one. Starting out, “Pod = K8s’s wrapper around one container” is a fine model.

One more thing — you almost never put kind: Pod directly into a manifest in production. That’s #4 Deployment’s job. We’re handling Pods directly in this post to lay the foundation. The unit a person actually writes is almost always a controller — Deployment, StatefulSet, Job — and Pods are what those controllers produce.

kubectl commands in one table #

A single table of the everyday commands that show up over and over in this series.

CommandWhat it does
kubectl get <res>List resources. e.g. kubectl get pods, kubectl get nodes
kubectl get <res> <name>Show a summary of one specific resource
kubectl get <res> -o wideAdd columns like IP, Node beyond the defaults
kubectl get <res> <name> -o yamlThe full definition of that resource as YAML
kubectl describe <res> <name>Detailed info plus recent Events
kubectl logs <pod>A Pod container’s stdout/stderr
kubectl logs -f <pod>follow — like tail -f
kubectl exec -it <pod> -- <cmd>Run a command inside a running Pod’s container
kubectl apply -f file.yamlApply a manifest to the cluster (declarative)
kubectl delete -f file.yamlDelete the resources that manifest produced
kubectl delete <res> <name>Delete a resource by name
kubectl run <name> --image=<img>Imperatively create a single Pod on the spot

<res> slots in things like pods, pod, po, deployments, deploy, services, svc. Most common resources have short forms, which cuts typing fast.

Imperative vs declarative #

There are two ways of working with the cluster through kubectl.

  • Imperativekubectl run, kubectl create deployment, etc. — “make this thing now.” Quick and light, but to reproduce it you need that command written down somewhere.
  • Declarative — describe “this is the shape it should be” in a YAML manifest and run kubectl apply -f file.yaml. The manifest file is the record of cluster state; it goes in Git, and rerunning apply gives you the same state.

Real-world work is almost entirely declarative. The decisive part is that cluster state is recorded as code. This series uses imperative for one Pod just once, then moves to manifests immediately.

First Pod, imperatively — kubectl run #

The shortest path. We use nginx:1.27 since it’s available everywhere.

One Pod, imperatively
kubectl run hello --image=nginx:1.27 --port=80
Example output
pod/hello created

--port=80 is informational. Making external traffic hit that port is a #5 Service job. For now, treat it as a note: “the nginx in this Pod listens on 80.”

Check what got created.

List Pods
kubectl get pods
Example output
NAME    READY   STATUS    RESTARTS   AGE
hello   1/1     Running   0          12s

READY 1/1 means 1 of 1 containers in the Pod is ready, and STATUS Running is what we want. Quick column rundown — NAME / READY / STATUS / RESTARTS / AGE. You’ll see this layout constantly.

For more detail, add -o wide.

Extra columns
kubectl get pods -o wide
Example output
NAME    READY   STATUS    RESTARTS   AGE   IP           NODE                 NOMINATED NODE   READINESS GATES
hello   1/1     Running   0          25s   10.244.0.5   kind-control-plane   <none>           <none>

IP is the Pod’s cluster-internal IP and NODE is which worker node it landed on. With single-node kind they all sit on kind-control-plane, but on a multi-node cluster you’d see how Pods got distributed. NOMINATED NODE and READINESS GATES are usually empty and only fill in for advanced scheduling/conditions.

describe — what happened on the way here #

The list view doesn’t tell you how the Pod got there. describe fills that in.

Pod details
kubectl describe pod hello
Example output — excerpt
Name:         hello
Namespace:    default
Priority:     0
Node:         kind-control-plane/172.18.0.2
Start Time:   ...
Labels:       run=hello
...
Containers:
  hello:
    Image:          nginx:1.27
    Port:           80/TCP
    State:          Running
      Started:      ...
    Ready:          True
    Restart Count:  0
...
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  30s   default-scheduler  Successfully assigned default/hello to kind-control-plane
  Normal  Pulling    29s   kubelet            Pulling image "nginx:1.27"
  Normal  Pulled     25s   kubelet            Successfully pulled image "nginx:1.27"
  Normal  Created    25s   kubelet            Created container hello
  Normal  Started    25s   kubelet            Started container hello

The top half is the Pod’s definition (node, labels, container, state); the bottom is Events in time order. The Events section is the first weapon for K8s debugging — ScheduledPullingPulledCreatedStarted. The same flow we only saw as a diagram in #1 is written out here. The scheduler picked a node, that node’s kubelet pulled the image, created the container, and started it.

When something goes wrong, the answer is almost always inside Events. A wrong image name shows up as Failed to pull image; a container that dies right after starting shows up as Back-off restarting failed container.

logs — what the Pod is saying #

A Pod container’s stdout/stderr lives behind kubectl logs.

View logs
kubectl logs hello
Example output — excerpt
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
...
/docker-entrypoint.sh: Configuration complete; ready for start up
... [notice] start worker processes

This maps 1:1 to Docker’s docker logs. The common variants line up too.

Useful forms
kubectl logs -f hello             # follow
kubectl logs --tail=100 hello     # last 100 lines
kubectl logs --since=10m hello    # last 10 minutes
kubectl logs --previous hello     # logs from the previously dead container

The last one — --previous — is K8s-specific and worth getting comfortable with. If the container died once and restarted, the logs from before it died belong to the previous container instance, not the current stdout. You’ll reach for this often when chasing crashes.

exec — get inside the Pod #

Maps to Docker’s docker exec.

Inside the Pod's container
kubectl exec -it hello -- bash

-it is interactive + TTY, and the -- after that means everything beyond this point is the command to run inside the Pod. People skip the -- all the time, and then kubectl interprets the rest as its own flags — easy mistake.

In there, peek at nginx then exit.

Inside the container
root@hello:/# curl -s localhost:80 | head -5
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
root@hello:/# exit

For a one-shot command, drop the -it.

One-shot command
kubectl exec hello -- nginx -v
# nginx version: nginx/1.27.x

First clean-up #

That’s one full imperative cycle. Tear it down.

Delete the Pod
kubectl delete pod hello
Example output
pod "hello" deleted

kubectl get pods showing nothing puts you back at the start.

Same thing declaratively — a YAML manifest #

Now we write the same Pod as a manifest. Almost every K8s manifest starts with the same four top-level fields.

FieldMeaning
apiVersionThe K8s API version that defines this resource. Pod sits in the stable group, so v1
kindKind of resource. Here, Pod
metadataIdentifying info — name, labels, namespace
spec“How this resource should look.” Shape varies by kind

The shape of these four is identical whether you’re writing a Pod, a Deployment, or a Service. Get it once and you’ve got the spine of every manifest.

Write hello-pod.yaml:

hello-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: hello
  labels:
    app: hello
spec:
  containers:
    - name: web
      image: nginx:1.27
      ports:
        - containerPort: 80

From the same directory, run apply.

Create the Pod from manifest
kubectl apply -f hello-pod.yaml
Example output
pod/hello created

kubectl get pods shows the same thing as kubectl run did. The difference is outside the cluster.

Why manifests are better #

  • They go in Git. Cluster state becomes code, so “the shape of this environment” gets reviewed in PRs and lives in history.
  • Re-running apply gives the same state. Imperative depends on memory or shell history; the manifest is the source of truth.
  • apply is idempotent. If it exists, only the diff is applied; if not, it’s created. That property fits CI/CD perfectly.
  • It scales up to controllers naturally. Change the same manifest’s kind to Deployment and you’re at #4.

What K8s fills in for you #

We only wrote a few fields, but the actual object in the cluster has more. Take a look.

See the actual object as YAML
kubectl get pod hello -o yaml | head -40
Example output — excerpt
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: "..."
  labels:
    app: hello
  name: hello
  namespace: default
  resourceVersion: "..."
  uid: ...-...-...-...-...
spec:
  containers:
    - image: nginx:1.27
      imagePullPolicy: IfNotPresent
      name: web
      ports:
        - containerPort: 80
          protocol: TCP
      ...
  nodeName: kind-control-plane
  ...
status:
  conditions:
    - ...
  phase: Running
  podIP: 10.244.0.5
  ...

Our part (name, labels, image, port) is intact, and K8s filled in things like:

  • metadata.uid, metadata.resourceVersion, metadata.creationTimestamp — the object’s identity
  • spec.nodeName — the node the scheduler picked
  • defaults like spec.containers[].imagePullPolicy: IfNotPresent
  • the entire status block — Phase, Pod IP, conditions, the actual state right now

The desired state vs actual state from #1 is right here in one object. spec is desired, status is actual. Controllers compare those two endlessly.

A pass over Pod lifecycle #

Pods have a concept called Phase. The STATUS column from kubectl get pod mostly reflects this. Five values:

PhaseMeaning
PendingThe object exists but containers haven’t started — pulling images, waiting for a node assignment
RunningAssigned to a node and at least one container is running
SucceededAll containers exited successfully (exit 0). Won’t be restarted
FailedAll containers terminated; at least one terminated abnormally
UnknownThe API server lost contact with the node and can’t tell

Pods that should stay up (web servers) live their lives in Running. Pods that run once and finish (batch jobs) end in Succeeded or Failed.

The Events section in kubectl describe walks this same flow — ScheduledPullingPulledCreatedStarted. Where the sequence stops is the first fork in debugging.

Two common failures #

The two failures you’ll meet first:

ImagePullBackOff / ErrImagePull — couldn’t pull the image. Typo in the name, missed private registry auth, or the tag really doesn’t exist. kubectl describe pod <name>’s Events shows Failed to pull image "..." with a reason underneath. Start there.

CrashLoopBackOff — the container dies right after starting, K8s restarts it, it dies again, repeat. First clue: kubectl logs <pod>. If you need the just-died container’s logs: kubectl logs --previous <pod>. The RESTARTS column climbing fast is also a giveaway.

These two patterns repeat throughout the series. Events from describe first, logs second — that order solves about 90%.

Why one Pod isn’t enough #

By now one thing nags — what happens if this Pod dies? Try forcing it.

Force-delete the Pod
kubectl delete pod hello
Example output
pod "hello" deleted
Check again
kubectl get pods
Example output
No resources found in default namespace.

It’s just gone. Nothing comes back automatically. From K8s’s view, nobody told it “this Pod must exist” — we created the object once with apply, then a human deleted it, so K8s respects the human’s intent.

For the reconcile loop from #1 to do its work, there has to be a higher-level declaration like “N of these should be running.” That higher-level declaration belongs to the next post’s main character — Deployment. Deployment creates a ReplicaSet, and the ReplicaSet takes responsibility for “keep N Pods of this template alive.” Run the same experiment with a Deployment and a new Pod pops up the moment you delete the old one.

K8s docs often call Pods mortal — destined to die. A node dies, a container OOMs, somebody deletes the wrong thing — the Pod just disappears, and nothing replaces it on its own. Auto-restart and rescheduling aren’t free from K8s itself; they come through controllers.

So we end up back at the same line — kind: Pod directly is rare in production. Outside special cases (a debug Pod, a Pod produced by a Job, a Pod placed on every node by a DaemonSet) what we write in manifests is almost always a controller. The Pod in this post is the foundation for understanding what those controllers actually produce inside.

Clean up #

Sweep up today’s experiments. The cleanest delete is by the same manifest you applied.

Clean up by manifest
kubectl delete -f hello-pod.yaml
Example output
pod "hello" deleted

By name works too.

Clean up by name
kubectl delete pod hello

Confirm the default namespace is empty.

Empty?
kubectl get pods
Example output
No resources found in default namespace.

The system pods in kube-system from #2 are still up. Those belong to the cluster’s own operations, separate from our workloads. Keeping the two split apart with namespaces is a #7 topic.

Summary #

What this post pinned down:

  • A Pod is K8s’s smallest execution unit — one container, or a few that run together, sharing the same IP / port space and some volumes. 99% of real-world Pods are one container.
  • The everyday kubectl commands are basically get / describe / logs / exec / apply / delete. Short forms (po, deploy, svc) come naturally with practice.
  • Imperative (kubectl run) is fast but not reproducible; declarative (YAML + kubectl apply) records cluster state as code. Real work is almost entirely declarative.
  • The spine of any manifest is apiVersion / kind / metadata / spec. K8s fills in metadata.uid, spec.nodeName, status, and so on — desired and actual state side by side in one object.
  • Pod Phases are Pending / Running / Succeeded / Failed / Unknown. The debugging order is kubectl describe Events first, then kubectl logs (or --previous) second.
  • Pods are mortal — they just disappear when they die. The controllers that take responsibility for auto-restart and rescheduling are separate, and the next post starts with the most common one: Deployment.

Next — Deployment / ReplicaSet #

The closing line of this post — Pods just disappear when deleted — is exactly the start of the next one. We need an abstraction that maintains “N of these should be up” without humans watching.

#4 Deployment / ReplicaSet covers (1) how a ReplicaSet guarantees a Pod count, (2) what Deployment adds on top (rolling updates, rollbacks), and (3) writing the same nginx Pod as a Deployment manifest with replicas: 3, then watching auto-recovery when one Pod is deleted. The single Pod from this post starts behaving like a real workload there.

X