Docker 顯示健康,Laravel 部署仍未通過就緒檢查
Docker said healthy. Our Laravel deployment still failed readiness.
Laravel 藍綠部署中,Docker 將容器標記為健康,但候選版本未通過就緒檢查,因探測雖連到正確容器,HTTP Host 卻是未獲允許的容器服務名稱。作者讓探測連線繼續指向候選容器,改用部署設定中的應用程式主機名稱,並以 HTTP 200 及包含預期狀態的 JSON 作為切換條件。他們沒有把 Docker 服務名稱加入允許清單,並保留不相關主機名稱必須無法通過的檢查。
The candidate containers started. Nginx accepted its configuration. Docker marked the application healthy. Our blue-green deployment still stopped:
Candidate blue did not become ready; the public upstream was not changed.
Candidate was not activated; the previous HTTP release is still serving.
This was a real failure in our Laravel deployment after we tightened hostname validation. The probe reached the right container and sent the wrong HTTP hostname.
The working release stayed online because the probe ran before the traffic switch. Here is how we diagnosed the mismatch without disabling the protection.
What “healthy” meant in this deployment
Our PHP-FPM container healthcheck validates the FPM configuration and opens its local TCP listener. It does not send a request through Nginx into Laravel.
The deployment helper makes a separate request to /health through the candidate web container. It requires HTTP 200 and JSON containing "status":"ok" before switching the gateway.
Those checks were measuring different things:
| Check | What it established |
|---|---|
| FPM configuration and local socket | The configured process could accept a connection |
| Candidate HTTP readiness | The candidate web/application path accepted this request |
| Public HTTPS check | The externally exposed path worked for the checked URL |
This is our configuration, not a universal definition of Docker health. A Docker healthcheck can make an HTTP request too. Its result only tells you about the test you configured.
The URL contained a hidden assumption
Our candidate web server was reachable as web-blue on its private Docker network. A diagnostic request from that network looked like this:
# Run from a container on the candidate's Docker network.
curl --noproxy '*' --connect-timeout 2 --max-time 5 \
-sS -o /dev/null -w '%{http_code}\n' \
http://web-blue/health
The connection target was correct. But curl also sent Host: web-blue.
After hostname protection was enabled, Laravel rejected that name. Docker DNS resolving a service does not make the service name an allowed application hostname.
There were two separate values to inspect:
Connection destination: web-blue:80
Application hostname: app.example.test
app.example.test is a placeholder here. In our deployment, the expected hostname comes from the server-controlled APP_URL.
Nginx may also use Host to choose the virtual server, so the same class of mistake can happen before PHP is involved. See Nginx request processing. In our application, the allowlist also existed in Laravel; bypassing the public proxy did not bypass that check.
Change the Host, not the destination
The corrected private HTTP probe still connected to the candidate:
curl --noproxy '*' --connect-timeout 2 --max-time 5 \
-sS -o /dev/null -w '%{http_code}\n' \
-H 'Host: app.example.test' \
http://web-blue/health
Changing the URL to the public domain alone would have been a bad test: public DNS could send the request to the old release. We needed evidence about the new slot.
An equivalent curl diagnostic uses --connect-to:
curl --noproxy '*' --connect-timeout 2 --max-time 5 \
-sS --connect-to app.example.test:80:web-blue:80 \
http://app.example.test/health
The URL supplies the application identity while the connection override selects the candidate.
These examples use HTTP on a private hop. If your candidate terminates HTTPS, use the intended hostname in the HTTPS URL and connect it to the candidate's TLS port. Overriding only Host does not change the TLS SNI or the certificate hostname being verified. curl documents that distinction. The TLS variant is documentation-based guidance; the reproduction below tests HTTP.
A small reproduction, without Laravel
To isolate the cause, we used this Nginx fixture:
server {
listen 80 default_server;
server_name app.example.test;
if ($http_host !~* "^app\.example\.test(?::[0-9]{1,5})?$") {
return 421;
}
location = /health {
default_type application/json;
return 200 '{"status":"ok","version":"candidate"}';
}
}
This is deliberately a synthetic HTTP boundary. Its container healthcheck runs nginx -t; it is not a reproduction of PHP-FPM or the full Laravel application.
The local checks produced:
| Request/check | Result |
|---|---|
| Synthetic container healthcheck | Healthy |
Host web-blue
|
HTTP 421 |
Host app.example.test
|
HTTP 200 and expected JSON |
Host app.example.test:443 over the HTTP fixture |
HTTP 200 and expected JSON |
Host other.example.test
|
HTTP 421 |
--connect-to with the canonical hostname |
HTTP 200 and expected JSON |
The explicit :443 case checks Host parsing only; it is not a TLS test. These results were reproduced locally on November 3, 2026, with an isolated Nginx container. No production settings were changed.
The release gate needed more than the right header
The curl status-only command is useful for diagnosis. Our activation gate also parses the response body. A generic Nginx welcome page returning 200 must not count as a ready Laravel application.
We kept these requirements in the helper:
- Connect to one of the two known candidate services.
- Derive Host from validated deployment configuration, including an explicit port when configured.
- Require HTTP 200 and the expected JSON health status.
- Do not follow redirects: a redirect might land on the active release or a login page.
- Bound each request and the overall readiness wait.
- Supply required staging Basic Auth from server-side configuration, without logging credentials.
A fast 421 was the useful clue in this case. A 401, timeout, connection refusal or 502 needs its own investigation. Increasing the timeout cannot fix a request that is deterministically rejected for its Host.
We did not add the Docker service names to the public allowlist. We also retained a negative check: an unrelated hostname still had to fail after the canonical hostname started passing.
The failed deployment was the safer result
The order of operations mattered:
Start candidate → check candidate → switch gateway → verify selected slot
When readiness failed, the gateway still pointed at the working slot. Fixing the probe allowed the candidate through the same gate; it did not remove the gate.
That does not prove every release is safe. Database migrations must remain compatible with both slots, and a successful /health does not exercise login, checkout, queue processing or the public CDN path.
The change we needed was small: preserve the candidate destination and send the configured application hostname. If your containers are healthy but a release is stuck, inspect the exact request your deployment helper sends before loosening the application policy.
Adapted from our original deployment incident write-up.
Disclosure: this adaptation was prepared with an AI coding agent from the project's incident notes, deployment code and existing article. The HTTP reproduction was rerun locally; this is not an independently measured production availability claim.
來源:DEV Community · dev.to