Skip to content

Commit a3a1f01

Browse files
committed
fix: bind gpu-health-monitor metrics server for IPv6-only clusters
Add a --metrics-addr flag to gpu_health_monitor and pass it to start_http_server so the Prometheus metrics endpoint can bind dual-stack. The flag defaults to 0.0.0.0 (no change for existing invocations). Wire a new global.metricsAddress Helm value (default "::") into the dcgm 3.x/4.x daemonsets so a freshly installed chart binds dual-stack and kubelet liveness/readiness probes reach the endpoint over IPv6. Refs #1407 Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
1 parent 42672ae commit a3a1f01

6 files changed

Lines changed: 127 additions & 3 deletions

File tree

distros/kubernetes/nvsentinel/charts/gpu-health-monitor/templates/daemonset-dcgm-3.x.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ spec:
5353
- {{ include "gpu-health-monitor.dcgmCliMode" . | quote }}
5454
- --port
5555
- "{{ .Values.global.metricsPort }}"
56+
- --metrics-addr
57+
- "{{ .Values.global.metricsAddress | default "0.0.0.0" }}"
5658
- --verbose
5759
- {{ .Values.verbose | quote }}
5860
- --state-file

distros/kubernetes/nvsentinel/charts/gpu-health-monitor/templates/daemonset-dcgm-4.x.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ spec:
5353
- {{ include "gpu-health-monitor.dcgmCliMode" . | quote }}
5454
- --port
5555
- "{{ .Values.global.metricsPort }}"
56+
- --metrics-addr
57+
- "{{ .Values.global.metricsAddress | default "0.0.0.0" }}"
5658
- --verbose
5759
- {{ .Values.verbose | quote }}
5860
- --state-file

distros/kubernetes/nvsentinel/values.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ global:
2121
tag: "12-debian-12-r30"
2222
pullPolicy: IfNotPresent
2323
metricsPort: 2112
24+
# IPv4 bind address for the GPU health monitor metrics server. Set to "::"
25+
# to enable IPv6 / dual-stack binding. This requires an IPv6-capable kernel;
26+
# nodes booted with ipv6.disable=1 fail with EAFNOSUPPORT and CrashLoop.
27+
metricsAddress: "0.0.0.0"
2428
# Default Kubernetes API client limits for core controllers. Components can
2529
# override these with their own qps and burst values.
2630
# qps: positive values throttle requests, 0 uses the client-go default,

health-monitors/gpu-health-monitor/gpu_health_monitor/cli.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ def _init_event_processor(
7474
)
7575
@click.option("--config-file", type=click.Path(), help="Path to config file", required=True)
7676
@click.option("--port", type=int, help="Port to use for metrics server", required=True)
77+
@click.option(
78+
"--metrics-addr",
79+
type=str,
80+
default="0.0.0.0",
81+
show_default=True,
82+
help="Address the metrics server binds to. Use '::' for IPv6 / dual-stack clusters.",
83+
required=False,
84+
)
7785
@click.option("--verbose", type=bool, default=False, help="Enable debug logging", required=False)
7886
@click.option("--state-file", type=click.Path(), help="gpu health monitor state file path", required=True)
7987
@click.option("--dcgm-k8s-service-enabled", type=bool, help="Is DCGM K8s service Enabled", required=True)
@@ -124,6 +132,7 @@ def cli(
124132
dcgm_error_mapping_config_file,
125133
config_file,
126134
port,
135+
metrics_addr,
127136
verbose,
128137
state_file,
129138
dcgm_k8s_service_enabled,
@@ -261,7 +270,7 @@ def cli(
261270
# expose a documented fixed RPC timeout, so fleets should validate this
262271
# deadline in STORE_ONLY mode before enabling remediation. Set to 0 to disable.
263272
probe_deadline_seconds = dcgm_config.getfloat("ProbeDeadlineSeconds", fallback=poll_interval * 3)
264-
prom_server, t = start_health_server(port, staleness_seconds=poll_interval * 3)
273+
prom_server, t = start_health_server(port, staleness_seconds=poll_interval * 3, addr=metrics_addr)
265274

266275
def process_exit_signal(signum, frame):
267276
exit.set()

health-monitors/gpu-health-monitor/gpu_health_monitor/healthz.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
false positives from NTP clock adjustments.
2121
"""
2222

23+
import socket
2324
import threading
2425
import time
2526
from http.server import ThreadingHTTPServer
@@ -80,13 +81,17 @@ def do_GET(self) -> None:
8081
super().do_GET()
8182

8283

83-
def start_server(port: int, staleness_seconds: float = 300.0) -> tuple[ThreadingHTTPServer, threading.Thread]:
84+
def start_server(
85+
port: int, staleness_seconds: float = 300.0, addr: str = "0.0.0.0"
86+
) -> tuple[ThreadingHTTPServer, threading.Thread]:
8487
"""Start an HTTP server serving /metrics and /healthz.
8588
8689
Args:
8790
port: TCP port to listen on.
8891
staleness_seconds: Maximum seconds between reconcile iterations
8992
before /healthz returns 503.
93+
addr: Address to bind. Defaults to 0.0.0.0 (IPv4). Pass "::" to bind
94+
IPv6, which also accepts IPv4-mapped clients on dual-stack nodes.
9095
9196
Returns:
9297
(ThreadingHTTPServer, daemon Thread) — same interface as
@@ -98,7 +103,13 @@ def start_server(port: int, staleness_seconds: float = 300.0) -> tuple[Threading
98103
# Reset the grace period to start from server startup, not module import.
99104
_last_reconcile.mark_alive()
100105

101-
httpd = ThreadingHTTPServer(("", port), _HealthMetricsHandler)
106+
server_cls = ThreadingHTTPServer
107+
if ":" in addr:
108+
# ThreadingHTTPServer defaults to AF_INET, which cannot bind an IPv6 literal.
109+
server_cls = type(
110+
"_ThreadingHTTPServerV6", (ThreadingHTTPServer,), {"address_family": socket.AF_INET6}
111+
)
112+
httpd = server_cls((addr, port), _HealthMetricsHandler)
102113
t = threading.Thread(target=httpd.serve_forever, daemon=True)
103114
t.start()
104115

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Tests for the gpu-health-monitor CLI, focused on the metrics server binding."""
16+
17+
from unittest.mock import MagicMock, patch
18+
19+
from click.testing import CliRunner
20+
21+
from gpu_health_monitor.cli import cli
22+
23+
24+
def _find_option(param_name):
25+
for param in cli.params:
26+
if param.name == param_name:
27+
return param
28+
return None
29+
30+
31+
def test_metrics_addr_option_defaults_to_ipv4():
32+
"""--metrics-addr exists and defaults to 0.0.0.0 (no behavior change by default)."""
33+
option = _find_option("metrics_addr")
34+
assert option is not None
35+
assert option.default == "0.0.0.0"
36+
assert option.required is False
37+
38+
39+
def _write_config(tmp_path):
40+
config_file = tmp_path / "config.ini"
41+
config_file.write_text(
42+
"[logging]\n"
43+
"[dcgm]\n"
44+
"PollIntervalSeconds = 60\n"
45+
"[cli]\n"
46+
"EnabledEventProcessors = PlatformConnectorEventProcessor\n"
47+
"[eventprocessors.platformconnector]\n"
48+
"SocketPath = /tmp/does-not-matter.sock\n"
49+
)
50+
mapping_file = tmp_path / "dcgmerrors.csv"
51+
mapping_file.write_text("0,DCGM_FR_UNKNOWN\n")
52+
return config_file, mapping_file
53+
54+
55+
def _run_cli(tmp_path, extra_args):
56+
config_file, mapping_file = _write_config(tmp_path)
57+
args = [
58+
"--dcgm-addr",
59+
"localhost:5555",
60+
"--dcgm-error-mapping-config-file",
61+
str(mapping_file),
62+
"--config-file",
63+
str(config_file),
64+
"--port",
65+
"2112",
66+
"--state-file",
67+
str(tmp_path / "statefile"),
68+
"--dcgm-k8s-service-enabled",
69+
"false",
70+
*extra_args,
71+
]
72+
with patch("gpu_health_monitor.cli.start_health_server") as mock_start, patch(
73+
"gpu_health_monitor.cli._init_event_processor"
74+
), patch("gpu_health_monitor.cli.dcgm.DCGMWatcher") as mock_watcher:
75+
mock_start.return_value = (MagicMock(), MagicMock())
76+
runner = CliRunner()
77+
result = runner.invoke(cli, args, env={"NODE_NAME": "test-node"})
78+
return result, mock_start, mock_watcher
79+
80+
81+
def test_health_server_binds_explicit_metrics_addr(tmp_path):
82+
"""--metrics-addr :: is passed through to the health server as addr='::'."""
83+
result, mock_start, _ = _run_cli(tmp_path, ["--metrics-addr", "::"])
84+
assert result.exit_code == 0, result.output
85+
mock_start.assert_called_once()
86+
assert mock_start.call_args.args[0] == 2112
87+
assert mock_start.call_args.kwargs["addr"] == "::"
88+
89+
90+
def test_health_server_defaults_to_ipv4(tmp_path):
91+
"""Without --metrics-addr the server still binds 0.0.0.0 (backward compatible)."""
92+
result, mock_start, _ = _run_cli(tmp_path, [])
93+
assert result.exit_code == 0, result.output
94+
mock_start.assert_called_once()
95+
assert mock_start.call_args.args[0] == 2112
96+
assert mock_start.call_args.kwargs["addr"] == "0.0.0.0"

0 commit comments

Comments
 (0)