├── my_project
├── __init__.py
├── wsgi.py
├── settings
│ ├── deploy.py
│ └── __init__.py
└── urls.py
├── _config.yml
├── .gitignore
├── Makefile
├── requirements.txt
├── setup.cfg
├── .dockerignore
├── docker-entrypoint.sh
├── docker-compose.yml
├── manage.py
├── .travis.yml
├── check.py
├── Dockerfile
├── README.rst.j2
└── README.rst
/my_project/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-slate
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | db.sqlite3
3 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .DEFAULT_GOAL := readme
2 |
3 |
4 | readme:
5 | j2 README.rst.j2 > README.rst
6 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Django>=2.2,<2.3
2 | uwsgi>=2.0,<2.1
3 | dj-database-url>=0.5,<0.6
4 | psycopg2>=2.8,<2.9
5 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length=120
3 | ignore=W503
4 |
5 | [isort]
6 | line_length=120
7 | multi_line_output=2
8 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .travis.yml
2 | __pycache__
3 | check.py
4 | db.sqlite3
5 | docker-compose.yml
6 | Dockerfile
7 | setup.cfg
8 |
--------------------------------------------------------------------------------
/docker-entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | until psql $DATABASE_URL -c '\l'; do
5 | >&2 echo "Postgres is unavailable - sleeping"
6 | sleep 1
7 | done
8 |
9 | >&2 echo "Postgres is up - continuing"
10 |
11 | if [ "x$DJANGO_MANAGEPY_MIGRATE" = 'xon' ]; then
12 | python manage.py migrate --noinput
13 | fi
14 |
15 | exec "$@"
16 |
--------------------------------------------------------------------------------
/my_project/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for my_project project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my_project.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 |
3 | services:
4 | db:
5 | environment:
6 | POSTGRES_DB: app_db
7 | POSTGRES_USER: app_user
8 | POSTGRES_PASSWORD: changeme
9 | restart: always
10 | image: postgres:12
11 | expose:
12 | - "5432"
13 | app:
14 | environment:
15 | DATABASE_URL: postgres://app_user:changeme@db/app_db
16 | DJANGO_MANAGEPY_MIGRATE: "on"
17 | build:
18 | context: .
19 | dockerfile: ./Dockerfile
20 | links:
21 | - db:db
22 | ports:
23 | - "8000:8000"
24 |
--------------------------------------------------------------------------------
/my_project/settings/deploy.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | import dj_database_url
4 |
5 | from . import * # noqa: F403
6 |
7 | # This is NOT a complete production settings file. For more, see:
8 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
9 |
10 | DEBUG = False
11 |
12 | ALLOWED_HOSTS = ['localhost']
13 |
14 | DATABASES['default'] = dj_database_url.config(conn_max_age=600) # noqa: F405
15 |
16 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') # noqa: F405
17 |
18 | STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
19 |
--------------------------------------------------------------------------------
/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my_project.settings')
9 | try:
10 | from django.core.management import execute_from_command_line
11 | except ImportError as exc:
12 | raise ImportError(
13 | "Couldn't import Django. Are you sure it's installed and "
14 | "available on your PYTHONPATH environment variable? Did you "
15 | "forget to activate a virtual environment?"
16 | ) from exc
17 | execute_from_command_line(sys.argv)
18 |
19 |
20 | if __name__ == '__main__':
21 | main()
22 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | dist: xenial
2 | language: python
3 | python:
4 | - "3.7"
5 | services:
6 | - docker
7 |
8 | env:
9 | global:
10 | - GREP_TIMEOUT=10
11 |
12 | # Update Docker Engine
13 | before_install:
14 | - sudo apt-get update
15 | - sudo apt-get install -qy -o Dpkg::Options::="--force-confold" docker-ce coreutils
16 |
17 | install:
18 | - pip install requests==2.21.0 beautifulsoup4==4.7.1 flake8==3.7.7 isort==4.3.16 j2cli==0.3.10
19 |
20 | before_script:
21 | - docker-compose up --build -d
22 | - docker-compose ps
23 | - timeout $GREP_TIMEOUT grep -m 1 'spawned uWSGI http 1' <(docker-compose logs --follow app 2>&1)
24 |
25 | script:
26 | - python3 check.py
27 | - flake8
28 | - isort --recursive --check-only --diff
29 | - make readme
30 | - git diff --exit-code
31 |
32 | after_script:
33 | - docker-compose logs
34 |
--------------------------------------------------------------------------------
/my_project/urls.py:
--------------------------------------------------------------------------------
1 | """my_project URL Configuration
2 |
3 | The `urlpatterns` list routes URLs to views. For more information please see:
4 | https://docs.djangoproject.com/en/dev/topics/http/urls/
5 | Examples:
6 | Function views
7 | 1. Add an import: from my_app import views
8 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
9 | Class-based views
10 | 1. Add an import: from other_app.views import Home
11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
12 | Including another URLconf
13 | 1. Import the include() function: from django.urls import include, path
14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
15 | """
16 | from django.contrib import admin
17 | from django.urls import path
18 |
19 | urlpatterns = [
20 | path('admin/', admin.site.urls),
21 | ]
22 |
--------------------------------------------------------------------------------
/check.py:
--------------------------------------------------------------------------------
1 | "Run some basic tests against the docker-compose app"
2 | import logging
3 | from urllib.parse import urljoin
4 |
5 | import requests
6 | from bs4 import BeautifulSoup
7 |
8 | logging.basicConfig(level=logging.DEBUG)
9 |
10 | BASE_URL = 'http://localhost:8000/'
11 |
12 | s = requests.Session()
13 |
14 | # uWSGI should return a 400 error for requests with a bad Host header
15 | r = s.get(urljoin(BASE_URL, ''), headers={'Host': 'badhost.com'})
16 | assert r.status_code == 400, r.status_code
17 | # uWSGI just returns an empty response. If the request makes it to Django
18 | # (which it shouldn't), this will be b'
Bad Request (400)
'.
19 | assert r.content == b'', r.content
20 |
21 | # Our project doesn't have a homepage URL
22 | r = s.get(urljoin(BASE_URL, ''))
23 | assert r.status_code == 404, r.status_code
24 |
25 | # We should still be able to get to the admin
26 | r = s.get(urljoin(BASE_URL, 'admin/'))
27 | assert r.status_code == 200, r.status_code
28 |
29 | # Which, in turn, should have some CSS files we can try to download
30 | soup = BeautifulSoup(r.content, features="html.parser")
31 | for link_href in [l.get('href') for l in soup.find_all('link')]:
32 | # If static files fail to download, uWSGI must not be set up properly to
33 | # serve them.
34 | r = s.get(urljoin(BASE_URL, link_href))
35 | assert r.status_code == 200, \
36 | 'r.status_code=%s, link_href=%s' % (r.status_code, link_href)
37 | # If there's no 'Expires' header, uWSGI probably didn't get built with
38 | # regexp support (likely due to a missing system package).
39 | assert 'Expires' in r.headers, \
40 | 'r.headers=%s, link_href=%s' % (r.headers, link_href)
41 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.7-slim
2 |
3 | # Create a group and user to run our app
4 | ARG APP_USER=appuser
5 | RUN groupadd -r ${APP_USER} && useradd --no-log-init -r -g ${APP_USER} ${APP_USER}
6 |
7 | # Install packages needed to run your application (not build deps):
8 | # mime-support -- for mime types when serving static files
9 | # postgresql-client -- for running database commands
10 | # We need to recreate the /usr/share/man/man{1..8} directories first because
11 | # they were clobbered by a parent image.
12 | RUN set -ex \
13 | && RUN_DEPS=" \
14 | libpcre3 \
15 | mime-support \
16 | postgresql-client \
17 | " \
18 | && seq 1 8 | xargs -I{} mkdir -p /usr/share/man/man{} \
19 | && apt-get update && apt-get install -y --no-install-recommends $RUN_DEPS \
20 | && rm -rf /var/lib/apt/lists/*
21 |
22 | # Copy in your requirements file
23 | ADD requirements.txt /requirements.txt
24 |
25 | # OR, if you're using a directory for your requirements, copy everything (comment out the above and uncomment this if so):
26 | # ADD requirements /requirements
27 |
28 | # Install build deps, then run `pip install`, then remove unneeded build deps all in a single step.
29 | # Correct the path to your production requirements file, if needed.
30 | RUN set -ex \
31 | && BUILD_DEPS=" \
32 | build-essential \
33 | libpcre3-dev \
34 | libpq-dev \
35 | " \
36 | && apt-get update && apt-get install -y --no-install-recommends $BUILD_DEPS \
37 | && pip install --no-cache-dir -r /requirements.txt \
38 | \
39 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $BUILD_DEPS \
40 | && rm -rf /var/lib/apt/lists/*
41 |
42 | # Copy your application code to the container (make sure you create a .dockerignore file if any large files or directories should be excluded)
43 | RUN mkdir /code/
44 | WORKDIR /code/
45 | ADD . /code/
46 |
47 | # uWSGI will listen on this port
48 | EXPOSE 8000
49 |
50 | # Add any static environment variables needed by Django or your settings file here:
51 | ENV DJANGO_SETTINGS_MODULE=my_project.settings.deploy
52 |
53 | # Call collectstatic (customize the following line with the minimal environment variables needed for manage.py to run):
54 | RUN DATABASE_URL='' python manage.py collectstatic --noinput
55 |
56 | # Tell uWSGI where to find your wsgi file (change this):
57 | ENV UWSGI_WSGI_FILE=my_project/wsgi.py
58 |
59 | # Base uWSGI configuration (you shouldn't need to change these):
60 | ENV UWSGI_HTTP=:8000 UWSGI_MASTER=1 UWSGI_HTTP_AUTO_CHUNKED=1 UWSGI_HTTP_KEEPALIVE=1 UWSGI_LAZY_APPS=1 UWSGI_WSGI_ENV_BEHAVIOR=holy
61 |
62 | # Number of uWSGI workers and threads per worker (customize as needed):
63 | ENV UWSGI_WORKERS=2 UWSGI_THREADS=4
64 |
65 | # uWSGI static file serving configuration (customize or comment out if not needed):
66 | ENV UWSGI_STATIC_MAP="/static/=/code/static/" UWSGI_STATIC_EXPIRES_URI="/static/.*\.[a-f0-9]{12,}\.(css|js|png|jpg|jpeg|gif|ico|woff|ttf|otf|svg|scss|map|txt) 315360000"
67 |
68 | # Deny invalid hosts before they get to Django (uncomment and change to your hostname(s)):
69 | ENV UWSGI_ROUTE_HOST="^(?!localhost:8000$) break:400"
70 |
71 | # Change to a non-root user
72 | USER ${APP_USER}:${APP_USER}
73 |
74 | # Uncomment after creating your docker-entrypoint.sh
75 | ENTRYPOINT ["/code/docker-entrypoint.sh"]
76 |
77 | # Start uWSGI
78 | CMD ["uwsgi", "--show-config"]
79 |
--------------------------------------------------------------------------------
/my_project/settings/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for my_project project.
3 |
4 | Generated by 'django-admin startproject' using Django 2.2rc1.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/dev/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/dev/ref/settings/
11 | """
12 |
13 | import os
14 |
15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
17 |
18 |
19 | # Quick-start development settings - unsuitable for production
20 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
21 |
22 | # SECURITY WARNING: keep the secret key used in production secret!
23 | SECRET_KEY = '*_y(zx!8d_b_tlbv*(j0=z=__5dsyv-36#$^@-*x5r%b#l78uw'
24 |
25 | # SECURITY WARNING: don't run with debug turned on in production!
26 | DEBUG = True
27 |
28 | ALLOWED_HOSTS = []
29 |
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = [
34 | 'django.contrib.admin',
35 | 'django.contrib.auth',
36 | 'django.contrib.contenttypes',
37 | 'django.contrib.sessions',
38 | 'django.contrib.messages',
39 | 'django.contrib.staticfiles',
40 | ]
41 |
42 | MIDDLEWARE = [
43 | 'django.middleware.security.SecurityMiddleware',
44 | 'django.contrib.sessions.middleware.SessionMiddleware',
45 | 'django.middleware.common.CommonMiddleware',
46 | 'django.middleware.csrf.CsrfViewMiddleware',
47 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
48 | 'django.contrib.messages.middleware.MessageMiddleware',
49 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
50 | ]
51 |
52 | ROOT_URLCONF = 'my_project.urls'
53 |
54 | TEMPLATES = [
55 | {
56 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
57 | 'DIRS': [],
58 | 'APP_DIRS': True,
59 | 'OPTIONS': {
60 | 'context_processors': [
61 | 'django.template.context_processors.debug',
62 | 'django.template.context_processors.request',
63 | 'django.contrib.auth.context_processors.auth',
64 | 'django.contrib.messages.context_processors.messages',
65 | ],
66 | },
67 | },
68 | ]
69 |
70 | WSGI_APPLICATION = 'my_project.wsgi.application'
71 |
72 |
73 | # Database
74 | # https://docs.djangoproject.com/en/dev/ref/settings/#databases
75 |
76 | DATABASES = {
77 | 'default': {
78 | 'ENGINE': 'django.db.backends.sqlite3',
79 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
80 | }
81 | }
82 |
83 |
84 | # Password validation
85 | # https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
86 |
87 | AUTH_PASSWORD_VALIDATORS = [
88 | {
89 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
90 | },
91 | {
92 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
93 | },
94 | {
95 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
96 | },
97 | {
98 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
99 | },
100 | ]
101 |
102 |
103 | # Internationalization
104 | # https://docs.djangoproject.com/en/dev/topics/i18n/
105 |
106 | LANGUAGE_CODE = 'en-us'
107 |
108 | TIME_ZONE = 'UTC'
109 |
110 | USE_I18N = True
111 |
112 | USE_L10N = True
113 |
114 | USE_TZ = True
115 |
116 |
117 | # Static files (CSS, JavaScript, Images)
118 | # https://docs.djangoproject.com/en/dev/howto/static-files/
119 |
120 | STATIC_URL = '/static/'
121 |
--------------------------------------------------------------------------------
/README.rst.j2:
--------------------------------------------------------------------------------
1 | This is a DRAFT (possibly including untested, yet-to-be released) edits to the blog post published here: https://www.caktusgroup.com/blog/2017/03/14/production-ready-dockerfile-your-python-django-app/
2 |
3 | **Have a suggested tweak or fix?** Feel free to submit a PR.
4 |
5 |
6 | A Production-ready Dockerfile for Your Python/Django App
7 | ========================================================
8 |
9 | **Update (October 29, 2019):** I updated this post with more recent Django and Postgres versions, to use Python and pip directly in the container (instead of in a separate virtual environment, which was unnecessary), and switched to a non-root user via Docker instead of uWSGI.
10 |
11 | Docker has matured a lot since it was released. We've been watching it closely at Caktus, and have been thrilled by the adoption -- both by the community and by service providers. As a team of Python and Django developers, we're always searching for best of breed deployment tools. Docker is a clear fit for packaging the underlying code for many projects, including the Python and Django apps we build at Caktus.
12 |
13 | This post also includes an `accompanying GitHub repo `_.
14 |
15 | Technical Overview
16 | ------------------
17 |
18 | There are many ways to containerize a Python/Django app, no one of which could be considered "the best." That being said, I think the following approach provides a good balance of simplicity, configurability, and container size. The specific tools I use are: `Docker `_ (of course), the `python:3.7-slim `_ Docker image (based on Debian Stretch), and `uWSGI `_.
19 |
20 | In a previous version of this post, I used `Alpine Linux `_ as the base image for this
21 | Dockerfile. But now, I'm switching to a Debian- and glibc-based image, because I found `an inconvenient workaround `_ was required for `musl libc `_'s ``strftime()`` implementation. The Debian-based "slim" images are still relatively small; the bare-minimum image described below increases from about 170 MB to 227 MB (~33%) when switching from ``python:3.7-alpine`` to ``python:3.7-slim`` (and updating all the corresponding system packages).
22 |
23 | There are many WSGI servers available for Python, and we use both Gunicorn and uWSGI at Caktus. A couple of the benefits of uWSGI are that (1) it's almost entirely configurable through environment variables (which fits well with containers), and (2) it includes `native HTTP support `_, which can circumvent the need for a separate HTTP server like Apache or Nginx.
24 |
25 |
26 | The Dockerfile
27 | --------------
28 |
29 | Without further ado, here's a production-ready ``Dockerfile`` you can use as a starting point for your project (it should be added in your top level project directory, next to the ``manage.py`` script provided by your Django project):
30 |
31 | .. code-block:: docker
32 | {# re-comment out the lines that we've uncommented in the sample files, for testing purposes -#}
33 | {% filter replace("ENTRYPOINT", "# ENTRYPOINT") -%}
34 | {% filter replace("ENV UWSGI_ROUTE_HOST", "# ENV UWSGI_ROUTE_HOST") -%}
35 | {% filter indent(width=4) %}
36 | {% include "Dockerfile" %}
37 | {%- endfilter %}
38 | {%- endfilter %}
39 | {%- endfilter %}
40 | We extend from the "slim" flavor of the official Docker image for Python 3.7, install a few dependencies for running our application (i.e., that we want to keep in the final version of the image), copy the folder containing our requirements files to the container, and then, in a single line, (a) install the build dependencies needed, (b) ``pip install`` the requirements themselves (edit this line to match the location of your requirements file, if needed), (c) remove the C compiler and any other OS packages no longer needed, and (d) remove the package lists since they're no longer needed. It's important to keep this all on one line so that Docker will cache the entire operation as a single layer.
41 |
42 | Next, we copy our application code to the image, set some default environment variables, and run ``collectstatic``. Be sure to change the values for ``DJANGO_SETTINGS_MODULE`` and ``UWSGI_WSGI_FILE`` to the correct paths for your application (note that the former requires a Python package path, while the latter requires a file system path).
43 |
44 | A few notes about other aspects of this Dockerfile:
45 |
46 | * I only included a minimal set of OS dependencies here. If this is an established production app, you'll most likely need to visit https://packages.debian.org, search for the Debian package names of the OS dependencies you need, including the ``-dev`` supplemental packages as needed, and add them either to ``RUN_DEPS`` or ``BUILD_DEPS`` in your Dockerfile.
47 | * Adding ``--no-cache-dir`` to the ``pip install`` command saves a additional disk space, as this prevents ``pip`` from `caching downloads `_ and `caching wheels `_ locally. Since you won't need to install requirements again after the Docker image has been created, this can be added to the ``pip install`` command. Thanks Hemanth Kumar for this tip!
48 | * uWSGI contains a lot of optimizations for running many apps from the same uWSGI process. These optimizations aren't really needed when running a single app in a Docker container, and can `cause issues `_ when used with certain 3rd-party packages. I've added ``UWSGI_LAZY_APPS=1`` and ``UWSGI_WSGI_ENV_BEHAVIOR=holy`` to the uWSGI configuration to provide a more stable uWSGI experience (the latter will be the default in the next uWSGI release).
49 | * The ``UWSGI_HTTP_AUTO_CHUNKED`` and ``UWSGI_HTTP_KEEPALIVE`` options to uWSGI are needed in the event the container will be hosted behind an Amazon Elastic Load Balancer (ELB), because Django doesn't set a valid ``Content-Length`` header by default, unless the ``ConditionalGetMiddleware`` is enabled. See `the note `_ at the end of the uWSGI documentation on HTTP support for further detail.
50 |
51 |
52 | Requirements and Settings Files
53 | -------------------------------
54 |
55 | Production-ready requirements and settings files are outside the scope of this post, but you'll need to include a few things in your requirements file(s), if they're not there already::
56 | {% filter indent(width=4) %}
57 | {% include "requirements.txt" %}
58 | {%- endfilter %}
59 | I didn't pin these to specific versions here to help future-proof this post somewhat, but you'll likely want to pin these (and other) requirements to specific versions so things don't suddenly start breaking in production. Of course, you don't have to use any of these packages, but you'll need to adjust the corresponding code elsewhere in this post if you don't.
60 |
61 | My ``deploy.py`` settings file looks like this:
62 |
63 | .. code-block:: python
64 | {% filter indent(width=4) %}
65 | {% include "my_project/settings/deploy.py" %}
66 | {%- endfilter %}
67 | This bears repeating: This is **not** a production-ready settings file, and you should review `the checklist `_ in the Django docs (and run ``python manage.py check --deploy --settings=my_project.settings.deploy``) to ensure you've properly secured your production settings file.
68 |
69 |
70 | Building and Testing the Container
71 | ----------------------------------
72 |
73 | Now that you have the essentials in place, you can build your Docker image locally as follows:
74 |
75 | .. code-block:: bash
76 |
77 | docker build -t my-app .
78 |
79 | This will go through all the commands in your Dockerfile, and if successful, store an image with your local Docker server that you could then run:
80 |
81 | .. code-block:: bash
82 |
83 | docker run -e DATABASE_URL='' -t my-app
84 |
85 | This command is merely a smoke test to make sure uWSGI runs, and won't connect to a database or any other external services.
86 |
87 |
88 | Running Commands During Container Start-Up
89 | ------------------------------------------
90 |
91 | As a final step, I recommend creating an ``ENTRYPOINT`` script to run commands as needed during container start-up. This will let us accomplish any number of things, such as making sure Postgres is available or running ``migrate`` during container start-up. Save the following to a file named ``docker-entrypoint.sh`` in the same directory as your ``Dockerfile``:
92 |
93 | .. code-block:: bash
94 | {% filter indent(width=4) %}
95 | {% include "docker-entrypoint.sh" %}
96 | {%- endfilter %}
97 | Make sure this file is executable, i.e.:
98 |
99 | .. code-block:: bash
100 |
101 | chmod a+x docker-entrypoint.sh
102 |
103 | Next, uncomment the following line to your ``Dockerfile``, just above the ``CMD`` statement:
104 |
105 | .. code-block:: docker
106 |
107 | ENTRYPOINT ["/code/docker-entrypoint.sh"]
108 |
109 | This will (a) make sure a database is available (usually only needed when used with Docker Compose) and (b) run outstanding migrations, if any, if the ``DJANGO_MANAGEPY_MIGRATE`` is set to ``on`` in your environment. Even if you add this entrypoint script as-is, you could still choose to run ``migrate`` or ``collectstatic`` in separate steps in your deployment before releasing the new container. The only reason you might not want to do this is if your application is highly sensitive to container start-up time, or if you want to avoid any database calls as the container starts up (e.g., for local testing). If you do rely on these commands being run during container start-up, be sure to set the relevant variables in your container's environment.
110 |
111 |
112 | Creating a Production-Like Environment Locally with Docker Compose
113 | ------------------------------------------------------------------
114 |
115 | To run a complete copy of production services locally, you can use `Docker Compose `_. The following ``docker-compose.yml`` will create a barebones, ephemeral, AWS-like container environment with Postgres for testing your production environment locally.
116 |
117 | *This is intended for local testing of your production environment only, and will not save data from stateful services like Postgres upon container shutdown.*
118 |
119 | .. code-block:: yaml
120 | {% filter indent(width=4) %}
121 | {% include "docker-compose.yml" %}
122 | {%- endfilter %}
123 | Copy this into a file named ``docker-compose.yml`` in the same directory as your ``Dockerfile``, and then run:
124 |
125 | .. code-block:: bash
126 |
127 | docker-compose up --build -d
128 |
129 | This downloads (or builds) and starts the two containers listed above. You can view output from the containers by running:
130 |
131 | .. code-block:: bash
132 |
133 | docker-compose logs
134 |
135 | If all services launched successfully, you should now be able to access your application at http://localhost:8000/ in a web browser.
136 |
137 | If you need to debug your application container, a handy way to launch an instance it and poke around is:
138 |
139 | .. code-block:: bash
140 |
141 | docker-compose run app /bin/bash
142 |
143 |
144 | Static Files
145 | ------------
146 |
147 | You may have noticed that we set up static file serving in uWSGI via the ``UWSGI_STATIC_MAP`` and ``UWSGI_STATIC_EXPIRES_URI`` environment variables. If preferred, you can turn this off and use `Django Whitenoise `_ or `copy your static files straight to S3 `_.
148 |
149 |
150 | Blocking ``Invalid HTTP_HOST header`` Errors with uWSGI
151 | -------------------------------------------------------
152 |
153 | To avoid Django's ``Invalid HTTP_HOST header`` errors (and prevent any such spurious requests from taking up any more CPU cycles than absolutely necessary), you can also configure uWSGI to return an ``HTTP 400`` response immediately without ever invoking your application code. This can be accomplished by uncommenting and customizing the ``UWSGI_ROUTE_HOST`` line in the Dockerfile above.
154 |
155 |
156 | Summary
157 | -------
158 |
159 | That concludes this high-level introduction to containerizing your Python/Django app for hosting on AWS Elastic Beanstalk (EB), Elastic Container Service (ECS), or elsewhere. Each application and Dockerfile will be slightly different, but I hope this provides a good starting point for your containers. Shameless plug: if you're looking for a simple (and at least temporarily free) way to test your Docker containers on AWS using an Elastic Beanstalk Multicontainer Docker environment or the Elastic Container Service, check out Caktus' very own `AWS Web Stacks `_. Good luck!
160 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | This is a DRAFT (possibly including untested, yet-to-be released) edits to the blog post published here: https://www.caktusgroup.com/blog/2017/03/14/production-ready-dockerfile-your-python-django-app/
2 |
3 | **Have a suggested tweak or fix?** Feel free to submit a PR.
4 |
5 |
6 | A Production-ready Dockerfile for Your Python/Django App
7 | ========================================================
8 |
9 | **Update (October 29, 2019):** I updated this post with more recent Django and Postgres versions, to use Python and pip directly in the container (instead of in a separate virtual environment, which was unnecessary), and switched to a non-root user via Docker instead of uWSGI.
10 |
11 | Docker has matured a lot since it was released. We've been watching it closely at Caktus, and have been thrilled by the adoption -- both by the community and by service providers. As a team of Python and Django developers, we're always searching for best of breed deployment tools. Docker is a clear fit for packaging the underlying code for many projects, including the Python and Django apps we build at Caktus.
12 |
13 | This post also includes an `accompanying GitHub repo `_.
14 |
15 | Technical Overview
16 | ------------------
17 |
18 | There are many ways to containerize a Python/Django app, no one of which could be considered "the best." That being said, I think the following approach provides a good balance of simplicity, configurability, and container size. The specific tools I use are: `Docker `_ (of course), the `python:3.7-slim `_ Docker image (based on Debian Stretch), and `uWSGI `_.
19 |
20 | In a previous version of this post, I used `Alpine Linux `_ as the base image for this
21 | Dockerfile. But now, I'm switching to a Debian- and glibc-based image, because I found `an inconvenient workaround `_ was required for `musl libc `_'s ``strftime()`` implementation. The Debian-based "slim" images are still relatively small; the bare-minimum image described below increases from about 170 MB to 227 MB (~33%) when switching from ``python:3.7-alpine`` to ``python:3.7-slim`` (and updating all the corresponding system packages).
22 |
23 | There are many WSGI servers available for Python, and we use both Gunicorn and uWSGI at Caktus. A couple of the benefits of uWSGI are that (1) it's almost entirely configurable through environment variables (which fits well with containers), and (2) it includes `native HTTP support `_, which can circumvent the need for a separate HTTP server like Apache or Nginx.
24 |
25 |
26 | The Dockerfile
27 | --------------
28 |
29 | Without further ado, here's a production-ready ``Dockerfile`` you can use as a starting point for your project (it should be added in your top level project directory, next to the ``manage.py`` script provided by your Django project):
30 |
31 | .. code-block:: docker
32 |
33 | FROM python:3.7-slim
34 |
35 | # Create a group and user to run our app
36 | ARG APP_USER=appuser
37 | RUN groupadd -r ${APP_USER} && useradd --no-log-init -r -g ${APP_USER} ${APP_USER}
38 |
39 | # Install packages needed to run your application (not build deps):
40 | # mime-support -- for mime types when serving static files
41 | # postgresql-client -- for running database commands
42 | # We need to recreate the /usr/share/man/man{1..8} directories first because
43 | # they were clobbered by a parent image.
44 | RUN set -ex \
45 | && RUN_DEPS=" \
46 | libpcre3 \
47 | mime-support \
48 | postgresql-client \
49 | " \
50 | && seq 1 8 | xargs -I{} mkdir -p /usr/share/man/man{} \
51 | && apt-get update && apt-get install -y --no-install-recommends $RUN_DEPS \
52 | && rm -rf /var/lib/apt/lists/*
53 |
54 | # Copy in your requirements file
55 | ADD requirements.txt /requirements.txt
56 |
57 | # OR, if you're using a directory for your requirements, copy everything (comment out the above and uncomment this if so):
58 | # ADD requirements /requirements
59 |
60 | # Install build deps, then run `pip install`, then remove unneeded build deps all in a single step.
61 | # Correct the path to your production requirements file, if needed.
62 | RUN set -ex \
63 | && BUILD_DEPS=" \
64 | build-essential \
65 | libpcre3-dev \
66 | libpq-dev \
67 | " \
68 | && apt-get update && apt-get install -y --no-install-recommends $BUILD_DEPS \
69 | && pip install --no-cache-dir -r /requirements.txt \
70 | \
71 | && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false $BUILD_DEPS \
72 | && rm -rf /var/lib/apt/lists/*
73 |
74 | # Copy your application code to the container (make sure you create a .dockerignore file if any large files or directories should be excluded)
75 | RUN mkdir /code/
76 | WORKDIR /code/
77 | ADD . /code/
78 |
79 | # uWSGI will listen on this port
80 | EXPOSE 8000
81 |
82 | # Add any static environment variables needed by Django or your settings file here:
83 | ENV DJANGO_SETTINGS_MODULE=my_project.settings.deploy
84 |
85 | # Call collectstatic (customize the following line with the minimal environment variables needed for manage.py to run):
86 | RUN DATABASE_URL='' python manage.py collectstatic --noinput
87 |
88 | # Tell uWSGI where to find your wsgi file (change this):
89 | ENV UWSGI_WSGI_FILE=my_project/wsgi.py
90 |
91 | # Base uWSGI configuration (you shouldn't need to change these):
92 | ENV UWSGI_HTTP=:8000 UWSGI_MASTER=1 UWSGI_HTTP_AUTO_CHUNKED=1 UWSGI_HTTP_KEEPALIVE=1 UWSGI_LAZY_APPS=1 UWSGI_WSGI_ENV_BEHAVIOR=holy
93 |
94 | # Number of uWSGI workers and threads per worker (customize as needed):
95 | ENV UWSGI_WORKERS=2 UWSGI_THREADS=4
96 |
97 | # uWSGI static file serving configuration (customize or comment out if not needed):
98 | ENV UWSGI_STATIC_MAP="/static/=/code/static/" UWSGI_STATIC_EXPIRES_URI="/static/.*\.[a-f0-9]{12,}\.(css|js|png|jpg|jpeg|gif|ico|woff|ttf|otf|svg|scss|map|txt) 315360000"
99 |
100 | # Deny invalid hosts before they get to Django (uncomment and change to your hostname(s)):
101 | # ENV UWSGI_ROUTE_HOST="^(?!localhost:8000$) break:400"
102 |
103 | # Change to a non-root user
104 | USER ${APP_USER}:${APP_USER}
105 |
106 | # Uncomment after creating your docker-entrypoint.sh
107 | # ENTRYPOINT ["/code/docker-entrypoint.sh"]
108 |
109 | # Start uWSGI
110 | CMD ["uwsgi", "--show-config"]
111 |
112 | We extend from the "slim" flavor of the official Docker image for Python 3.7, install a few dependencies for running our application (i.e., that we want to keep in the final version of the image), copy the folder containing our requirements files to the container, and then, in a single line, (a) install the build dependencies needed, (b) ``pip install`` the requirements themselves (edit this line to match the location of your requirements file, if needed), (c) remove the C compiler and any other OS packages no longer needed, and (d) remove the package lists since they're no longer needed. It's important to keep this all on one line so that Docker will cache the entire operation as a single layer.
113 |
114 | Next, we copy our application code to the image, set some default environment variables, and run ``collectstatic``. Be sure to change the values for ``DJANGO_SETTINGS_MODULE`` and ``UWSGI_WSGI_FILE`` to the correct paths for your application (note that the former requires a Python package path, while the latter requires a file system path).
115 |
116 | A few notes about other aspects of this Dockerfile:
117 |
118 | * I only included a minimal set of OS dependencies here. If this is an established production app, you'll most likely need to visit https://packages.debian.org, search for the Debian package names of the OS dependencies you need, including the ``-dev`` supplemental packages as needed, and add them either to ``RUN_DEPS`` or ``BUILD_DEPS`` in your Dockerfile.
119 | * Adding ``--no-cache-dir`` to the ``pip install`` command saves a additional disk space, as this prevents ``pip`` from `caching downloads `_ and `caching wheels `_ locally. Since you won't need to install requirements again after the Docker image has been created, this can be added to the ``pip install`` command. Thanks Hemanth Kumar for this tip!
120 | * uWSGI contains a lot of optimizations for running many apps from the same uWSGI process. These optimizations aren't really needed when running a single app in a Docker container, and can `cause issues `_ when used with certain 3rd-party packages. I've added ``UWSGI_LAZY_APPS=1`` and ``UWSGI_WSGI_ENV_BEHAVIOR=holy`` to the uWSGI configuration to provide a more stable uWSGI experience (the latter will be the default in the next uWSGI release).
121 | * The ``UWSGI_HTTP_AUTO_CHUNKED`` and ``UWSGI_HTTP_KEEPALIVE`` options to uWSGI are needed in the event the container will be hosted behind an Amazon Elastic Load Balancer (ELB), because Django doesn't set a valid ``Content-Length`` header by default, unless the ``ConditionalGetMiddleware`` is enabled. See `the note `_ at the end of the uWSGI documentation on HTTP support for further detail.
122 |
123 |
124 | Requirements and Settings Files
125 | -------------------------------
126 |
127 | Production-ready requirements and settings files are outside the scope of this post, but you'll need to include a few things in your requirements file(s), if they're not there already::
128 |
129 | Django>=2.2,<2.3
130 | uwsgi>=2.0,<2.1
131 | dj-database-url>=0.5,<0.6
132 | psycopg2>=2.8,<2.9
133 |
134 | I didn't pin these to specific versions here to help future-proof this post somewhat, but you'll likely want to pin these (and other) requirements to specific versions so things don't suddenly start breaking in production. Of course, you don't have to use any of these packages, but you'll need to adjust the corresponding code elsewhere in this post if you don't.
135 |
136 | My ``deploy.py`` settings file looks like this:
137 |
138 | .. code-block:: python
139 |
140 | import os
141 |
142 | import dj_database_url
143 |
144 | from . import * # noqa: F403
145 |
146 | # This is NOT a complete production settings file. For more, see:
147 | # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
148 |
149 | DEBUG = False
150 |
151 | ALLOWED_HOSTS = ['localhost']
152 |
153 | DATABASES['default'] = dj_database_url.config(conn_max_age=600) # noqa: F405
154 |
155 | STATIC_ROOT = os.path.join(BASE_DIR, 'static') # noqa: F405
156 |
157 | STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
158 |
159 | This bears repeating: This is **not** a production-ready settings file, and you should review `the checklist `_ in the Django docs (and run ``python manage.py check --deploy --settings=my_project.settings.deploy``) to ensure you've properly secured your production settings file.
160 |
161 |
162 | Building and Testing the Container
163 | ----------------------------------
164 |
165 | Now that you have the essentials in place, you can build your Docker image locally as follows:
166 |
167 | .. code-block:: bash
168 |
169 | docker build -t my-app .
170 |
171 | This will go through all the commands in your Dockerfile, and if successful, store an image with your local Docker server that you could then run:
172 |
173 | .. code-block:: bash
174 |
175 | docker run -e DATABASE_URL='' -t my-app
176 |
177 | This command is merely a smoke test to make sure uWSGI runs, and won't connect to a database or any other external services.
178 |
179 |
180 | Running Commands During Container Start-Up
181 | ------------------------------------------
182 |
183 | As a final step, I recommend creating an ``ENTRYPOINT`` script to run commands as needed during container start-up. This will let us accomplish any number of things, such as making sure Postgres is available or running ``migrate`` during container start-up. Save the following to a file named ``docker-entrypoint.sh`` in the same directory as your ``Dockerfile``:
184 |
185 | .. code-block:: bash
186 |
187 | #!/bin/sh
188 | set -e
189 |
190 | until psql $DATABASE_URL -c '\l'; do
191 | >&2 echo "Postgres is unavailable - sleeping"
192 | sleep 1
193 | done
194 |
195 | >&2 echo "Postgres is up - continuing"
196 |
197 | if [ "x$DJANGO_MANAGEPY_MIGRATE" = 'xon' ]; then
198 | python manage.py migrate --noinput
199 | fi
200 |
201 | exec "$@"
202 |
203 | Make sure this file is executable, i.e.:
204 |
205 | .. code-block:: bash
206 |
207 | chmod a+x docker-entrypoint.sh
208 |
209 | Next, uncomment the following line to your ``Dockerfile``, just above the ``CMD`` statement:
210 |
211 | .. code-block:: docker
212 |
213 | ENTRYPOINT ["/code/docker-entrypoint.sh"]
214 |
215 | This will (a) make sure a database is available (usually only needed when used with Docker Compose) and (b) run outstanding migrations, if any, if the ``DJANGO_MANAGEPY_MIGRATE`` is set to ``on`` in your environment. Even if you add this entrypoint script as-is, you could still choose to run ``migrate`` or ``collectstatic`` in separate steps in your deployment before releasing the new container. The only reason you might not want to do this is if your application is highly sensitive to container start-up time, or if you want to avoid any database calls as the container starts up (e.g., for local testing). If you do rely on these commands being run during container start-up, be sure to set the relevant variables in your container's environment.
216 |
217 |
218 | Creating a Production-Like Environment Locally with Docker Compose
219 | ------------------------------------------------------------------
220 |
221 | To run a complete copy of production services locally, you can use `Docker Compose `_. The following ``docker-compose.yml`` will create a barebones, ephemeral, AWS-like container environment with Postgres for testing your production environment locally.
222 |
223 | *This is intended for local testing of your production environment only, and will not save data from stateful services like Postgres upon container shutdown.*
224 |
225 | .. code-block:: yaml
226 |
227 | version: "2"
228 |
229 | services:
230 | db:
231 | environment:
232 | POSTGRES_DB: app_db
233 | POSTGRES_USER: app_user
234 | POSTGRES_PASSWORD: changeme
235 | restart: always
236 | image: postgres:12
237 | expose:
238 | - "5432"
239 | app:
240 | environment:
241 | DATABASE_URL: postgres://app_user:changeme@db/app_db
242 | DJANGO_MANAGEPY_MIGRATE: "on"
243 | build:
244 | context: .
245 | dockerfile: ./Dockerfile
246 | links:
247 | - db:db
248 | ports:
249 | - "8000:8000"
250 |
251 | Copy this into a file named ``docker-compose.yml`` in the same directory as your ``Dockerfile``, and then run:
252 |
253 | .. code-block:: bash
254 |
255 | docker-compose up --build -d
256 |
257 | This downloads (or builds) and starts the two containers listed above. You can view output from the containers by running:
258 |
259 | .. code-block:: bash
260 |
261 | docker-compose logs
262 |
263 | If all services launched successfully, you should now be able to access your application at http://localhost:8000/ in a web browser.
264 |
265 | If you need to debug your application container, a handy way to launch an instance it and poke around is:
266 |
267 | .. code-block:: bash
268 |
269 | docker-compose run app /bin/bash
270 |
271 |
272 | Static Files
273 | ------------
274 |
275 | You may have noticed that we set up static file serving in uWSGI via the ``UWSGI_STATIC_MAP`` and ``UWSGI_STATIC_EXPIRES_URI`` environment variables. If preferred, you can turn this off and use `Django Whitenoise `_ or `copy your static files straight to S3 `_.
276 |
277 |
278 | Blocking ``Invalid HTTP_HOST header`` Errors with uWSGI
279 | -------------------------------------------------------
280 |
281 | To avoid Django's ``Invalid HTTP_HOST header`` errors (and prevent any such spurious requests from taking up any more CPU cycles than absolutely necessary), you can also configure uWSGI to return an ``HTTP 400`` response immediately without ever invoking your application code. This can be accomplished by uncommenting and customizing the ``UWSGI_ROUTE_HOST`` line in the Dockerfile above.
282 |
283 |
284 | Summary
285 | -------
286 |
287 | That concludes this high-level introduction to containerizing your Python/Django app for hosting on AWS Elastic Beanstalk (EB), Elastic Container Service (ECS), or elsewhere. Each application and Dockerfile will be slightly different, but I hope this provides a good starting point for your containers. Shameless plug: if you're looking for a simple (and at least temporarily free) way to test your Docker containers on AWS using an Elastic Beanstalk Multicontainer Docker environment or the Elastic Container Service, check out Caktus' very own `AWS Web Stacks `_. Good luck!
288 |
--------------------------------------------------------------------------------