Django on Kubernetes

When I search "django kubernetes", it seems pretty straightforward what should come back. Worst case, there should be a lengthy blog post I can skim through to get to the sweet goodness that is sample config files. Best case, someone's already filtered out the chaff and is throwing config files directly at my eyeballs. Lovely.

Unfortunately, what I found fit one of two categories:

  1. Extremely detailed posts going into the nuts and bolts of Django, Kubernetes, cloud environments, and all that good stuff. Inexplicably, however, missing detail when it came to real-world examples.
  2. Guides for running Django on K8 for one specific cloud provider (looking at you GCP)

So then what is this?

This is definitely not extremely detailed. And it's also definitely not cloud-specific. What this post contains is simply a rundown of some bare-bones config files, hopefully these can steer you clear of the time-pits which sucked me in. What it assumes is working familiarity with Kubernetes, Django, and Helm (https://helm.sh). Let's get to it.


A really stupid Django app

And by really stupid, I mean just the output of django-admin.py startproject pizza

Now, let's dockerize this sucker.

# Dockerfile
FROM python:3.6.7-alpine3.7

RUN mkdir -p /code

WORKDIR /code

# Annotate Port
EXPOSE 3000

# System Deps
RUN apk update && \
    apk add --no-cache \
        gcc \
        musl-dev \
        libc-dev \
        linux-headers \
        postgresql-dev

# Python Application Deps
COPY requirements.txt .
RUN pip install -r requirements.txt

# We'll use Gunicorn to run our app
RUN pip install gunicorn

# Application Setup
COPY pizza/ ./pizza/

WORKDIR /code/pizza

ENTRYPOINT ["gunicorn"]
CMD ["pizza.wsgi"]

What are we including in our K8 deployment?

For the purpose of keeping our skeleton as lean as possible, we'll be assuming a dependency on some external database (e.g. RDS, whatever the equivalent of RDS is on GCP, and god help you if you're on Azure, I suppose it's magnetic tape)

Collectstatic

If you're like me, the collectstatic command is the perpetual afterthought, the "oh yeah, that". Therefore, you might be wondering why this is the first configuration listed (I sure would be). The reason is simple: The Deployment will have different configuration depending on your collectstatic needs. If you use something like django-s3-storage where you're shuttling staticfiles to some external location on collection, you're going to need a different approach then if you're just collecting to a local volume.

Scenario 1: Local Storage

In the local storage scenario, we bake collectstatic directly in as an initContainer to the deployment, like so:

initContainers:
  - name: {{ .Chart.Name }}-collectstatic
    image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
    imagePullPolicy: Always
    volumeMounts:
        - name: staticfiles
          mountPath: /var/www/html/

It's important to note that in this configuration, we'll want our STATIC_ROOT set to /var/www/html to work properly. Later on, in the deployment section, we'll see the full configuration for this, including the definition of the deployment volumes.

Scenario 2: External Storage

For the masochists among us, external storage presents its own set of hurdles. While my experience here is constrained to S3, it would be surprising if the difficulties weren't universal. In my first attempt at deployment, I made no changes and left the collectstatic command in the initContainers section ("oh yeah, that"). However, this resulted in intermittent failures and the dreaded "CrashLoopBackoff". Upon a quick inspection, it became clear the issue had to do with multiple pods (in my case, there were 3 replicas) executing API read/writes against S3 simultaneously, effectively sabotaging each others operation. It became clear then that another solution was necessary. Enter the Job.

# collectstatic-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ include "pizza.name" . }}-collectstatic-job
  labels:
    app.kubernetes.io/name: {{ include "pizza.name" . }}
    helm.sh/chart: {{ include "pizza.chart" . }}
    app.kubernetes.io/instance: {{ .Release.Name }}
    app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
  template:
    spec:
      containers:
      - name: {{ .Chart.Name }}
        image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
        imagePullPolicy: Always
        command: ['python', 'manage.py', 'collectstatic', '--noinput', '--ignore', 'node_modules']
        env:
          - name: STATIC_ROOT
            value: /var/www/html/static/
          - name: SECRET_KEY
            valueFrom:
              secretKeyRef:
                name: {{ include "pizza.name" . }}-env
                key: secret_key
          - name: DB_NAME
            valueFrom:
              secretKeyRef:
                name: {{ include "pizza.name" . }}-env
                key: db_name
          - name: DB_USER
            valueFrom:
              secretKeyRef:
                name: {{ include "pizza.name" . }}-env
                key: db_user
          - name: DB_PASSWORD
            valueFrom:
              secretKeyRef:
                name: {{ include "pizza.name" . }}-env
                key: db_password
          - name: DB_HOST
            valueFrom:
              secretKeyRef:
                name: {{ include "pizza.name" . }}-env
                key: db_host         
      restartPolicy: Never
  backoffLimit: 4

We'll be seeing this guy again when we get to the Deployment section, where we get to see how it integrates with the larger pipeline.

ONE IMPORTANT NOTE
Unless you only ever want to run collectstatic on the first deployment of your app (and you don't), or want to launch under a new release name for every upgrade (you don't), you're going to need to delete this job between runs to make sure it runs as part of your normal deployment flow. For us, this means adding the following command in our .circleci/config.yml file before helm upgrade --install:

KUBECONFIG=~/.kube/config kubectl delete job pizza-collectstatic-job --ignore-not-found
# helm upgrade here

RBAC

Based on your answer to the Collectstatic question above, you may or may not need RBAC configured. If you did require the use of a job to collect your static files, you should configure RBAC now, sorry 🤷.

# rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ include "pizza.name" . }}
---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: default
  name: {{ include "pizza.name" . }}
rules:
  - apiGroups: ["", "batch"]
    resources: ["jobs"]
    verbs: ["get", "watch", "list"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: {{ include "pizza.name" . }}
  namespace: default
subjects:
  - kind: ServiceAccount
    name: {{ include "pizza.name" . }}
    namespace: default
roleRef:
  kind: Role
  name: {{ include "pizza.name" . }}
  apiGroup: rbac.authorization.k8s.io

Configmap

Our configmap simply holds the nginx config file data. This should look familiar:

kind: ConfigMap
apiVersion: v1
metadata:
  name: {{ include "pizza.fullname" . }}-sites-enabled-configmap
data:
  ucr-app.conf: |
    upstream app_server {
      server 127.0.0.1:3000 fail_timeout=0;
    }

    server {
      listen {{ .Values.nginx.listenPort }};
      client_max_body_size 4G;

      # set the correct host(s) for your site
      server_name {{ join " " .Values.nginx.hosts }};

      access_log /var/log/nginx/access.log combined;
      error_log  /var/log/nginx/error.log warn;

      keepalive_timeout 5;

      # path for static files (only needed for serving local staticfiles)
      root /var/www/html/;

      location / {
        # checks for static file, if not found proxy to app
        try_files $uri @proxy_to_app;
      }

      location @proxy_to_app {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Host $http_host;

        proxy_redirect off;
        proxy_pass http://app_server;
      }

      error_page 500 502 503 504 /500.html;
      location = /500.html {
        root /var/www/html/;
      }
    }

Deployment

The blood and guts of the operation, here you go:

# deployment.yaml
apiVersion: apps/v1beta2
kind: Deployment
metadata:
  name: {{ include "pizza.fullname" . }}
  labels:
    app.kubernetes.io/name: {{ include "pizza.name" . }}
    helm.sh/chart: {{ include "pizza.chart" . }}
    app.kubernetes.io/instance: {{ .Release.Name }}
    app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app.kubernetes.io/name: {{ include "pizza.name" . }}
      app.kubernetes.io/instance: {{ .Release.Name }}
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ include "pizza.name" . }}
        app.kubernetes.io/instance: {{ .Release.Name }}
    spec:
      serviceAccountName: {{ include "pizza.name" . }} # only needed if RBAC configured above
      volumes:
        - name: nginx-conf
          configMap:
            name: {{ include "pizza.name" . }}-sites-enabled-configmap
        - name: staticfiles
          emptyDir: {}
      initContainers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: IfNotPresent
          command: ["python", "manage.py", "migrate"]
          env:
            - name: SECRET_KEY
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: secret_key
            - name: DB_NAME
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: db_name
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: db_user
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: db_password
            - name: DB_HOST
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: db_host 
        # USE THIS BLOCK IF YOU CONFIGURED A JOB FOR COLLECTING STATIC FILES          
        - name: wait-for-collectstatic
          image: lachlanevenson/k8s-kubectl:v1.12.4
          imagePullPolicy: IfNotPresent
          command:
            - "kubectl"
            - "get"
            - "jobs"
            - {{ include "pizza.name" . }}-collectstatic-job
            - "-o"
            - jsonpath='{.status.conditions[?(@.type=="Complete")].status}' | grep True ; do sleep 1 ; done
         # USE THIS BLOCK IF YOU'RE USING LOCAL STATIC FILES   
        - name: collectstatic
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: IfNotPresent
          command: ["python", "manage.py", "collectstatic", "--noinput"]
          volumeMounts:
            - name: staticfiles
              mountPath: /var/www/html/
      containers:
        - name: nginx
          image: nginx:stable
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 80
              protocol: TCP
          volumeMounts:
            - name: nginx-conf
              mountPath: /etc/nginx/conf.d/
            - name: staticfiles # only necessary if serving staticfiles locally
              mountPath: /var/www/html/
          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 3
            periodSeconds: 3
          livenessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 3
            periodSeconds: 3
          resources:
            requests:
              cpu: 10m
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: 3000
              protocol: TCP
          env:
            - name: SECRET_KEY
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: secret_key
            - name: DB_NAME
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: db_name
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: db_user
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: db_password
            - name: DB_HOST
              valueFrom:
                secretKeyRef:
                  name: {{ include "pizza.name" . }}-env
                  key: db_host                 
            - name: GUNICORN_CMD_ARGS
              value: "--bind=127.0.0.1:3000 --workers=2"

Service

After that beefy config, luckily we get a breather here:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "pizza.fullname" . }}
  labels:
    app: {{ include "pizza.name" . }}
    app.kubernetes.io/name: {{ include "pizza.name" . }}
    helm.sh/chart: {{ include "pizza.chart" . }}
    app.kubernetes.io/instance: {{ .Release.Name }}
    app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
  type: LoadBalancer
  ports:
    - port: {{ .Values.service.port }}
      targetPort: {{ .Values.nginx.listenPort }}
      protocol: TCP
      # these annotations are AWS specific, adjust to your cloud provider
      annotations:
        # only needed if https 
        service.beta.kubernetes.io/aws-load-balancer-ssl-cert: arn:aws:acm-cert-arn
        service.beta.kubernetes.io/aws-load-balancer-backend-protocol: http
  selector:
    app.kubernetes.io/name: {{ include "pizza.name" . }}
    app.kubernetes.io/instance: {{ .Release.Name }}

One itty bitty caveat here is that using an Ingress Controller is probably the "better way" to do this. But for the sake of brevity, we've gone with a LoadBalancer🤷.


What you have now

If you took the time to read through all the above, then firstly - thank you. Secondly, you should have the following resources defined in your helm folder:

  • deployment.yaml
  • service.yaml
  • rbac.yaml
  • collectstatic-job.yaml
  • sites-enabled-configmap.yaml

And, as the great Emeril Lagasse might say, "Bam!"


Notes

  • You'll see a bunch of references to a {{ include "pizza.name" . }}-env secret in the configurations. That's just a preference I have (maintaining application secrets in a application-env K8 secret)
  • Historically I've strictly been a uwsgi man. But, after running multiple Django apps on Kubernetes and seeing elevated 502 rates, a switch to Gunicorn was the natural next step. With the swap the 502's have disappeared, so I highly recommend making that change!

Shameless P(l)ug

At MeanPug, we build products for all clients, large and small. If you or someone you know is looking for a partner to help build out your next great idea (or maybe something slightly less sexy like, oh I don't know, migrating to Kubernetes), give us a ping. We'd love to hear from you.