Deploy Any Python Project to Kubernetes

As your project grows, it might get to the point that it becomes too hard to handle with just single VM or some simple SaaS solution. You can solve that by switching to more robust solution like Kubernetes. That might however, be little too complex if you are not familiar with it's concepts or if just never used it before. So, to help you out - in this article - we will go over all you need to get yourself started and have your Python project deployed on cluster - including cluster setup, all the Kubernetes manifests and some extra automation to make your life easier down the road!

This is a follow up to previous article(s) about Automating Every Aspect of Your Python Project, so you might want check that out before reading this one.

TL;DR: Here is my repository with full source code and docs: https://github.com/MartinHeinz/python-project-blueprint

Comfy Development Setup

To be productive in your development process, you need to have comfy local development setup. Which in this case means having simple to use Kubernetes on local, closely mirroring your real, production cluster and for that, we will use KinD:

KinD (Kubernetes-in-Docker), as the name implies, runs Kubernetes clusters in Docker containers. It is the official tool used by Kubernetes maintainers for Kubernetes v1.11+ conformance testing. It supports multi-node clusters as well as HA clusters. Because it runs K8s in Docker, KinD can run on Windows, Mac, and Linux. So, you can run it anywhere, you just need Docker installed.

So, let's install KinD (on Linux - if you are on Windows, see installation info here):


~ $ curl -Lo ./kind https://github.com/kubernetes-sigs/kind/releases/download/v0.7.0/kind-$(uname)-amd64
~ $ chmod +x ./kind
~ $ sudo mv ./kind /usr/local/bin/kind
~ $ kind --version
kind version 0.7.0

With that, we are ready to setup our cluster. For that we will need following YAML file:


kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  kubeadmConfigPatches:
  - |
    kind: InitConfiguration
    nodeRegistration:
      kubeletExtraArgs:
        node-labels: "ingress-ready=true"
        authorization-mode: "AlwaysAllow"
  extraPortMappings:
  - containerPort: 80
    hostPort: 80
    protocol: TCP
  - containerPort: 443
    hostPort: 443
    protocol: TCP
- role: worker
- role: worker

This manifest describes our cluster. It will have 3 nodes - control plane (role: control-plane) and 2 workers role: worker. We are also giving it a few more settings and arguments to make it possible to setup ingress controller later, so that we can have HTTPS on this cluster. All you need to know about those settings, is that extraPortMappings tells cluster to forward ports from the host to an ingress controller running on a node.

Note: All the manifests for both cluster and Python application are available in my repo here in k8s directory.

Now, we need to run few commands to bring it up:


$ ~ kind create cluster --config kind-config.yaml --name cluster
$ ~ kubectl cluster-info --context kind-cluster
Kubernetes master is running at https://127.0.0.1:32769
KubeDNS is running at https://127.0.0.1:32769/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

$ ~ kubectl get nodes
NAME                    STATUS    ROLES     AGE       VERSION
cluster-control-plane   Ready     master    2m39s     v1.17.0
cluster-worker          Ready     <none>    2m3s      v1.17.0
cluster-worker2         Ready     <none>    2m3s      v1.17.0

To create cluster, we just need to run the first command. After that we can check whether it's good to go by running cluster-info and get nodes commands. Typing out these commands gets annoying after some time, so we will Make it simpler later, but with that we have cluster up and running.

Next, we want to setup ingress for our cluster. For that we have to run a few kubectl commands to make it work with KinD:


~ $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/static/mandatory.yaml
~ $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/static/provider/baremetal/service-nodeport.yaml
~ $ kubectl patch deployments -n ingress-nginx nginx-ingress-controller -p \
  '{"spec":{"template":{"spec":{"containers":[{"name":"nginx-ingress-controller","ports":[{"containerPort":80,"hostPort":80},{"containerPort":443,"hostPort":443}]}],"nodeSelector":{"ingress-ready":"true"},"tolerations":[{"key":"node-role.kubernetes.io/master","operator":"Equal","effect":"NoSchedule"}]}}}}'

First we deploy mandatory ingress-nginx components. On top of that we expose the nginx service using NodePort, which is what the second command does. Last command applies some KinD specific patches for ingress controller.

Defining Manifests

With cluster ready, it's time to setup and deploy our application. For that we will use very simple Flask application - echo server:


# __main__.py

from flask import Flask, request

app = Flask(__name__)


@app.route("/")
def echo():
    return f"You said: {request.args.get('text', '')}\n"


if __name__ == "__main__":
    app.run(host='0.0.0.0', port=5000)

I chose Flask application instead of some CLI tool (or Python package), as we need the application that won't terminate instantaneously, as some Python package would. Also, notice host parameter being set to 0.0.0.0, without this it would not be possible to reach the application when we expose it through Kubernetes service and ingress.

Next thing we need is YAML manifests for this application, let's break it up into separate objects:

- Namespace:


apiVersion: v1
kind: Namespace
metadata:
  name: blueprint

Nothing to really talk about here. We generally don't want to deploy applications in default namespace, so this is the one will use instead.

- ConfigMap:


apiVersion: v1
kind: ConfigMap
metadata:
  name: env-config-blueprint
  namespace: blueprint
data:  # Example vars that will get picked up by Flask application
  FLASK_ENV: development
  FLASK_DEBUG: "1"

This is where we can define variables for the application. These vars from data section will be injected into application container as environment variables. As an example, I included FLASK_ENV and FLASK_DEBUG, which will be picked up by Flask automatically when the application starts.

- Secret:


apiVersion: v1
kind: Secret
metadata:
  name: env-secrets-blueprint
  namespace: blueprint
data:
  VAR: VkFMVUU=  # base64 of "VALUE"

The same way as we specified plaintext vars, we can use Secret to add things like credential and keys to our application. This object should however, not be pushed to your repository as it contains sensitive data. We can create it dynamically with following command:


~ $ kubectl create secret generic env-secrets-blueprint -n blueprint \
    --from-literal=VAR=VALUE \
    --from-literal=VAR2=VALUE2 \
    --dry-run -o yaml >> app.yaml

Note: This and other commands, needed to deploy the application are listed in README in repository as well as at the bottom manifests file here

- Deployment:


apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: blueprint
  name: blueprint
  namespace: blueprint
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: blueprint
    spec:
      containers:
#        - image: docker.pkg.github.com/martinheinz/python-project-blueprint/example:flask
#          ^^^^^  https://github.com/containerd/containerd/issues/3291 https://github.com/kubernetes-sigs/kind/issues/870
        - image: martinheinz/python-project-blueprint:flask
          name: blueprint
          imagePullPolicy: Always
          ports:
            - containerPort: 5000
              protocol: TCP
          envFrom:
            - configMapRef:
                name: env-config-blueprint
            - secretRef:
                name: env-secrets-blueprint
#      imagePullSecrets:  # In case you are using private registry (see kubectl command at the bottom on how to create regcred)
#        - name: regcred
  selector:
    matchLabels:
      app: blueprint

Now for the most important part - the Deployment. The relevant part here is the spec section which specifies image, ports and environment variables. For image we specify image from Docker Hub. In case we wanted to use some private registry like Artifactory, we would have to add imagePullSecret which gives the cluster credential for pulling the image. This secret can be created using following command:


kubectl create secret docker-registry regcred \
    --docker-server=docker.pkg.github.com \
    --docker-username=<USERNAME> --docker-password=<GITHUB_TOKEN> \
    --dry-run -o yaml >> app.yaml

This shows how you would allow pulling of your images from GitHub Package Registry, which unfortunately doesn't work with KinD right now, because of issues listed in the above YAML, but it would work just fine with your production cluster in Cloud (assuming it's not using KinD).

If you want to avoid pushing image to remote registry every time you redeploy your application, then you can load your image into cluster using kind load docker-image martinheinz/python-project-blueprint:flask.

After image, we also specify ports. These are ports on which our application is listening on, in this case 5000, because our app starts using app.run(host='0.0.0.0', port=5000).

Last part, the envFrom section is used to inject plaintext variables and secrets from ConfigMap and Secret shown above, by specifying their names in the respective Ref fields.

- Service:


apiVersion: v1
kind: Service
metadata:
  name: blueprint
  namespace: blueprint
  labels:
    app: blueprint
spec:
  selector:
    app: blueprint
  ports:
    - name: http
      targetPort: 5000  # port the container accepts traffic on
      port: 443  # port other pods use to access the Service
      protocol: TCP

Now, that we have application listening on a port, we need Service that will expose it. All this objects defines is that application listening on port 5000 should be exposed on cluster node on port 443.

- Ingress:


apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: blueprint-ingress
  namespace: blueprint
  annotations:
    nginx.ingress.kubernetes.io/ssl-passthrough: "true"
    nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
  labels:
    app: blueprint
spec:
  tls:
    - hosts:
      - localhost
      secretName: tls-secret
  rules:
    - host: localhost
      http:
        paths:
          - path: "/"
            backend:
              serviceName: blueprint
              servicePort: 443

Last big piece of puzzle - the Ingress - an object that manages external access to the Services in a cluster. Let's first look at the rules section - in this case we define that our host is localhost. We also set path to / meaning that any request sent to localhost/ belongs to associated backend defined by name of the previously shown Service and its port.

The other section here is tls. This section provides HTTPS for listed hosts by specifying Secret that includes tls.crt and tls.key. Let's create this Secret:


KEY_FILE="blueprint.key"
CERT_FILE="blueprint.crt"
HOST="localhost"
CERT_NAME=tls-secret
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ${KEY_FILE} -out ${CERT_FILE} -subj "/CN=${HOST}/O=${HOST}"
kubectl create secret tls ${CERT_NAME} --key ${KEY_FILE} --cert ${CERT_FILE} --dry-run -o yaml >> app.yaml

Above snippet first sets a few variables which are then used to generate certificate and key files for TLS using openssl. Last command creates Secret containing these 2 files.

Deploying Application

With all the manifests ready, we can finally deploy our application:


$ ~ kubectl config set-context kind-cluster --namespace blueprint

$ ~ KEY_FILE="blueprint.key"
$ ~ CERT_FILE="blueprint.crt"
$ ~ HOST="localhost"
$ ~ CERT_NAME=tls-secret

$ ~ openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ${KEY_FILE} -out ${CERT_FILE} -subj "/CN=${HOST}/O=${HOST}"

$ ~ kubectl create secret generic env-secrets-blueprint --from-literal=VAR=VALUE --dry-run -o yaml >> app.yaml && echo "---" >> app.yaml
$ ~ kubectl create secret tls ${CERT_NAME} --key ${KEY_FILE} --cert ${CERT_FILE} --dry-run -o yaml >> app.yaml

$ ~ kubectl apply -f app.yaml
namespace/blueprint unchanged
deployment.apps/blueprint created
configmap/env-config-blueprint unchanged
service/blueprint created
ingress.extensions/blueprint-ingress unchanged
secret/env-secrets-blueprint configured
secret/tls-secret configured

$ ~ kubectl get all
NAME                             READY   STATUS    RESTARTS   AGE
pod/blueprint-5d86484b76-dkw7z   1/1     Running   0          13s

NAME                TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
service/blueprint   ClusterIP   10.96.253.31   <none>        443/TCP   13s

NAME                        READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/blueprint   1/1     1            1           13s

NAME                                   DESIRED   CURRENT   READY   AGE
replicaset.apps/blueprint-5d86484b76   1         1         1       13s

$ ~ curl -k https://localhost/?text=Hello
You said: Hello

Most of the commands above we've already seen in sections before. What's new is kubectl apply -f app.yaml which creates all the necessary object in our cluster. After it's created we can check presence of those objects using kubectl get all. Finally we can check whether the application is accessible using cURL and it is! So, with that, we have our application running on the cluster!

Make-ing It Simple

If you don't feel fully comfortable with all the kind and kubectl commands yet or you are just lazy like me and you don't want to type it all out, then I have couple of Make targets for you - to make your life easier:

- Bring up the cluster:


cluster:
	@if [ $$(kind get clusters | wc -l) = 0 ]; then \
		kind create cluster --config ./k8s/cluster/kind-config.yaml --name kind; \
	fi
	@kubectl cluster-info --context kind-kind
	@kubectl get nodes
	@kubectl config set-context kind-kind --namespace $(MODULE)

The make cluster command will setup the cluster for you if it's not yet ready and if it is, it will give you all the info about it. This is nice if you need to check status of nodes and switch to your development namespace.

- Redeploy/Restart application:


deploy-local:
	@kubectl rollout restart deployment $(MODULE)

This one is very simple - all it does is roll out new deployment - so in case there is new image, it will deploy it, otherwise it will just restart your application.

- Debugging:


cluster-debug:
	@echo "\n${BLUE}Current Pods${NC}\n"
	@kubectl describe pods
	@echo "\n${BLUE}Recent Logs${NC}\n"
	@kubectl logs --since=1h -lapp=$(MODULE)

In case you need to debug your application, you will probably want to see recent events related to the application pod as well as recent (last hour) logs. That's exactly what make cluster-debug does for you.

- Get remote shell:


cluster-rsh:
	# if your container has bash available
	@kubectl exec -it $$(kubectl get pod -l app=${MODULE} -o jsonpath="{.items[0].metadata.name}") -- /bin/bash

If logs are not enough to solve some issues you might be having and you decide that you need to poke around inside the container, then you can run make cluster-rsh to get remote shell.

- Update manifests:


manifest-update:
	@kubectl apply -f ./k8s/app.yaml

We've seen this command before. It just re-applies YAML manifests, which is handy when you are tweaking some properties of Kubernetes objects.

Conclusion

This article wasn't meant to be Kubernetes tutorial, but I think it shows enough to get you started and have your application up and running pretty quickly. To learn more about Kubernetes, I recommend just playing, tweaking and changing things around in manifests and seeing what happens. That's in my opinion good way to find out how things work and to get comfortable with kubectl commands. If you have any questions, suggestions or issues feel free to reach out to me or create issue in my repository. In this repo, you can also find docs and all the manifests shown in this post.

Resources

Subscribe: