Skip to content

Commit

Permalink
Improve Gunicorn performance auto-tuning
Browse files Browse the repository at this point in the history
tiangolo/uvicorn-gunicorn-docker#5
tiangolo/uvicorn-gunicorn-starlette-docker#4
tiangolo/uvicorn-gunicorn-fastapi-docker#6

The "auto-tuning" advertised in tiangolo/uvicorn-gunicorn-docker is
basically a few lines of the `gunicorn_conf.py` that determine the
number of Gunicorn workers to run. It would be helpful to write some
unit test cases for this feature, but without being in a separate unit,
it is difficult to unit test in isolation.

This commit will refactor the performance auto-tuning into a function,
`gunicorn_conf.calculate_workers`, and will add unit test cases to
verify the resulting number of worker processes.
  • Loading branch information
br3ndonland committed Sep 20, 2020
1 parent 9ddbc4b commit 02b249e
Show file tree
Hide file tree
Showing 2 changed files with 84 additions and 18 deletions.
43 changes: 27 additions & 16 deletions inboard/gunicorn_conf.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,42 @@
import multiprocessing
import os
from typing import Union

from inboard.start import configure_logging


def calculate_workers(
max_workers_str: Union[str, None],
web_concurrency_str: Union[str, None],
workers_per_core_str: str,
cores: int = multiprocessing.cpu_count(),
) -> int:
"""Calculate the number of Gunicorn worker processes."""
use_default_workers = max(int(float(workers_per_core_str) * cores), 2)
if max_workers_str and int(max_workers_str) > 0:
use_max_workers = int(max_workers_str)
if web_concurrency_str and int(web_concurrency_str) > 0:
use_web_concurrency = max(int(web_concurrency_str), 2)
return (
min(use_max_workers, use_web_concurrency)
if max_workers_str and web_concurrency_str
else use_web_concurrency
if web_concurrency_str
else use_default_workers
)


# Gunicorn setup
max_workers_str = os.getenv("MAX_WORKERS")
max_workers_str = os.getenv("MAX_WORKERS", None)
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
use_max_workers = None
if max_workers_str and int(max_workers_str) > 0:
use_max_workers = int(max_workers_str)
workers = calculate_workers(max_workers_str, web_concurrency_str, workers_per_core_str)
worker_tmp_dir = "/dev/shm"
host = os.getenv("HOST", "0.0.0.0")
port = os.getenv("PORT", "80")
bind_env = os.getenv("BIND", None)
use_loglevel = os.getenv("LOG_LEVEL", "info")
use_bind = bind_env if bind_env else f"{host}:{port}"
cores = multiprocessing.cpu_count()
workers_per_core = float(workers_per_core_str)
default_web_concurrency = workers_per_core * cores
if web_concurrency_str and int(web_concurrency_str) > 0:
web_concurrency = int(web_concurrency_str)
else:
web_concurrency = max(int(default_web_concurrency), 2)
if use_max_workers:
web_concurrency = min(web_concurrency, use_max_workers)
use_loglevel = os.getenv("LOG_LEVEL", "info")
accesslog_var = os.getenv("ACCESS_LOG", "-")
use_accesslog = accesslog_var or None
errorlog_var = os.getenv("ERROR_LOG", "-")
Expand All @@ -37,10 +50,8 @@
logging_conf=os.getenv("LOGGING_CONF", "inboard.logging_conf")
)
loglevel = use_loglevel
workers = web_concurrency
bind = use_bind
errorlog = use_errorlog
worker_tmp_dir = "/dev/shm"
accesslog = use_accesslog
graceful_timeout = int(graceful_timeout_str)
timeout = int(timeout_str)
Expand Down
59 changes: 57 additions & 2 deletions tests/test_start.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import multiprocessing
import os
from pathlib import Path
from typing import Any, Dict
Expand All @@ -7,7 +8,7 @@
from _pytest.monkeypatch import MonkeyPatch
from pytest_mock import MockerFixture

from inboard import start
from inboard import gunicorn_conf, start


class TestConfPaths:
Expand Down Expand Up @@ -41,6 +42,48 @@ def test_set_incorrect_conf_path(self, monkeypatch: MonkeyPatch) -> None:
start.set_conf_path("gunicorn")


class TestConfigureGunicorn:
"""Test Gunicorn configuration independently of Gunicorn server.
---
"""

def test_gunicorn_conf_workers_default(self) -> None:
"""Test default number of Gunicorn worker processes."""
assert gunicorn_conf.workers >= 2
assert gunicorn_conf.workers == multiprocessing.cpu_count()

def test_gunicorn_conf_workers_custom(self, monkeypatch: MonkeyPatch) -> None:
"""Test custom Gunicorn worker process calculation."""
monkeypatch.setenv("MAX_WORKERS", "1")
monkeypatch.setenv("WEB_CONCURRENCY", "4")
monkeypatch.setenv("WORKERS_PER_CORE", "0.5")
assert os.getenv("MAX_WORKERS") == "1"
assert os.getenv("WEB_CONCURRENCY") == "4"
assert os.getenv("WORKERS_PER_CORE") == "0.5"
assert (
gunicorn_conf.calculate_workers(
str(os.getenv("MAX_WORKERS")),
str(os.getenv("WEB_CONCURRENCY")),
str(os.getenv("WORKERS_PER_CORE")),
)
== 1
)
monkeypatch.delenv("MAX_WORKERS")
assert (
gunicorn_conf.calculate_workers(
None,
str(os.getenv("WEB_CONCURRENCY")),
str(os.getenv("WORKERS_PER_CORE")),
)
== 4
)
monkeypatch.delenv("WEB_CONCURRENCY")
cores: int = multiprocessing.cpu_count()
assert gunicorn_conf.calculate_workers(
None, "2", str(os.getenv("WORKERS_PER_CORE")), cores=cores
) == int(cores * 0.5)


class TestConfigureLogging:
"""Test logging configuration methods.
---
Expand Down Expand Up @@ -393,6 +436,7 @@ def test_start_server_uvicorn_gunicorn(
f"--worker-tmp-dir {tmp_path}",
)
monkeypatch.setenv("PROCESS_MANAGER", "gunicorn")
assert gunicorn_conf_path.parent.exists()
assert os.getenv("GUNICORN_CONF") == str(gunicorn_conf_path)
assert os.getenv("PROCESS_MANAGER") == "gunicorn"
mock_run = mocker.patch("subprocess.run", autospec=True)
Expand Down Expand Up @@ -431,7 +475,7 @@ def test_start_server_uvicorn_gunicorn_custom_config(
mocker: MockerFixture,
monkeypatch: MonkeyPatch,
) -> None:
"""Test `start.start_server` with Uvicorn managed by Gunicorn."""
"""Test customized `start.start_server` with Uvicorn managed by Gunicorn."""
monkeypatch.setenv(
"GUNICORN_CMD_ARGS",
f"--worker-tmp-dir {gunicorn_conf_tmp_file_path.parent}",
Expand All @@ -441,12 +485,23 @@ def test_start_server_uvicorn_gunicorn_custom_config(
monkeypatch.setenv("MAX_WORKERS", "1")
monkeypatch.setenv("PROCESS_MANAGER", "gunicorn")
monkeypatch.setenv("WEB_CONCURRENCY", "4")
monkeypatch.setenv("WORKERS_PER_CORE", "0.5")
assert gunicorn_conf_tmp_file_path.parent.exists()
assert os.getenv("GUNICORN_CONF") == str(gunicorn_conf_tmp_file_path)
assert os.getenv("LOG_FORMAT") == "gunicorn"
assert os.getenv("LOG_LEVEL") == "debug"
assert os.getenv("MAX_WORKERS") == "1"
assert os.getenv("PROCESS_MANAGER") == "gunicorn"
assert os.getenv("WEB_CONCURRENCY") == "4"
assert os.getenv("WORKERS_PER_CORE") == "0.5"
assert (
gunicorn_conf.calculate_workers(
str(os.getenv("MAX_WORKERS")),
str(os.getenv("WEB_CONCURRENCY")),
str(os.getenv("WORKERS_PER_CORE")),
)
== 1
)
mock_run = mocker.patch("subprocess.run", autospec=True)
start.start_server(
str(os.getenv("PROCESS_MANAGER")),
Expand Down

0 comments on commit 02b249e

Please sign in to comment.