Skip to content

Commit 93f3fc3

Browse files
author
Bryan Sieber
committed
Getting Dockerflow ready
1 parent 111df49 commit 93f3fc3

13 files changed

+373
-5
lines changed

Makefile

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Set these in the environment to override them. This is helpful for
2+
# development if you have file ownership problems because the user
3+
# in the container doesn't match the user on your host.
4+
_UID ?= 10001
5+
_GID ?= 10001
6+
7+
.PHONY: help
8+
help:
9+
@echo "Usage: make RULE"
10+
@echo ""
11+
@echo "JBI make rules:"
12+
@echo ""
13+
@echo " build - build docker containers"
14+
@echo " lint - lint check for code"
15+
@echo " start - run the API service"
16+
@echo ""
17+
@echo " test - run test suite"
18+
@echo " shell - open a shell in the web container"
19+
@echo " test-shell - open a shell in test environment"
20+
@echo ""
21+
@echo " help - see this text"
22+
23+
24+
.PHONY: build
25+
build:
26+
docker-compose -f ./docker-compose.yaml -f ./tests/infra/docker-compose.test.yaml build \
27+
--build-arg userid=${_UID} --build-arg groupid=${_GID}
28+
29+
.PHONY: lint
30+
lint:
31+
docker-compose -f ./docker-compose.yaml -f ./tests/infra/docker-compose.lint.yaml build \
32+
--build-arg userid=${_UID} --build-arg groupid=${_GID} lint
33+
34+
35+
.PHONY: shell
36+
shell:
37+
docker-compose -f ./docker-compose.yaml run web
38+
39+
.PHONY: start
40+
start:
41+
docker-compose up
42+
43+
.PHONY: test
44+
test:
45+
docker-compose -f ./docker-compose.yaml -f ./tests/infra/docker-compose.test.yaml run tests
46+
ifneq (1, ${MK_KEEP_DOCKER_UP})
47+
# Due to https://github.com/docker/compose/issues/2791 we have to explicitly
48+
# rm all running containers
49+
docker-compose down
50+
endif
51+
52+
.PHONY: test-shell
53+
test-shell: .env
54+
docker-compose -f ./docker-compose.yaml -f ./tests/infra/docker-compose.test.yaml run web

docker-compose.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
version: "3.8"
2+
services:
3+
web:
4+
build:
5+
context: .
6+
dockerfile: ./infra/Dockerfile
7+
target: development
8+
volumes:
9+
- type: bind
10+
source: .
11+
target: /app
12+
ports:
13+
- ${PORT:-8000}:${PORT:-8000}
14+
# Let the init system handle signals for us.
15+
# among other things this helps shutdown be fast
16+
init: true
17+
# env_file:
18+
# - ./infra/config/local_dev.env
19+
# - .env

infra/Dockerfile

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Creating a python base with shared environment variables
2+
FROM python:3.9.9-slim as python-base
3+
ENV PYTHONPATH=/app \
4+
PYTHONUNBUFFERED=1 \
5+
PYTHONDONTWRITEBYTECODE=1 \
6+
PIP_NO_CACHE_DIR=off \
7+
PIP_DISABLE_PIP_VERSION_CHECK=on \
8+
PIP_DEFAULT_TIMEOUT=100 \
9+
POETRY_HOME="/opt/poetry" \
10+
POETRY_VIRTUALENVS_IN_PROJECT=true \
11+
POETRY_NO_INTERACTION=1 \
12+
PYSETUP_PATH="/opt/pysetup" \
13+
VENV_PATH="/opt/pysetup/.venv" \
14+
PORT=8000
15+
16+
ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH"
17+
18+
# Set up user and group
19+
ARG userid=10001
20+
ARG groupid=10001
21+
WORKDIR /app
22+
RUN groupadd --gid $groupid app && \
23+
useradd -g app --uid $userid --shell /usr/sbin/nologin --create-home app
24+
25+
RUN mkdir -p $POETRY_HOME && \
26+
chown app:app /opt/poetry && \
27+
mkdir -p $PYSETUP_PATH && \
28+
chown app:app $PYSETUP_PATH && \
29+
mkdir -p /app && \
30+
chown app:app /app
31+
32+
RUN apt-get update && \
33+
apt-get install --assume-yes apt-utils && \
34+
echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections && \
35+
apt-get install --no-install-recommends -y \
36+
libpq5
37+
38+
# builder-base is used to build dependencies
39+
FROM python-base as builder-base
40+
RUN apt-get install --no-install-recommends -y \
41+
curl \
42+
build-essential \
43+
libpq-dev
44+
45+
# Install Poetry - respects $POETRY_VERSION & $POETRY_HOME
46+
USER app
47+
ENV POETRY_VERSION=1.1.5
48+
RUN curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python
49+
50+
# We copy our Python requirements here to cache them
51+
# and install only runtime deps using poetry
52+
WORKDIR $PYSETUP_PATH
53+
COPY --chown=app:app ./poetry.lock ./pyproject.toml ./
54+
RUN poetry install --no-dev --no-root
55+
56+
57+
# 'development' stage installs all dev deps and can be used to develop code.
58+
# For example using docker-compose to mount local volume under /app
59+
FROM python-base as development
60+
ENV FASTAPI_ENV=development
61+
62+
# Copying poetry and venv into image
63+
USER app
64+
COPY --from=builder-base $POETRY_HOME $POETRY_HOME
65+
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH
66+
67+
# Copying in our entrypoint
68+
COPY --chown=app:app ./infra/docker-entrypoint.sh /docker-entrypoint.sh
69+
RUN chmod +x /docker-entrypoint.sh
70+
71+
# venv already has runtime deps installed we get a quicker install
72+
WORKDIR $PYSETUP_PATH
73+
RUN poetry install --no-root
74+
75+
WORKDIR /app
76+
COPY --chown=app:app . .
77+
78+
EXPOSE $PORT
79+
ENTRYPOINT ["/docker-entrypoint.sh"]
80+
CMD uvicorn src.app.api:app --reload --host=0.0.0.0 --port=$PORT
81+
82+
83+
# 'lint' stage runs similar checks to pre-commit / scripts/lint.sh
84+
# running in check mode means build will fail if any linting errors occur
85+
FROM development AS lint
86+
RUN bandit -lll --recursive src --exclude "src/poetry.lock,src/.venv,src/.mypy,src/build"
87+
RUN mypy src
88+
RUN black --config ./pyproject.toml --check src tests
89+
RUN isort --recursive --settings-path ./pyproject.toml --check-only src
90+
RUN pylint src tests/unit
91+
CMD ./infra/lint.sh
92+
93+
94+
# 'test' stage runs our unit tests with pytest and
95+
# coverage. Build will fail if test coverage is under 80%
96+
FROM development AS test
97+
CMD ./infra/test.sh
98+
99+
100+
# 'production' stage uses the clean 'python-base' stage and copyies
101+
# in only our runtime deps that were installed in the 'builder-base'
102+
FROM python-base as production
103+
ENV FASTAPI_ENV=production \
104+
IS_GUNICORN=1 \
105+
PROMETHEUS_MULTIPROC=1
106+
107+
COPY --from=builder-base $VENV_PATH $VENV_PATH
108+
COPY ./infra/gunicorn_conf.py /gunicorn_conf.py
109+
110+
COPY ./infra/docker-entrypoint.sh /docker-entrypoint.sh
111+
RUN chmod +x /docker-entrypoint.sh
112+
113+
COPY . /app
114+
WORKDIR /app
115+
116+
EXPOSE $PORT
117+
ENTRYPOINT ["/docker-entrypoint.sh"]
118+
CMD gunicorn -k uvicorn.workers.UvicornWorker -c /gunicorn_conf.py src.app.api:app

infra/docker-entrypoint.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/bin/sh
2+
3+
set -e
4+
5+
# activate our virtual environment here
6+
. /opt/pysetup/.venv/bin/activate
7+
8+
# setup an empty directory for Prometheus metrics
9+
if [ ${PROMETHEUS_MULTIPROC:-0} -eq 1 ]; then
10+
prometheus_multiproc_dir=`mktemp -td prometheus.XXXXXXXXXX` || exit 1
11+
export prometheus_multiproc_dir
12+
fi
13+
14+
# Evaluating passed command:
15+
exec "$@"
16+
17+
if [ -z ${prometheus_multiproc_dir+x} ]; then
18+
rm -rf ${prometheus_multiproc_dir}
19+
fi

infra/gunicorn_conf.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# From: https://github.com/tiangolo/uvicorn-gunicorn-docker/blob/2daa3e3873c837d5781feb4ff6a40a89f791f81b/docker-images/gunicorn_conf.py
2+
# pylint: disable=invalid-name
3+
4+
import json
5+
import multiprocessing
6+
import os
7+
8+
from prometheus_client import multiprocess
9+
10+
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
11+
max_workers_str = os.getenv("MAX_WORKERS")
12+
use_max_workers = None
13+
if max_workers_str:
14+
use_max_workers = int(max_workers_str)
15+
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
16+
17+
host = os.getenv("HOST", "0.0.0.0")
18+
port = os.getenv("PORT", "80")
19+
bind_env = os.getenv("BIND", None)
20+
use_loglevel = os.getenv("LOG_LEVEL", "info")
21+
if bind_env:
22+
use_bind = bind_env
23+
else:
24+
use_bind = f"{host}:{port}"
25+
26+
cores = multiprocessing.cpu_count()
27+
workers_per_core = float(workers_per_core_str)
28+
default_web_concurrency = workers_per_core * cores
29+
if web_concurrency_str:
30+
web_concurrency = int(web_concurrency_str)
31+
assert web_concurrency > 0
32+
else:
33+
web_concurrency = max(int(default_web_concurrency), 2)
34+
if use_max_workers:
35+
web_concurrency = min(web_concurrency, use_max_workers)
36+
accesslog_var = os.getenv("ACCESS_LOG", "-")
37+
use_accesslog = accesslog_var or None
38+
errorlog_var = os.getenv("ERROR_LOG", "-")
39+
use_errorlog = errorlog_var or None
40+
graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120")
41+
timeout_str = os.getenv("TIMEOUT", "120")
42+
keepalive_str = os.getenv("KEEP_ALIVE", "5")
43+
44+
# Gunicorn config variables
45+
loglevel = use_loglevel
46+
workers = web_concurrency
47+
bind = use_bind
48+
errorlog = use_errorlog
49+
worker_tmp_dir = "/dev/shm"
50+
accesslog = use_accesslog
51+
graceful_timeout = int(graceful_timeout_str)
52+
timeout = int(timeout_str)
53+
keepalive = int(keepalive_str)
54+
55+
56+
# For debugging and testing
57+
log_data = {
58+
"loglevel": loglevel,
59+
"workers": workers,
60+
"bind": bind,
61+
"graceful_timeout": graceful_timeout,
62+
"timeout": timeout,
63+
"keepalive": keepalive,
64+
"errorlog": errorlog,
65+
"accesslog": accesslog,
66+
# Additional, non-gunicorn variables
67+
"workers_per_core": workers_per_core,
68+
"use_max_workers": use_max_workers,
69+
"host": host,
70+
"port": port,
71+
}
72+
print(json.dumps(log_data))
73+
74+
75+
def child_exit(server, worker):
76+
multiprocess.mark_process_dead(worker.pid)

infra/lint.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/sh
2+
3+
set -e
4+
5+
CURRENT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
6+
BASE_DIR="$(dirname "$CURRENT_DIR")"
7+
HAS_GIT="$(command -v git || echo '')"
8+
echo $HAS_GIT
9+
10+
bandit -lll --recursive "${BASE_DIR}" --exclude "${BASE_DIR}/poetry.lock,${BASE_DIR}/.venv,${BASE_DIR}/.mypy,${BASE_DIR}/build"
11+
12+
if [ -n "$HAS_GIT" ]; then
13+
# Scan only files checked into the repo, omit poetry.lock
14+
SECRETS_TO_SCAN=`git ls-tree --full-tree -r --name-only HEAD | grep -v poetry.lock`
15+
detect-secrets-hook $SECRETS_TO_SCAN --baseline .secrets.baseline
16+
fi
17+
18+
mypy "${BASE_DIR}"
19+
black --config "${BASE_DIR}/pyproject.toml" "${BASE_DIR}"
20+
isort --recursive --settings-path "${BASE_DIR}/pyproject.toml" "${BASE_DIR}"
21+
pylint src tests/unit

infra/test.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/bin/sh
2+
3+
set -e
4+
5+
CURRENT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
6+
BASE_DIR="$(dirname "$CURRENT_DIR")"
7+
8+
coverage run --rcfile "${BASE_DIR}/pyproject.toml" -m pytest
9+
coverage report --rcfile "${BASE_DIR}/pyproject.toml" -m --fail-under 80
10+
coverage html --rcfile "${BASE_DIR}/pyproject.toml"

poetry.lock

Lines changed: 19 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ fastapi = "^0.73.0"
1111
pydantic = "^1.9.0"
1212
uvicorn = {extras = ["standard"], version = "^0.17.4"}
1313
gunicorn = "^20.1.0"
14+
prometheus-client = "^0.13.1"
1415

1516
[tool.poetry.dev-dependencies]
1617
pre-commit = "^2.17.0"
@@ -39,6 +40,7 @@ testpaths = [
3940
"C0114", #missing-module-docstring
4041
"C0115", #missing-class-docstring
4142
"C0116", #missing-function-docstring
43+
"C0301", #line-too-long
4244
"R0903", #too-few-public-methods
4345
"W0613", #unused-argument
4446
]

0 commit comments

Comments
 (0)