mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
fix(svc-prx-openresty): size worker_connections independently of pids_limit
The shared proxy's nginx.conf derived worker_connections from the
openresty container's pids_limit. pids_limit caps processes and threads,
worker_connections caps simultaneous sockets per worker (client and
upstream), so with pids_limit 256 and one worker the whole host's proxy
held about 256 sockets, roughly 128 proxied requests in flight across
every vhost. Raising pids_limit to buy connections would loosen the
fork-bomb guard.
svc-prx-openresty now declares services.openresty.worker_connections
(4096 per worker, the common nginx production range, enough for several
chunk-heavy apps such as OpenWebUI at ~150-170 requests per page load).
WEBSERVER_PROXY_WORKER_CONNECTIONS reads it through lookup('config'),
without a default, so a missing key fails loudly. pids_limit stays 256.
WEBSERVER_PROXY_WORKER_RLIMIT_NOFILE is derived as 2x worker_connections
(8192), one descriptor for each client and upstream socket of a proxied
request, and nginx.conf renders it as worker_rlimit_nofile. The openresty
container pins its nofile ulimit (soft and hard) to the same value:
workers call setrlimit as root without CAP_SYS_RESOURCE, so they can only
lower the hard limit, and the inherited hard limit depends on the host
daemon (Docker >= 25 defaults to 1024:524288, DiD nests whatever the
outer container has). An explicit ulimit makes the rlimit succeed on
every host instead of logging a setrlimit alert.
The unit test pins worker_connections 4096, worker_rlimit_nofile 8192 and
the container ulimit, and fails when worker_connections follows
pids_limit again.
Live proof (compose deploy of web-svc-html, both plays failed=0): the
deployed nginx.conf renders worker_rlimit_nofile 8192 and
worker_connections 4096; openresty's master, worker and cache manager
run with "Max open files 8192 8192"; the openresty log carries no
setrlimit or "worker_connections are not enough" line.
Closes #1300
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba4c06b943
commit
f52da1c875
5 files changed
+119
-22
No files matched your search
@@ -34,8 +34,9 @@ WEBSERVER_PROXY_WORKER_PROCESSES: >-
|
||||
] | max
|
||||
}}
|
||||
|
||||
WEBSERVER_PROXY_WORKER_CONNECTIONS: >-
|
||||
{{ lookup('applications') | resource_filter('svc-prx-openresty', 'pids_limit', '', RESOURCE_PIDS_LIMIT) | int }}
|
||||
WEBSERVER_PROXY_WORKER_CONNECTIONS: "{{ lookup('config', 'svc-prx-openresty', 'services.openresty.worker_connections') | int }}"
|
||||
|
||||
WEBSERVER_PROXY_WORKER_RLIMIT_NOFILE: "{{ (WEBSERVER_PROXY_WORKER_CONNECTIONS | int) * 2 }}"
|
||||
|
||||
# Response security headers of every vhost; an app overrides any key under `security_headers` in its meta/server.yml.
|
||||
WEBSERVER_SECURITY_HEADERS:
|
||||
|
||||
@@ -35,6 +35,7 @@ openresty:
|
||||
custom: true
|
||||
lifecycle: beta
|
||||
pids_limit: 256
|
||||
worker_connections: 4096
|
||||
|
||||
# nocheck: variants-coverage — sso-variant-tor requires the onion on in every variant, so the false polarity has no variant left to live in
|
||||
tor:
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
network_mode: "host"
|
||||
{% endif %}
|
||||
{{ lookup('container_volumes', service_name) | indent(4) }}
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: {{ WEBSERVER_PROXY_WORKER_RLIMIT_NOFILE }}
|
||||
hard: {{ WEBSERVER_PROXY_WORKER_RLIMIT_NOFILE }}
|
||||
command: ["openresty", "-g", "daemon off;"]
|
||||
{{ lookup('container_healthcheck', service_name) | indent(4) }}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
worker_processes {{ WEBSERVER_PROXY_WORKER_PROCESSES }};
|
||||
worker_rlimit_nofile {{ WEBSERVER_PROXY_WORKER_RLIMIT_NOFILE }};
|
||||
events
|
||||
{
|
||||
worker_connections {{ WEBSERVER_PROXY_WORKER_CONNECTIONS }};
|
||||
|
||||
@@ -8,6 +8,8 @@ proxy with that role's ``application_id``, so a 64-pid role capped every vhost a
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
@@ -21,7 +23,8 @@ from utils.cache.carrier import (
|
||||
merged_applications_cache_key,
|
||||
)
|
||||
from utils.cache.files import PROJECT_ROOT, read_text
|
||||
from utils.cache.yaml import load_yaml
|
||||
from utils.cache.yaml import load_yaml, load_yaml_str
|
||||
from utils.roles.mapping import ROLE_FILE_META_SERVICES, ROLE_FILE_TEMPL_COMPOSE
|
||||
|
||||
_WEBSERVER_VARS = Path(PROJECT_ROOT) / "group_vars" / "all" / "04_webserver.yml"
|
||||
_NGINX_CONF = (
|
||||
@@ -31,24 +34,45 @@ _NGINX_CONF = (
|
||||
/ "templates"
|
||||
/ "nginx.conf.j2"
|
||||
)
|
||||
_OPENRESTY_ROLE = Path(PROJECT_ROOT) / "roles" / "svc-prx-openresty"
|
||||
_OPENRESTY_SERVICES = _OPENRESTY_ROLE / ROLE_FILE_META_SERVICES
|
||||
_OPENRESTY_COMPOSE = _OPENRESTY_ROLE / ROLE_FILE_TEMPL_COMPOSE
|
||||
|
||||
_FIRST_LOADER = "web-svc-mirror"
|
||||
|
||||
|
||||
def _applications(proxy_cpus: float) -> dict[str, Any]:
|
||||
"""Return a proxy with 256 pids and a first-loading role with 64 pids, 1 cpu.
|
||||
def _applications(openresty: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return *openresty* as the proxy and a first-loading role with 64 pids, 1 cpu.
|
||||
|
||||
Args:
|
||||
proxy_cpus: the proxy's ``cpus``.
|
||||
openresty: the proxy's ``services.openresty`` entry.
|
||||
"""
|
||||
return {
|
||||
"svc-prx-openresty": {
|
||||
"services": {"openresty": {"cpus": proxy_cpus, "pids_limit": 256}}
|
||||
},
|
||||
"svc-prx-openresty": {"services": {"openresty": openresty}},
|
||||
_FIRST_LOADER: {"services": {"mirror": {"cpus": 1, "pids_limit": 64}}},
|
||||
}
|
||||
|
||||
|
||||
def _proxy(cpus: float, pids_limit: int, worker_connections: int) -> dict[str, Any]:
|
||||
"""Return a ``services.openresty`` entry.
|
||||
|
||||
Args:
|
||||
cpus: the proxy's ``cpus``.
|
||||
pids_limit: the proxy's ``pids_limit``.
|
||||
worker_connections: the proxy's ``worker_connections``.
|
||||
"""
|
||||
return {
|
||||
"cpus": cpus,
|
||||
"pids_limit": pids_limit,
|
||||
"worker_connections": worker_connections,
|
||||
}
|
||||
|
||||
|
||||
def _shipped() -> dict[str, Any]:
|
||||
"""Return ``services.openresty`` as ``svc-prx-openresty/meta/services.yml`` ships it."""
|
||||
return load_yaml(_OPENRESTY_SERVICES)["openresty"]
|
||||
|
||||
|
||||
class TestWebserverWorkerLimits(unittest.TestCase):
|
||||
group_vars: ClassVar[dict[str, Any]]
|
||||
|
||||
@@ -57,7 +81,6 @@ class TestWebserverWorkerLimits(unittest.TestCase):
|
||||
cls.group_vars = load_yaml(_WEBSERVER_VARS)
|
||||
|
||||
def setUp(self) -> None:
|
||||
_reset_cache_for_tests()
|
||||
self.addCleanup(_reset_cache_for_tests)
|
||||
|
||||
def _render(
|
||||
@@ -73,6 +96,7 @@ class TestWebserverWorkerLimits(unittest.TestCase):
|
||||
Returns:
|
||||
The rendered text, stripped.
|
||||
"""
|
||||
_reset_cache_for_tests()
|
||||
variables: dict[str, Any] = {
|
||||
key: trust_as_template(value) if isinstance(value, str) else value
|
||||
for key, value in self.group_vars.items()
|
||||
@@ -91,25 +115,60 @@ class TestWebserverWorkerLimits(unittest.TestCase):
|
||||
templar = Templar(loader=DataLoader(), variables=variables)
|
||||
return str(templar.template(trust_as_template(expression))).strip()
|
||||
|
||||
def _nginx_worker_lines(self, openresty: dict[str, Any]) -> list[str]:
|
||||
"""Render the ``worker_*`` directives of the shared ``nginx.conf``.
|
||||
|
||||
Args:
|
||||
openresty: the proxy's ``services.openresty`` entry.
|
||||
"""
|
||||
worker_lines = [
|
||||
line.strip()
|
||||
for line in read_text(str(_NGINX_CONF)).splitlines()
|
||||
if line.strip().startswith("worker_")
|
||||
]
|
||||
return self._render(
|
||||
"\n".join(worker_lines), _applications(openresty)
|
||||
).splitlines()
|
||||
|
||||
def test_proxy_connections_follow_the_proxy_alone(self) -> None:
|
||||
for scope in ({}, {"service_name": "mirror"}):
|
||||
with self.subTest(**scope):
|
||||
self.assertEqual(
|
||||
self._render(
|
||||
"{{ WEBSERVER_PROXY_WORKER_CONNECTIONS }}",
|
||||
_applications(4),
|
||||
_applications(_proxy(4, 256, 4096)),
|
||||
**scope,
|
||||
),
|
||||
"256",
|
||||
"4096",
|
||||
)
|
||||
|
||||
def test_proxy_connections_ignore_pids_limit(self) -> None:
|
||||
for pids_limit in (64, 256, 100000):
|
||||
with self.subTest(pids_limit=pids_limit):
|
||||
self.assertEqual(
|
||||
self._render(
|
||||
"{{ WEBSERVER_PROXY_WORKER_CONNECTIONS }}",
|
||||
_applications(_proxy(0.5, pids_limit, 1234)),
|
||||
),
|
||||
"1234",
|
||||
)
|
||||
|
||||
def test_rlimit_nofile_doubles_the_connections(self) -> None:
|
||||
self.assertEqual(
|
||||
self._render(
|
||||
"{{ WEBSERVER_PROXY_WORKER_RLIMIT_NOFILE }}",
|
||||
_applications(_proxy(0.5, 256, 1234)),
|
||||
),
|
||||
"2468",
|
||||
)
|
||||
|
||||
def test_proxy_processes_follow_the_proxy_alone(self) -> None:
|
||||
for scope in ({}, {"service_name": "mirror"}):
|
||||
with self.subTest(**scope):
|
||||
self.assertEqual(
|
||||
self._render(
|
||||
"{{ WEBSERVER_PROXY_WORKER_PROCESSES }}",
|
||||
_applications(4),
|
||||
_applications(_proxy(4, 256, 4096)),
|
||||
**scope,
|
||||
),
|
||||
"4",
|
||||
@@ -117,12 +176,15 @@ class TestWebserverWorkerLimits(unittest.TestCase):
|
||||
|
||||
def test_a_sub_cpu_proxy_still_runs_one_worker(self) -> None:
|
||||
self.assertEqual(
|
||||
self._render("{{ WEBSERVER_PROXY_WORKER_PROCESSES }}", _applications(0.5)),
|
||||
self._render(
|
||||
"{{ WEBSERVER_PROXY_WORKER_PROCESSES }}",
|
||||
_applications(_proxy(0.5, 256, 4096)),
|
||||
),
|
||||
"1",
|
||||
)
|
||||
|
||||
def test_app_internal_limits_still_pair_with_the_play(self) -> None:
|
||||
applications = _applications(4)
|
||||
applications = _applications(_proxy(4, 256, 4096))
|
||||
self.assertEqual(
|
||||
self._render("{{ WEBSERVER_WORKER_CONNECTIONS }}", applications), "64"
|
||||
)
|
||||
@@ -131,16 +193,44 @@ class TestWebserverWorkerLimits(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_shared_proxy_nginx_conf_uses_the_proxy_limits(self) -> None:
|
||||
worker_lines = [
|
||||
line.strip()
|
||||
for line in read_text(str(_NGINX_CONF)).splitlines()
|
||||
if line.strip().startswith("worker_")
|
||||
]
|
||||
rendered = self._render("\n".join(worker_lines), _applications(4))
|
||||
self.assertEqual(
|
||||
rendered.splitlines(), ["worker_processes 4;", "worker_connections 256;"]
|
||||
self._nginx_worker_lines(_proxy(4, 256, 2048)),
|
||||
[
|
||||
"worker_processes 4;",
|
||||
"worker_rlimit_nofile 4096;",
|
||||
"worker_connections 2048;",
|
||||
],
|
||||
)
|
||||
|
||||
def test_shipped_proxy_limits(self) -> None:
|
||||
self.assertEqual(_shipped()["pids_limit"], 256)
|
||||
self.assertEqual(
|
||||
self._nginx_worker_lines(_shipped()),
|
||||
[
|
||||
"worker_processes 1;",
|
||||
"worker_rlimit_nofile 8192;",
|
||||
"worker_connections 4096;",
|
||||
],
|
||||
)
|
||||
|
||||
def test_container_nofile_ulimit_allows_the_worker_rlimit(self) -> None:
|
||||
match = re.search(
|
||||
r"^( *)ulimits:\n(?:\1 .*\n)+",
|
||||
read_text(str(_OPENRESTY_COMPOSE)),
|
||||
re.MULTILINE,
|
||||
)
|
||||
self.assertIsNotNone(match, f"{_OPENRESTY_COMPOSE} declares no ulimits")
|
||||
applications = _applications(_shipped())
|
||||
nofile = load_yaml_str(
|
||||
self._render(textwrap.dedent(match.group(0)), applications)
|
||||
)["ulimits"]["nofile"]
|
||||
rlimit = int(
|
||||
self._render("{{ WEBSERVER_PROXY_WORKER_RLIMIT_NOFILE }}", applications)
|
||||
)
|
||||
self.assertEqual(rlimit, 8192)
|
||||
self.assertGreaterEqual(nofile["soft"], rlimit)
|
||||
self.assertGreaterEqual(nofile["hard"], rlimit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user