detached 50a9fab

fix: honor labeled ports in readiness probes

Readiness probes were still resolving the first exposed port, which could
miss the actual proxied service on multi-port images. Probe resolution now
respects labeled proxy ports so auto readiness checks match live routing.
commit
50a9fab98539917323a02c8bbb357f1518c01e09
author
brice · · 2026-03-12 19:03 UTC
parent
a8e226f
signature
unsigned
2 files · +40 -22
M internal/usecase/container/service.go +31 -10
@@ -7,6 +7,7 @@ import (
77 "fmt"
88 "maps"
99 "slices"
10+ "strconv"
1011 "strings"
1112 "sync"
1213 "time"
@@ -2344,7 +2345,7 @@ func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containe
23442345 if !ok || healthPath == "" {
23452346 return false, nil
23462347 }
2347- ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)
2348+ ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
23482349 if probeErr != nil || ip == "" || port <= 0 {
23492350 log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint")
23502351 return false, nil
@@ -2367,7 +2368,7 @@ func (s *Service) tryDefaultHTTPProbe(ctx context.Context, containerID string, c
23672368 if containerConfig == nil || len(containerConfig.Ports) == 0 {
23682369 return false, nil
23692370 }
2370- ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)
2371+ ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
23712372 if probeErr != nil || ip == "" || port <= 0 {
23722373 log.Debug().Err(probeErr).Msg("readiness cascade: default HTTP probe skipped, could not resolve container endpoint")
23732374 return false, nil
@@ -2388,7 +2389,7 @@ func (s *Service) tryTCPProbe(ctx context.Context, containerID string, container
23882389 if containerConfig == nil || len(containerConfig.Ports) == 0 {
23892390 return false, nil
23902391 }
2391- ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)
2392+ ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
23922393 if probeErr != nil || ip == "" || port <= 0 {
23932394 log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint")
23942395 return false, nil
@@ -2407,16 +2408,33 @@ func (s *Service) tryTCPProbe(ctx context.Context, containerID string, container
24072408 // from the host. We use the host port binding (127.0.0.1:<mapped_port>)
24082409 // which is always reachable. Falls back to the container's internal IP
24092410 // only if no host port mapping exists (e.g. host-network mode).
2410-func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) {
2411+func (s *Service) resolveProbeEndpoint(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) (string, int, error) {
24112412 log := zerowrap.FromCtx(ctx)
24122413
2413- // First, get the container's internal port from network info
2414- _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2415- if err != nil {
2416- return "", 0, err
2414+ internalPort := 0
2415+ if containerConfig != nil {
2416+ if labels := containerConfig.Labels; labels != nil {
2417+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
2418+ if portStr, ok := labels[key]; ok && portStr != "" {
2419+ port, err := strconv.Atoi(portStr)
2420+ if err == nil && port > 0 {
2421+ internalPort = port
2422+ break
2423+ }
2424+ log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid probe port label value")
2425+ }
2426+ }
2427+ }
2428+ }
2429+
2430+ if internalPort == 0 {
2431+ _, resolvedPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2432+ if err != nil {
2433+ return "", 0, err
2434+ }
2435+ internalPort = resolvedPort
24172436 }
24182437
2419- // Try to resolve via host port binding (works in rootless podman/Docker)
24202438 hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort)
24212439 if hostErr == nil && hostPort > 0 {
24222440 log.Debug().
@@ -2426,7 +2444,6 @@ func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID stri
24262444 return "127.0.0.1", hostPort, nil
24272445 }
24282446
2429- // Fallback: use internal IP (works in Docker rootful / host network)
24302447 ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID)
24312448 if fallbackErr != nil {
24322449 return "", 0, fallbackErr
@@ -2438,6 +2455,10 @@ func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID stri
24382455 return ip, internalPort, nil
24392456 }
24402457
2458+func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) {
2459+ return s.resolveProbeEndpoint(ctx, containerID, nil)
2460+}
2461+
24412462 // waitForReadyByDelay waits using the legacy running+delay strategy.
24422463 func (s *Service) waitForReadyByDelay(ctx context.Context, containerID string) error {
24432464 log := zerowrap.FromCtx(ctx)
@@ -7,6 +7,7 @@ import (
7 "fmt"7 "fmt"
8 "maps"8 "maps"
9 "slices"9 "slices"
10+ "strconv"
10 "strings"11 "strings"
11 "sync"12 "sync"
12 "time"13 "time"
@@ -2344,7 +2345,7 @@ func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containe
2344 if !ok || healthPath == "" {2345 if !ok || healthPath == "" {
2345 return false, nil2346 return false, nil
2346 }2347 }
2347- ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)2348+ ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
2348 if probeErr != nil || ip == "" || port <= 0 {2349 if probeErr != nil || ip == "" || port <= 0 {
2349 log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint")2350 log.Debug().Err(probeErr).Msg("readiness cascade: HTTP probe skipped, could not resolve container endpoint")
2350 return false, nil2351 return false, nil
@@ -2367,7 +2368,7 @@ func (s *Service) tryDefaultHTTPProbe(ctx context.Context, containerID string, c
2367 if containerConfig == nil || len(containerConfig.Ports) == 0 {2368 if containerConfig == nil || len(containerConfig.Ports) == 0 {
2368 return false, nil2369 return false, nil
2369 }2370 }
2370- ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)2371+ ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
2371 if probeErr != nil || ip == "" || port <= 0 {2372 if probeErr != nil || ip == "" || port <= 0 {
2372 log.Debug().Err(probeErr).Msg("readiness cascade: default HTTP probe skipped, could not resolve container endpoint")2373 log.Debug().Err(probeErr).Msg("readiness cascade: default HTTP probe skipped, could not resolve container endpoint")
2373 return false, nil2374 return false, nil
@@ -2388,7 +2389,7 @@ func (s *Service) tryTCPProbe(ctx context.Context, containerID string, container
2388 if containerConfig == nil || len(containerConfig.Ports) == 0 {2389 if containerConfig == nil || len(containerConfig.Ports) == 0 {
2389 return false, nil2390 return false, nil
2390 }2391 }
2391- ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)2392+ ip, port, probeErr := s.resolveProbeEndpoint(ctx, containerID, containerConfig)
2392 if probeErr != nil || ip == "" || port <= 0 {2393 if probeErr != nil || ip == "" || port <= 0 {
2393 log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint")2394 log.Debug().Err(probeErr).Msg("readiness cascade: TCP probe skipped, could not resolve container endpoint")
2394 return false, nil2395 return false, nil
@@ -2407,16 +2408,33 @@ func (s *Service) tryTCPProbe(ctx context.Context, containerID string, container
2407 // from the host. We use the host port binding (127.0.0.1:<mapped_port>)2408 // from the host. We use the host port binding (127.0.0.1:<mapped_port>)
2408 // which is always reachable. Falls back to the container's internal IP2409 // which is always reachable. Falls back to the container's internal IP
2409 // only if no host port mapping exists (e.g. host-network mode).2410 // only if no host port mapping exists (e.g. host-network mode).
2410-func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) {2411+func (s *Service) resolveProbeEndpoint(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig) (string, int, error) {
2411 log := zerowrap.FromCtx(ctx)2412 log := zerowrap.FromCtx(ctx)
2412 2413
2413- // First, get the container's internal port from network info2414+ internalPort := 0
2414- _, internalPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)2415+ if containerConfig != nil {
2415- if err != nil {2416+ if labels := containerConfig.Labels; labels != nil {
2416- return "", 0, err2417+ for _, key := range []string{domain.LabelProxyPort, domain.LabelPort} {
2418+ if portStr, ok := labels[key]; ok && portStr != "" {
2419+ port, err := strconv.Atoi(portStr)
2420+ if err == nil && port > 0 {
2421+ internalPort = port
2422+ break
2423+ }
2424+ log.Warn().Str("label", key).Str("port_value", portStr).Msg("invalid probe port label value")
2425+ }
2426+ }
2427+ }
2428+ }
2429+
2430+ if internalPort == 0 {
2431+ _, resolvedPort, err := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2432+ if err != nil {
2433+ return "", 0, err
2434+ }
2435+ internalPort = resolvedPort
2417 }2436 }
2418 2437
2419- // Try to resolve via host port binding (works in rootless podman/Docker)
2420 hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort)2438 hostPort, hostErr := s.runtime.GetContainerPort(ctx, containerID, internalPort)
2421 if hostErr == nil && hostPort > 0 {2439 if hostErr == nil && hostPort > 0 {
2422 log.Debug().2440 log.Debug().
@@ -2426,7 +2444,6 @@ func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID stri
2426 return "127.0.0.1", hostPort, nil2444 return "127.0.0.1", hostPort, nil
2427 }2445 }
2428 2446
2429- // Fallback: use internal IP (works in Docker rootful / host network)
2430 ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID)2447 ip, _, fallbackErr := s.runtime.GetContainerNetworkInfo(ctx, containerID)
2431 if fallbackErr != nil {2448 if fallbackErr != nil {
2432 return "", 0, fallbackErr2449 return "", 0, fallbackErr
@@ -2438,6 +2455,10 @@ func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID stri
2438 return ip, internalPort, nil2455 return ip, internalPort, nil
2439 }2456 }
2440 2457
2458+func (s *Service) resolveContainerEndpoint(ctx context.Context, containerID string) (string, int, error) {
2459+ return s.resolveProbeEndpoint(ctx, containerID, nil)
2460+}
2461+
2441 // waitForReadyByDelay waits using the legacy running+delay strategy.2462 // waitForReadyByDelay waits using the legacy running+delay strategy.
2442 func (s *Service) waitForReadyByDelay(ctx context.Context, containerID string) error {2463 func (s *Service) waitForReadyByDelay(ctx context.Context, containerID string) error {
2443 log := zerowrap.FromCtx(ctx)2464 log := zerowrap.FromCtx(ctx)
M internal/usecase/container/service_readiness_test.go +9 -12
@@ -47,10 +47,11 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T
4747 assert.NoError(t, err)
4848 }
4949
50-func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabel(t *testing.T) {
50+func TestService_WaitForReady_AutoCascadeUsesDefaultHTTPAliveProbeWhenNoHealthcheckAndNoHealthLabel(t *testing.T) {
5151 runtime := mocks.NewMockContainerRuntime(t)
5252 svc := NewService(runtime, nil, nil, nil, Config{
53- ReadinessMode: "auto",
53+ ReadinessMode: "auto",
54+ HTTPProbeTimeout: 50 * time.Millisecond,
5455 })
5556
5657 ctx := testContext()
@@ -68,21 +69,17 @@ func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabe
6869 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
6970 // No Docker healthcheck
7071 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
71- // Cascade resolves container endpoint for TCP probe
72+ // Cascade resolves container endpoint for default HTTP alive probe
7273 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()
7374 // Host port binding resolution — return the same loopback addr so probe hits it
7475 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once()
7576
76- // TCP probe will try to connect — which will fail since the port is closed.
77- // Use a short health timeout so the test doesn't hang.
78- svc.mu.Lock()
79- svc.config.TCPProbeTimeout = 50 * time.Millisecond
80- svc.mu.Unlock()
77+ // Default HTTP alive probe will try to connect — which will fail since the port is closed.
8178
8279 err := svc.waitForReady(ctx, containerID, containerConfig)
83- // Expect TCP probe timeout (no server listening)
80+ // Expect HTTP alive probe timeout (no server listening)
8481 assert.Error(t, err)
85- assert.Contains(t, err.Error(), "TCP probe timeout")
82+ assert.Contains(t, err.Error(), "HTTP alive probe timeout")
8683 }
8784
8885 func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) {
@@ -169,8 +166,8 @@ func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T
169166 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
170167 // No Docker healthcheck
171168 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
172- // Network info fails — can't do TCP probe, fall through to delay
173- runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Once()
169+ // Network info fails for default HTTP alive probe, then again for TCP fallback before delay
170+ runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Twice()
174171 // Delay-mode fallback verification
175172 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
176173
@@ -47,10 +47,11 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T
47 assert.NoError(t, err)47 assert.NoError(t, err)
48 }48 }
49 49
50-func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabel(t *testing.T) {50+func TestService_WaitForReady_AutoCascadeUsesDefaultHTTPAliveProbeWhenNoHealthcheckAndNoHealthLabel(t *testing.T) {
51 runtime := mocks.NewMockContainerRuntime(t)51 runtime := mocks.NewMockContainerRuntime(t)
52 svc := NewService(runtime, nil, nil, nil, Config{52 svc := NewService(runtime, nil, nil, nil, Config{
53- ReadinessMode: "auto",53+ ReadinessMode: "auto",
54+ HTTPProbeTimeout: 50 * time.Millisecond,
54 })55 })
55 56
56 ctx := testContext()57 ctx := testContext()
@@ -68,21 +69,17 @@ func TestService_WaitForReady_AutoCascadeUsesTCPWhenNoHealthcheckAndNoHealthLabe
68 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()69 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
69 // No Docker healthcheck70 // No Docker healthcheck
70 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()71 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
71- // Cascade resolves container endpoint for TCP probe72+ // Cascade resolves container endpoint for default HTTP alive probe
72 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()73 runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return(probeIP, probePort, nil).Once()
73 // Host port binding resolution — return the same loopback addr so probe hits it74 // Host port binding resolution — return the same loopback addr so probe hits it
74 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once()75 runtime.EXPECT().GetContainerPort(mock.Anything, containerID, probePort).Return(probePort, nil).Once()
75 76
76- // TCP probe will try to connect — which will fail since the port is closed.77+ // Default HTTP alive probe will try to connect — which will fail since the port is closed.
77- // Use a short health timeout so the test doesn't hang.
78- svc.mu.Lock()
79- svc.config.TCPProbeTimeout = 50 * time.Millisecond
80- svc.mu.Unlock()
81 78
82 err := svc.waitForReady(ctx, containerID, containerConfig)79 err := svc.waitForReady(ctx, containerID, containerConfig)
83- // Expect TCP probe timeout (no server listening)80+ // Expect HTTP alive probe timeout (no server listening)
84 assert.Error(t, err)81 assert.Error(t, err)
85- assert.Contains(t, err.Error(), "TCP probe timeout")82+ assert.Contains(t, err.Error(), "HTTP alive probe timeout")
86 }83 }
87 84
88 func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) {85 func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *testing.T) {
@@ -169,8 +166,8 @@ func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T
169 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()166 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
170 // No Docker healthcheck167 // No Docker healthcheck
171 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()168 runtime.EXPECT().GetContainerHealthStatus(mock.Anything, containerID).Return("", false, nil).Once()
172- // Network info fails — can't do TCP probe, fall through to delay169+ // Network info fails for default HTTP alive probe, then again for TCP fallback before delay
173- runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Once()170+ runtime.EXPECT().GetContainerNetworkInfo(mock.Anything, containerID).Return("", 0, assert.AnError).Twice()
174 // Delay-mode fallback verification171 // Delay-mode fallback verification
175 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()172 runtime.EXPECT().IsContainerRunning(mock.Anything, containerID).Return(true, nil).Once()
176 173

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close