K8s Basics #3: kubectl and Your First Pod
#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.
- #1 What is Kubernetes — why do we need a container orchestrator?
- #2 Local environments — minikube / kind / Docker Desktop k8s
- #3 kubectl and your first Pod ← this post
- #4 Deployment / ReplicaSet
- #5 Service — ClusterIP / NodePort / LoadBalancer
- #6 ConfigMap / Secret
- #7 Namespaces and labels
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 #1 — a 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.
| Command | What 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 wide | Add columns like IP, Node beyond the defaults |
kubectl get <res> <name> -o yaml | The 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.yaml | Apply a manifest to the cluster (declarative) |
kubectl delete -f file.yaml | Delete 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.
- Imperative —
kubectl 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 rerunningapplygives 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.
kubectl run hello --image=nginx:1.27 --port=80pod/hello created
--port=80is 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.
kubectl get podsNAME READY STATUS RESTARTS AGE
hello 1/1 Running 0 12sREADY 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.
kubectl get pods -o wideNAME 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.
kubectl describe pod helloName: 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 helloThe 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 — Scheduled → Pulling → Pulled → Created → Started. 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.
kubectl logs hello/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 processesThis maps 1:1 to Docker’s docker logs. The common variants line up too.
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 containerThe 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.
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.
root@hello:/# curl -s localhost:80 | head -5
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
root@hello:/# exitFor a one-shot command, drop the -it.
kubectl exec hello -- nginx -v
# nginx version: nginx/1.27.xFirst clean-up #
That’s one full imperative cycle. Tear it down.
kubectl delete pod hellopod "hello" deletedkubectl 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.
| Field | Meaning |
|---|---|
apiVersion | The K8s API version that defines this resource. Pod sits in the stable group, so v1 |
kind | Kind of resource. Here, Pod |
metadata | Identifying 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:
apiVersion: v1
kind: Pod
metadata:
name: hello
labels:
app: hello
spec:
containers:
- name: web
image: nginx:1.27
ports:
- containerPort: 80From the same directory, run apply.
kubectl apply -f hello-pod.yamlpod/hello createdkubectl 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
applygives the same state. Imperative depends on memory or shell history; the manifest is the source of truth. applyis 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
kindtoDeploymentand 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.
kubectl get pod hello -o yaml | head -40apiVersion: 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 identityspec.nodeName— the node the scheduler picked- defaults like
spec.containers[].imagePullPolicy: IfNotPresent - the entire
statusblock — 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:
| Phase | Meaning |
|---|---|
Pending | The object exists but containers haven’t started — pulling images, waiting for a node assignment |
Running | Assigned to a node and at least one container is running |
Succeeded | All containers exited successfully (exit 0). Won’t be restarted |
Failed | All containers terminated; at least one terminated abnormally |
Unknown | The 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 — Scheduled → Pulling → Pulled → Created → Started. 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.
kubectl delete pod hellopod "hello" deletedkubectl get podsNo 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.
kubectl delete -f hello-pod.yamlpod "hello" deletedBy name works too.
kubectl delete pod helloConfirm the default namespace is empty.
kubectl get podsNo 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
kubectlcommands are basicallyget/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 inmetadata.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 iskubectl describeEvents first, thenkubectl 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.