detached a8e226f

fix: use HTTP alive probe as default readiness check instead of TCP

TCP-only readiness allowed premature traffic cutover — the new container
accepted TCP connections before the app was actually serving HTTP, causing
502/500s during zero-downtime deploys.

The readiness cascade now defaults to an HTTP GET on / (any HTTP response
= ready) before falling back to TCP. Also fixes log streaming context
cancellation by detaching from the deploy request context.
commit
a8e226f065704d1e49d53346716f4f54a04239b7
author
brice · · 2026-03-12 06:52 UTC
parent
36dfce1
signature
unsigned
2 files · +67 -5
M internal/usecase/container/readiness.go +33 -0
@@ -135,3 +135,36 @@ func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
135135 }
136136 return err
137137 }
138+
139+// httpAliveProbe performs HTTP GET requests to url, retrying every 1s until
140+// any HTTP response is received or timeout. Unlike httpProbe, ANY status code
141+// counts as success -- the goal is to verify the HTTP server is accepting and
142+// processing requests, not that a specific endpoint is healthy.
143+// Used as the default readiness check for web containers.
144+func httpAliveProbe(ctx context.Context, url string, timeout time.Duration) error {
145+ client := &http.Client{
146+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
147+ return http.ErrUseLastResponse
148+ },
149+ }
150+ deadline := time.Now().Add(timeout)
151+ err := probeLoop(ctx, deadline, time.Second, 2*time.Second, func(attemptCtx context.Context) (bool, error) {
152+ req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, url, nil)
153+ if reqErr != nil {
154+ return false, reqErr
155+ }
156+
157+ resp, err := client.Do(req)
158+ if err != nil {
159+ // Connection refused, timeout, etc. -- not ready yet.
160+ return false, nil
161+ }
162+ resp.Body.Close()
163+ // Any HTTP response means the server is alive and processing.
164+ return true, nil
165+ })
166+ if errors.Is(err, errProbeTimeout) {
167+ return fmt.Errorf("HTTP alive probe timeout after %s: no HTTP response from %s", timeout, url)
168+ }
169+ return err
170+}
@@ -135,3 +135,36 @@ func httpProbe(ctx context.Context, url string, timeout time.Duration) error {
135 }135 }
136 return err136 return err
137 }137 }
138+
139+// httpAliveProbe performs HTTP GET requests to url, retrying every 1s until
140+// any HTTP response is received or timeout. Unlike httpProbe, ANY status code
141+// counts as success -- the goal is to verify the HTTP server is accepting and
142+// processing requests, not that a specific endpoint is healthy.
143+// Used as the default readiness check for web containers.
144+func httpAliveProbe(ctx context.Context, url string, timeout time.Duration) error {
145+ client := &http.Client{
146+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
147+ return http.ErrUseLastResponse
148+ },
149+ }
150+ deadline := time.Now().Add(timeout)
151+ err := probeLoop(ctx, deadline, time.Second, 2*time.Second, func(attemptCtx context.Context) (bool, error) {
152+ req, reqErr := http.NewRequestWithContext(attemptCtx, http.MethodGet, url, nil)
153+ if reqErr != nil {
154+ return false, reqErr
155+ }
156+
157+ resp, err := client.Do(req)
158+ if err != nil {
159+ // Connection refused, timeout, etc. -- not ready yet.
160+ return false, nil
161+ }
162+ resp.Body.Close()
163+ // Any HTTP response means the server is alive and processing.
164+ return true, nil
165+ })
166+ if errors.Is(err, errProbeTimeout) {
167+ return fmt.Errorf("HTTP alive probe timeout after %s: no HTTP response from %s", timeout, url)
168+ }
169+ return err
170+}
M internal/usecase/container/service.go +34 -5
@@ -1330,7 +1330,7 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
13301330 return
13311331 }
13321332
1333- if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
1333+ if err := s.logWriter.StartLogging(context.WithoutCancel(ctx), containerID, domainName, logStream); err != nil {
13341334 log.Warn().Err(err).Msg("failed to start container log collection")
13351335 logStream.Close()
13361336 }
@@ -2297,8 +2297,9 @@ func (s *Service) waitForReady(ctx context.Context, containerID string, containe
22972297 // readinessCascade auto-detects the strongest available readiness signal:
22982298 // 1. Docker healthcheck (if present) → wait for healthy status
22992299 // 2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx
2300-// 3. TCP probe (if port info available) → connect until accepted
2301-// 4. Delay fallback (last resort) → waitForReadyByDelay
2300+// 3. Default HTTP probe (GET / on exposed port) → wait for 2xx/3xx
2301+// 4. TCP probe (if port info available) → connect until accepted
2302+// 5. Delay fallback (last resort) → waitForReadyByDelay
23022303 func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error {
23032304 log := zerowrap.FromCtx(ctx)
23042305
@@ -2317,12 +2318,17 @@ func (s *Service) readinessCascade(ctx context.Context, containerID string, cont
23172318 return probeErr
23182319 }
23192320
2320- // 3. TCP probe (if port info available)
2321+ // 3. Default HTTP probe on root path (if port info available)
2322+ if probed, probeErr := s.tryDefaultHTTPProbe(ctx, containerID, containerConfig, cfg); probed {
2323+ return probeErr
2324+ }
2325+
2326+ // 4. TCP probe (if port info available)
23212327 if probed, probeErr := s.tryTCPProbe(ctx, containerID, containerConfig, cfg); probed {
23222328 return probeErr
23232329 }
23242330
2325- // 4. Delay fallback
2331+ // 5. Delay fallback
23262332 log.Info().Msg("readiness cascade: using delay fallback")
23272333 return s.waitForReadyByDelay(ctx, containerID)
23282334 }
@@ -2352,6 +2358,29 @@ func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containe
23522358 return true, httpProbe(ctx, url, timeout)
23532359 }
23542360
2361+// tryDefaultHTTPProbe attempts an HTTP GET on "/" using the container's
2362+// exposed port. This is the default readiness check for web containers —
2363+// it verifies the app is actually serving HTTP, not just accepting TCP.
2364+// Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2365+func (s *Service) tryDefaultHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
2366+ log := zerowrap.FromCtx(ctx)
2367+ if containerConfig == nil || len(containerConfig.Ports) == 0 {
2368+ return false, nil
2369+ }
2370+ ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)
2371+ if probeErr != nil || ip == "" || port <= 0 {
2372+ log.Debug().Err(probeErr).Msg("readiness cascade: default HTTP probe skipped, could not resolve container endpoint")
2373+ return false, nil
2374+ }
2375+ url := fmt.Sprintf("http://%s:%d/", ip, port)
2376+ timeout := cfg.HTTPProbeTimeout
2377+ if timeout == 0 {
2378+ timeout = 60 * time.Second
2379+ }
2380+ log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using default HTTP alive probe")
2381+ return true, httpAliveProbe(ctx, url, timeout)
2382+}
2383+
23552384 // tryTCPProbe attempts a TCP probe if port info is available.
23562385 // Returns (true, err) if the probe was attempted, (false, nil) if skipped.
23572386 func (s *Service) tryTCPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
@@ -1330,7 +1330,7 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
1330 return1330 return
1331 }1331 }
1332 1332
1333- if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {1333+ if err := s.logWriter.StartLogging(context.WithoutCancel(ctx), containerID, domainName, logStream); err != nil {
1334 log.Warn().Err(err).Msg("failed to start container log collection")1334 log.Warn().Err(err).Msg("failed to start container log collection")
1335 logStream.Close()1335 logStream.Close()
1336 }1336 }
@@ -2297,8 +2297,9 @@ func (s *Service) waitForReady(ctx context.Context, containerID string, containe
2297 // readinessCascade auto-detects the strongest available readiness signal:2297 // readinessCascade auto-detects the strongest available readiness signal:
2298 // 1. Docker healthcheck (if present) → wait for healthy status2298 // 1. Docker healthcheck (if present) → wait for healthy status
2299 // 2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx2299 // 2. HTTP probe (if gordon.health label set) → GET until 2xx/3xx
2300-// 3. TCP probe (if port info available) → connect until accepted2300+// 3. Default HTTP probe (GET / on exposed port) → wait for 2xx/3xx
2301-// 4. Delay fallback (last resort) → waitForReadyByDelay2301+// 4. TCP probe (if port info available) → connect until accepted
2302+// 5. Delay fallback (last resort) → waitForReadyByDelay
2302 func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error {2303 func (s *Service) readinessCascade(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) error {
2303 log := zerowrap.FromCtx(ctx)2304 log := zerowrap.FromCtx(ctx)
2304 2305
@@ -2317,12 +2318,17 @@ func (s *Service) readinessCascade(ctx context.Context, containerID string, cont
2317 return probeErr2318 return probeErr
2318 }2319 }
2319 2320
2320- // 3. TCP probe (if port info available)2321+ // 3. Default HTTP probe on root path (if port info available)
2322+ if probed, probeErr := s.tryDefaultHTTPProbe(ctx, containerID, containerConfig, cfg); probed {
2323+ return probeErr
2324+ }
2325+
2326+ // 4. TCP probe (if port info available)
2321 if probed, probeErr := s.tryTCPProbe(ctx, containerID, containerConfig, cfg); probed {2327 if probed, probeErr := s.tryTCPProbe(ctx, containerID, containerConfig, cfg); probed {
2322 return probeErr2328 return probeErr
2323 }2329 }
2324 2330
2325- // 4. Delay fallback2331+ // 5. Delay fallback
2326 log.Info().Msg("readiness cascade: using delay fallback")2332 log.Info().Msg("readiness cascade: using delay fallback")
2327 return s.waitForReadyByDelay(ctx, containerID)2333 return s.waitForReadyByDelay(ctx, containerID)
2328 }2334 }
@@ -2352,6 +2358,29 @@ func (s *Service) tryHTTPProbe(ctx context.Context, containerID string, containe
2352 return true, httpProbe(ctx, url, timeout)2358 return true, httpProbe(ctx, url, timeout)
2353 }2359 }
2354 2360
2361+// tryDefaultHTTPProbe attempts an HTTP GET on "/" using the container's
2362+// exposed port. This is the default readiness check for web containers —
2363+// it verifies the app is actually serving HTTP, not just accepting TCP.
2364+// Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2365+func (s *Service) tryDefaultHTTPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {
2366+ log := zerowrap.FromCtx(ctx)
2367+ if containerConfig == nil || len(containerConfig.Ports) == 0 {
2368+ return false, nil
2369+ }
2370+ ip, port, probeErr := s.resolveContainerEndpoint(ctx, containerID)
2371+ if probeErr != nil || ip == "" || port <= 0 {
2372+ log.Debug().Err(probeErr).Msg("readiness cascade: default HTTP probe skipped, could not resolve container endpoint")
2373+ return false, nil
2374+ }
2375+ url := fmt.Sprintf("http://%s:%d/", ip, port)
2376+ timeout := cfg.HTTPProbeTimeout
2377+ if timeout == 0 {
2378+ timeout = 60 * time.Second
2379+ }
2380+ log.Info().Str("url", url).Dur("timeout", timeout).Msg("readiness cascade: using default HTTP alive probe")
2381+ return true, httpAliveProbe(ctx, url, timeout)
2382+}
2383+
2355 // tryTCPProbe attempts a TCP probe if port info is available.2384 // tryTCPProbe attempts a TCP probe if port info is available.
2356 // Returns (true, err) if the probe was attempted, (false, nil) if skipped.2385 // Returns (true, err) if the probe was attempted, (false, nil) if skipped.
2357 func (s *Service) tryTCPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {2386 func (s *Service) tryTCPProbe(ctx context.Context, containerID string, containerConfig *domain.ContainerConfig, cfg Config) (bool, error) {

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