Skip to content

Commit 3e844d1

Browse files
authored
Merge pull request #2 from br3ndonland/br3ndonland/config
Add app modules, Gunicorn config, and start scripts
2 parents d49b7f6 + 6394689 commit 3e844d1

File tree

14 files changed

+501
-18
lines changed

14 files changed

+501
-18
lines changed

.dockerignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
*/*cache*
1+
**/*cache*

Dockerfile

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
FROM python:3.8 AS base
22
LABEL maintainer="Brendon Smith"
3-
COPY poetry.lock pyproject.toml inboard /app/
4-
WORKDIR /app/
5-
RUN python -m pip install poetry && poetry config virtualenvs.create false && poetry install --no-dev --no-interaction --no-root -E fastapi
3+
COPY poetry.lock pyproject.toml inboard /
4+
ENV APP_MODULE=base.main:app GUNICORN_CONF=/gunicorn_conf.py POETRY_VIRTUALENVS_CREATE=false PYTHONPATH=/app
5+
RUN python -m pip install poetry && poetry install --no-dev --no-interaction --no-root -E fastapi
6+
CMD python /start.py
67

7-
FROM base as fastapi
8+
FROM base AS fastapi
9+
ENV APP_MODULE=fastapibase.main:app
810

9-
FROM base as starlette
11+
FROM base AS starlette
12+
ENV APP_MODULE=starlettebase.main:app

README.md

Lines changed: 276 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,33 @@ _Docker images to power your Python APIs and help you ship faster. With support
99

1010
Brendon Smith ([br3ndonland](https://github.com/br3ndonland/))
1111

12+
## Table of Contents <!-- omit in toc -->
13+
14+
- [Description](#description)
15+
- [Instructions](#instructions)
16+
- [Configure Docker for GitHub Packages](#configure-docker-for-github-packages)
17+
- [Pull images](#pull-images)
18+
- [Use images in a _Dockerfile_](#use-images-in-a-dockerfile)
19+
- [Run containers](#run-containers)
20+
- [Configuration](#configuration)
21+
- [General](#general)
22+
- [Gunicorn and Uvicorn](#gunicorn-and-uvicorn)
23+
- [Development](#development)
24+
- [Code style](#code-style)
25+
- [Building development images](#building-development-images)
26+
- [Running development containers](#running-development-containers)
27+
1228
## Description
1329

1430
This is a refactor of [tiangolo/uvicorn-gunicorn-docker](https://github.com/tiangolo/uvicorn-gunicorn-docker) with the following advantages:
1531

16-
- **One repo**. The tiangolo/uvicorn-gunicorn images are in at least three separate repos, [tiangolo/uvicorn-gunicorn-docker](https://github.com/tiangolo/uvicorn-gunicorn-docker), [tiangolo/uvicorn-gunicorn-starlette-docker](https://github.com/tiangolo/uvicorn-gunicorn-starlette-docker), and [tiangolo/uvicorn-gunicorn-starlette-docker](https://github.com/tiangolo/uvicorn-gunicorn-starlette-docker), with large amounts of code duplication, making maintenance difficult for an already-busy maintainer. This repo combines three into one.
17-
- **One _Dockerfile_.** This repository leverages [multi-stage builds](https://docs.docker.com/develop/develop-images/multistage-build/) to produce multiple Docker images from one _Dockerfile_.
18-
- **One Python requirements file.** This project leverages Poetry with Poetry Extras for dependency management with the _pyproject.toml_.
19-
- **One platform.** Docker Hub is superfluous. You're already on GitHub. Why not [pull Docker images from GitHub Packages](https://docs.github.com/en/packages/using-github-packages-with-your-projects-ecosystem/configuring-docker-for-use-with-github-packages)?
32+
- **One repo**. The tiangolo/uvicorn-gunicorn images are in at least three separate repos ([tiangolo/uvicorn-gunicorn-docker](https://github.com/tiangolo/uvicorn-gunicorn-docker), [tiangolo/uvicorn-gunicorn-fastapi-docker](https://github.com/tiangolo/uvicorn-gunicorn-fastapi-docker), and [tiangolo/uvicorn-gunicorn-starlette-docker](https://github.com/tiangolo/uvicorn-gunicorn-starlette-docker)), with large amounts of code duplication, making maintenance difficult for an already-busy maintainer. This repo combines three into one.
33+
- **One _Dockerfile_.** This repo leverages [multi-stage builds](https://docs.docker.com/develop/develop-images/multistage-build/) to produce multiple Docker images from one _Dockerfile_.
34+
- **One Python requirements file.** This repo uses [Poetry](https://github.com/python-poetry/poetry) with Poetry Extras for dependency management with a single _pyproject.toml_.
35+
- **One programming language.** Pure Python with no shell scripts.
36+
- **One platform.** You're already on GitHub. Why not [pull Docker images from GitHub Packages](https://docs.github.com/en/packages/using-github-packages-with-your-projects-ecosystem/configuring-docker-for-use-with-github-packages)?
2037

21-
## Quickstart
38+
## Instructions
2239

2340
### Configure Docker for GitHub Packages
2441

@@ -62,14 +79,267 @@ docker pull docker.pkg.github.com/br3ndonland/inboard/starlette
6279

6380
### Use images in a _Dockerfile_
6481

82+
For a [Poetry](https://github.com/python-poetry/poetry) project with the following directory structure:
83+
84+
- `repo`
85+
- `package`
86+
- `main.py`
87+
- `prestart.py`
88+
- `Dockerfile`
89+
- `poetry.lock`
90+
- `pyproject.toml`
91+
92+
The _Dockerfile_ could look like this:
93+
94+
```dockerfile
95+
FROM docker.pkg.github.com/br3ndonland/inboard/fastapi
96+
97+
# Install Python requirements
98+
COPY poetry.lock pyproject.toml /
99+
RUN poetry install --no-dev --no-interaction --no-root
100+
101+
# Install Python app
102+
COPY package /app/
103+
104+
# RUN command already included in base image
105+
```
106+
107+
Organizing the _Dockerfile_ this way helps [leverage the Docker build cache](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#leverage-build-cache). Files and commands that change most frequently are added last to the _Dockerfile_. Next time the image is built, Docker will skip any layers that didn't change, speeding up builds.
108+
109+
For a standard `pip` install:
110+
111+
- `repo`
112+
- `package`
113+
- `main.py`
114+
- `prestart.py`
115+
- `Dockerfile`
116+
- `requirements.txt`
117+
65118
```dockerfile
66119
FROM docker.pkg.github.com/br3ndonland/inboard/fastapi
120+
121+
# Install Python requirements
122+
COPY requirements.txt /
123+
RUN pip install -r requirements.txt
124+
125+
# Install Python app
126+
COPY package /app/
127+
128+
# RUN command already included in base image
129+
```
130+
131+
The image could then be built with:
132+
133+
```sh
134+
cd /path/to/repo
135+
docker build . -t imagename:latest
67136
```
68137

69138
### Run containers
70139

71140
Run container:
72141

73142
```sh
74-
docker run -d -p 80:80 br3ndonland/inboard/fastapi
143+
docker run -d -p 80:80 imagename
144+
```
145+
146+
Run container with mounted volume and Uvicorn reloading for development:
147+
148+
```sh
149+
cd /path/to/repo
150+
docker run -d -p 80:80 -e "LOG_LEVEL=debug" -e "WITH_RELOAD=true" -v $(pwd)/package:/app imagename
151+
```
152+
153+
- `WITH_RELOAD=true`: `start.py` will run Uvicorn with reloading and without Gunicorn. The Gunicorn configuration won't apply, but these environment variables will still work as [described](#configuration):
154+
- `MODULE_NAME`
155+
- `VARIABLE_NAME`
156+
- `APP_MODULE`
157+
- `HOST`
158+
- `PORT`
159+
- `LOG_LEVEL`
160+
- `-v $(pwd)/package:/app`: the specified directory (`/path/to/repo/package` in this example) will be [mounted as a volume](https://docs.docker.com/engine/reference/run/#volume-shared-filesystems) inside of the container at `/app`. When files in the working directory change, Docker and Uvicorn will sync the files to the running Docker container.
161+
- The final argument is the Docker image name (`imagename` in this example). Replace with your image name.
162+
163+
Hit an API endpoint:
164+
165+
```sh
166+
docker pull docker.pkg.github.com/br3ndonland/inboard/base
167+
docker run -d -p 80:80 docker.pkg.github.com/br3ndonland/inboard/base
168+
http :80 # HTTPie: https://httpie.org/
169+
```
170+
171+
```text
172+
HTTP/1.1 200 OK
173+
content-type: text/plain
174+
date: Sat, 15 Aug 2020 14:43:53 GMT
175+
server: uvicorn
176+
transfer-encoding: chunked
177+
178+
Hello World, from Uvicorn, Gunicorn, and Python 3.8!
75179
```
180+
181+
## Configuration
182+
183+
To set environment variables when starting the Docker image:
184+
185+
```sh
186+
docker run -d -p 80:80 -e APP_MODULE="custom.module:api" -e WORKERS_PER_CORE="2" myimage
187+
```
188+
189+
To set environment variables within a _Dockerfile_:
190+
191+
```dockerfile
192+
FROM docker.pkg.github.com/br3ndonland/inboard/fastapi
193+
ENV APP_MODULE="custom.module:api" WORKERS_PER_CORE="2"
194+
```
195+
196+
### General
197+
198+
- `MODULE_NAME`: Python module (file) with app instance.
199+
- Default:
200+
- `main` if there's a file `/app/main.py`
201+
- Else `app.main` if there's a file `/app/app/main.py`
202+
- Custom: For a module at `/app/custom/module.py`, `MODULE_NAME="custom.module"`
203+
- `VARIABLE_NAME`: Variable (object) inside of the Python module that contains the ASGI application instance.
204+
205+
- Default: `app`
206+
- Custom: For an application instance named `api`, `VARIABLE_NAME="api"`
207+
208+
```py
209+
from fastapi import FastAPI
210+
211+
api = FastAPI()
212+
213+
@api.get("/")
214+
def read_root():
215+
return {"message": "Hello World!"}
216+
```
217+
218+
- `APP_MODULE`: Combination of `MODULE_NAME` and `VARIABLE_NAME` pointing to the app instance.
219+
- Default:
220+
- `MODULE_NAME:VARIABLE_NAME` (`main:app` or `app.main:app`)
221+
- Custom: For a module at `/app/custom/module.py` and variable `api`, `APP_MODULE="custom.module:api"`
222+
- `PRE_START_PATH`: Path to a pre-start script. Add a file `prestart.py` or `prestart.sh` to the application directory, and copy the directory into the Docker image as described (for a project with the Python application in `repo/package`, `COPY package /app/`). The container will automatically detect and run the prestart script before starting the web server.
223+
224+
- Default: `/app/prestart.py`
225+
- Custom: `PRE_START_PATH="/custom/script.sh"`
226+
227+
### Gunicorn and Uvicorn
228+
229+
- `GUNICORN_CONF`: Path to a [Gunicorn configuration file](https://docs.gunicorn.org/en/latest/settings.html#config-file).
230+
- Default:
231+
- `/app/gunicorn_conf.py` if exists
232+
- Else `/app/app/gunicorn_conf.py` if exists
233+
- Else `/gunicorn_conf.py` (the default file provided with the Docker image)
234+
- Custom:
235+
- `GUNICORN_CONF="/app/custom_gunicorn_conf.py"`
236+
- Feel free to use the [`gunicorn_conf.py`](./inboard/gunicorn_conf.py) from this repo as a starting point for your own custom configuration.
237+
- `HOST`: Host IP address (inside of the container) where Gunicorn will listen for requests.
238+
- Default: `0.0.0.0`
239+
- Custom: _TODO_
240+
- `PORT`: Port the container should listen on.
241+
- Default: `80`
242+
- Custom: `PORT="8080"`
243+
- [`BIND`](https://docs.gunicorn.org/en/latest/settings.html#server-socket): The actual host and port passed to Gunicorn.
244+
- Default: `HOST:PORT` (`0.0.0.0:80`)
245+
- Custom: `BIND="0.0.0.0:8080"`
246+
- [`WORKER_CLASS`](https://docs.gunicorn.org/en/latest/settings.html#worker-processes): The class to be used by Gunicorn for the workers.
247+
- Default: `uvicorn.workers.UvicornWorker`
248+
- Custom: For the alternate Uvicorn worker, `WORKER_CLASS="uvicorn.workers.UvicornH11Worker"`
249+
- [`WORKERS_PER_CORE`](https://docs.gunicorn.org/en/latest/settings.html#worker-processes): Number of Gunicorn workers per CPU core.
250+
- Default: `1`
251+
- Custom: `WORKERS_PER_CORE="2"`
252+
- Notes:
253+
- This image will check how many CPU cores are available in the current server running your container. It will set the number of workers to the number of CPU cores multiplied by this value.
254+
- On a server with 2 CPU cores, `WORKERS_PER_CORE="3"` will run 6 worker processes.
255+
- Floating point values are permitted. If you have a powerful server (let's say, with 8 CPU cores) running several applications, including an ASGI application that won't need high performance, but you don't want to waste server resources, you could set the environment variable to `WORKERS_PER_CORE="0.5"`. A server with 8 CPU cores would start only 4 worker processes.
256+
- By default, if `WORKERS_PER_CORE` is `1` and the server has only 1 CPU core, 2 workers will be started instead of 1, to avoid poor performance and blocking applications. This behavior can be overridden using `WEB_CONCURRENCY`.
257+
- `MAX_WORKERS`: Maximum number of workers to use, independent of number of CPU cores.
258+
- Default: unlimited (not set)
259+
- Custom: `MAX_WORKERS="24"`
260+
- [`WEB_CONCURRENCY`](https://docs.gunicorn.org/en/latest/settings.html#worker-processes): Set number of workers independently of number of CPU cores.
261+
- Default:
262+
- Number of CPU cores multiplied by the environment variable `WORKERS_PER_CORE`.
263+
- In a server with 2 cores and default `WORKERS_PER_CORE="1"`, default `2`.
264+
- Custom: To have 4 workers, `WEB_CONCURRENCY="4"`
265+
- [`TIMEOUT`](https://docs.gunicorn.org/en/stable/settings.html#timeout): Workers silent for more than this many seconds are killed and restarted.
266+
- Default: `120`
267+
- Custom: `TIMEOUT="20"`
268+
- [`GRACEFUL_TIMEOUT`](https://docs.gunicorn.org/en/stable/settings.html#graceful-timeout): Number of seconds to allow workers finish serving requests before restart.
269+
- Default:`120`
270+
- Custom: `GRACEFUL_TIMEOUT="20"`
271+
- [`KEEP_ALIVE`](https://docs.gunicorn.org/en/stable/settings.html#keepalive): Number of seconds to wait for requests on a Keep-Alive connection.
272+
- Default: `2`
273+
- Custom: `KEEP_ALIVE="20"`
274+
- `LOG_LEVEL`: Log level for [Gunicorn](https://docs.gunicorn.org/en/latest/settings.html#logging) or [Uvicorn](https://www.uvicorn.org/settings/#logging).
275+
- Default: `info`
276+
- Custom (organized from greatest to least amount of logging):
277+
- `debug`
278+
- `info`
279+
- `warning`
280+
- `error`
281+
- `critical`
282+
- `ACCESS_LOG`: Access log file to which to write.
283+
- Default: `"-"` (`stdout`, print in Docker logs)
284+
- Custom:
285+
- `ACCESS_LOG="./path/to/accesslogfile.txt"`
286+
- `ACCESS_LOG=` (set to an empty value) to disable
287+
- `ERROR_LOG`: Error log file to which to write.
288+
- Default: `"-"` (`stdout`, print in Docker logs)
289+
- Custom:
290+
- `ERROR_LOG="./path/to/errorlogfile.txt"`
291+
- `ERROR_LOG=` (set to an empty value) to disable
292+
- `GUNICORN_CMD_ARGS`: Additional [command-line arguments for Gunicorn](https://docs.gunicorn.org/en/stable/settings.html). These settings will have precedence over the other environment variables and any Gunicorn config file.
293+
- Custom: To use a custom TLS certificate, copy or mount the certificate and private key into the Docker image, and set [`--keyfile` and `--certfile`](http://docs.gunicorn.org/en/latest/settings.html#ssl) to the location of the files.
294+
```sh
295+
docker run -d -p 443:443 \
296+
-e GUNICORN_CMD_ARGS="--keyfile=/secrets/key.pem --certfile=/secrets/cert.pem" \
297+
-e PORT=443 myimage
298+
```
299+
300+
## Development
301+
302+
### Code style
303+
304+
- Python code is formatted with [Black](https://black.readthedocs.io/en/stable/). Configuration for Black is stored in _[pyproject.toml](pyproject.toml)_.
305+
- Python imports are organized automatically with [isort](https://timothycrosley.github.io/isort/).
306+
- The isort package organizes imports in three sections:
307+
1. Standard library
308+
2. Dependencies
309+
3. Project
310+
- Within each of those groups, `import` statements occur first, then `from` statements, in alphabetical order.
311+
- You can run isort from the command line with `poetry run isort .`.
312+
- Configuration for isort is stored in _[pyproject.toml](pyproject.toml)_.
313+
- Other web code (JSON, Markdown, YAML) is formatted with [Prettier](https://prettier.io/).
314+
315+
### Building development images
316+
317+
To build the Docker images for each stage:
318+
319+
```sh
320+
git clone git@github.com:br3ndonland/inboard.git
321+
cd inboard
322+
docker build . --target base -t localhost/br3ndonland/inboard/base:latest
323+
docker build . --target fastapi -t localhost/br3ndonland/inboard/fastapi:latest
324+
docker build . --target starlette -t localhost/br3ndonland/inboard/starlette:latest
325+
```
326+
327+
### Running development containers
328+
329+
```sh
330+
# Uvicorn with reloading
331+
cd inboard
332+
docker run -d -p 80:80 -e "LOG_LEVEL=debug" -e "WITH_RELOAD=true" \
333+
-v $(pwd)/inboard/app:/app localhost/br3ndonland/inboard/base
334+
docker run -d -p 80:80 -e "LOG_LEVEL=debug" -e "WITH_RELOAD=true" \
335+
-v $(pwd)/inboard/app:/app localhost/br3ndonland/inboard/fastapi
336+
docker run -d -p 80:80 -e "LOG_LEVEL=debug" -e "WITH_RELOAD=true" \
337+
-v $(pwd)/inboard/app:/app localhost/br3ndonland/inboard/starlette
338+
339+
# Gunicorn and Uvicorn
340+
docker run -d -p 80:80 localhost/br3ndonland/inboard/base
341+
docker run -d -p 80:80 localhost/br3ndonland/inboard/fastapi
342+
docker run -d -p 80:80 localhost/br3ndonland/inboard/starlette
343+
```
344+
345+
Change the port numbers to run multiple containers simultaneously (`-p 81:80`).

inboard/app/__init__.py

Whitespace-only changes.

inboard/app/base/__init__.py

Whitespace-only changes.

inboard/app/base/main.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import os
2+
import sys
3+
from typing import Awaitable, Callable, Dict
4+
5+
6+
class App:
7+
"""Define a simple ASGI interface for use with Uvicorn.
8+
---
9+
https://www.uvicorn.org/
10+
"""
11+
12+
def __init__(self, scope: Dict) -> None:
13+
assert scope["type"] == "http"
14+
self.scope = scope
15+
16+
async def __call__(
17+
self, receive: Dict, send: Callable[[Dict], Awaitable]
18+
) -> Dict[str, str]:
19+
await send(
20+
{
21+
"type": "http.response.start",
22+
"status": 200,
23+
"headers": [[b"content-type", b"text/plain"]],
24+
}
25+
)
26+
version = f"{sys.version_info.major}.{sys.version_info.minor}"
27+
server = "Uvicorn" if bool(os.getenv("WITH_RELOAD")) else "Uvicorn, Gunicorn,"
28+
message = f"Hello World, from {server} and Python {version}!"
29+
response: Dict = {"type": "http.response.body", "body": message.encode("utf-8")}
30+
await send(response)
31+
return response
32+
33+
34+
app = App

inboard/app/fastapibase/__init__.py

Whitespace-only changes.

inboard/app/fastapibase/main.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import os
2+
import sys
3+
from typing import Dict
4+
5+
from fastapi import FastAPI
6+
7+
server = "Uvicorn" if bool(os.getenv("WITH_RELOAD")) else "Uvicorn, Gunicorn"
8+
version = f"{sys.version_info.major}.{sys.version_info.minor}"
9+
10+
app = FastAPI()
11+
12+
13+
@app.get("/")
14+
async def root() -> Dict[str, str]:
15+
message = f"Hello World, from {server}, FastAPI, and Python {version}!"
16+
return {"message": message}

inboard/app/prestart.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/usr/bin/env python3
2+
3+
print("Running prestart.py. Add database migrations and other scripts here.")

inboard/app/starlettebase/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)