Skip to content

Commit 2554c71

Browse files
committed
Support prefer-local mode for swarm
1 parent 2d4dcdb commit 2554c71

27 files changed

Lines changed: 821 additions & 229 deletions

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Control the default behavior of `nginx-proxy`:
8585
| `NGINX_WORKER_CONNECTIONS` | `65535` | Max connections per worker. |
8686
| `CERT_RENEW_THRESHOLD_DAYS` | `30` | By default certificates are renewed when they have <=30 days remaining. |
8787
| `ENABLE_IPV6` | `false` | Enable IPv6 support on nginx. |
88-
| `DOCKER_SWARM` | `ignore` | Controls Docker Swarm discovery. Supported values are `ignore`, `exclude`, `enable`, and `strict`; see [Docker Swarm Support](#docker-swarm-support-preview). |
88+
| `DOCKER_SWARM` | `ignore` | Controls Docker Swarm discovery. Supported values are `ignore`, `exclude`, `enable`, `prefer-local`, and `strict`; see [Docker Swarm Support](#docker-swarm-support-preview). |
8989
| `SWARM_DOCKER_HOST` | - | URL of the Swarm manager socket (e.g., `tcp://manager:2375`). |
9090
| `CERTAPI_URL` | - | External Certificate API URL. |
9191
| `CERTAPI_BATCH_DOMAINS` | `true` | When using `CERTAPI_URL`, request safe domain batching (`batch_domains=true`) to avoid recursive domain-order errors. |
@@ -150,6 +150,7 @@ Docker Swarm discovery is controlled by the `DOCKER_SWARM` environment variable
150150
| `ignore` | Included | Not discovered | Default Docker-only behavior. Swarm task containers are treated like standalone containers if they are visible on the local Docker socket. |
151151
| `exclude` | Included | Not discovered | Docker-only discovery while explicitly ignoring containers that belong to Swarm services. |
152152
| `enable` | Included | Included | Mixed mode. Use this when `nginx-proxy` should route both standalone containers and Swarm services. |
153+
| `prefer-local` | Included | Included | Mixed Swarm mode that prefers healthy local task containers and keeps the service VIP as a fallback. |
153154
| `strict` | Excluded | Included | Swarm-only mode. Use this when `nginx-proxy` should route only Swarm services. |
154155

155156
`ignore` is the default and does not require the Docker node to be in Swarm mode. In this mode, `nginx-proxy` only reads the normal Docker container API. If a Swarm task container is visible on the local Docker socket, it can be registered as if it were a regular container.
@@ -158,9 +159,11 @@ Docker Swarm discovery is controlled by the `DOCKER_SWARM` environment variable
158159

159160
`enable` reads both local containers and Swarm services. Standalone containers are discovered from the local Docker socket. Swarm services are discovered from the Swarm manager API, and task containers are skipped so each service is registered once.
160161

162+
`prefer-local` reads both local containers and Swarm services, but local Swarm task containers are also discovered from the local Docker socket. When a route has local containers and the Swarm service VIP, nginx sends normal traffic to the local containers and marks the service VIP as a `backup` upstream server. If no local container is available, the service VIP is used normally. Existing container healthcheck and `BACKEND_START_GRACE_SECONDS` behavior still applies before local containers are registered.
163+
161164
`strict` reads only Swarm services. Local standalone containers are ignored, and Swarm task containers are also ignored. This is the mode to use when this proxy instance is dedicated to Swarm routing.
162165

163-
For `enable` and `strict`, the Swarm API client must be connected to a manager node because Docker only allows managers to list services. If `nginx-proxy` is running on a worker node, set `SWARM_DOCKER_HOST` to a reachable manager Docker API endpoint:
166+
For `enable`, `prefer-local`, and `strict`, the Swarm API client must be connected to a manager node because Docker only allows managers to list services. If `nginx-proxy` is running on a worker node, set `SWARM_DOCKER_HOST` to a reachable manager Docker API endpoint:
164167

165168
```bash
166169
-e DOCKER_SWARM=enable \

dev-requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
pytest==8.2.2
1+
pytest==9.0.3
22
pytest-cov
33
websocket-client
44
python-dotenv

nginx/Nginx.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -107,19 +107,19 @@ def _parse_error_line(self, error_msg):
107107
# Try to find the specific error line for the config file we are managing
108108
config_filename = os.path.basename(self.config_file_path)
109109
escaped_filename = re.escape(config_filename)
110-
110+
111111
# Search for: filename:line_number
112112
match = re.search(f"{escaped_filename}:(\\d+)", error_msg)
113113
if match:
114114
return int(match.group(1))
115-
115+
116116
# Fallback: Search for any line number pattern usually at end of line in Nginx errors
117117
lines = error_msg.splitlines()
118118
for line in lines:
119119
if "emerg" in line or "error" in line:
120-
match = re.search(r':(\d+)(?:\s|$)', line)
121-
if match:
122-
return int(match.group(1))
120+
match = re.search(r":(\d+)(?:\s|$)", line)
121+
if match:
122+
return int(match.group(1))
123123
return None
124124

125125
def _print_error_context(self, config_str, line_no):
@@ -130,10 +130,10 @@ def _print_error_context(self, config_str, line_no):
130130
return False
131131

132132
print(f"Error Location in Config (Line {line_no}):", file=sys.stderr)
133-
134-
start_idx = max(0, line_no - 6)
133+
134+
start_idx = max(0, line_no - 6)
135135
end_idx = min(total_lines, line_no + 5)
136-
136+
137137
for i in range(start_idx, end_idx):
138138
current_line = i + 1
139139
marker = ">>" if current_line == line_no else " "

nginx_proxy/BackendTarget.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def __init__(
1717
network_settings: dict = None,
1818
ports: dict = None,
1919
backend_type: str = "container",
20+
backup: bool = False,
2021
):
2122
self.name = name
2223
self.id = id
@@ -30,6 +31,7 @@ def __init__(
3031
self.network_settings = network_settings if network_settings else {}
3132
self.ports = ports if ports else {}
3233
self.type = backend_type
34+
self.backup = backup
3335

3436
@staticmethod
3537
def from_container(container: DockerContainer):

nginx_proxy/DockerEventListener.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def run(self):
3737
t1.start()
3838
threads.append(t1)
3939

40-
if swarm_mode in ("enable", "strict") and self.swarm_client is not None:
40+
if swarm_mode in ("enable", "prefer-local", "strict") and self.swarm_client is not None:
4141
t2 = threading.Thread(target=self._listen, args=(self.swarm_client,), daemon=True)
4242
t2.start()
4343
threads.append(t2)
@@ -53,7 +53,7 @@ def _listen(self, client):
5353
types = []
5454
events = ["health_status"] # common events
5555

56-
if client == self.swarm_client and swarm_mode in ("enable", "strict"):
56+
if client == self.swarm_client and swarm_mode in ("enable", "prefer-local", "strict"):
5757
types.append("service")
5858
events.extend(["create", "update", "remove"])
5959

@@ -114,9 +114,9 @@ def _process_service_event(self, action, event):
114114
def _process_container_event(self, action, event):
115115
container_id = event.get("Actor", {}).get("ID") or event.get("id")
116116
attributes = event.get("Actor", {}).get("Attributes", {})
117-
117+
118118
swarm_mode = self.web_server.config.get("docker_swarm", "ignore")
119-
if swarm_mode != "ignore" and "com.docker.swarm.service.id" in attributes:
119+
if swarm_mode not in ("ignore", "prefer-local") and "com.docker.swarm.service.id" in attributes:
120120
# print(f"Skipping event {action} for service task container {container_id}")
121121
return
122122

@@ -135,7 +135,7 @@ def _process_container_health_event(self, action, event):
135135
attributes = event.get("Actor", {}).get("Attributes", {})
136136

137137
swarm_mode = self.web_server.config.get("docker_swarm", "ignore")
138-
if swarm_mode != "ignore" and "com.docker.swarm.service.id" in attributes:
138+
if swarm_mode not in ("ignore", "prefer-local") and "com.docker.swarm.service.id" in attributes:
139139
return
140140

141141
health_status = (action or "").strip().lower().removeprefix("health_status:").strip()
@@ -153,7 +153,9 @@ def _handle_container_start(self, container_id: str, attributes=None):
153153
self._activate_backend_if_running(container_id, container=container)
154154
else:
155155
self._waiting_for_healthy.add(container_id)
156-
self._log_container_event("Container waiting ", container_id, container=container, detail="for healthy")
156+
self._log_container_event(
157+
"Container waiting ", container_id, container=container, detail="for healthy"
158+
)
157159
return
158160

159161
grace_seconds = float(self.web_server.config.get("backend_start_grace_seconds", 0) or 0)
@@ -220,7 +222,9 @@ def _container_is_running(container) -> bool:
220222
state_status = container.attrs.get("State", {}).get("Status")
221223
return state_status == "running" or getattr(container, "status", None) == "running"
222224

223-
def _log_container_event(self, label: str, container_id: str, container=None, attributes=None, detail: str | None = None):
225+
def _log_container_event(
226+
self, label: str, container_id: str, container=None, attributes=None, detail: str | None = None
227+
):
224228
container_name = self._container_name(container=container, container_id=container_id, attributes=attributes)
225229
parts = [label, "Id:" + container_id[:12]]
226230
if container_name:
@@ -296,7 +300,7 @@ def _should_forward_network_connect(self, container_id: str) -> bool:
296300

297301
swarm_mode = self.web_server.config.get("docker_swarm", "ignore")
298302
labels = container.attrs.get("Config", {}).get("Labels", {})
299-
if swarm_mode != "ignore" and "com.docker.swarm.service.id" in labels:
303+
if swarm_mode not in ("ignore", "prefer-local") and "com.docker.swarm.service.id" in labels:
300304
return False
301305
return True
302306

@@ -305,7 +309,9 @@ def _is_pending_startup(self, container_id: str) -> bool:
305309

306310
def _load_started_container_ids(self) -> set[str]:
307311
try:
308-
return {container.id for container in self.client.containers.list() if self._container_is_running(container)}
312+
return {
313+
container.id for container in self.client.containers.list() if self._container_is_running(container)
314+
}
309315
except (KeyboardInterrupt, SystemExit):
310316
raise
311317
except Exception:

nginx_proxy/NginxProxyApp.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,8 @@ def _loadconfig(self) -> NginxProxyAppConfig:
7272
port = parsed.port
7373
if port is None:
7474
port = 443 if parsed.scheme == "https" else 80
75-
76-
certapi = {
77-
"url": certapi_url,
78-
"host": parsed.hostname,
79-
"scheme": parsed.scheme,
80-
"port": port
81-
}
75+
76+
certapi = {"url": certapi_url, "host": parsed.hostname, "scheme": parsed.scheme, "port": port}
8277

8378
wellknown_path = os.getenv("WELLKNOWN_PATH", "/.well-known/acme-challenge/").strip()
8479
# Ensure wellknown_path starts with / and ends with /
@@ -163,7 +158,7 @@ def _init_docker_client(self) -> None:
163158

164159
# Validate Swarm mode if enabled
165160
swarm_mode = self.config["docker_swarm"]
166-
if swarm_mode in ("enable", "strict"):
161+
if swarm_mode in ("enable", "prefer-local", "strict"):
167162
try:
168163
info = self.swarm_client.info()
169164
swarm_info = info.get("Swarm", {})
@@ -186,7 +181,9 @@ def _init_docker_client(self) -> None:
186181

187182
def start(self):
188183
self.server = WebServer(self.docker_client, self.config, swarm_client=self.swarm_client)
189-
self.docker_event_listener = DockerEventListener(self.server, self.docker_client, swarm_client=self.swarm_client)
184+
self.docker_event_listener = DockerEventListener(
185+
self.server, self.docker_client, swarm_client=self.swarm_client
186+
)
190187

191188
def stop(self):
192189
print("Stopping NginxProxyApp...")

nginx_proxy/WebServer.py

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def __init__(
6969
)
7070
self.basic_auth_processor = post_processors.BasicAuthProcessor(self.config["conf_dir"] + "/basic_auth")
7171
self.redirect_processor = post_processors.RedirectProcessor()
72-
self.sticky_session_processor = post_processors.StickySessionProcessor()
72+
self.upstream_processor = post_processors.UpstreamProcessor()
7373

7474
# Render default config for Nginx setup
7575
default_nginx_config = self.template.render(config=self.config)
@@ -137,7 +137,9 @@ def _do_reload(self, forced=False) -> bool:
137137
location.container = list(location.backends)[0]
138138
hosts.append(host)
139139

140-
upstreams = self.sticky_session_processor.process(hosts)
140+
upstreams = self.upstream_processor.process(
141+
hosts, prefer_local=self.config.get("docker_swarm") == "prefer-local"
142+
)
141143
self.basic_auth_processor.process_basic_auth(hosts)
142144
self.ssl_processor.process_ssl_certificates(hosts)
143145
hosts = self._ensure_https_redirects(hosts)
@@ -210,7 +212,7 @@ def register_backend(self, backend: BackendTarget):
210212
# removes container from the maintained list.
211213
# this is called when a caontainer dies or leaves a known network
212214
def remove_backend(self, container_id: str):
213-
deleted, deleted_domain = self.config_data.remove_backend(container_id)
215+
deleted, deleted_domain = self._remove_backend_without_reload(container_id)
214216
if deleted:
215217
print(
216218
"Container removed ",
@@ -220,6 +222,9 @@ def remove_backend(self, container_id: str):
220222
)
221223
self.reload()
222224

225+
def _remove_backend_without_reload(self, container_id: str):
226+
return self.config_data.remove_backend(container_id)
227+
223228
def reload(self, immediate=False, force=False) -> bool:
224229
"""
225230
Schedules or performs a reload of the Nginx configuration.
@@ -269,9 +274,15 @@ def connect(self, network, container, scope):
269274
container_obj = self.client.containers.get(container)
270275
if container_obj.status != "running":
271276
return
272-
if self._container_has_healthcheck(container_obj) and self._container_health_status(container_obj) != "healthy":
277+
if (
278+
self._container_has_healthcheck(container_obj)
279+
and self._container_health_status(container_obj) != "healthy"
280+
):
273281
return
274-
if swarm_mode != "ignore" and "com.docker.swarm.service.id" in container_obj.attrs["Config"].get("Labels", {}):
282+
if swarm_mode not in (
283+
"ignore",
284+
"prefer-local",
285+
) and "com.docker.swarm.service.id" in container_obj.attrs["Config"].get("Labels", {}):
275286
# print(f"Skipping network connect for service task container {container}")
276287
return
277288
backend = BackendTarget.from_container(container_obj)
@@ -290,30 +301,41 @@ def update_backend(self, backend: BackendTarget):
290301
:return: true if state change affected the nginx configuration else false
291302
"""
292303
try:
293-
if not self.config_data.has_backend(backend.id):
294-
if self.register_backend(backend):
295-
self.reload()
296-
return True
304+
existing_backend = self.config_data.has_backend(backend.id)
305+
if existing_backend and backend.type != "service":
306+
return False
307+
308+
removed = None
309+
if existing_backend:
310+
removed, _ = self._remove_backend_without_reload(backend.id)
311+
312+
registered = self.register_backend(backend)
313+
if registered or removed:
314+
self.reload()
315+
return True
297316
except requests.exceptions.HTTPError as e:
298317
pass
299318
return False
300319

301320
def rescan_all_container(self, bypass_start_grace=False):
302321
"""
303-
Rescan all the containers and services to detect changes.
322+
Rescan all the containers and services to detect changes.
304323
Previously this only did containers, but now it's a full rescan for consistency.
305324
"""
306325
swarm_mode = self.config.get("docker_swarm", "ignore")
307326
with self._lock:
308327
# Clear previous state to ensure we don't leak dead containers/services
309328
self.config_data.clear()
310-
329+
311330
# 1. Register local containers (unless in strict swarm mode)
312331
if swarm_mode != "strict" and self.client is not None:
313332
try:
314333
containers = self.client.containers.list()
315334
for container in containers:
316-
if swarm_mode != "ignore" and "com.docker.swarm.service.id" in container.attrs["Config"].get("Labels", {}):
335+
if swarm_mode not in (
336+
"ignore",
337+
"prefer-local",
338+
) and "com.docker.swarm.service.id" in container.attrs["Config"].get("Labels", {}):
317339
continue
318340
if not self._should_register_container_now(container, bypass_start_grace=bypass_start_grace):
319341
continue
@@ -324,8 +346,8 @@ def rescan_all_container(self, bypass_start_grace=False):
324346
except Exception as e:
325347
print(f"Error scanning containers: {e}", file=sys.stderr)
326348

327-
# 2. Register swarm services (if enable or strict)
328-
if swarm_mode in ("enable", "strict"):
349+
# 2. Register swarm services (if enable, prefer-local, or strict)
350+
if swarm_mode in ("enable", "prefer-local", "strict"):
329351
try:
330352
info = self.swarm_client.info()
331353
swarm_info = info.get("Swarm", {})
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
from .basic_auth_processor import BasicAuthProcessor
22
from .redirect_processor import RedirectProcessor
33
from .ssl_certificate_processor import SslCertificateProcessor
4-
from .sticky_session_processor import StickySessionProcessor
4+
from .upstream_processor import UpstreamProcessor

nginx_proxy/post_processors/sticky_session_processor.py

Lines changed: 0 additions & 46 deletions
This file was deleted.

0 commit comments

Comments
 (0)