A Kubernetes Walkthrough
Here are all the notes I need to get me from zero to installation, to deployment, ingress, encryption and monitoring of a service!
Create a Cluster
You can use various k8s implementations: Minikube or Rancher (with k3s) are good starting points, but check out minikube, k8s, k3s, k0s, microk8s, etc. They each have their useful features (like minikube tunnel
, minikube ip
or traefik
), but are generally compatible with each other.
To administer your kubernetes service, you must have an administration tool - kubectl
.
Check your k8s is up and running:
$ minikube version
minikube version: v1.18.0
commit: ec61815d60f66a6e4f6353030a40b12362557caa-dirty
$ minikube start
* minikube v1.18.0 on Ubuntu 18.04 (amd64)
* Using the none driver based on existing profile
X The requested memory allocation of 2200MiB does not leave room for system overhead (total system memory: 2460MiB). You may face stability issues.
* Suggestion: Start minikube with less memory allocated: 'minikube start --memory=2200mb'
* Starting control plane node minikube in cluster minikube
* Restarting existing none bare metal machine for "minikube" ...
* OS release is Ubuntu 18.04.5 LTS
* Preparing Kubernetes v1.20.2 on Docker 19.03.13 ...
- kubelet.resolv-conf=/run/systemd/resolve/resolv.conf
* Configuring local host environment ...
* Verifying Kubernetes components...
- Using image gcr.io/k8s-minikube/storage-provisioner:v4
* Enabled addons: storage-provisioner, default-storageclass
* Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default
Check that kubectl is installed:
$ kubectl version
Client Version: version.Info{Major:"1", Minor:"20", GitVersion:"v1.20.4", GitCommit:"e87da0bd6e03ec3fea7933c4b5263d151aafd07c", GitTreeState:"clean", BuildDate:"2021-02-18T16:12:00Z", GoVersion:"go1.15.8", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"20", GitVersion:"v1.20.2", GitCommit:"faecb196815e248d3ecfb03c680a4507229c2a56", GitTreeState:"clean", BuildDate:"2021-01-13T13:20:00Z", GoVersion:"go1.15.5", Compiler:"gc", Platform:"linux/amd64"}
Get info about cluster:
$ kubectl cluster-info
Kubernetes control plane is running at https://10.0.0.6:8443
KubeDNS is running at https://10.0.0.6:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.
View node info:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
minikube Ready control-plane,master 77s v1.20.2
Deploy an App
Create a deployment with a publicly available docker image:
$ kubectl create deployment kubernetes-bootcamp --image=gcr.io/google-samples/kubernetes-bootcamp:v1
deployment.apps/kubernetes-bootcamp created
Show deployments:
$ kubectl get deployments
NAME READY UP-TO-DATE AVAILABLE AGE
kubernetes-bootcamp 1/1 1 1 68s
Create a proxy that will forward requests into the kubernetes private network:
$ kubectl proxy
Starting to serve on 127.0.0.1:8001
You can then make simple requests to this proxy, like this:
$ curl http://localhost:8001/version
{
"major": "1",
"minor": "20",
"gitVersion": "v1.20.2",
"gitCommit": "faecb196815e248d3ecfb03c680a4507229c2a56",
"gitTreeState": "clean",
"buildDate": "2021-01-13T13:20:00Z",
"goVersion": "go1.15.5",
"compiler": "gc",
"platform": "linux/amd64"
In order to pass more sophisticated messages to one of our managed applications, we need its id:
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
kubernetes-bootcamp-57978f5f5d-c4nng 1/1 Running 0 10m
$ kubectl get pods -o go-template --template '\n'
kubernetes-bootcamp-57978f5f5d-c4nng
$ export POD_NAME=$(kubectl get pods -o go-template --template '\n')
Then we can send messages to this pod:
$ curl http://localhost:8001/api/v1/namespaces/default/pods/$POD_NAME/proxy
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-c8m9c | v=1
We can also message the proxy api directly:
$ curl http://localhost:8001/api/v1/namespaces/default/pods/$POD_NAME
This requests shows you the full pod configuration:
{
"kind": "Pod",
"apiVersion": "v1",
"metadata": {
"name": "kubernetes-bootcamp-57978f5f5d-c4nng",
"generateName": "kubernetes-bootcamp-57978f5f5d-",
"namespace": "default",
"uid": "8fe45ce3-0a35-4149-a6be-2117ee7cbf7f",
"resourceVersion": "997",
"creationTimestamp": "2022-08-24T12:26:29Z",
"labels": {
"app": "kubernetes-bootcamp",
"pod-template-hash": "57978f5f5d"
},
"ownerReferences": [
{
"apiVersion": "apps/v1",
"kind": "ReplicaSet",
"name": "kubernetes-bootcamp-57978f5f5d",
"uid": "b6042aad-5fec-4a22-bca5-e6c562aaf6ab",
"controller": true,
"blockOwnerDeletion": true
}
],
"managedFields": [
{
"manager": "kube-controller-manager",
"operation": "Update",
"apiVersion": "v1",
"time": "2022-08-24T12:26:29Z",
"fieldsType": "FieldsV1",
"fieldsV1": {"f:metadata":{"f:generateName":{},"f:labels":{".":{},"f:app":{},"f:pod-template-hash":{}},"f:ownerReferences":{".":{},"k:{\"uid\":\"b6042aad-5fec-4a22-bca5-e6c562aaf6ab\"}":{".":{},"f:apiVersion":{},"f:blockOwnerDeletion":{},"f:controller":{},"f:kind":{},"f:name":{},"f:uid":{}}}},"f:spec":{"f:containers":{"k:{\"name\":\"kubernetes-bootcamp\"}":{".":{},"f:image":{},"f:imagePullPolicy":{},"f:name":{},"f:resources":{},"f:terminationMessagePath":{},"f:terminationMessagePolicy":{}}},"f:dnsPolicy":{},"f:enableServiceLinks":{},"f:restartPolicy":{},"f:schedulerName":{},"f:securityContext":{},"f:terminationGracePeriodSeconds":{}}}
},
{
"manager": "kubelet",
"operation": "Update",
"apiVersion": "v1",
"time": "2022-08-24T12:26:32Z",
"fieldsType": "FieldsV1",
"fieldsV1": {"f:status":{"f:conditions":{"k:{\"type\":\"ContainersReady\"}":{".":{},"f:lastProbeTime":{},"f:lastTransitionTime":{},"f:status":{},"f:type":{}},"k:{\"type\":\"Initialized\"}":{".":{},"f:lastProbeTime":{},"f:lastTransitionTime":{},"f:status":{},"f:type":{}},"k:{\"type\":\"Ready\"}":{".":{},"f:lastProbeTime":{},"f:lastTransitionTime":{},"f:status":{},"f:type":{}}},"f:containerStatuses":{},"f:hostIP":{},"f:phase":{},"f:podIP":{},"f:podIPs":{".":{},"k:{\"ip\":\"172.18.0.6\"}":{".":{},"f:ip":{}}},"f:startTime":{}}}
}
]
},
"spec": {
"volumes": [
{
"name": "default-token-9dbv6",
"secret": {
"secretName": "default-token-9dbv6",
"defaultMode": 420
}
}
],
"containers": [
{
"name": "kubernetes-bootcamp",
"image": "gcr.io/google-samples/kubernetes-bootcamp:v1",
"resources": {
},
"volumeMounts": [
{
"name": "default-token-9dbv6",
"readOnly": true,
"mountPath": "/var/run/secrets/kubernetes.io/serviceaccount"
}
],
"terminationMessagePath": "/dev/termination-log",
"terminationMessagePolicy": "File",
"imagePullPolicy": "IfNotPresent"
}
],
"restartPolicy": "Always",
"terminationGracePeriodSeconds": 30,
"dnsPolicy": "ClusterFirst",
"serviceAccountName": "default",
"serviceAccount": "default",
"nodeName": "minikube",
"securityContext": {
},
"schedulerName": "default-scheduler",
"tolerations": [
{
"key": "node.kubernetes.io/not-ready",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
},
{
"key": "node.kubernetes.io/unreachable",
"operator": "Exists",
"effect": "NoExecute",
"tolerationSeconds": 300
}
],
"priority": 0,
"enableServiceLinks": true,
"preemptionPolicy": "PreemptLowerPriority"
},
"status": {
"phase": "Running",
"conditions": [
{
"type": "Initialized",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2022-08-24T12:26:29Z"
},
{
"type": "Ready",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2022-08-24T12:26:32Z"
},
{
"type": "ContainersReady",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2022-08-24T12:26:32Z"
},
{
"type": "PodScheduled",
"status": "True",
"lastProbeTime": null,
"lastTransitionTime": "2022-08-24T12:26:29Z"
}
],
"hostIP": "10.0.0.8",
"podIP": "172.18.0.6",
"podIPs": [
{
"ip": "172.18.0.6"
}
],
"startTime": "2022-08-24T12:26:29Z",
"containerStatuses": [
{
"name": "kubernetes-bootcamp",
"state": {
"running": {
"startedAt": "2022-08-24T12:26:31Z"
}
},
"lastState": {
},
"ready": true,
"restartCount": 0,
"image": "jocatalin/kubernetes-bootcamp:v1",
"imageID": "docker-pullable://jocatalin/kubernetes-bootcamp@sha256:0d6b8ee63bb57c5f5b6156f446b3bc3b3c143d233037f3a2f00e279c8fcc64af",
"containerID": "docker://f47b1cd0383064d111cc112310e3fb3680a0ec7874817cfbac7473b0a6ecb186",
"started": true
}
],
"qosClass": "BestEffort"
}
}
This is the reference that kubernetes checks to ensure that what is actually running is consistent with the configuration that we want to be running. Any differences are automatically rectified by the kubernetes controller.
You will usually communicate with a pod without creating this proxy, though. Instead, you will create a Service.
App Status
There are a couple of go-to ways to get information about pods, nodes, services, deployments, etc. get
and describe
work with most objects.
$ kubectl get nodes
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
lima-rancher-desktop Ready control-plane,master 11d v1.24.4+k3s1
Outputting in wide format lets you see more information about objects.
$ kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP OS-IMAGE KERNEL-VERSION CONTAINER-RUNTIME
lima-rancher-desktop Ready control-plane,master 11d v1.24.4+k3s1 192.168.1.62 <none> Alpine Linux v3.16 5.15.57-0-virt containerd://1.6.6
describe
gives more detail, and shows recent events that might be useful to know about:
$ kubectl describe nodes
Name: lima-rancher-desktop
Roles: control-plane,master
Labels: beta.kubernetes.io/arch=amd64
beta.kubernetes.io/instance-type=k3s
beta.kubernetes.io/os=linux
egress.k3s.io/cluster=true
kubernetes.io/arch=amd64
kubernetes.io/hostname=lima-rancher-desktop
kubernetes.io/os=linux
node-role.kubernetes.io/control-plane=true
node-role.kubernetes.io/master=true
node.kubernetes.io/instance-type=k3s
Annotations: alpha.kubernetes.io/provided-node-ip: 192.168.1.62
flannel.alpha.coreos.com/backend-data: {"VNI":1,"VtepMAC":"9e:dd:b2:30:cf:33"}
flannel.alpha.coreos.com/backend-type: vxlan
flannel.alpha.coreos.com/kube-subnet-manager: true
flannel.alpha.coreos.com/public-ip: 192.168.1.62
k3s.io/hostname: lima-rancher-desktop
k3s.io/internal-ip: 192.168.1.62
k3s.io/node-args:
["server","--https-listen-port","6443","--flannel-iface","rd0","--disable","traefik","--container-runtime-endpoint","/run/k3s/containerd/c...
k3s.io/node-config-hash: G4I3ATTKUNLCZ2PTZF77CHL2LW2RZWWPWVR3A32X4RKONERQ6UCA====
k3s.io/node-env: {"K3S_DATA_DIR":"/var/lib/rancher/k3s/data/577968fa3d58539cc4265245941b7be688833e6bf5ad7869fa2afe02f15f1cd2"}
node.alpha.kubernetes.io/ttl: 0
volumes.kubernetes.io/controller-managed-attach-detach: true
CreationTimestamp: Tue, 23 Aug 2022 18:07:39 +0100
Taints: <none>
Unschedulable: false
Lease:
HolderIdentity: lima-rancher-desktop
AcquireTime: <unset>
RenewTime: Sun, 04 Sep 2022 15:43:19 +0100
Conditions:
Type Status LastHeartbeatTime LastTransitionTime Reason Message
---- ------ ----------------- ------------------ ------ -------
MemoryPressure False Sun, 04 Sep 2022 15:42:39 +0100 Fri, 02 Sep 2022 23:40:25 +0100 KubeletHasSufficientMemory kubelet has sufficient memory available
DiskPressure False Sun, 04 Sep 2022 15:42:39 +0100 Fri, 02 Sep 2022 23:40:25 +0100 KubeletHasNoDiskPressure kubelet has no disk pressure
PIDPressure False Sun, 04 Sep 2022 15:42:39 +0100 Fri, 02 Sep 2022 23:40:25 +0100 KubeletHasSufficientPID kubelet has sufficient PID available
Ready True Sun, 04 Sep 2022 15:42:39 +0100 Sat, 03 Sep 2022 20:04:03 +0100 KubeletReady kubelet is posting ready status
Addresses:
InternalIP: 192.168.1.62
Hostname: lima-rancher-desktop
Capacity:
cpu: 2
ephemeral-storage: 102625208Ki
hugepages-1Gi: 0
hugepages-2Mi: 0
memory: 6091188Ki
pods: 110
Allocatable:
cpu: 2
ephemeral-storage: 99833802265
hugepages-1Gi: 0
hugepages-2Mi: 0
memory: 6091188Ki
pods: 110
System Info:
Machine ID: a54277578e5fd09a29516dc8b2a2a049
System UUID: a54277578e5fd09a29516dc8b2a2a049
Boot ID: 8ff701c3-731e-4676-8ffe-8322150e7807
Kernel Version: 5.15.57-0-virt
OS Image: Alpine Linux v3.16
Operating System: linux
Architecture: amd64
Container Runtime Version: containerd://1.6.6
Kubelet Version: v1.24.4+k3s1
Kube-Proxy Version: v1.24.4+k3s1
PodCIDR: 10.42.0.0/24
PodCIDRs: 10.42.0.0/24
ProviderID: k3s://lima-rancher-desktop
Non-terminated Pods: (8 in total)
Namespace Name CPU Requests CPU Limits Memory Requests Memory Limits Age
--------- ---- ------------ ---------- --------------- ------------- ---
kube-system svclb-lb-blog-98ecb8d3-ggnkv 0 (0%) 0 (0%) 0 (0%) 0 (0%) 6d3h
default blog-f496555c7-m72qc 0 (0%) 0 (0%) 0 (0%) 0 (0%) 23h
default blog-f496555c7-f7w7l 0 (0%) 0 (0%) 0 (0%) 0 (0%) 2d5h
default blog-f496555c7-q7wjt 0 (0%) 0 (0%) 0 (0%) 0 (0%) 2d5h
default ingress-nginx-controller-6bf7bc7f94-m78b8 100m (5%) 0 (0%) 90Mi (1%) 0 (0%) 19h
kube-system metrics-server-668d979685-6fnj7 100m (5%) 0 (0%) 70Mi (1%) 0 (0%) 11d
kube-system coredns-b96499967-5gtw5 100m (5%) 0 (0%) 70Mi (1%) 170Mi (2%) 11d
kube-system local-path-provisioner-7b7dc8d6f5-kls56 0 (0%) 0 (0%) 0 (0%) 0 (0%) 11d
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 300m (15%) 0 (0%)
memory 230Mi (3%) 170Mi (2%)
ephemeral-storage 0 (0%) 0 (0%)
hugepages-1Gi 0 (0%) 0 (0%)
hugepages-2Mi 0 (0%) 0 (0%)
Events: <none>
You can use kubectl to examine logs, or inspect the pod environment:
$ kubectl logs $POD_NAME
Kubernetes Bootcamp App Started At: 2022-08-24T12:55:49.787Z | Running On: kubernetes-bootcamp-fb5c67579-c8m9c
Running On: kubernetes-bootcamp-fb5c67579-c8m9c | Total Requests: 1 | App Uptime: 493.64 seconds | Log Time: 2022-08-24T13:04:03.427Z
Running On: kubernetes-bootcamp-fb5c67579-c8m9c | Total Requests: 2 | App Uptime: 539.727 seconds | Log Time: 2022-08-24T13:04:49.514Z
$ kubectl exec $POD_NAME -- env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=kubernetes-bootcamp-fb5c67579-c8m9c
KUBERNETES_PORT_443_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
KUBERNETES_SERVICE_HOST=10.96.0.1
KUBERNETES_SERVICE_PORT=443
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
NPM_CONFIG_LOGLEVEL=info
NODE_VERSION=6.3.1
HOME=/root
…or even open a shell:
$ kubectl exec -ti $POD_NAME -- bash
root@kubernetes-bootcamp-fb5c67579-c8m9c:/#
You can also use top
commands to get resource stats:
$ kubectl top node
NAME CPU(cores) CPU% MEMORY(bytes) MEMORY%
lima-rancher-desktop 791m 39% 1496Mi 25%
$ kubectl top pod
NAME CPU(cores) MEMORY(bytes)
blog-f496555c7-7fn7z 0m 2Mi
blog-f496555c7-fcc8z 0m 7Mi
blog-f496555c7-whnml 0m 2Mi
cm-acme-http-solver-hkpw6 0m 3Mi
Monitoring
Lens
Lens is a nice UI tool for viewing and managing k8s clusters.
$ brew install --cask lens
Prometheus
Prometheus and Grafana are a useful combination for monitoring your cluster. A seperarate note to come on these.. In the meantime, see: Control plane metrics with Prometheus - Amazon EKS
file:///Volumes/protected_data_1/Media/YouTube%20Notes/Professional/DevOps/Kubernetes/Jeff_Geerling/20210210-Kubernetes_101-_Episode_10-_Monitoring_with_Lens_Prometheus_and_Grafana-zW-E8THfvPY.mp4
Creating a Service
A service defines a collection of pods and a policy and IP address by which to access them. A default kubernetes service is instantiated when the cluster is started:
$ kubectl get services
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 25s
To access this externally, we need the expose
command.
$ kubectl expose deployment/kubernetes-bootcamp --type="NodePort" --port 8080
service/kubernetes-bootcamp exposed
$ kubectl get services
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 3m49s
kubernetes-bootcamp NodePort 10.100.188.108 <none> 8080:31601/TCP 69s
For creating an external web server, you’d want to use a LoadBalancer
type of service. This is usually provided by cloud services, and is not available on minikube. However, k3s comes with traefik (although I actually prefer to make use of ingress-nginx)
We can then interrogate the service to view its configuration:
$ kubectl describe services/kubernetes-bootcamp
Name: kubernetes-bootcamp
Namespace: default
Labels: app=kubernetes-bootcamp
Annotations: <none>
Selector: app=kubernetes-bootcamp
Type: NodePort
IP Families: <none>
IP: 10.100.188.108
IPs: 10.100.188.108
Port: <unset> 8080/TCP
TargetPort: 8080/TCP
NodePort: <unset> 31601/TCP
Endpoints: 172.18.0.2:8080
Session Affinity: None
External Traffic Policy: Cluster
Events: <none>
To parse the specific node port ID, we can use:
$ kubectl get services/kubernetes-bootcamp -o go-template=''
31601
$ export NODE_PORT=$(kubectl get services/kubernetes-bootcamp -o go-template='')
$ echo NODE_PORT=$NODE_PORT
NODE_PORT=31601
And to access the application via the service:
$ curl $(minikube ip):$NODE_PORT
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-sfbjd | v=1
Labels
A label will automatically be generated. You can see this with describe deployment
$ kubectl describe deployment
Name: kubernetes-bootcamp
Namespace: default
CreationTimestamp: Wed, 24 Aug 2022 16:39:19 +0000
Labels: app=kubernetes-bootcamp
Annotations: deployment.kubernetes.io/revision: 1
Selector: app=kubernetes-bootcamp
Replicas: 1 desired | 1 updated | 1 total | 1 available | 0 unavailable
StrategyType: RollingUpdate
MinReadySeconds: 0
RollingUpdateStrategy: 25% max unavailable, 25% max surge
Pod Template:
Labels: app=kubernetes-bootcamp
Containers:
kubernetes-bootcamp:
Image: gcr.io/google-samples/kubernetes-bootcamp:v1
Port: 8080/TCP
Host Port: 0/TCP
Environment: <none>
Mounts: <none>
Volumes: <none>
Conditions:
Type Status Reason
---- ------ ------
Available True MinimumReplicasAvailable
Progressing True NewReplicaSetAvailable
OldReplicaSets: <none>
NewReplicaSet: kubernetes-bootcamp-fb5c67579 (1/1 replicas created)
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal ScalingReplicaSet 10m deployment-controller Scaled up replica set kubernetes-bootcamp-fb5c67579 to 1
Here you can see that the deployment label is app=kubernetes-bootcamp
You can use this to view the pods and services of the deployment:
$ kubectl get pods -l app=kubernetes-bootcamp
NAME READY STATUS RESTARTS AGE
kubernetes-bootcamp-fb5c67579-sfbjd 1/1 Running 0 20m
$ kubectl get services -l app=kubernetes-bootcamp
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
kubernetes-bootcamp NodePort 10.100.188.108 <none> 8080:31601/TCP 18m
We can also add new labels to a pod with:
$ kubectl label pods $POD_NAME version=v1
pod/kubernetes-bootcamp-fb5c67579-sfbjd labeled
Deleting a service
You can use a label to delete a service:
$ kubectl delete service -l app=kubernetes-bootcamp
service "kubernetes-bootcamp" deleted
The pod and node will continue as before, simply no longer exposed. To shut down the application, you need to also delete the deployment.
Scaling Kubernetes
I’ve split my notes about k8s scaling solutions in two:
- [[Kubernetes Application Scaling]] deals with scaling workloads by creating more pods.
- [[Kubernetes Load Balancing]] considers exposing those pods in services and load balancing work between them.
Deployments
Rolling Updates
Performing a rolling update is simple: Update the configuration so that a new version of the application image is specified:
$ kubectl set image deployments/kubernetes-bootcamp kubernetes-bootcamp=jocatalin/kubernetes-bootcamp:v2
deployment.apps/kubernetes-bootcamp image updated
Once the deployment application is changed, kubernetes will automatically migrate the deployment to match the specification. By default, only 1 image becomes unavailable at any 1 time.
$ kubectl rollout status deployments/kubernetes-bootcamp
deployment "kubernetes-bootcamp" successfully rolled out
If a deployment fails or has a problem, you can rollback to the last known good state:
$ kubectl rollout undo deployments/kubernetes-bootcamp
deployment.apps/kubernetes-bootcamp rolled back
Rollback
kubectl rollout history deployment blog
...
kubectl rollout undo deployment blog
For more sophisticated deployment, check out Argo CD (blog post coming soon).