Skip to content

CKAD cheat sheet

  • kubernetes
  • ckad
  • kubectl
  • cheatsheet

Based on: Kubernetes v1.35

Summary

CKAD commands and YAML in one place.

Rule of thumb: generate the skeleton with an imperative command, then edit YAML only for fields the command can’t set (or you don’t remember). Look them up with kubectl explain or copy an example from the official docs.

Example:

k run web --image=nginx --port=80 --dry-run=client -o yaml > web.yaml  # 1. generate the Pod YAML skeleton with a command
vi web.yaml                                                            # 2. add fields the command can't set (probe, volume, etc.)
k apply -f web.yaml                                                    # 3. apply
k get pod web                                                          # 4. check the result
DomainWeight
Application Design and Build20%
Application Deployment20%
Application Observability and Maintenance15%
Application Environment, Configuration and Security25%
Services and Networking20%

Sites you can open during the exam: kubernetes.io/docs, kubernetes.io/blog, helm.sh/docs (per the Linux Foundation’s allowed resources).

Exam environment

  • For each task, ssh <host> into the given host, solve it there, then exit
  • Every ssh host comes with the k alias and Bash completion preconfigured
  • Your own aliases, exports, and vim settings are gone once you switch hosts — type options out every time
  • Pass -n <ns> explicitly on every command

Generating and editing YAML

k run web --image=nginx --dry-run=client -o yaml > pod.yaml  # write YAML to a file instead of creating
k apply -f pod.yaml                                          # apply after editing
  • Most of a Pod spec can’t be changed after creation; apply fails with Forbidden: pod updates may not change fields ...
    • Mutable: container image, activeDeadlineSeconds, adding tolerations
    • Immutable, e.g.: probes, resources, env, command/args, volumeMounts, securityContext, serviceAccountName
    • Fix: k replace --force -f pod.yaml — deletes the existing Pod and recreates it under the same name
    • For a Deployment, editing the Pod template and running apply is enough (the Deployment replaces the Pods)

kubectl explain

k explain pod.spec.containers.livenessProbe  # field description
k explain pod.spec --recursive | less        # full field tree

Application Design and Build

Curriculum: define, build, and modify container images; choose a workload resource; multi-container Pod patterns (sidecar, init, etc.); persistent and ephemeral volumes.

container image

docker build -t myapp:v1 .                         # or podman build
docker tag myapp:v1 registry.example.com/myapp:v1  # add a tag with the remote registry path
docker push registry.example.com/myapp:v1          # upload to the registry
FROM nginx:1.27
COPY index.html /usr/share/nginx/html/index.html

Choosing a workload

ResourceUseCreate with
Podone-off, testingk run
Deploymentstateless, rolling updatesk create deploy
StatefulSetstable names and storageYAML
DaemonSetone per nodeDeployment YAML with kind changed, replicas and strategy removed
Jobruns to completionk create job
CronJobruns on a schedulek create cronjob
k run web --image=nginx --port=80 --labels=app=web,tier=fe                # create a Pod with port and labels
k run tmp --image=busybox --restart=Never --rm -it -- sh                  # shell into a temporary Pod, deleted on exit
k run box --image=busybox --dry-run=client -o yaml --command -- sh -c 'sleep 3600' > pod.yaml  # Pod YAML with a command set

k create deploy web --image=nginx --replicas=3 --port=80                  # Deployment with 3 replicas
k create job hello --image=busybox -- echo "Hello World"                  # one-shot Job
k create cronjob hello --image=busybox --schedule="*/1 * * * *" -- echo "Hello World"  # CronJob running every minute
k create job manual-run --from=cronjob/hello                              # run a CronJob once right now

Where the arguments after -- end up:

Image (Dockerfile)Pod specRole
ENTRYPOINTcommandthe program to run
CMDargsdefault arguments passed to that program
k run box --image=busybox -- sleep 3600            # args: ["sleep", "3600"] -- keeps ENTRYPOINT, replaces only CMD
k run box --image=busybox --command -- sleep 3600  # command: ["sleep", "3600"] -- replaces ENTRYPOINT
  • busybox has no ENTRYPOINT, so both run sleep 3600
  • On an image with ENTRYPOINT ["python"], leaving out --command runs python sleep 3600 -> error
  • If the task says “run this command”, use --command

Extra Job/CronJob fields (no command-line flags for these):

apiVersion: batch/v1
kind: CronJob
metadata:
  name: hello
spec:
  schedule: "*/5 * * * *"
  jobTemplate:
    spec:
      completions: 3
      parallelism: 2
      backoffLimit: 4
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: hello
              image: busybox
              command: ["sh", "-c", "date"]
  • A Job Pod’s restartPolicy must be Never or OnFailure
  • Docs: Jobs, CronJob

multi-container Pod

PatternDefined inBehavior
init containerinitContainersrun in order; all must succeed before app containers start
sidecar (native)initContainers + restartPolicy: Alwayskeeps running for the Pod’s lifetime, stable since v1.33
plain multi-containermultiple entries in containersstart together, no ordering guarantee
apiVersion: v1
kind: Pod
metadata:
  name: app
spec:
  initContainers:
    - name: wait-db
      image: busybox
      command:
        [
          "sh",
          "-c",
          "until nslookup db.default.svc.cluster.local; do sleep 2; done",
        ]
    - name: logshipper
      image: alpine
      restartPolicy: Always # sidecar
      command: ["sh", "-c", "tail -F /opt/logs.txt"]
      volumeMounts:
        - name: data
          mountPath: /opt
  containers:
    - name: app
      image: alpine
      command:
        [
          "sh",
          "-c",
          "while true; do echo logging >> /opt/logs.txt; sleep 1; done",
        ]
      volumeMounts:
        - name: data
          mountPath: /opt
  volumes:
    - name: data
      emptyDir: {}
  • Share files between containers by mounting the same emptyDir
  • Logs/exec for a specific container: k logs app -c logshipper, k exec -it app -c app -- sh
  • Docs: Init Containers, Sidecar Containers

volume

TypeLifetimeNotes
emptyDirPodtmpfs with medium: Memory
hostPathnodefor testing
configMap / secretsource resourcemounted as files
persistentVolumeClaimPVCbound to a PV
generic ephemeral (ephemeral)Podper-Pod PVC created and deleted for you

PV, PVC, and mount (no imperative command for these):

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-data
spec:
  capacity:
    storage: 1Gi
  accessModes: ["ReadWriteOnce"]
  storageClassName: manual
  hostPath:
    path: /mnt/data
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-data
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: manual
  resources:
    requests:
      storage: 500Mi
---
apiVersion: v1
kind: Pod
metadata:
  name: pvc-pod
spec:
  containers:
    - name: app
      image: nginx
      volumeMounts:
        - name: data
          mountPath: /usr/share/nginx/html
  volumes:
    - name: data
      persistentVolumeClaim:
        claimName: pvc-data

Application Deployment

Curriculum: deployment strategies (blue/green, canary), Deployment rolling updates, deploying existing packages with Helm, Kustomize.

rolling update, rollout

k set image deploy/web nginx=nginx:1.27    # container name=image
k rollout status deploy/web                # wait for / check rollout completion
k rollout history deploy/web               # list revisions
k rollout undo deploy/web                  # roll back to the previous revision
k rollout undo deploy/web --to-revision=1  # roll back to a specific revision
k rollout restart deploy/web               # recreate Pods without changing config
k scale deploy/web --replicas=5            # change the replica count
spec:
  strategy:
    type: RollingUpdate # or Recreate
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 0
  • When switching to Recreate, you must delete the rollingUpdate block
  • Docs: Deployments

blue/green, canary

No dedicated resource. Two Deployments plus a Service selector.

StrategyHow
blue/greenweb-blue and web-green Deployments side by side, switch the Service selector to version=green
canaryboth Deployments share a common label (app=web), the Service selects only that label, replica ratio splits traffic
# blue/green switch
k set selector svc web 'app=web,version=green'                            # replace the Service selector
# or
k patch svc web -p '{"spec":{"selector":{"app":"web","version":"green"}}}'  # same change via patch

# canary: stable 9, canary 1 -> about 10%
k scale deploy/web-stable --replicas=9                                    # 9 stable
k scale deploy/web-canary --replicas=1                                    # 1 canary
  • Check: k get endpointslices -l kubernetes.io/service-name=web, k get pod -l app=web --show-labels
  • Docs: Managing Workloads (canary deployments section)

Helm

helm repo add bitnami https://charts.bitnami.com/bitnami                  # register a chart repo
helm repo update                                                          # refresh the repo index
helm show values bitnami/nginx > values.yaml                              # view and save the chart's default values

helm install web bitnami/nginx -n web --create-namespace --set replicaCount=2  # install, creating the namespace, with a value override
helm install web bitnami/nginx -n web -f values.yaml                       # install with a values file
helm upgrade web bitnami/nginx -n web --set replicaCount=3                 # upgrade with a changed value
helm list -A                                                              # releases across all namespaces
helm history web -n web                                                   # list release revisions
helm rollback web 1 -n web                                                # roll back to revision 1
helm uninstall web -n web                                                 # delete the release

Kustomize

Leaves the source YAML untouched and deploys the result of applying the transformations in kustomization.yaml. No template syntax, built into kubectl.

app/
├── base/                       # shared source
│   ├── deployment.yaml
│   ├── service.yaml
│   └── kustomization.yaml      # resources: [deployment.yaml, service.yaml]
└── overlay/
    └── prod/
        └── kustomization.yaml  # pulls in base and transforms it for prod
k kustomize ./app/overlay/prod  # only print the transformed YAML (check before applying)
k apply -k ./app/overlay/prod   # apply the transformed result (-k = directory containing kustomization.yaml)
k delete -k ./app/overlay/prod  # delete the resources created from that result
# app/overlay/prod/kustomization.yaml
resources: # sources to transform (files or directories)
  - ../../base
namespace: prod # set every resource's namespace to prod
labels: # add a label to every resource
  - pairs:
      env: prod
images: # replace the tag of the nginx image
  - name: nginx
    newTag: "1.27"
patches: # change only specific fields of a specific resource
  - target: # target: Deployment web
      kind: Deployment
      name: web
    patch: |- # JSON patch: set replicas to 3
      - op: replace
        path: /spec/replicas
        value: 3

Application Observability and Maintenance

Curriculum: API deprecations, probes and health checks, monitoring with built-in CLI tools, container logs, debugging.

API deprecation

k api-resources -o wide                         # group, version, and verbs per resource
k explain cronjob | head -5                     # check GROUP / VERSION
k convert -f old.yaml --output-version apps/v1  # requires the kubectl-convert plugin

probe

probeOn failure
livenessrestart the container
readinessremoved from Service endpoints (no restart)
startupliveness/readiness paused until it succeeds; restart on failure
handlerField
HTTPhttpGet: {path, port}
TCPtcpSocket: {port}
commandexec: {command: [...]}
gRPCgrpc: {port}
spec:
  containers:
    - name: web
      image: nginx
      ports:
        - containerPort: 80
      startupProbe:
        httpGet:
          path: /
          port: 80
        failureThreshold: 30
        periodSeconds: 10
      livenessProbe:
        httpGet:
          path: /
          port: 80
        initialDelaySeconds: 5
        periodSeconds: 10
      readinessProbe:
        exec:
          command: ["cat", "/tmp/ready"]
        periodSeconds: 5

Monitoring CLI

k get pod -o wide                                   # include Pod IP and node
k get pod --show-labels -l app=web                  # filter by label + show labels
k get events --sort-by=.metadata.creationTimestamp  # events in time order
k top pod --sort-by=cpu                             # requires metrics-server
k top node                                          # node CPU/memory usage
k describe pod web                                  # detailed status and Events

container log

k logs web                   # default logs
k logs web -c sidecar        # multi-container
k logs web --all-containers  # logs from all containers
k logs web --previous        # the container before the restart
k logs web -f --tail=50      # follow, starting from the last 50 lines

Debugging

StatusCheck
Pendingdescribe Events: insufficient resources, nodeSelector, PVC
ImagePullBackOffimage name/tag, imagePullSecrets
CrashLoopBackOfflogs --previous, command/args, liveness probe
Running but unreachablereadiness, Service selector, targetPort
CreateContainerConfigErrormissing referenced ConfigMap/Secret/key
k exec -it web -- sh                                                      # shell into a running container
k debug web -it --image=busybox --target=web                              # ephemeral container
k debug web -it --image=busybox --copy-to=web-debug --share-processes                # debug a copy of the Pod with a shared process namespace
k run tmp --image=busybox --restart=Never --rm -it -- wget -qO- http://web:80  # test calling a Service from a temporary Pod

Application Environment, Configuration and Security

Curriculum: CRDs and Operators, authentication/authorization/admission control, requests/limits/quotas, ConfigMaps, Secrets, ServiceAccounts, SecurityContext and capabilities.

CRD, Operator

k get crd                            # list CRDs
k api-resources --api-group=<group>  # resources in a specific API group
k explain <kind>.spec                # fields from the CRD schema
k get <plural> -A                    # list all CRs

authentication, authorization, admission control

k create role pod-reader --verb=get,list,watch --resource=pods            # define namespaced permissions
k create rolebinding pod-reader-rb --role=pod-reader --serviceaccount=default:app-sa  # bind the Role to a ServiceAccount
k create clusterrole node-reader --verb=get,list --resource=nodes         # define cluster-wide permissions
k create clusterrolebinding node-reader-rb --clusterrole=node-reader --user=jane  # bind the ClusterRole to a user

k auth can-i list pods                                                    # check my own permissions
k auth can-i list pods --as=system:serviceaccount:default:app-sa -n default  # check as the ServiceAccount
StageRole
authenticationwho you are (certificates, tokens, ServiceAccounts)
authorizationwhether you’re allowed (RBAC Role/ClusterRole)
admission controlmutates/validates the request (LimitRange, ResourceQuota, Pod Security Admission, etc.)

requests, limits, quota

k set resources deploy/web --requests=cpu=100m,memory=128Mi --limits=cpu=200m,memory=256Mi  # set requests and limits
k create quota ns-quota --hard=pods=10,requests.cpu=1,requests.memory=1Gi,limits.cpu=2,limits.memory=2Gi  # create a namespace ResourceQuota
k describe quota -n <ns>                                                  # check quota usage
spec:
  containers:
    - name: web
      image: nginx
      resources:
        requests:
          cpu: 100m
          memory: 128Mi
        limits:
          cpu: 200m
          memory: 256Mi
---
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
spec:
  limits:
    - type: Container
      default:
        cpu: 200m
        memory: 256Mi
      defaultRequest:
        cpu: 100m
        memory: 128Mi

ConfigMap

k create cm app-config --from-literal=LOG_LEVEL=info --from-literal=PORT=8080  # create from key=value pairs
k create cm app-file --from-file=app.properties                           # create from a file's contents (key = file name)
k set env deploy/web --from=configmap/app-config                          # inject the whole ConfigMap as env
spec:
  containers:
    - name: web
      image: nginx
      env:
        - name: LOG_LEVEL
          valueFrom:
            configMapKeyRef:
              name: app-config
              key: LOG_LEVEL
      envFrom:
        - configMapRef:
            name: app-config
      volumeMounts:
        - name: config
          mountPath: /etc/app
  volumes:
    - name: config
      configMap:
        name: app-file
  • Values injected as env don’t pick up ConfigMap changes until the Pod restarts; volume mounts update automatically (except subPath mounts)
  • Docs: Configure a Pod to Use a ConfigMap

Secret

k create secret generic db-secret --from-literal=user=admin --from-literal=password=pass123  # create from key=value pairs
k create secret docker-registry regcred --docker-server=<registry> --docker-username=<u> --docker-password=<p>  # for private registry auth
k get secret db-secret -o jsonpath='{.data.password}' | base64 -d         # decode a value to check it
k set env deploy/web --from=secret/db-secret                              # inject the whole Secret as env
spec:
  imagePullSecrets:
    - name: regcred
  containers:
    - name: app
      image: nginx
      env:
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: password
      volumeMounts:
        - name: secret
          mountPath: /etc/secret
          readOnly: true
  volumes:
    - name: secret
      secret:
        secretName: db-secret

ServiceAccount

k create sa app-sa                      # create a ServiceAccount
k set serviceaccount deploy/web app-sa  # set the ServiceAccount on a Deployment
spec:
  serviceAccountName: app-sa
  automountServiceAccountToken: false

SecurityContext, capabilities

apiVersion: v1
kind: Pod
metadata:
  name: secure
spec:
  securityContext: # Pod level
    runAsUser: 1000
    runAsGroup: 3000
    fsGroup: 2000
  containers:
    - name: app
      image: busybox
      command: ["sh", "-c", "sleep 3600"]
      securityContext: # container level, overrides the Pod level
        runAsNonRoot: true
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          add: ["NET_ADMIN"]
          drop: ["ALL"]
FieldWhere
runAsUser, runAsGroup, runAsNonRootboth Pod and container
fsGroupPod only
capabilities, allowPrivilegeEscalation, readOnlyRootFilesystem, privilegedcontainer only

Services and Networking

Curriculum: basic understanding of NetworkPolicies, providing and troubleshooting access via Services, Ingress rules.

Service

k expose deploy/web --port=80 --target-port=8080 --name=web-svc  # ClusterIP Service, 80 -> container 8080
k expose deploy/web --port=80 --type=NodePort                    # NodePort Service
k create svc nodeport web --tcp=80:8080 --node-port=30080        # pin a specific nodePort
k run web --image=nginx --port=80 --expose                       # Pod + ClusterIP Service
typeReachable from
ClusterIPinside the cluster
NodePort<NodeIP>:<nodePort> (default range 30000-32767)
LoadBalancerexternal LB (cloud, etc.)
ExternalNameDNS CNAME
  • k create svc hardcodes the selector to app=<name>; edit the YAML for any other label
  • expose reuses the source resource’s selector

Troubleshooting order:

k get svc web-svc -o wide                                                 # selector, port
k get endpointslices -l kubernetes.io/service-name=web-svc                # are the endpoints empty?
k get pod -l app=web --show-labels                                        # do labels match the selector?
k run tmp --image=busybox --restart=Never --rm -it -- wget -qO- http://web-svc.<ns>.svc.cluster.local  # test calling the Service by FQDN

Ingress

k create ingress web --class=nginx --rule="foo.com/=web-svc:80"  # host foo.com, exact path / only (pathType Exact)
k create ingress web --rule="foo.com/api*=api-svc:8080"          # trailing * -> pathType Prefix (everything under /api)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
spec:
  ingressClassName: nginx
  rules:
    - host: foo.com
      http:
        paths:
          - path: /api
            pathType: Prefix # Exact / Prefix / ImplementationSpecific
            backend:
              service:
                name: api-svc
                port:
                  number: 8080
  • Check: k describe ingress web (backend endpoints), curl -H 'Host: foo.com' http://<ingress-ip>/api
  • Without an Ingress controller, the rules exist but do nothing
  • Docs: Ingress, kubectl create ingress

NetworkPolicy

No imperative command. Copy a docs example and edit it.

# block all ingress in the namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
spec:
  podSelector: {}
  policyTypes:
    - Ingress
---
# app=db only accepts 5432 from app=api; egress only DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: db-policy
spec:
  podSelector:
    matchLabels:
      app: db
  policyTypes:
    - Ingress
    - Egress
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app: api
      ports:
        - protocol: TCP
          port: 5432
  egress:
    - ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53

Combining selectors inside from:

# AND: must match both the namespace label and the Pod label
- from:
    - namespaceSelector:
        matchLabels:
          project: myproject
      podSelector:
        matchLabels:
          role: frontend
# OR: the whole namespace, or role=frontend Pods (in the policy's own namespace)
- from:
    - namespaceSelector:
        matchLabels:
          project: myproject
    - podSelector:
        matchLabels:
          role: frontend
  • A single - decides AND vs. OR
  • Policies add up as allow lists (OR); selected Pods block everything not explicitly allowed
  • With Egress in policyTypes but no DNS rule, Service name resolution fails
  • If the CNI doesn’t support NetworkPolicy, nothing is enforced
  • Check namespace labels: k get ns --show-labels (kubernetes.io/metadata.name is added automatically)
  • Docs: Network Policies

References