Preparing your Django Application for Google Cloud Run

Tariq Al-Sadoon
The Startup
Published in
16 min readMay 20, 2019

Recently Google released a new product on their cloud platform called Cloud Run. This product has the potential to reduce developer workload and early-development experimentation costs as it bridges the gap between serverless Cloud Functions and Kubernetes Engine: serverless brought you pay-per-use to run simple workloads on the cloud and kubernetes brought you orchestrated workloads for running your custom images. Cloud Run gives you the best of both worlds: run your custom Docker images with all your baked-in libraries on a system that scales all the way down to zero: no more hassle with setting up your infrastructure, no more paying for virtual machines that sit idle.

There are only a couple of conditions you need to fulfil:

  1. Your application has to respond to HTTP calls.
  2. Your application has to be completely stateless. Unlike with kubernetes engine, you can’t attach volumes to your workloads. All your persistent, non-static data has to be outside your container.
  3. Obviously, your application has to be containerised.
  4. Your container should listen to traffic on a port that Cloud Run can define on startup. For this reason when starting the web server inside the container, make sure it listens to listens on the port defined by the PORT environment variable.

A really great use case are personal homepages. They are basically business cards on the web that don’t need to exist unless someone is looking at them.

In the following example we’ll take a personal homepage created with Django-CMS and a Postgres database and modify it so it runs on Cloud Run with the database hosted on Cloud SQL and all media and static images hosted on GCP Buckets. These instructions assume familiarity with Cloud SQL so we will not go over the most basic steps in great detail, although I will cover some gotchas that have tripped me up before.

When IT and Opera Meet

Photo by Manuel Nägeli on Unsplash

One of the ways how I taught myself Django was making homepages for my friends with Django-CMS. At first as I didn’t know anything about servers or nginx, I set up the sites on Djangoeurope: it’s a really good Django hosting site with a dedicated and helpful staff and very friendly prices.

1,5 years ago when my opera singer friend Kristian Lindroos needed a homepage I decided to use the opportunity to again learn something new: nginx and hosting on AWS. Unfortunately before I knew it, my trial period on AWS ran out and I was left footing the bill for a T2.micro instance.

Kristian preparing for a performance, photo by Markku Pihlaja.

When Google announced Cloud Run I realised that it would be the perfect place for hosting the website: I always have a Cloud SQL instance running on GCP for personal projects so I could put the Postgres database there and have everything else run on Cloud Run.

To get our application to work on Cloud Run, we need to complete the following steps.

  1. Reduce startup time by removing all time-consuming tasks from the startup script.
  2. Switch from a local database to Cloud SQL.
  3. Have Cloud Run serve the application.
  4. Switch from disk storage to buckets.
  5. Have Cloud Run serve the application under the right domain.

Reduce Startup Time

A very common convention with Django applications is to cram everything in your startup script: first run migrations, and then run collectstatic, then start the application server. Indeed, my run.sh startup file looked like this:

#!/bin/bash

python manage.py migrate

python manage.py collectstatic

# Start the server
/usr/local/bin/gunicorn kristian.wsgi:application -w 2 -b :$PORT

This approach is OK if you are using a VM and your application is on all the time. With Cloud Run, your application will be spun up from scratch each time it has not received traffic for awhile and you do not want your users to wait for the migrations and collecting static files to go through. Especially when we switch to using a bucket as a storage backend, copying individual files will be agonisingly slow: even with this simple Django-CMS application running python manage.py collectstatic takes well over 2 minutes! We will comment out both lines so our startup script looks like this:

#!/bin/bash

# python manage.py migrate

# python manage.py collectstatic

# Start the server
/usr/local/bin/gunicorn kristian.wsgi:application -w 2 -b :$PORT

Switch from a Local Database to Cloud SQL

Creating a Cloud SQL Instance and Activating the Cloud SQL Proxy

First, we need to create a Cloud SQL instance. Simple and straightforward instructions can be found on the official documentation pages here.
Pro tip: as of time of writing this article, Cloud Run is only available in the us-central1 region so put your database instance there. At first I didn’t, which turned out to be a mistake. More on that later.

Next, we need to create a new user on your Cloud SQL instance. For simplicity’s sake we can use the same username and password as in our original setup and once everything is working we can change these.

After that we need to get the Cloud SQL proxy working to connect from our local setup (and eventually Cloud Run) to the database. We need to perform the following steps:

  1. Enable the Cloud SQL API to enable remote connections.
  2. Create a service account and give it the Cloud SQL client permission.
  3. Download the service account credentials file, let’s call it db-proxy.json.
  4. Download the proxy.
  5. Make the proxy executable.
  6. Start the proxy with the right instance connection name and the right port number.

The instance connection name can be found on the overview tab of your instance and will always be of the form

PROJECT_ID:REGION:INSTANCE_NAME

As for the port, we can just use anything that’s available. I already have Postgres running locally so 5432 is already taken, but 6543 works fine. The command to run the proxy will look like this:

./cloud_sql_proxy -instances=<PROJECT_ID:REGION:INSTANCE_NAME>=tcp:6543 -credentials=/path/to/credentials/db-proxy.json

Let’s run the command and move to the next section.

Creating and Populating the Database

Next we need to create a new database on Cloud SQL. Unfortunately we can’t use the Cloud SQL Google Cloud Console for this because the database ownership will automatically be assigned to postgres and this will mess up all table and sequence permissions required by Django. Instead we can use our open Cloud SQL proxy connection to connect to our database instance and create the new database.

Run the following command and enter the postgres password when prompted for it:

psql -U postgres -h 127.0.0.1 -p 6543 -d postgres

Next, grant your previously created user to postgres and create the database

-- necessary so postgres has permission to set your user as owner.
GRANT <USERNAME> TO postgres;
CREATE DATABASE <DATABASE_NAME> OWNER <USERNAME>;

All done. And guess what? Now we are ready to run our first test! I have everything running locally and it was connected to a test database on a local postgres server. Now I just need to change the port number from 5432 to 6543 in my settings.py and Django should be able to connect to the new database.

A great way to test this is running python manage.py showmigrations. And indeed, because the database is empty, I see plenty of migrations that have not been run:

The next step is to copy our database to Cloud SQL. I have already dumped the database using pg_dump so now I can use the still open proxy connection to restore the database. Because we are using a redirect to populate the database, we can’t have psql prompt us for a password. We can get around this by setting the password as an environment variable at the start of the command:

PGPASSWORD=<PASSWORD> psql -U <USERNAME> -h 127.0.0.1 -p 6543 -d <DATABASE_NAME> < dump.sql

Running python manage.py showmigrations shows us that the import worked!

Have Cloud Run Serve the Application

I’m a firm believer in periodic testing. That is, whenever working on a project and getting one stage ready, it’s always good to see how well your application works within the limitations of that stage. For example, now if we run our Application on Cloud Run, we should be able to start it in a somewhat broken state: the application should work, the HTML should be there as well as the static files, but we would be missing all media files and it should not be possible to upload any new media files.

To be able to test this, we need to perform the following operations:

  1. Enable Cloud Run in the project.
  2. Bake the Cloud SQL proxy into our application.
  3. Give the Cloud Run service permissions to access the database instance.
  4. Build the docker image and upload it to the Google Container Repository of our project.
  5. Create a Cloud Run Service that uses our image.

Step one is easy. In the cloud console, find the Cloud Run tab and there click ‘Try Cloud Run’.

In step two we need to make Dockerfile perform the same steps we did when running the application locally: Download the Cloud SQL proxy and turn it into an executable. To do this, we will modify our docker file. Before the modification the Dockerfile looks like this:

FROM python:3.6.4

RUN apt-get install bash
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY requirements.txt /usr/src/app/
RUN pip install --no-cache-dir -r requirements.txt

RUN ln -sf /dev/stdout /var/log/access.log && \
ln -sf /dev/stderr /var/log/error.log

ADD . /usr/src/app
CMD ["./run.sh"]

Now we just add two lines to it and job done.

FROM python:3.6.4

RUN apt-get install bash
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
COPY requirements.txt /usr/src/app/
RUN pip install --no-cache-dir -r requirements.txt

# download the cloudsql proxy
RUN wget https://dl.google.com/cloudsql/cloud_sql_proxy.linux.amd64 -O /usr/src/app/cloud_sql_proxy
# make cloudsql proxy executable
RUN chmod +x /usr/src/app/cloud_sql_proxy

RUN ln -sf /dev/stdout /var/log/access.log && \
ln -sf /dev/stderr /var/log/error.log

ADD . /usr/src/app
CMD ["./run.sh"]

There are several options to accomplish step three that gives Cloud Run the sufficient permissions:

  1. Put the previously downloaded db-proxy.json inside the container, run the Cloud SQL proxy as a separate process, connect using the port defined in the proxy command (credits for this method goes to Peter Malina and this Medium post) .
  2. Give the Cloud Run service account the Cloud SQL Client permission, run the Cloud SQL proxy as a separate process, connect using the port defined in the proxy command.
  3. There’s an upcoming feature that will bring direct Cloud SQL support to Cloud Run. This will enable connecting directly to the unix socket of the proxy that presumably will run outside your container, which will bring your cold start time down even further. UPDATE: this feature just made it to beta, instructions on how use can be found here.

Personally, I prefer option number one especially now that I’m new to Cloud Run. If we put the credentials inside the container, we should be able to already run the container from our local setup before running the container on Cloud Run. That way we can immediately catch any configuration bugs before deploying to Cloud Run instead of deploying it, seeing the application fail and then trying to figure out whether the cause is Cloud Run or our configuration. After getting more comfortable with Cloud run, I would go for option number 2, and definitively option number 3 once it becomes available.

But first things first. Let’s create a folder called secrets inside our our application root and add the db-proxy.json file to it. Then let’s open the run.sh file that we edited previously and add the command to start the proxy.

#!/bin/bash

# python manage.py migrate
# python manage.py collectstatic./cloud_sql_proxy -instances=<PROJECT_ID:REGION:INSTANCE_NAME>=tcp:6543 -credential_file=secrets/db-proxy-central.json &# wait for the proxy to spin up
sleep 1
# Start the server
/usr/local/bin/gunicorn kristian.wsgi:application -w 2 -b :$PORT

In step four, we just need to navigate to the directory containing the Dockerfile and run the build and push commands:

docker build -t gcr.io/<YOUR_PROJECT_ID>/<YOUR_IMAGE_NAME>:latest -f Dockerfile .
docker push gcr.io/<YOUR_PROJECT_ID>/<YOUR_IMAGE_NAME>:latest

But wait a minute! Now that we added the proxy and the credentials inside our cointainer, we can test locally before trying to deploy. Once the build operation completes, we can deploy the container with

docker run -e “PORT=8888” -p 8888:8888 gcr.io/<YOUR_PROJECT_ID>/<YOUR_IMAGE_NAME>:latest

Notice that we are passing the PORT environment variable to our container so the server knows which port to listen to. This is the same operation performed by Cloud Run. By navigating to 127.0.0.1:8888 we can confirm that the application is indeed running, static files and images are working but media files are not. Great Success!

Finally let’s deploy our somewhat broken image to Cloud Run. Navigate again to the Cloud Run tab and choose Create Service

As we have already pushed the image in the previous step it can be selected in the first dropdown. Then we give our image a name and allow unauthenticated invocations because we are deploying a website. Once done, we click create.

After a while, we see the service go green and it’s ready for traffic. And here comes some of the most awesome features of Cloud Run. It autogenerates a URL for us and even get’s a Let’s Encrypt https certificate for it. Sweet!

Now we can just click on the URLto see our beautiful… Internal server error? Let’s check out the logs to see what went wrong. We don’t have to scroll far to find the culprit:

Invalid HTTP_HOST header: ‘django-cms-wfyujfe4wa-uc.a.run.app’. You may need to add ‘django-cms-wfyujfe4wa-uc.a.run.app’ to ALLOWED_HOSTS.

The ALLOWED_HOSTS in the settings.py file already looks like this

ALLOWED_HOSTS = [os.environ.get('CURRENT_HOST', 'localhost'), '127.0.0.1', 'kristianlindroos.fi',                 'www.kristianlindroos.fi', ]

We just need to pass the auto-generated URL as a CURRENT_HOST environmental variable and we’re good to go. First let’s copy the auto-generated URL and then let’s click Deploy new revision to update our setup. This will bring up some configurable options as well as the option to add environment variables, which is what we are going to do and then click deploy.

After around ten seconds the process is complete, we can reload the page and it works!

In the next section we are going to switch from disk storage — which doesn’t work on Cloud Run — to using buckets. However, if you don’t need persistent storage and you are deploying, for example, a simple Django Rest Framework backend, then just skip to last section titled Have Cloud Run Serve the Application Under the Right Domain.

Switch from Disk Storage to Buckets

When moving from file storage to static storage we need to add a couple of dependencies and then modify our configuration. First, let’s install the necessary dependencies and update our requirements.txt

pip install django-storages[google]
pip freeze > requirements.txt

Next, let’s again go to our project on GCP console and do three things:

  1. Create a service account to access our buckets, download the credentials, rename them to bucket-admin.json, put this file in the secrets folder in our application that we create earlier.
  2. Create a bucket for static content and add two different permissions to it: the service account we just created gets the Storage Admin permission so it can read, write and list objects in the bucket, the allUsers identifier gets the Storage Object Viewer permission, effectively making the bucket public.
  3. Create a bucket for media content and add two different permissions to it: the service account we just created gets the Storage Admin permission so it can read, write and list objects in the bucket, the allUsers identifier gets the Storage Object Viewer permission, effectively making the bucket public.

After this, we will continue to modify our settings.py file according to this Stackoverflow answer.

First, in your project root create a python folder called config and a new file called storage_backends.py to it with the following content:

"""
GoogleCloudStorage extensions suitable for handing Django's
Static and Media files.

Requires following settings:
MEDIA_URL, GS_MEDIA_BUCKET_NAME
STATIC_URL, GS_STATIC_BUCKET_NAME

In addition to
https://django-storages.readthedocs.io/en/latest/backends/gcloud.html
"""
from django.conf import settings
from storages.backends.gcloud import GoogleCloudStorage
from storages.utils import setting
from urllib.parse import urljoin


class GoogleCloudMediaStorage(GoogleCloudStorage):
"""GoogleCloudStorage suitable for Django's Media files."""

def __init__(self, *args, **kwargs):
if not settings.MEDIA_URL:
raise Exception('MEDIA_URL has not been configured')
kwargs['bucket_name'] = setting('GS_MEDIA_BUCKET_NAME')
super(GoogleCloudMediaStorage, self).__init__(*args, **kwargs)

def url(self, name):
""".url that doesn't call Google."""
return urljoin(settings.MEDIA_URL, name)


class GoogleCloudStaticStorage(GoogleCloudStorage):
"""GoogleCloudStorage suitable for Django's Static files"""

def __init__(self, *args, **kwargs):
if not settings.STATIC_URL:
raise Exception('STATIC_URL has not been configured')
kwargs['bucket_name'] = setting('GS_STATIC_BUCKET_NAME')
super(GoogleCloudStaticStorage, self).__init__(*args, **kwargs)

def url(self, name):
""".url that doesn't call Google."""
return urljoin(settings.STATIC_URL, name)

This will eliminate some unnecessary calls to the bucket when constructing the URL, which will keep our GCP usage costs under better control. After the addition our Django project will look a bit like this:

Add the following lines to your settings.py file substituting the bucket names with your own:

# add the correct application credentials
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "secrets/bucket-admin.json"
# define the default file storage for both static and media
DEFAULT_FILE_STORAGE = 'config.storage_backends.GoogleCloudMediaStorage'
STATICFILES_STORAGE = 'config.storage_backends.GoogleCloudStaticStorage'
# add the names of the buckets
GS_MEDIA_BUCKET_NAME = '<MEDIA_BUCKET_NAME>'
GS_STATIC_BUCKET_NAME = '<STATIC_BUCKET_NAME>'
# define the static urls for both static and media
STATIC_URL = 'https://storage.googleapis.com/{}/'.format(GS_STATIC_BUCKET_NAME)
MEDIA_URL = 'https://storage.googleapis.com/{}/'.format(GS_MEDIA_BUCKET_NAME)

If we rebuild and relaunch our docker application locally, we see that everything works as intended, but we’re missing both static and media. By running python manage.py collectstatic inside the container we should get all our styles and static images into the newly created bucket.

docker exec <YOUR_RUNNING_DOCKER_NAME> python manage.py collectstatic --noinput

For some reason GCP buckets make displaying fonts a bit more difficult than other static assets (if you know why, please let me know in the comments). This means that if we are referencing fonts that are located in our bucket, we will get something like this when visiting the Django-CMS admin:

You can see that the icons which are fonts are not displayed correctly. This is because the CORS policy of the bucket. To allow accessing the font files from any destination, let’s head the advice of this Stackoverflow answer and create a cors.json file with the following content:

[
{
"origin": ["*"],
"responseHeader": ["Content-Type"],
"method": ["GET"],
"maxAgeSeconds": 3600
}
]

Then set the CORS policy with

gsutil cors set cors.json gs://<STATIC_BUCKET_NAME>

And the result will be:

While we’re at it, let’s set the CORS policy for our media bucket as well, otherwise we will not be able to see the image thumbnails when using Django-CMS admin.

gsutil cors set cors.json gs://<MEDIA_BUCKET_NAME>

Finally let’s push the final version of the application to GCR and create a new revision of our application on Cloud Run.

Have Cloud Run Serve the Application Under the Right Domain

Now the last thing we have to do is give Cloud Run a custom domain it can use. At the main Cloud Run view let’s click Manage custom domains and under that Add mapping

Let’s select the service we want to map and then type in the domain name we want to attach to it.

Next you will be asked to select a domain name provider. Kristian’s domain is purchased from a Finnish domain name provider called Zoner and obviously it will not be on the list, but in most cases the verification process is the same: you just have to add a TXT record to your DNS configuration so google knows that you have control of the domain. The content of the TXT record is pretty much the same regardless of domain name provider so I just selected Namecheap as domain name provider and and proceeded by adding the record TXT record there.

After the name has been verified you will be given the IP4 and IP6 addresses where the gateway to your Cloud Run Service will be. Copy these addresses and add them as A and AAA records to your DNS configuration. Give the DNS some time to resolve. For me it took half an hour for traffic to successfully route to the new service, but it can take anything between 4 to 24 hours for DNS caches to refresh.

And you’re done! You have migrated your Django application to use Cloud Run!

FAQ

How does Cloud Run perform?

Once your image is up and running, it will perform quite well. The service is in us-central1 and still I’m seeing latencies of around 500 milliseconds while accessing the website all the way from Finland. On the downside the cold start time I’m getting is around 11 seconds, which is still OK for a personal website but would prefer if I could bring it down to the sub 5 second range. I’m guessing that the main problem is that I’m using a regular Debian image as the base docker image. If I have the time, will try to switch to Alpine and see if that makes a difference.

Is it Cheaper?

Well yes, and no. It is definitely cheaper than running a complete virtual machine just to host one docker image, but I didn’t get the free ride I was hoping for: because the service is only available in us-central1, I couldn’t use my regular Postgres database instance I have running in europe-north but had to create a new db-f1-micro instance in us-central1, the cost of which is around 8 euros a month. (I mean technically I could keep my database instance in Finland, but when I tried and the SQL queries were bouncing over the Atlantic I was getting insane latencies: 30 seconds for a cold start, 5–7 seconds for every following request).

If we ignore the costs of running the database, then so far my costs for having this website on Cloud Run have been exactly 0€. I think running a personal website on Cloud Run will not exhaust even a noticeable fraction of the free tier, so the actual Cloud Run costs will stay 0€ for this project.

Is it Worth it?

Definitely! This is a great technology for running containers that receive relatively little traffic so the best use cases would be personal websites, prototyping and — once Cloud Run is out of beta — production instances for a startup that is trying to shoestring its way to the market. You only pay for what you use and your infrastructure costs are virtually zero.

Conclusion

I hope you found this article informative. If you have any suggestions for improvements please leave them in the comments below and I will try to address them to make this text as useful as possible.

Cheers!

--

--