in blog | Django Beats |
---|---|
original entry | Django Views as Serverless Functions on Fly Machines |
In this post, we demonstrate how to use Fly Machines to run Django Views as Serverless Functions. Django on Fly.io is pretty sweet! Check it out: you can be up and running on Fly.io in just minutes.
In my last post, we ran custom Django commands as serverless functions by defining a new process to run within a Machine. This article continues our series transforming our Django views into serverless functions by defining a service.
But first, let’s understand some concepts about Fly Machines.
processes
When running a custom Django command, we define the command to run as a separate process in processes
within a Machine:
import requests
FLY_API_HOSTNAME = "https://api.machines.dev"
FLY_API_TOKEN = os.environ.get("FLY_API_TOKEN")
FLY_APP_NAME = os.environ.get("FLY_APP_NAME")
FLY_IMAGE_REF = os.environ.get("FLY_IMAGE_REF")
headers = {
"Authorization": f"Bearer {FLY_API_TOKEN}",
"Content-Type": "application/json",
}
def create_process_machine(cmd):
config = {
"config": {
"image": FLY_IMAGE_REF,
"auto_destroy": True,
"processes": [ # β processes here!
{
"cmd": cmd.split(" ")
}
],
}
}
url = f"{FLY_API_HOSTNAME}/v1/apps/{FLY_APP_NAME}/machines"
response = requests.post(
url, headers=headers, json=config
)
if response.ok:
return response.json()
else:
response.raise_for_status()
The Django command (cmd
) is passed as an argument to the entrypoint of our Docker container. In this case, the Machine will stop when the process exits without an error. When that happens, the Machine will destroy itself once it’s complete, as per "auto_destroy": True
. This works well for Django commands, that can be run as its own process and exits when the command finishes.
On the other side, as mention before, when creating/destroying machines we should take the boot time into consideration. An interesting alternative is to reuse existing Machines by start/stop them accordingly. That’s what we use in this post: create a machine to offload some work, stop it when it’s idle and start it when a new request comes in. For that to happen automatically, we are setting up a service.
services
By default, Machines are closed to the public internet. To make them accessible, we need to allocate an IP address to the Fly App and add one or more services.
This happens automatically when we fly launch
a Django app on Fly.io and the fly.toml
config file is generated with:
# fly.toml
...
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[http_service]
is a simplified version of [[services]]
section for apps that only need HTTP and HTTPS services.
In services
, we specify how to expose our application to the outside world which include setting for routing incoming requests to the appropriate components of our application.
To create a Machine with a service via Machines API, we would have something like this:
import os
import requests
FLY_API_HOSTNAME = "https://api.machines.dev"
FLY_API_TOKEN = os.environ.get("FLY_API_TOKEN")
FLY_APP_NAME = os.environ.get("FLY_APP_NAME")
FLY_IMAGE_REF = os.environ.get("FLY_IMAGE_REF")
headers = {
"Authorization": f"Bearer {FLY_API_TOKEN}",
"Content-Type": "application/json",
}
def create_service_machine():
config = {
"config": {
"image": FLY_IMAGE_REF,
"services": [ # β services here!
{
"protocol": "tcp",
"internal_port": 8000,
# Fly Proxy stops Machines when
# this service goes idle
"autostop": True,
"ports": [
{
"handlers": ["tls", "http"],
# The internet-exposed port
# to receive traffic on
"port": 443,
},
{
"handlers": ["http"],
# The internet-exposed port
# to receive traffic on
"port": 80
},
],
}
],
}
}
url = (
f"{FLY_API_HOSTNAME}/v1/apps/{FLY_APP_NAME}/machines"
)
response = requests.post(
url, headers=headers, json=config
)
if response.ok:
return response.json()
else:
response.raise_for_status()
create_service_machine()
creates a new machine with public access - we might refer to this machine as the serverless machine along this article.
That’s awesome but as I mentioned before, this machine is accessible to the public internet. We don’t want this machine processing any incoming requests to our main application, but rather specific views.
To accomplish that I need to introduce you to our fly-replay
response header.
fly-replay
πFly Proxy sits between our application and the public internet and is responsible for routing requests to the appropriate Machines based on their location and availability - among many other interesting features!
One of its capabilities includes the ability to redeliver (replay) the original request somewhere else:
fly-replay: region=gru
fly-replay: app=another-app
fly-replay: instance=9020ee72fd7948
fly-replay: elsewhere=true
You can find more details in Playing Traffic Cop with Fly-Replay.
For our Django app, it means we can reroute the request to the serverless machine once we receive a request to a specific Django view.
Aha! Exactly what we’ve been looking for! π
Enough of concepts for nowβ¦ Let’s check how we can implement that in Django.
For our demo, we simulate a store with purchases and products. A purchase has a list of product items:
We want to offload the receipt’s PDF generation to the serverless function.
These are the main steps we want to achieve:
GET /purchases/serverless-receipts
request
and sends to our Django App
request
and it goes through the Middleware layer
FlyReplayMiddleware
checks if the request should be handled in the current Machine or should be replayed in another Machine by evaluating the path requested
GET /purchases/serverless-receipts
is our serverless view. Fetch the serverless machine info and add fly-replay: instance=<id>
to the response header, where <id>
is the Machine id
where we should run our serverless view
response
and recognizes the fly-replay
header, so it redirects the original request
to another Machine, in this case, our serverless machine
request
, processes it, and returns the response
back to the Fly Proxy
response
back to the client
This is the happy path that doesn’t take into consideration other possibilities:
fly-replay
response header.
FlyReplayMiddleware
will be responsible to reroute the request back to our main application.
We will discuss those scenarios throughout this article and how to handle them.
Time to start coding! π©π½βπ»β¨
Let’s start by defining our views:
# <app>/views.py
# store/views.py
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from store.helpers import generate_pdf # helper that generates the PDF
@require_GET
def machines_generate_receipts(request):
purchases = Purchase.objects.all()
buffer = generate_pdf(purchases) # generate the PDF
return HttpResponse(buffer, content_type="application/pdf")
@require_GET
def machines_generate_receipt(request, purchase_id):
purchase = get_object_or_404(Purchase, id=purchase_id)
buffer = generate_pdf([purchase]) # generate the PDF
return HttpResponse(buffer, content_type="application/pdf")
machines_generate_receipts()
generates and renders a PDF file for all the purchases we currently have. machines_generate_receipt()
generates and render a PDF file for a specific purchase.
The urls are defined as follows:
# <app>/urls.py
# store/urls.py
from django.urls import path
from .views import machines_generate_receipts
app_name = "store"
urlpatterns = [
path(
"purchases/serverless-receipts/",
machines_generate_receipts,
name="machines_generate_receipts",
),
path(
"purchases/serverless-receipts/<int:purchase_id>/",
machines_generate_receipt,
name="machines_generate_receipt",
),
]
We aim to run machines_generate_receipts
and machines_generate_receipt
views as serverless functions.
Now, let’s define in the settings.py
the necessary environment variables to create new machines:
# <project>/settings.py
# fly/settings.py
import environ
env = environ.Env()
FLY_API_HOSTNAME = "https://api.machines.dev"
FLY_API_TOKEN = env("FLY_API_TOKEN")
FLY_IMAGE_REF = env("FLY_IMAGE_REF")
FLY_APP_NAME = env("FLY_APP_NAME")
FLY_API_HOSTNAME
: the recommended way to connect with Machines API is to use the public API https://api.machines.dev
FLY_API_TOKEN
: it’s possible to create a new auth token in your Fly.io dashboard. For local development, you can simply access the token used by flyctl
by using:
fly secrets set FLY_API_TOKEN=$(fly auth token)
FLY_IMAGE_REF
and FLY_APP_NAME
: those are environment variables available to us when our app is deployed on Fly.io.
We also specify the list of views to run as serverless functions:
# <project>/settings.py
# fly/settings.py
FLY_MACHINES_SERVERLESS_FUNCTIONS = [
# ("<app>:<path_name>", (<methods>))
("store:machines_generate_receipts", ("GET",)),
("store:machines_generate_receipt", ("GET",)),
]
Now, let’s work with Fly Machines API.
To keep all the logic around Machines in one place, let’s define a machines.py
:
# <app>/machines.py
# store/machines.py
import requests
from django.conf import settings
from django.urls import resolve, Resolver404
FLY_API_HOSTNAME = settings.FLY_API_HOSTNAME
FLY_API_TOKEN = settings.FLY_API_TOKEN
FLY_APP_NAME = settings.FLY_APP_NAME
FLY_IMAGE_REF = settings.FLY_IMAGE_REF
headers = {
"Authorization": f"Bearer {FLY_API_TOKEN}",
"Content-Type": "application/json",
}
def machines_url():
return f"{FLY_API_HOSTNAME}/v1/apps/{FLY_APP_NAME}/machines"
def create_serverless_machine():
config = {
"config": {
"image": FLY_IMAGE_REF,
"env": {"IS_FLY_MACHINE_SERVERLESS": "true"},
"services": [
{
"protocol": "tcp",
"internal_port": 8000,
"autostop": True,
"ports": [
{
"handlers": ["tls", "http"],
"port": 443,
},
{
"handlers": ["http"],
"port": 80
},
],
}
],
}
}
response = requests.post(
machines_url(), headers=headers, json=config
)
if response.ok:
return response.json()
else:
response.raise_for_status()
def get_or_create_serverless_machine():
response = requests.get(
machines_url(),
headers=headers,
params={"include_deleted": False},
)
if response.ok:
machines = response.json()
for machine in machines:
if (
machine["config"]["env"].get(
"IS_FLY_MACHINE_SERVERLESS", "false"
)
== "true"
):
return machine
else:
response.raise_for_status()
return create_serverless_machine()
def is_serverless_function_request(request):
for pattern, methods in getattr(
settings, "FLY_MACHINES_SERVERLESS_FUNCTIONS", []
):
request_method = request.method
try:
resolved_match = resolve(request.path)
url_name = resolved_match.url_name
app_name = resolved_match.app_name
if (
f"{app_name}:{url_name}" == pattern
and request_method in methods
):
return True
except Resolver404:
continue
return False
That was a lot of code. Shall we break this down?
Let’s start from the top:
def create_serverless_machine():
config = {
"config": {
"image": FLY_IMAGE_REF,
"env": {"IS_FLY_MACHINE_SERVERLESS": "true"},
"services": [
{
"protocol": "tcp",
"internal_port": 8000,
"autostop": True,
"ports": [
{
"handlers": ["tls", "http"],
"port": 443,
},
{
"handlers": ["http"],
"port": 80
},
],
}
],
}
}
response = requests.post(
machines_url(), headers=headers, json=config
)
if response.ok:
return response.json()
else:
response.raise_for_status()
create_serverless_machine()
creates a new machine using the current Docker image of our Django app (FLY_IMAGE_REF
). It spins up a new machine identical to our main app. The configurations worth highlighting are:
"config": {
"env": {
"IS_FLY_MACHINE_SERVERLESS": "true"
}
...
}
config.env
: the key/value pair to be set as an environment variable.
We’ll use IS_FLY_MACHINE_SERVERLESS
environment variable to filter the machine in which the serverless view should run on. Note: any key/value pair should be set as <string>:<string>
, e.g. "IS_FLY_MACHINE_SERVERLESS": "true"
.
"config": {
...
"services": [{
...
"autostop": True,
}]
}
config.services
: setting autostop
to true
autostop
is false
by default. When true
, the Fly Proxy automatically stops the machine when idle, so you don’t pay for the machine when there is no traffic. That’s the catch!
def get_or_create_serverless_machine():
response = requests.get(
machines_url(),
headers=headers,
params={"include_deleted": False},
)
if response.ok:
machines = response.json()
for machine in machines:
if (
machine["config"]["env"].get(
"IS_FLY_MACHINE_SERVERLESS", "false"
)
== "true"
):
return machine
else:
response.raise_for_status()
return create_serverless_machine()
get_or_create_serverless_machine()
, as the name suggests, gets the serverless machine or creates a new one. This is done by fetching all non-deleted machines and checking the IS_FLY_MACHINE_SERVERLESS
environment variable we defined in create_serverless_machine()
.
def is_serverless_function_request(request):
for pattern, methods in getattr(
settings, "FLY_MACHINES_SERVERLESS_FUNCTIONS", []
):
request_method = request.method
try:
resolved_match = resolve(request.path)
app_name = resolved_match.app_name
url_name = resolved_match.url_name
if (
f"{app_name}:{url_name}" == pattern
and request_method in methods
):
return True
except Resolver404:
continue
return False
Lastly, is_serverless_function_request()
checks FLY_MACHINES_SERVERLESS_FUNCTIONS
list from our settings.py
and returns weather the current request.path
should be handled by our serverless machine.
Now, when a request to a serverless view comes in to our main app, what do we do? And when a request to a non-serverless view comes in to our serverless machine, what should we do?
There’s still a piece of the puzzle missing. π§©
To wrap up, let’s build the last piece: our custom Middleware.
The Django Middleware operates globally. It acts as an intermediary between the request from the user and the response from the server.
We need a way to intercept the request when it comes in to decide if we should reroute it to another machine.
First, let’s create a middleware.py
to define our custom middleware:
# <app>/middleware.py
# store/middleware.py
import os
import logging
import requests
from django.conf import settings
from django.http import HttpResponse
from .machines import (
get_or_create_serverless_machine,
is_serverless_function_request,
)
FLY_APP_NAME = settings.FLY_APP_NAME
class FlyReplayMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if is_serverless_function_request(request):
if not os.environ.get("IS_FLY_MACHINE_SERVERLESS"):
try:
response = HttpResponse()
serverless_machine = (
get_or_create_serverless_machine()
)
response[
"fly-replay"
] = f"instance={serverless_machine['id']}"
return response
except requests.exceptions.HTTPError as err:
return HttpResponse(
"Something went wrong", status=404
)
elif (
os.environ.get("IS_FLY_MACHINE_SERVERLESS") == "true"
):
response = HttpResponse()
response["fly-replay"] = f"elsewhere=true"
return response
response = self.get_response(request)
return response
and add it to settings.py
:
# <project>/settings.py
# fly/settings.py
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"store.middleware.FlyReplayMiddleware", # β Added!
]
As a reminder, Django applies the middleware in the order itβs defined in the list, top-down.
Now, let’s understand what our custom middleware is doing.
If our main app receives a request to a serverless view, it gets the serverless machine id
and adds the fly-replay: instance=<id>
to the response header to be replayed on that specific machine. The serverless machine is found based on the existence of IS_FLY_MACHINE_SERVERLESS
environment variable.
If our Serverless Machine receives a request to a non-serverless view, it adds the fly-replay: elsewhere=true
to the response header to be replayed anywhere else other than the serverless machine.
Otherwise, we just call the view and return the response as per usual.
Here is the table to better visualize the scenarios:
Views | Main App | Serverless Machine |
---|---|---|
Serverless | add fly-replay: instance=<id> to response header |
call the view and return a response |
Non-Serverless | call view and return a response | add fly-replay: elsewhere=true to the response header |
That’s it! You made it to the end! π
Last but not least, let’s try this demo out here:
Note: Receipts are generated using reportlab (reportlab==4.0.7
).
Machines API offers a way to create services that can seamlessly scale down to zero during periods of inactivity.
However, it’s crucial to emphasize that while working directly with the Machines API provides us with greater control over our Machines, it also involves assuming some of the orchestration responsibility.
This post covers creating a serverless Machine on the spot and using it to invoke specific Django views. Note that topics such as deployment, latency and replacing the serverless machine with new versions are not covered in the discussion. Let’s save it for a future blog post, shall we?
Our demo also demonstrates some important and interesting concepts about Fly Machines, in particular, processes
, services
, Fly Proxy and fly-replay
.
Such an approach can also be applied to other use cases previously discussed in this blog. If Django Async Views fall short in preventing the blocking of your main application, and a task queue such as Celery seems like overkill for your not-so-often but resource-intensive or I/O-bound operations, you might want to explore Fly Machines as an alternative.