Why Shell + Containers Is a Specific Skill
You’ve written docker exec -it container bash a thousand times interactively. Now you’re in a CI script that runs docker exec -it $CONTAINER pytest and it fails: the input device is not a TTY. You drop -it and the test passes — but in a different script, removing -it means colorless output that breaks a regex parser. You add -T to a docker-compose exec call to force no-TTY, and now signal forwarding doesn’t work and Ctrl-C in CI doesn’t stop the test.
Shell + containers has its own physics:
- TTY allocation is binary: with TTY, you get terminal semantics (color, line discipline, signal forwarding, but stdin must be a terminal). Without, you get raw bytes (pipes work, redirection works, but no color and signals behave differently).
execinto a container is “best effort”: pods can disappear betweenkubectl get podandkubectl exec. The exec succeeds, then the connection terminates with no clear error.docker logsandkubectl logsare not streaming pipes by default: they buffer, they have timestamp formats that change between versions, they truncate.- kubectl
-o jsonis enormous:kubectl get pods -o jsonfor a busy cluster can be 50+ MB. You cannot hold that in shell variables. Pipe it. - JSONPath is half-implemented:
kubectl get -o jsonpathdoesn’t support every JSONPath construct; complex queries need-o json | jq. Knowing when to use each saves hours. - Rolling restarts are racy:
kubectl rollout restartreturns immediately. Polling withkubectl rollout statusreturns success the moment a single new pod is ready, not when all old pods are gone.
This lesson covers the patterns that hold up: when to use -T, when to use -it, how to stream logs reliably, how to pick between JSONPath and jq, the kubectl rollout-status correctness gotchas, and a lib/k8s.sh you can source.
docker / podman: TTY, Stdin, and the -it Confusion
docker run, docker exec, and docker-compose exec all take -i (interactive: connect stdin) and -t (allocate a TTY). The 4 combinations:
| Flags | stdin | TTY | When to use |
|---|---|---|---|
| (none) | not connected | none | Detached or commands that don’t read stdin or output; fire-and-forget |
-i |
piped | none | Pipe data IN: cat data.json | docker exec -i pg psql |
-t |
not connected | yes | Rare; mostly only useful for “show me what the prompt looks like” |
-it |
piped | yes | Interactive shell, bash, python repl. Requires the caller to be a TTY too. |
The CI rule: in scripts and CI runners, never use -t. Use -i only if you need to pipe stdin into the container.
# WRONG in CI: -t fails because CI has no TTY.
docker exec -it myapp /opt/app/run-tests.sh
# RIGHT for CI: no -t, no -i (we're not piping stdin).
docker exec myapp /opt/app/run-tests.sh
# RIGHT when piping stdin:
echo 'SELECT 1;' | docker exec -i postgres psql -U postgres
# WRONG: combining stdin pipe with -t.
echo 'SELECT 1;' | docker exec -it postgres psql -U postgres
# In some Docker versions: works, but escape sequences pollute output.
# In others: 'the input device is not a TTY'.
docker-compose exec defaults to -it; you must pass -T to disable TTY:
# In CI:
docker-compose exec -T web pytest # no TTY allocated
docker-compose exec web bash # interactive (TTY allocated)
podman is mostly drop-in compatible, with the same semantics. Rootless podman has stricter cgroups v2 requirements but exec flags work the same.
Output capture: 2>&1, --log-driver, and the timestamp question
Containers write to stdout and stderr; the runtime captures both. By default docker logs interleaves them:
docker logs myapp # both streams, interleaved
docker logs myapp 2>/dev/null # just stdout (stderr redirected to /dev/null by docker logs)
docker logs myapp >app.out 2>app.err # split streams
docker logs myapp --since 5m # last 5 minutes
docker logs myapp --tail 100 # last 100 lines
docker logs myapp --follow # stream live (won't return until container exits or signal)
docker logs myapp --timestamps # prepend ISO timestamps
For automation that processes logs, use --timestamps --since and parse the prefix. Without --timestamps, you cannot tell when a line was emitted (the container’s date may not match the host’s clock or timezone).
Signal forwarding gotchas
Docker treats PID 1 specially: signals sent to the container must be forwarded to PID 1. If your shell script invokes docker run and you send SIGTERM to the script, will the container shut down gracefully?
# Default: docker forwards SIGTERM/SIGINT to PID 1. PID 1 must HANDLE them.
# Many shell scripts as PID 1 do NOT trap signals → container ignores SIGTERM.
# Workaround: use `tini` or `dumb-init` as PID 1.
docker run --init myapp ... # docker injects tini as PID 1; tini forwards signals to your real entry
docker stop sends SIGTERM, waits 10s by default, then sends SIGKILL. If your container’s PID 1 doesn’t handle SIGTERM, you lose 10 seconds and end ungracefully. --init or a proper signal-handling entry-point fixes this.
In a script:
# Trap to forward signals to a child container.
cleanup() { docker stop "$cid"; }
trap cleanup INT TERM EXIT
cid=$(docker run -d myapp)
docker logs --follow "$cid" &
log_pid=$!
wait "$log_pid"
kubectl: JSON Output Is the Real API
kubectl get accepts -o for output format. The interesting ones for shell automation:
-o value |
Output | Use case |
|---|---|---|
wide |
Tab-separated, includes node, IP, etc. | Quick eyeball; not for parsing (column widths shift) |
name |
Just resource names: pod/web-1 |
Fast list for piping to other kubectl commands |
json |
Full JSON | Source of truth; pipe to jq |
yaml |
Full YAML | Diffing manifests (with --export deprecated; use --show-managed-fields=false) |
jsonpath='{...}' |
jsonpath expression | Simple field extraction without a jq dependency |
jsonpath-as-json='{...}' |
JSON-encoded jsonpath result | Get arrays as JSON |
go-template='...' |
Go-template expression | Maximum flexibility but obscure syntax |
custom-columns=NAME:.metadata.name,... |
Tab-aligned table | Human-friendly; regex-fragile for parsing |
The “wide” / “table” parser trap
# Looks fine.
kubectl get pods -o wide | awk '{print $1}'
# Until a column shifts because a status string is "ContainerStatusUnknown" instead of "Running"
# and now $1 picks up the wrong field.
Never parse -o wide or column-aligned output. Always:
# Lightweight: jsonpath for one or two fields.
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.phase}{"\n"}{end}'
# Heavy: -o json | jq.
kubectl get pods -o json | jq -r '.items[] | "\(.metadata.name)\t\(.status.phase)"'
Both produce TSV output with named fields, robust to ordering changes.
JSONPath vs jq: when to use which
JSONPath (built into kubectl) when:
- You want to avoid a
jqdependency. - The field path is simple: one to three levels deep.
- You don’t need filters, sorting, or transformations.
# Get a single field.
kubectl get pod web-0 -o jsonpath='{.status.podIP}'
# Iterate over array.
kubectl get pods -o jsonpath='{range .items[*]}{.metadata.name}={.status.phase}{"\n"}{end}'
# Filter (limited; only equality):
kubectl get pods -o jsonpath="{.items[?(@.status.phase=='Running')].metadata.name}"
jq (external) when:
- You need filtering on multiple conditions.
- You need to sort or compute (e.g. count Running vs not).
- You need to extract nested array fields.
- You need to produce JSON output for further processing.
# Pods not in Running state, with their reasons.
kubectl get pods -o json | jq -r '
.items[]
| select(.status.phase != "Running")
| "\(.metadata.name)\t\(.status.phase)\t\(.status.reason // "")"
'
# Total restart count by container.
kubectl get pods -o json | jq -r '
[.items[].status.containerStatuses[]?.restartCount // 0]
| add
'
# All container images sorted unique.
kubectl get pods -A -o json | jq -r '
[.items[].spec.containers[].image] | unique | .[]
'
The rule of thumb: if you write -o jsonpath with more than one filter or you start nesting range, switch to -o json | jq.
kubectl exec: same TTY rules apply
# CI / scripts: no TTY.
kubectl exec my-pod -c my-container -- /opt/app/script.sh
# Pipe stdin.
echo 'select 1;' | kubectl exec -i my-pod -- psql -U postgres
# Interactive (humans only):
kubectl exec -it my-pod -- bash
The -- separator is important. Without it, kubectl exec my-pod /opt/foo --bar may parse --bar as a kubectl flag.
kubectl exec is racy with pod lifecycle
# Get a pod name.
pod=$(kubectl get pod -l app=web -o jsonpath='{.items[0].metadata.name}')
# Pod may be terminating between this line and the next.
kubectl exec "$pod" -- /opt/app/script.sh
# May error: "container is terminating" or hang if pod is being recreated.
Defensive pattern:
# Retry exec a few times, picking a fresh ready pod each iteration.
exec_in_ready_pod() {
local label="$1"; shift
local attempts=5 pod
for ((i=1; i<=attempts; i++)); do
pod=$(kubectl get pod -l "$label" \
-o jsonpath='{range .items[?(@.status.phase=="Running")]}{.metadata.name}{"\n"}{end}' \
| head -n1)
[[ -z "$pod" ]] && { sleep 2; continue; }
if kubectl exec "$pod" -- "$@"; then
return 0
fi
sleep 2
done
echo "exec failed after $attempts attempts" >&2
return 1
}
exec_in_ready_pod app=web /opt/app/healthcheck.sh
Streaming Logs: Bounded, Filtered, Searchable
kubectl logs and docker logs both support --follow. To stream into a pipeline that searches for errors:
# Stream logs from all pods of a deployment, prefixed with pod name, filter for ERROR.
kubectl logs -l app=web --all-containers --tail=100 --follow --prefix \
| grep --line-buffered ERROR
The --prefix adds the pod name. --line-buffered makes grep flush per-line so you see output in real time (without it, grep buffers in 4KB chunks).
For docker:
docker logs --follow --tail=100 myapp 2>&1 \
| grep --line-buffered -E 'ERROR|FATAL'
The 2>&1 is critical: docker’s stderr is the container’s stderr, where most errors actually go. Without it, you only see stdout.
Log capture with timeout (CI: “wait for app to log ‘Ready’”)
wait_for_log_marker() {
local pod="$1" marker="$2" timeout="${3:-60}"
timeout "$timeout" bash -c "
kubectl logs --follow '$pod' 2>&1 | while IFS= read -r line; do
printf '%s\n' \"\$line\"
if [[ \"\$line\" == *'$marker'* ]]; then
exit 0
fi
done
exit 1
"
}
wait_for_log_marker web-0 "Server listening on :8080" 30 \
&& echo "ready"
The outer timeout kills the whole pipeline if the marker doesn’t appear in time. The inner shell exits early when the marker is found.
Rollout Status: The “Done” Question
kubectl rollout status deployment/web is the canonical “is it done?” command. It blocks until rollout completes — but the definition of “complete” is subtle.
kubectl rollout restart deployment/web
kubectl rollout status deployment/web --timeout=5m
echo "rollout finished"
What “done” actually means: rollout status returns success when:
- The desired replica count matches the available count, AND
- All pods of the new generation are ready, AND
- No old-generation pods are in the deployment’s ReplicaSet.
It does NOT mean:
- All old pods have been deleted (they may still be terminating in the background).
- All connections drained.
- The new image actually works in production traffic.
For “all old pods gone” guarantees, poll explicitly:
wait_for_old_pods_gone() {
local deploy="$1" namespace="${2:-default}" timeout="${3:-300}"
local end=$(($(date +%s) + timeout))
while (( $(date +%s) < end )); do
local old_count
old_count=$(kubectl -n "$namespace" get pods \
-l "app=$deploy" \
-o json \
| jq '[.items[] | select(.metadata.deletionTimestamp != null)] | length')
[[ "$old_count" -eq 0 ]] && return 0
sleep 2
done
return 1
}
deletionTimestamp != null means the pod is being terminated (graceful shutdown in progress). When the count is 0, every old pod is fully gone.
Wait for a custom condition
# Wait until N replicas of a deployment are Ready.
wait_for_ready_replicas() {
local deploy="$1" want="$2" namespace="${3:-default}" timeout="${4:-300}"
local end=$(($(date +%s) + timeout))
while (( $(date +%s) < end )); do
local got
got=$(kubectl -n "$namespace" get deploy "$deploy" \
-o jsonpath='{.status.readyReplicas}')
[[ "${got:-0}" -ge "$want" ]] && return 0
sleep 2
done
return 1
}
wait_for_ready_replicas web 3 || { echo "deployment not ready"; exit 1; }
A Drop-In Library: lib/k8s.sh
# lib/k8s.sh — kubectl helpers for shell automation.
# ─── Resolution helpers ────────────────────────────────────────────────────
# First running pod matching a label selector.
k8s_pod_running() {
local label="$1" namespace="${2:-default}"
kubectl -n "$namespace" get pod -l "$label" \
-o jsonpath='{range .items[?(@.status.phase=="Running")]}{.metadata.name}{"\n"}{end}' \
| head -n1
}
# All pod names matching a label.
k8s_pods() {
local label="$1" namespace="${2:-default}"
kubectl -n "$namespace" get pod -l "$label" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}'
}
# Container names in a pod.
k8s_pod_containers() {
local pod="$1" namespace="${2:-default}"
kubectl -n "$namespace" get pod "$pod" -o jsonpath='{range .spec.containers[*]}{.name}{"\n"}{end}'
}
# ─── Status helpers ────────────────────────────────────────────────────────
k8s_pod_phase() {
local pod="$1" namespace="${2:-default}"
kubectl -n "$namespace" get pod "$pod" -o jsonpath='{.status.phase}'
}
k8s_pod_ready() {
local pod="$1" namespace="${2:-default}"
local ready
ready=$(kubectl -n "$namespace" get pod "$pod" \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')
[[ "$ready" == "True" ]]
}
k8s_pods_not_running() {
local namespace="${1:-default}"
kubectl -n "$namespace" get pods -o json | jq -r '
.items[]
| select(.status.phase != "Running")
| "\(.metadata.name)\t\(.status.phase)\t\(.status.reason // "")"
'
}
# ─── Exec & logs ───────────────────────────────────────────────────────────
# Exec a command in the first ready pod matching a label, with retries.
k8s_exec_label() {
local label="$1" namespace="$2"; shift 2
local attempts=5 pod
for ((i=1; i<=attempts; i++)); do
pod=$(k8s_pod_running "$label" "$namespace")
[[ -z "$pod" ]] && { sleep 2; continue; }
if kubectl -n "$namespace" exec "$pod" -- "$@"; then
return 0
fi
sleep 2
done
echo "k8s_exec_label: failed after $attempts attempts (label=$label)" >&2
return 1
}
# Stream logs from all pods of a label, with prefix and line-buffered grep.
k8s_logs_grep() {
local label="$1" namespace="$2" pattern="$3"
kubectl -n "$namespace" logs -l "$label" --all-containers --tail=100 --follow --prefix 2>&1 \
| grep --line-buffered -E "$pattern"
}
# ─── Wait helpers ──────────────────────────────────────────────────────────
# Wait until a deployment has at least N ready replicas.
k8s_wait_ready() {
local deploy="$1" want="$2" namespace="${3:-default}" timeout="${4:-300}"
local end=$(($(date +%s) + timeout))
while (( $(date +%s) < end )); do
local got
got=$(kubectl -n "$namespace" get deploy "$deploy" \
-o jsonpath='{.status.readyReplicas}' 2>/dev/null || echo 0)
[[ "${got:-0}" -ge "$want" ]] && return 0
sleep 2
done
return 1
}
# Wait until no pods are in the Terminating state for a label.
k8s_wait_no_terminating() {
local label="$1" namespace="${2:-default}" timeout="${3:-300}"
local end=$(($(date +%s) + timeout))
while (( $(date +%s) < end )); do
local count
count=$(kubectl -n "$namespace" get pod -l "$label" -o json \
| jq '[.items[] | select(.metadata.deletionTimestamp != null)] | length')
[[ "$count" -eq 0 ]] && return 0
sleep 2
done
return 1
}
# Wait until a log line matching $marker appears (with timeout).
k8s_wait_for_log() {
local pod="$1" marker="$2" namespace="${3:-default}" timeout="${4:-60}"
timeout "$timeout" bash -c "
kubectl -n '$namespace' logs --follow '$pod' 2>&1 | while IFS= read -r line; do
printf '%s\n' \"\$line\"
[[ \"\$line\" == *'$marker'* ]] && exit 0
done
exit 1
"
}
# ─── Manifest helpers ──────────────────────────────────────────────────────
# Render a manifest with `envsubst`-style substitution.
k8s_apply_template() {
local file="$1"
envsubst < "$file" | kubectl apply -f -
}
# Diff a manifest before applying.
k8s_diff_template() {
local file="$1"
envsubst < "$file" | kubectl diff -f -
}
Real-World Recipes
Recipe 1: Safe rolling restart with verification
. lib/k8s.sh
safe_restart() {
local deploy="$1" namespace="${2:-default}"
local want
want=$(kubectl -n "$namespace" get deploy "$deploy" -o jsonpath='{.spec.replicas}')
echo "restarting $deploy ($want replicas)"
kubectl -n "$namespace" rollout restart deployment/"$deploy"
echo "waiting for rollout..."
kubectl -n "$namespace" rollout status deployment/"$deploy" --timeout=10m \
|| { echo "rollout failed"; return 1; }
echo "waiting for old pods to terminate..."
k8s_wait_no_terminating "app=$deploy" "$namespace" 600 \
|| { echo "old pods still terminating"; return 1; }
echo "verifying $want pods are Ready..."
k8s_wait_ready "$deploy" "$want" "$namespace" 60 \
|| { echo "not enough Ready replicas"; return 1; }
echo "done."
}
safe_restart web
Recipe 2: Run a database migration in a one-shot job
run_migration_job() {
local image="$1" db_url="$2" namespace="${3:-default}"
local name="migrate-$(date +%s)"
cat <<EOF | kubectl -n "$namespace" apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: $name
spec:
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: $image
command: ["/opt/app/migrate.sh"]
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
EOF
# Wait for completion.
kubectl -n "$namespace" wait --for=condition=complete --timeout=10m "job/$name" \
&& { kubectl -n "$namespace" delete "job/$name"; return 0; } \
|| {
echo "migration job failed; logs:"
kubectl -n "$namespace" logs "job/$name"
kubectl -n "$namespace" delete "job/$name"
return 1
}
}
run_migration_job myapp:v2 "$DB_URL"
Recipe 3: Collect logs from all pods of a service
# Tar up logs from all pods of a deployment for offline analysis.
collect_logs() {
local label="$1" namespace="${2:-default}" outdir="${3:-./logs}"
mkdir -p "$outdir"
local pod
while read -r pod; do
[[ -z "$pod" ]] && continue
echo "collecting $pod..."
kubectl -n "$namespace" logs "$pod" --all-containers --prefix \
> "$outdir/$pod.log" 2>&1
# Previous container logs (if it crashed and was restarted).
kubectl -n "$namespace" logs "$pod" --all-containers --prefix --previous 2>/dev/null \
> "$outdir/$pod.previous.log" || rm -f "$outdir/$pod.previous.log"
done < <(kubectl -n "$namespace" get pods -l "$label" -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}')
tar czf "logs-$(date +%s).tgz" -C "$outdir" .
echo "logs in $outdir, archive: logs-*.tgz"
}
collect_logs app=web prod /tmp/incident-logs
Recipe 4: Health-check parser with structured output
# Print a status table for all pods in a namespace, sorted by health.
namespace_health() {
local namespace="${1:-default}"
kubectl -n "$namespace" get pods -o json | jq -r '
.items[]
| {
name: .metadata.name,
phase: .status.phase,
ready: ([.status.conditions[]? | select(.type=="Ready") | .status] | first // "Unknown"),
restarts: ([.status.containerStatuses[]?.restartCount // 0] | add // 0),
age: .status.startTime
}
| "\(.phase)\t\(.ready)\t\(.restarts)\t\(.name)\t\(.age)"
' | sort | column -t -s$'\t'
}
namespace_health prod
# Output:
# Pending False 0 api-7d4b8f-xyz 2025-01-13T08:12:00Z
# Running True 0 api-7d4b8f-abc 2025-01-13T07:45:00Z
# Running True 2 worker-5f-pqr 2025-01-13T07:30:00Z
Recipe 5: Drift detection between Helm release and live state
# Compare what's deployed to what's in the chart.
helm_drift() {
local release="$1" namespace="${2:-default}"
helm -n "$namespace" template "$release" \
--release-name "$release" \
--include-crds \
> /tmp/expected.yaml
kubectl -n "$namespace" diff -f /tmp/expected.yaml
}
kubectl diff shells out to a diff tool (default: diff); KUBECTL_EXTERNAL_DIFF=delta gives nicer output if delta is installed.
Footgun List
-itin CI fails. No TTY in CI. Drop-t. Use-ionly if you pipe stdin.kubectl execwithout--can mis-parse arguments to your inner command. Alwayskubectl exec POD -- CMD ARGS.- Pod names with random suffixes change every rollout. Don’t hard-code
web-7d4b8f-abc; always resolve via labels. kubectl rollout statusreturns success before old pods terminate. Addk8s_wait_no_terminatingfor “fully done.”kubectl logs --previousgets the previous container’s logs (after a restart). Without--previous, you get the current run only.- Parsing
-o widebreaks when columns shift. Always JSONPath or jq. kubectl get -Afans out across all namespaces. A singlekubectl get pods -A -o jsonon a 1000-pod cluster can be 100+ MB and 10+ seconds. Filter with-lif possible.docker logsdoesn’t capture container exit code. Usedocker waitafterdocker run -dto get exit code.docker execdoesn’t run in the container’s PID namespace if you specify--pid host. Verify which namespace your exec lands in.kubectl waitrequires the resource to exist first. If youkubectl applyand immediatelykubectl wait, the wait may fail because the resource hasn’t been created yet. Add a small sleep or poll for existence.kubectl port-forwardis interactive. It forwards until killed. In scripts, run it in the background and remember to clean it up:port_forward_pid=$!; trap 'kill $port_forward_pid' EXIT.kubectl config use-contextis global. Two scripts running in parallel can fight over context. Pass--contextexplicitly:kubectl --context=prod-cluster get pods.
Quick-Reference Card
┌─ TTY MATRIX (docker / kubectl exec) ──────────────────────────────────┐
│ no flags detached/no-stdin commands │
│ -i pipe stdin into container; no TTY │
│ -t allocate TTY (rare alone) │
│ -it interactive shell (HUMANS ONLY; fails in CI) │
│ docker-compose exec defaults to -it; use -T to force no-TTY in CI │
└────────────────────────────────────────────────────────────────────────┘
┌─ JSONPATH vs jq ──────────────────────────────────────────────────────┐
│ jsonpath: simple paths, no jq dep, limited filters │
│ jq: complex filters, sorting, transformations │
│ Rule: > 1 filter or nested range → switch to jq │
└────────────────────────────────────────────────────────────────────────┘
┌─ ROLLOUT "DONE" ──────────────────────────────────────────────────────┐
│ rollout status new pods ready, old gen out of replicaset │
│ but NOT all old pods deleted (still terminating in background) │
│ Add: wait_no_terminating loop for full guarantee │
└────────────────────────────────────────────────────────────────────────┘
┌─ ESSENTIAL COMMANDS ──────────────────────────────────────────────────┐
│ kubectl get pod -l app=web -o json | jq ... │
│ kubectl logs -l app=web --all-containers --tail=N --follow --prefix │
│ kubectl exec POD -c CONTAINER -- CMD │
│ kubectl rollout restart deployment/NAME │
│ kubectl rollout status deployment/NAME --timeout=Nm │
│ kubectl wait --for=condition=Ready pod -l app=web │
│ kubectl diff -f manifest.yaml │
└────────────────────────────────────────────────────────────────────────┘
┌─ POD LIFECYCLE ───────────────────────────────────────────────────────┐
│ Pending → ContainerCreating → Running → (Succeeded|Failed) │
│ metadata.deletionTimestamp != null = Terminating │
│ status.containerStatuses[].state.{running,waiting,terminated} │
│ status.containerStatuses[].restartCount = OOMKill / crash count │
└────────────────────────────────────────────────────────────────────────┘
┌─ docker / podman ─────────────────────────────────────────────────────┐
│ docker run --init PID 1 = tini; signal forwarding works │
│ docker logs --since 5m --tail 100 --timestamps --follow │
│ docker stop CID SIGTERM → wait → SIGKILL after 10s │
│ docker wait CID prints exit code, blocks until exit │
│ docker inspect -f '{{.State.ExitCode}}' CID │
└────────────────────────────────────────────────────────────────────────┘
What’s Next
Containers and Kubernetes give you scheduled, scaled compute. Cloud CLIs (AWS, Azure, GCP) give you the platform underneath: IAM, storage, DNS, networking. The next lesson, Cloud CLIs From Shell: AWS, Azure, GCP — Auth, Pagination, Parallel Calls & Output Discipline, covers credential resolution, the auth-environment chain, paginating large result sets without timeouts, parallelizing API calls safely, and writing scripts that don’t accidentally exceed rate limits or rotate credentials mid-run.