Deploy Bokeh Apps on Google Cloud Run

Yogesh Dhande

Google's Cloud Run platform started supporting websockets recently which now makes it possible to run interactive Bokeh apps. Cloud Run is a fully managed platform to run containerized applications. I've been using it for my hobby projects as I do not have to worry about setting up virtual machine instances or any other infrastructure. The number of running containers are scaled up or down automatically as needed so you only pay for the time your app is being used. If you don't have a lot of traffic to your app, in most cases you will end up paying close to nothing. The chart below is taken from my Cloud Run dashboard to show how the number of containers scales over time.

Cloud Run Dashboard Image

To get started, we first need to install the Google Cloud SDK (gcloud) by following the steps outlined here: https://cloud.google.com/sdk/docs/install

Next, initialize gcloud by setting up GCP login credentials.

gcloud init

As you follow the prompts, you can chose to create a new configuration or re-initialize the existing configuration. When asked, log in with your GCP account and select a project. The prompt also allows you to create a new project at this step if you don't already have a project you'd like to use.

If you run into any issues, here's the original article I referenced to go through these steps: https://cloud.google.com/sdk/docs/initializing

Before starting to write our application, we need to set up a few more things to ensure we have permissions to use the necessary services.

First, navigate to https://console.cloud.google.com/billing/projects and add a billing account to your project.

Next, enable required APIs by clicking on the Enable the APIs link in this guide. That link will allow you to only set up the APIs required for deploying on Cloud Run without having to worry about all other APIs available on GCP so I highly recommend following the Enable the APIs link from that guide. Select your GCP project from the drop-down menu and click Continue.

Enable the APIs Screenshot

And finally, we need to set up required IAM permissions by clicking on the Go to Cloud Build Settings Page link in this guide https://cloud.google.com/build/docs/deploying-builds/deploy-cloud-run

On the pop-up menu, click GRANT ACCESS TO ALL SERVICE ACCOUNTS.

IAM permissions screenshot

The steps we have taken so far only need to be done once when you start a new project. Now that we have all of the project settings and permissions set up, we can start building our Bokeh app. We'll build it as a directory application so that it's easy to add complexity later as we build on top of the initial application.

To create a minimal application, all we need is a folder with a main.py file that contains the Bokeh app.

Project Folder Structure

We will be deploying a simple app that I have directly taken from the Bokeh documentation, so we don't need to spend too much time on it. My main.py looked like below.

# Source: https://github.com/bokeh/bokeh/blob/branch-2.4/examples/app/sliders.py

import numpy as np

from bokeh.io import curdoc
from bokeh.layouts import column, row
from bokeh.models import ColumnDataSource, Slider, TextInput
from bokeh.plotting import figure

# Set up data
N = 200
x = np.linspace(0, 4*np.pi, N)
y = np.sin(x)
source = ColumnDataSource(data=dict(x=x, y=y))


# Set up plot
plot = figure(height=400, width=400, title="my sine wave",
              tools="crosshair,pan,reset,save,wheel_zoom",
              x_range=[0, 4*np.pi], y_range=[-2.5, 2.5])

plot.line('x', 'y', source=source, line_width=3, line_alpha=0.6)


# Set up widgets
text = TextInput(title="title", value='my sine wave')
offset = Slider(title="offset", value=0.0, start=-5.0, end=5.0, step=0.1)
amplitude = Slider(title="amplitude", value=1.0, start=-5.0, end=5.0, step=0.1)
phase = Slider(title="phase", value=0.0, start=0.0, end=2*np.pi)
freq = Slider(title="frequency", value=1.0, start=0.1, end=5.1, step=0.1)


# Set up callbacks
def update_title(attrname, old, new):
    plot.title.text = text.value

text.on_change('value', update_title)

def update_data(attrname, old, new):

    # Get the current slider values
    a = amplitude.value
    b = offset.value
    w = phase.value
    k = freq.value

    # Generate the new curve
    x = np.linspace(0, 4*np.pi, N)
    y = a*np.sin(k*x + w) + b

    source.data = dict(x=x, y=y)

for w in [offset, amplitude, phase, freq]:
    w.on_change('value', update_data)


# Set up layouts and add to document
inputs = column(text, offset, amplitude, phase, freq)

curdoc().add_root(row(inputs, plot, width=800))
curdoc().title = "Sliders"

If we want to run this application locally, we can navigate to the parent directory and run the command bokeh serve demo, but we need to add two more files to build and deploy this application to Cloud Run. One of them is Dockerfile that includes instructions to create a docker image that will be run by Cloud Run and the other is cloudbuild.yaml which includes instructions for the gcloud SDK on how to build the docker image, upload it to the Google Container Registry, and run it as a container service on Cloud Run. At the end of all this, our folder structure will look like this:

Project Folder Structure

If you are not familiar with docker, I recommend watching this video on Youtube: https://www.youtube.com/watch?v=i7ABlHngi1Q&ab_channel=TravisMedia. It was quite useful to me as I was getting started last year.

Since we will be using Cloud Build to build and run our image, we don't need to have docker installed locally.

Let's start with the Dockerfile. We'll base our image on the official Anaconda image. Copy all our applications files and create a non-root user named bokeh that will run our application. It is always a good practice to not run container as root, especially when deploying to cloud services. The other key thing we need to do is to set the PORT environment variable and use it in the command to run the bokeh application. Cloud Run expects the application to run on post 8080 by default, whereas bokeh applications are run on post 5006 by default. By using the port variable, we are instructing Bokeh to run the application on port 8080.

FROM continuumio/anaconda3

WORKDIR /apps

COPY . .

ENV PORT=8080

ARG USER=bokeh
RUN useradd -s /bin/bash -m ${USER}
USER ${USER}

CMD bokeh serve --port $PORT demo

Once we have the Dockerfile set up, we can write cloudbuild.yaml. This file includes three steps:

  1. Build the container image
  2. Push the container image to Google Container Registry
  3. Deploy the container image to Cloud Run as a service

You can pretty much copy all of the contents of this file below, but you will probably want to change the container image name bokeh-app-image and the Cloud Run service name bokeh-app-service to whatever makes sense in your case.

steps:
  # Build the container image
  - name: "gcr.io/cloud-builders/docker"
    args: ["build", "-t", "gcr.io/$PROJECT_ID/bokeh-app-image", "."]
  # Push the container image to Container Registry
  - name: "gcr.io/cloud-builders/docker"
    args: ["push", "gcr.io/$PROJECT_ID/bokeh-app-image"]
  # Deploy container image to Cloud Run
  - name: "gcr.io/cloud-builders/gcloud"
    args: [
        "run",
        "deploy",
        "bokeh-demo-app-service-1", # change to your service name
        "--image",
        "gcr.io/$PROJECT_ID/bokeh-app-image",
        "--max-instances",
        "1",
        "--memory",
        "128M",
        "--concurrency",
        "80",
        "--region",
        "us-central1",
        "--platform",
        "managed",
        "--allow-unauthenticated",
      ]
images:
  - gcr.io/$PROJECT_ID/bokeh-app-image

Now that we have both Dockerfile and cloudbuild.yaml, deploying our app is as easy as running the following shell command

gcloud builds submit

You will see a log output which should output the URL of the cloud run service from the deployment.

Cloud Run Deployment Logs

You can also navigate to https://console.cloud.google.com/run, click on the service and get the URL from the GCP console.

Cloud Run Service URL

Alternatively, type the following command in a terminal to get the service URL

gcloud run services list --platform managed | awk 'NR==2 {print $4}'

You can now go to the URL to visit your Bokeh app. Unfortunately, the app won't load at this stage. Bokeh blocks any incoming connections that aren't explicitly whitelisted. We have to edit the Dockerfile to make this change and deploy the service again. This is a workaround since you can't know the service URL until it is deployed. If you were planning on connecting a custom domain to the service, you could whitelist that domain before deploying the service and avoid having to go through the following steps.

The updated Dockerfile now has an additional --allow-websocket-origin parameter in the CMD instruction. Make sure to exclude the protocol information https:// from the url when passing it to --allow-websocket-origin

FROM continuumio/anaconda3

WORKDIR /apps

COPY . .

ENV PORT=8080

ARG USER=bokeh
RUN useradd -s /bin/bash -m ${USER}
USER ${USER}

CMD bokeh serve --port $PORT demo --allow-websocket-origin bokeh-demo-app-service-1-f56pgsgjvq-uc.a.run.app

Once again, deploy the service.

gcloud builds submit

Go to the URL and visit your bokeh app. Here's the URL of the bokeh service I created for this demo: https://bokeh-demo-app-service-1-f56pgsgjvq-uc.a.run.app

Next, let's try connecting a custom domain to our Bokeh app. The first step is to verify ownership of the domain. If you purchased your domain from Google Domains using the same Google account used for the GCP project, your domain is likely to be already verified. To check your list of verified domains, type the following command in the terminal.

gcloud domains list-user-verified

If you don't see your domain in the output, you will need to verify the domain first. You can do that with the following command.

gcloud domains verify example.com

The above command will open a brower tab and give you instructions on how to verifiy the domain. You will need to log into your account at the domain name provider and add a TXT record to the DNS configuration. If you run into any issues at this step, please refer to this article: https://support.google.com/a/answer/183895

Once the domain is verified with Google, we can map our Cloud Run service to the domain. The service name was first used in cloudbuild.yaml in the following line: "bokeh-demo-app-service-1", # change to your service name

Type the following command to create a service to domain mapping. Make sure to only include the root domain name without any protocol information (e.g. https) or subdomain (e.g. blog.example.com).

You will be prompted to select service region. We'll use the same same region we previously deployed our service in: us-central1.

gcloud beta run domain-mappings create --service bokeh-demo-app-service-1 --domain example.com

If successful, you will see instructions on the DNS records that need to be updated. I needed to add four A and four AAAA records to my DNS settings.

Screenshot DNS Records

I've added a screenshot of my Google Domains DNS settings below for reference.

Screenshot Google Domains DNS Settings

Once the domain is mapped, we also need to make sure that our Bokeh app will allow connections from this new domain. In Dockerfile, add the custom domain to the list of --allow-websocket-origin arguments. The update Dockerfile should look like this:

FROM continuumio/anaconda3

WORKDIR /apps

COPY . .

ENV PORT=8080

ARG USER=bokeh
RUN useradd -s /bin/bash -m ${USER}
USER ${USER}

CMD bokeh serve --port $PORT demo \
    --allow-websocket-origin bokeh-demo-app-service-1-f56pgsgjvq-uc.a.run.app \
    --allow-websocket-origin example.com

Let's deploy our Cloud Run service one more time.

gcloud builds submit

We can now navigate to our custom domain to access our Bokeh app.

I've embedded the app in an iframe below, so you can see it in action here!

from IPython.display import IFrame
IFrame(src='https://bokeh-demo-app-service-1-f56pgsgjvq-uc.a.run.app', width=1000, height=700)

Made with REPL Notes Build your own website in minutes with Jupyter notebooks.