With the rising of cloud technologies, companies had a chance to create, deploy and manage their applications without paying upfront. In the old days, you need to buy some rack, network cables, servers, coolers, etc. It was taking too much time, and generally, huge tech companies took advantage of their vendor-locking technology stacks. You didn’t have much choice, right? With the free software movement and foundations like CNCF, standardization of the technologies becomes much more important. Nobody wants vendor-locking because it kills disruptive ideas.

Then suddenly Docker became popular(or is it?) and companies realized they don’t need to use the same stack for every problem with containerization technologies. You can choose your programming language, database, caching mechanisms, etc. If it works once, it works every time. Right? We all know it is not true. Distributed systems give us scalability, agility, availability, and all of the other good advantages. But what was the price for it? Operational costs and bigger complexity problems. You can run your container with whatever you want but in the end, you have a much more complex system than ever. How can you trace, monitor, and gets logs from every container? How about authentication, authorization, secret management, traffic management, and access control?

Those problems created solutions like Kubernetes. With this blog post and simple template project, we can learn more about Kubernetes deployments. It is a huge area to explore but I think deployments are a great place to start. At the end of the day, you need to enter this world with just a simple deployment. Kubernetes is generally used for companies that are using microservices and none of them changed their infrastructure in a day. They all started with a simple deployment. You don’t need to think about tracing, monitoring, secret management, etc. Don’t worry. Kubernetes will lead you to these problems. Focus on them one at a time.

Before reading the rest of the post, be sure that you have an idea about Kubernetes components and how they are working with each other. I am just gonna focus on the deployment side of the Kubernetes.

Motivation Link to heading

I can’t write every single aspect of Deployments in a simple blog post. My goal is to give you a simple template project that you can use for your projects. I am gonna explain the template project in detail. You can use it as a reference for your projects. Don’t worry there will be lots of tips and tricks along the way. Also, I’m gonna keep updating the project with new features.

Demo Requirements: Link to heading

Clone template project.

git clone https://github.com/fatihkc/ultimate-k8s-deployment-guide.git

Diagram Link to heading

Diagram

Kind Link to heading

Kind is a great tool for creating Kubernetes clusters without losing time. Normally you need virtual machines for installation but Kind is using containers as virtual machines. That is brilliant technology. Simple to use, need much fewer resources, and is fast. You can also use it for testing different Kubernetes versions and checking if your application is ready for an upgrade or not. You can always choose hard way. It is really good for understanding what is going on with your cluster. 3 years ago I was installing Kubernetes with Ansible and Vagrant. Check this project if you want to know more about it.

kind create cluster --config kind/cluster.yaml --name guide --image=kindest/node:v1.23.6

Helm Link to heading

Helm is a package manager for Kubernetes applications. If you are new to Helm, I don’t recommend creating a default template(helm create chart). Because it is more complicated than it needs to be. I am gonna write about important things for deployments and explain them one by one. Let’s start with our Chart.yaml file.

apiVersion: v2
name: helm-chart
description: A Helm chart for Kubernetes
type: application
version: 0.1.0
appVersion: "1.0.0"

All you need to focus on is the version and appVersion field. Why do we have two different version variables? Let’s say you have an application that runs with 1.0.0. You can increase it via semantic versioning tools and then pass it to the Helm chart. I recommend increasing the chart version is very important for this scenario. Also using appVersion for your image version tag is recommended. But you can update your chart without increasing the application version too. You can add or create new YAML files, and make improvements and appVersion can stay the same. Then you should only increase the version variable.

Templates Link to heading

Templates

Templates are your YAML files that use to create Kubernetes resources. The important thing is to divide them by their resource type and name them resource-type.yaml. Let’s create a simple deployment and check what they are using for.

Deployment Link to heading

apiVersion: apps/v1 # API version to use for all resources in the manifest
kind: Deployment # Kind of the resource to create
metadata:
  name: {{ .Release.Name }} # Name of the resource to create
  namespace: {{ .Release.Namespace }} # Namespace of the resource to create
  labels:
    app: {{ .Values.deployment.name }} # Label to apply to the resource
spec:
  replicas: {{ .Values.deployment.replicas }} # Number of replicas to create
  selector:
    matchLabels:
      app: {{ .Values.deployment.name }} # Label to select the resource
  template:
    metadata:
      labels:
        app: {{ .Values.deployment.name }} # Label to apply to the resource
    spec:
      containers:
        - name: {{ .Values.deployment.container.name }} # Name of the container in the pod
          image: {{ .Values.deployment.container.image }} # Image to use for the container
          imagePullPolicy: {{ .Values.deployment.container.imagePullPolicy }} # Image pull policy to use for the container
          ports:
            - containerPort: {{ .Values.deployment.container.port }} # Port to expose on the container
              protocol: {{ .Values.deployment.container.protocol }} # Protocol to use for the port

This might look a little bit complicated. How do we know where to write these keywords? There is two spec, multiple labels, and so many brackets. Well, you just need to check the documentation and learn how to read a YAML file. YAML files are all about spaces and keywords. As you can see, we say that I need to use apps/v1 API for my Deployment kind of resource. Kubernetes is just a big API server. Don’t forget that. There are many API’s in Kubernetes. Check them with;

kubectl api-version
kubectl api-resources

Then we give a name to the resource. Some people use names like “ReleaseName-deployment” but I prefer keeping it the same with the release name. Deployment is responsible for running multiple containers so we choose how many replicas we want. Selectors using for finding which pods we are gonna manage with Deployment. In the background, a Replica Set will be created it will be responsible for running the pods. If you are not familiar with Replica Sets, check this article.

Then we gave information about our containers. I only have one but it is enough for now. You can declare more containers in a pod.

Values Link to heading

What about the values that are all over the deployment.yaml? Well, they are saving so much time and you can able to use different values files for different environment files. You use most of the things as values and easily change them with values.yaml file. Like cluster.yaml for Kind. You can use them with {{ .Values.deployment.name }}. Just check values.yaml file.

Deployment strategy Link to heading

Deployment strategy is a way to control how many replicas are created. You can use different strategies for different deployments. I prefer RollingUpdate for seamless upgrades.

  strategy:
    type: RollingUpdate # Type of the deployment strategy
    rollingUpdate:
      maxSurge: {{ .Values.deployment.strategy.rollingUpdate.maxSurge }} # Maximum number of pods to create
      maxUnavailable: {{ .Values.deployment.strategy.rollingUpdate.maxUnavailable }} # Maximum number of pods to delete

Environment variables Link to heading

Environment variables are used to pass information to the containers. You can use them in your containers with $VARIABLE_NAME. I am using it for changing the USER variable. It will affect my application output.

  env:
    - name: USER
      value: {{ .Values.deployment.env.USER }} # Value of the environment variable

ConfigMap Link to heading

ConfigMap and environment variables are very similar. The only difference is when you change your environment variables changes, the pods are gonna restart and then take the new value. But ConfigMap is not gonna restart your pod. You need to restart it manually. If your application can handle it, it is a good idea to use ConfigMap.

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}
  namespace: {{ .Release.Namespace }}
data:
  USER: "{{ .Values.USER }}"

Secrets and Volumes Link to heading

    volumeMounts:
    - mountPath: "/tmp" # Mount path for the container
      name: test-volume # Name of the volume mount
      readOnly: true # Whether the volume is read-only
volumes:
  - name: test-volume # Name of the volume
    secret:  # Volume to use for the secret
      secretName: test # Name of the secret to use for the volume

Secrets are very similar to environment variables with small differences. If your variable contains sensitive information like database passwords and credential files for third-party services, you can use secrets. All of your YAML files are stored in etcd but secrets are encrypted. ConfigMaps are not encrypted. When you list your secret in Kubernetes you will see it is base64 encoded. You can use base64 decode to get the original value. So where is encryption and why does base64 encode? Let’s say your application uses credential files like Firebase. It has multiple lines with different syntax than just a simple password. Encoding it keeps the spaces and new lines. If you want to completely hide your secret, you can use solutions like Hashicorp Vault.

I could use it like ConfigMap but I prefer using volumes. Because your application logs can show your environment variables like secrets. If you prefer volumes, it is a long shot to expose secrets. If you have a configuration file, then it will be the perfect fit. You don’t use volume as your main storage. Instead, use persistent volumes. It is a really deep dive, just check this article. One last thing, don’t use secret.yaml like me. It is not a good practice. Don’t keep it in your repository. You can use it as a template and create it with Helm.

$ k exec -it webserver-5d7d6ccc8d-l8ftz cat /tmp/secret-file.txt
top secret

Container resources Link to heading

resources:
  requests:
    cpu: {{ .Values.deployment.container.resources.requests.cpu }} # CPU to request for the container
    memory: {{ .Values.deployment.container.resources.requests.memory }} # Memory to request for the container
  limits:
    cpu: {{ .Values.deployment.container.resources.limits.cpu }} # CPU to limit for the container
    memory: {{ .Values.deployment.container.resources.limits.memory }} # Memory to limit for the container

This part is a little bit tricky. Kube-scheduler is responsible for a simple decision. Which pod, which node? If you have a request for a pod, make sure that you will have enough CPU and memory in a node. It allocates your requests. You can use much more resources in a node but minimums are clear for kube-scheduler. Limits are responsible for top usage. Do you need them? If you are not an expert in this area, simply no. Because in a high traffic situation where pods need more resources, you are limiting it and that means not giving a response to high demand. We don’t want that right? Unless you have a different situation with your infrastructure. I am gonna use it for demo purposes. Use metrics-server for monitoring your pod’s usage.

Health probes Link to heading

livenessProbe:
  httpGet:
    path: {{ .Values.deployment.container.livenessProbe.path}} # Path to check for liveness
    port: {{ .Values.deployment.container.livenessProbe.port }} # Port to check for liveness
  initialDelaySeconds: {{ .Values.deployment.container.livenessProbe.initialDelaySeconds }} # Initial delay before liveness check
  timeoutSeconds: {{ .Values.deployment.container.livenessProbe.timeoutSeconds }} # Timeout before liveness check
readinessProbe:
  httpGet:
    path: {{ .Values.deployment.container.readinessProbe.path }} # Path to check for readiness
    port: {{ .Values.deployment.container.readinessProbe.port }} # Port to check for readiness
  initialDelaySeconds: {{ .Values.deployment.container.readinessProbe.initialDelaySeconds }} # Initial delay before readiness check
  timeoutSeconds: {{ .Values.deployment.container.readinessProbe.timeoutSeconds }} # Timeout before readiness check

Health probes are really important for the availability of your application. You don’t want to send a request to the failed pod, right? Liveness probes are used for understanding whether or not your pod can accept traffic. If it fails, it kills the pod and restarts it. Let’s say it is ready for accepting connections. But is it ready for action? Readiness probes are used for checking third-party dependencies. Can you reach the database? Is another related service alive or not? Can pod achieve its job?

Liveness probes must be simple like sending a ping. On the other hand, readiness probes must be sure that they can accept traffic. Otherwise, other ready pods will handle the traffic. A piece of advice, don’t check third-party dependencies in liveness because it can kill all of your applications. Check this awesome article about health probes. These probes are not coming out of the box, unfortunately. Your application code must handle them by exposing the application’s health status. We have HTTP health probes. What about gRPC connections? Well, that’s another adventure to discover.

Security Context Link to heading

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  runAsUser: 1000
  capabilities:
    drop:
    - ALL

The security context is one of the most important things about deployment. This mechanism is changing with the new Kubernetes releases but the idea is still the same. Do you want to allow privilege escalation? No. Read-only filesystem? Hell yeah. And more things like that. Don’t forget to drop all capabilities. Check out documentation about security contexts.

Affinity Link to heading

affinity:
  nodeAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 10 # Weight of the node affinity
        preference:
          matchExpressions:
          - key: kubernetes.io/arch # Key of the node affinity
            operator: In # Operator to use for the node affinity
            values:
            - arm64 # Value of the node affinity
      - weight: 10 # Weight of the node affinity
        preference:
          matchExpressions:
          - key: kubernetes.io/os # Key of the node affinity
            operator: In # Operator to use for the node affinity
            values:
            - linux # Value of the node affinity

Affinity is really good for large environments. You can have different types of nodes. They can have different architecture, operating systems, sizes, etc. For example, I’m using an affinity for Karpenter. Karpenter allows you to scale out within 60 seconds. If your pod needs resources and can’t find them, Karpenter creates a new node and assign your pod. I’m using EC2 Spot instances for this purpose. You just need to choose your deployments and make them scalable with Karpenter. Affinity is making sure that this pod will run on the nodes that have required labels. In our example, I used architecture and operating system but If you have solutions like Karpenter, It becomes much more important.

Topology Spread Constraints Link to heading

topologySpreadConstraints:
  - maxSkew: 1 # Maximum number of pods to spread
    topologyKey: "topology.kubernetes.io/zone" # Key to use for spreading
    whenUnsatisfiable: ScheduleAnyway # Action to take if the constraint is not satisfied
    labelSelector:
      matchLabels:
        app: {{ .Release.Name }} # Label to select the resource
  - maxSkew: 1
    topologyKey: "kubernetes.io/hostname" # Key to use for spreading
    whenUnsatisfiable: ScheduleAnyway # Action to take if the constraint is not satisfied
    labelSelector:
      matchLabels:
        app: {{ .Release.Name }} # Label to select the resource

Now we are sure that our application will run on arm64 architecture with Linux operating system. What if all of our pods run on the same node? If that node is terminated then our application will not available. We must spread them. Topology spread constraints allow us to make sure our pod will run on different hosts, zone, or any other topology. For demo purposes I only chose hostname.

Service Link to heading

Service is a Kubernetes resource that allows you to expose your application. It is a load balancer for your pods. You can use it for internal or external traffic. I choose NodePort for my service. It is a simple way to expose your application. You can use it for testing purposes. It exposes a port on each node. You can access your application with the node’s IP address and the port.

apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }} # Name of the service
  namespace: {{ .Release.Namespace }} # Namespace of the service
spec:
  type: NodePort
  selector:
    app: {{ .Values.service.selector.name }}
  ports:
    - protocol: {{ .Values.service.ports.protocol }}
      port: {{ .Values.service.ports.port }}
      targetPort: {{ .Values.service.ports.targetPort }}
      nodePort: {{ .Values.service.ports.nodePort }}

Action Link to heading

Now we are ready to deploy our application. We have a chart and resource templates. We can use the helm install command for that.

helm upgrade --install webserver helm-chart -f helm-chart/values.yaml -n $NAMESPACE

I generally use “upgrade –install” commands instead of “install” because I can use the same command for updating my application. If anything is missing, it will install it. If something changed, it will update it. Let’s check our resources.

kubectl get all -n $NAMESPACE

NAME                             READY   STATUS    RESTARTS   AGE   IP            NODE            NOMINATED NODE   READINESS GATES
pod/webserver-5d7d6ccc8d-l8ftz   1/1     Running   0          83m   10.244.1.11   guide-worker    <none>           <none>
pod/webserver-5d7d6ccc8d-ncdxq   1/1     Running   0          83m   10.244.2.7    guide-worker2   <none>           <none>
pod/webserver-5d7d6ccc8d-xpljk   1/1     Running   0          82m   10.244.1.13   guide-worker    <none>           <none>

NAME                 TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)          AGE   SELECTOR
service/kubernetes   ClusterIP   10.96.0.1      <none>        443/TCP          46h   <none>
service/webserver    NodePort    10.96.29.110   <none>        8080:30000/TCP   23h   app=webserver

NAME                        READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS   IMAGES                SELECTOR
deployment.apps/webserver   3/3     3            3           23h   webserver    fatihkoc/app:latest   app=webserver

NAME                                   DESIRED   CURRENT   READY   AGE   CONTAINERS   IMAGES                SELECTOR
replicaset.apps/webserver-5d7d6ccc8d   3         3         3       83m   webserver    fatihkoc/app:latest   app=webserver,pod-template-hash=5d7d6ccc8d
replicaset.apps/webserver-68667fc8c7   0         0         0       23h   webserver    fatihkoc/app:latest   app=webserver,pod-template-hash=68667fc8c7
replicaset.apps/webserver-689c788945   0         0         0       23h   webserver    fatihkoc/app:latest   app=webserver,pod-template-hash=689c788945

Everything looks ready. Let’s access our app and see how it works.

curl http://localhost:30000
Hello, Fatih! Your secret is: top secret

You might think why I used port 30000. Well, the easiest way to access your application is to use NodePort. You can use LoadBalancer or Ingress but I don’t want to make it complicated. In Kind configuration, I exposed port 8080 to 30000. You can change it on your own.

Conclusion Link to heading

In this blog post, we checked most of the components about Deployment. Of course, there are tons of things to learn. I tried to make it simple and easy to understand. I hope you enjoyed it. If you have any questions, feel free to ask.