Multi-Tenancy With WSGI

Multi-Tenancy Setup

Multi-tenancy in Django may be established in various ways. Here, we look specifically at a setup which allows multiple tenants using one codebase and multiple WSGI scripts.

Structure

We first assume the following structure is present on the server:

example_app
|-- current -> releases/1.1.0
|-- releases
|   `-- 1.1.0
|       |-- main
|       |   `-- settings.py
|       |-- manage.py
|       `-- tenants -> ../../shared/tenants
`-- shared
    `-- tenants
        |-- acme_example_app
        |   |-- settings.py
        |   |-- wsgi.py
        |   `-- www
        |       `-- content
        |-- liberty_example_app
        |   |-- settings.py
        |   |-- wsgi.py
        |   `-- www
        |       `-- content
        `-- zed_example_app
            |-- settings.py
            |-- wsgi.py
            `-- www
                `-- content

The codebase is located in releases, and the current release is always symlinked. Tenants are located in the shared/ directory and do no change with each deployment.

Project root in this example is releases/1.1.0/ and the shared tenants/ directory is symlinked next to manage.py.

Settings

Each tenant has their own wsgi.py script and their own settings.py file. The WSGI script for the tenant sets up the location of the tenant as well as the location of the project and Python:

"""
WSGI config for a tenant.

"""
import os 
import signal
import site
import sys
import time
import traceback

# Add python site packages from the virtualenv as a site directory. Using
# addsitedir() causes additional processing for egg files, etc.
site.addsitedir("/path/to/example_app/current/python/lib/python3.6/site-packages/")
from django.core.wsgi import get_wsgi_application

# The path to the Django project must be added.
sys.path.insert(1, "/path/to/example_app/current/source")

# This is needed if the tenant will include custom apps.
#sys.path.insert(2, "/path/to/example_app/shared/tenants/acme_example_app")

# This wsgi file is relative to the settings file in the tenant directory.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")

# We handle the WSGI application different than the default method so errors
# will be more obvious.
# application = get_wsgi_application()
try:
    application = get_wsgi_application()
except Exception:
    if "mod_wsgi" in sys.modules:
        traceback.print_exc()
        os.kill(os.getpid(), signal.SIGINT)
        time.sleep(2.5)

The tenant's settings file sets up the database and other tenant-specific values:

# Import default settings. Override or add to these settings below.
# noinspection PyUnresolvedReferences
from main.settings import *

ALLOWED_HOSTS = [
    "acme.example.app",
    ".acme.example.app.",
]

# Tenant root. The directory in which the all tenant files are stored.
TENANT_ROOT = "/path/to/example_app/shared/tenants/tenant_example_app"

# Database settings. Each tenant gets their own database.
DATABASES['default'] = {
    'ENGINE': 'django.db.backends.postgresql',
    'NAME': 'acme_example_app',
    'USER': 'acme_example_app',
    'PASSWORD': 'some secure password',
    'HOST': 'db1.example.app',
}

# Control which apps are available to the tenant.
#INSTALLED_APPS += [
#]

# Tenants get their own media directory.
MEDIA_ROOT = "%s/www/content" % TENANT_ROOT

SITE_TITLE = "ACME Inc"
SITE_URL = "https://tenant.example.app"

Web Server

The virtual host specifies the resources to be loaded. This example is for Apache:

<VirtualHost *:80>

    ServerName acme.example.app
    DocumentRoot "/path/to/example_app/shared/tenants/acme_example_app/www"

    <Directory "/path/to/example_app/www">
        Order allow,deny
        Allow from all
    </Directory>

    Alias "/_assets" "/path/to/example_app/current/source/www/assets"
    Alias "/_content" "/path/to/example_app/shared/tenants/acme_example_app/www/content"

    WSGIDaemonProcess acme_example_app python-path=/path/to/example_app/shared/tenants/acme_example_app:/path/to/example_app/current/python/lib/python3.6/site-packages

    WSGIProcessGroup acme_example_app
    WSGIScriptAlias "/" "/path/to/example_app/shared/tenants/acme_example_app/wsgi.py"

    <Directory "/path/to/example_app/shared/tenants/acme_example_app">
        Order allow,deny
        Allow from all
    </Directory>

</VirtualHost>

Pros and Cons of This Approach

Pros

  • It is easy to get started.
  • Since each customer has a settings.py file, it is possible to customize on a per-customer basis.
  • Development occurs normally, and without consideration for tenants.
  • There are no special considerations for migrations.
  • Resources and data are complete separated for each customer.
  • Scaling is built in.

Cons

  • Deployment becomes more complex.
  • Management commands must specify the tenant settings, for example ./manage.py migrate --settings=tenants.acme_example_app.settings

Conclusion

This approach allows a clean separation of tenants and their data while operating with a single codebase. Although deployment is more complex, it is scriptable, and tenant creation, update, and removal is also scriptable.

One considerable advantage is that development of Django models do not require any special considerations; there is no site ID or schema modification required because each tenant has their own database.



Posted in Multi-Tenancy by Shawn Davis, June 15, 2020