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:
- 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.
- 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 aapplication-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.