Skip to content

Commit 02b249e

Browse files
committed
Improve Gunicorn performance auto-tuning
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.
1 parent 9ddbc4b commit 02b249e

2 files changed

Lines changed: 84 additions & 18 deletions

File tree

inboard/gunicorn_conf.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,42 @@
11
import multiprocessing
22
import os
3+
from typing import Union
34

45
from inboard.start import configure_logging
56

7+
8+
def calculate_workers(
9+
max_workers_str: Union[str, None],
10+
web_concurrency_str: Union[str, None],
11+
workers_per_core_str: str,
12+
cores: int = multiprocessing.cpu_count(),
13+
) -> int:
14+
"""Calculate the number of Gunicorn worker processes."""
15+
use_default_workers = max(int(float(workers_per_core_str) * cores), 2)
16+
if max_workers_str and int(max_workers_str) > 0:
17+
use_max_workers = int(max_workers_str)
18+
if web_concurrency_str and int(web_concurrency_str) > 0:
19+
use_web_concurrency = max(int(web_concurrency_str), 2)
20+
return (
21+
min(use_max_workers, use_web_concurrency)
22+
if max_workers_str and web_concurrency_str
23+
else use_web_concurrency
24+
if web_concurrency_str
25+
else use_default_workers
26+
)
27+
28+
629
# Gunicorn setup
7-
max_workers_str = os.getenv("MAX_WORKERS")
30+
max_workers_str = os.getenv("MAX_WORKERS", None)
831
web_concurrency_str = os.getenv("WEB_CONCURRENCY", None)
932
workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1")
10-
use_max_workers = None
11-
if max_workers_str and int(max_workers_str) > 0:
12-
use_max_workers = int(max_workers_str)
33+
workers = calculate_workers(max_workers_str, web_concurrency_str, workers_per_core_str)
34+
worker_tmp_dir = "/dev/shm"
1335
host = os.getenv("HOST", "0.0.0.0")
1436
port = os.getenv("PORT", "80")
1537
bind_env = os.getenv("BIND", None)
16-
use_loglevel = os.getenv("LOG_LEVEL", "info")
1738
use_bind = bind_env if bind_env else f"{host}:{port}"
18-
cores = multiprocessing.cpu_count()
19-
workers_per_core = float(workers_per_core_str)
20-
default_web_concurrency = workers_per_core * cores
21-
if web_concurrency_str and int(web_concurrency_str) > 0:
22-
web_concurrency = int(web_concurrency_str)
23-
else:
24-
web_concurrency = max(int(default_web_concurrency), 2)
25-
if use_max_workers:
26-
web_concurrency = min(web_concurrency, use_max_workers)
39+
use_loglevel = os.getenv("LOG_LEVEL", "info")
2740
accesslog_var = os.getenv("ACCESS_LOG", "-")
2841
use_accesslog = accesslog_var or None
2942
errorlog_var = os.getenv("ERROR_LOG", "-")
@@ -37,10 +50,8 @@
3750
logging_conf=os.getenv("LOGGING_CONF", "inboard.logging_conf")
3851
)
3952
loglevel = use_loglevel
40-
workers = web_concurrency
4153
bind = use_bind
4254
errorlog = use_errorlog
43-
worker_tmp_dir = "/dev/shm"
4455
accesslog = use_accesslog
4556
graceful_timeout = int(graceful_timeout_str)
4657
timeout = int(timeout_str)

tests/test_start.py

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
import multiprocessing
23
import os
34
from pathlib import Path
45
from typing import Any, Dict
@@ -7,7 +8,7 @@
78
from _pytest.monkeypatch import MonkeyPatch
89
from pytest_mock import MockerFixture
910

10-
from inboard import start
11+
from inboard import gunicorn_conf, start
1112

1213

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

4344

45+
class TestConfigureGunicorn:
46+
"""Test Gunicorn configuration independently of Gunicorn server.
47+
---
48+
"""
49+
50+
def test_gunicorn_conf_workers_default(self) -> None:
51+
"""Test default number of Gunicorn worker processes."""
52+
assert gunicorn_conf.workers >= 2
53+
assert gunicorn_conf.workers == multiprocessing.cpu_count()
54+
55+
def test_gunicorn_conf_workers_custom(self, monkeypatch: MonkeyPatch) -> None:
56+
"""Test custom Gunicorn worker process calculation."""
57+
monkeypatch.setenv("MAX_WORKERS", "1")
58+
monkeypatch.setenv("WEB_CONCURRENCY", "4")
59+
monkeypatch.setenv("WORKERS_PER_CORE", "0.5")
60+
assert os.getenv("MAX_WORKERS") == "1"
61+
assert os.getenv("WEB_CONCURRENCY") == "4"
62+
assert os.getenv("WORKERS_PER_CORE") == "0.5"
63+
assert (
64+
gunicorn_conf.calculate_workers(
65+
str(os.getenv("MAX_WORKERS")),
66+
str(os.getenv("WEB_CONCURRENCY")),
67+
str(os.getenv("WORKERS_PER_CORE")),
68+
)
69+
== 1
70+
)
71+
monkeypatch.delenv("MAX_WORKERS")
72+
assert (
73+
gunicorn_conf.calculate_workers(
74+
None,
75+
str(os.getenv("WEB_CONCURRENCY")),
76+
str(os.getenv("WORKERS_PER_CORE")),
77+
)
78+
== 4
79+
)
80+
monkeypatch.delenv("WEB_CONCURRENCY")
81+
cores: int = multiprocessing.cpu_count()
82+
assert gunicorn_conf.calculate_workers(
83+
None, "2", str(os.getenv("WORKERS_PER_CORE")), cores=cores
84+
) == int(cores * 0.5)
85+
86+
4487
class TestConfigureLogging:
4588
"""Test logging configuration methods.
4689
---
@@ -393,6 +436,7 @@ def test_start_server_uvicorn_gunicorn(
393436
f"--worker-tmp-dir {tmp_path}",
394437
)
395438
monkeypatch.setenv("PROCESS_MANAGER", "gunicorn")
439+
assert gunicorn_conf_path.parent.exists()
396440
assert os.getenv("GUNICORN_CONF") == str(gunicorn_conf_path)
397441
assert os.getenv("PROCESS_MANAGER") == "gunicorn"
398442
mock_run = mocker.patch("subprocess.run", autospec=True)
@@ -431,7 +475,7 @@ def test_start_server_uvicorn_gunicorn_custom_config(
431475
mocker: MockerFixture,
432476
monkeypatch: MonkeyPatch,
433477
) -> None:
434-
"""Test `start.start_server` with Uvicorn managed by Gunicorn."""
478+
"""Test customized `start.start_server` with Uvicorn managed by Gunicorn."""
435479
monkeypatch.setenv(
436480
"GUNICORN_CMD_ARGS",
437481
f"--worker-tmp-dir {gunicorn_conf_tmp_file_path.parent}",
@@ -441,12 +485,23 @@ def test_start_server_uvicorn_gunicorn_custom_config(
441485
monkeypatch.setenv("MAX_WORKERS", "1")
442486
monkeypatch.setenv("PROCESS_MANAGER", "gunicorn")
443487
monkeypatch.setenv("WEB_CONCURRENCY", "4")
488+
monkeypatch.setenv("WORKERS_PER_CORE", "0.5")
489+
assert gunicorn_conf_tmp_file_path.parent.exists()
444490
assert os.getenv("GUNICORN_CONF") == str(gunicorn_conf_tmp_file_path)
445491
assert os.getenv("LOG_FORMAT") == "gunicorn"
446492
assert os.getenv("LOG_LEVEL") == "debug"
447493
assert os.getenv("MAX_WORKERS") == "1"
448494
assert os.getenv("PROCESS_MANAGER") == "gunicorn"
449495
assert os.getenv("WEB_CONCURRENCY") == "4"
496+
assert os.getenv("WORKERS_PER_CORE") == "0.5"
497+
assert (
498+
gunicorn_conf.calculate_workers(
499+
str(os.getenv("MAX_WORKERS")),
500+
str(os.getenv("WEB_CONCURRENCY")),
501+
str(os.getenv("WORKERS_PER_CORE")),
502+
)
503+
== 1
504+
)
450505
mock_run = mocker.patch("subprocess.run", autospec=True)
451506
start.start_server(
452507
str(os.getenv("PROCESS_MANAGER")),

0 commit comments

Comments
 (0)