mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-09 13:39:26 +00:00
fix(docker): wait for gateway readiness (#4806)
* fix(docker): verify gateway startup readiness * fix(docker): clarify compose wait requirement
This commit is contained in:
parent
c542185a7f
commit
5d520e44a8
@ -41,6 +41,8 @@ fonts, images, audio, and video uncompressed at the proxy layer.
|
||||
Both compose files publish that entry as `"${BIND_HOST:-127.0.0.1}:${PORT:-2026}:2026"`
|
||||
— **loopback by default**, matching the README's documented deployment model. A bare
|
||||
`"${PORT}:2026"` binds `0.0.0.0`, which does not.
|
||||
The root `PORT` value is Docker ingress configuration only; local orchestration pins
|
||||
Next.js to `3000` so loading `.env` cannot make `make dev` wait on the wrong port.
|
||||
Nginx itself listens `default_server` on IPv4+IPv6 and the
|
||||
Gateway binds `0.0.0.0:8001` inside the container on purpose — both are container-
|
||||
internal; the published nginx port is the entire external surface, and the Gateway's
|
||||
@ -126,6 +128,12 @@ make up / down # Build/stop the production Docker stack (browser at localhost:
|
||||
make docker-start / docker-stop / docker-logs # Docker development environment
|
||||
```
|
||||
|
||||
Production startup uses the image's pre-built Python environment with `uv run
|
||||
--no-sync`, gives the Gateway a real `/health` probe, and makes `make up` wait
|
||||
for that probe before printing its success banner. A readiness failure must
|
||||
surface Compose status and recent Gateway logs instead of claiming the stack is
|
||||
running.
|
||||
|
||||
Docker log and restart commands resolve `DEER_FLOW_ROOT` from the current
|
||||
checkout before invoking Compose, matching the start and stop commands.
|
||||
|
||||
|
||||
10
README.md
10
README.md
@ -286,6 +286,12 @@ make down # Stop and remove containers
|
||||
|
||||
Access: http://localhost:2026
|
||||
|
||||
`make up` waits for the Gateway `/health` endpoint before reporting success.
|
||||
If the Gateway does not become healthy within the startup window, deployment
|
||||
exits non-zero and prints the container status plus recent Gateway logs. The
|
||||
production image starts from its already-built environment and never resolves
|
||||
or installs Python dependencies at container startup.
|
||||
|
||||
For persistent deployments, configure `database.backend` as `sqlite` or
|
||||
`postgres`. The selected backend is shared by the LangGraph checkpointer,
|
||||
LangGraph Store, and DeerFlow application data. The deprecated `checkpointer`
|
||||
@ -347,6 +353,10 @@ On Windows, run the local development flow from Git Bash. Native `cmd.exe` and P
|
||||
|
||||
6. **Access**: http://localhost:2026
|
||||
|
||||
Local services always use their internal ports (`8001`, `3000`, and `2026`).
|
||||
The root `.env` variable `PORT` configures only the published Docker ingress;
|
||||
it does not change the Next.js port used by `make dev`.
|
||||
|
||||
#### Startup Modes
|
||||
|
||||
DeerFlow runs the agent runtime inside the Gateway API. Development mode enables hot-reload; production mode uses a pre-built frontend.
|
||||
|
||||
112
backend/tests/test_gateway_startup.py
Normal file
112
backend/tests/test_gateway_startup.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""Regression coverage for local and Docker Gateway startup contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _read(path: str) -> str:
|
||||
return (REPO_ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _deploy_fixture(tmp_path: Path, *, docker_script: str) -> tuple[Path, dict[str, str]]:
|
||||
worktree = tmp_path / "repo"
|
||||
shutil.copytree(REPO_ROOT / "scripts", worktree / "scripts")
|
||||
shutil.copytree(REPO_ROOT / "docker", worktree / "docker")
|
||||
(worktree / "backend").mkdir()
|
||||
(worktree / "config.yaml").write_text("sandbox:\n use: deerflow.sandbox:LocalSandboxProvider\n", encoding="utf-8")
|
||||
(worktree / "extensions_config.json").write_text('{"mcpServers":{},"skills":{}}\n', encoding="utf-8")
|
||||
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
docker = bin_dir / "docker"
|
||||
docker.write_text(docker_script, encoding="utf-8")
|
||||
docker.chmod(0o755)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
|
||||
env["BETTER_AUTH_SECRET"] = "test-better-auth-secret"
|
||||
env["DEER_FLOW_INTERNAL_AUTH_TOKEN"] = "test-internal-auth-token"
|
||||
return worktree, env
|
||||
|
||||
|
||||
def test_gateway_runtime_commands_never_sync_dependencies() -> None:
|
||||
"""A built environment must not resolve or install packages at process start."""
|
||||
compose = _read("docker/docker-compose.yaml")
|
||||
serve = _read("scripts/serve.sh")
|
||||
|
||||
assert "PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app" in compose
|
||||
assert "PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app" in serve
|
||||
|
||||
|
||||
def test_local_frontend_ignores_public_docker_port() -> None:
|
||||
"""Root PORT config is public Docker ingress, not the local Next.js port."""
|
||||
serve = _read("scripts/serve.sh")
|
||||
|
||||
assert "env PORT=3000" in serve
|
||||
assert 'run_service "Frontend"' in serve
|
||||
assert "3000 120" in serve
|
||||
|
||||
|
||||
def test_production_gateway_has_a_real_readiness_probe() -> None:
|
||||
"""Compose readiness must exercise the Gateway HTTP endpoint."""
|
||||
compose = _read("docker/docker-compose.yaml")
|
||||
|
||||
assert "healthcheck:" in compose
|
||||
assert "http://127.0.0.1:8001/health" in compose
|
||||
assert "gateway:\n condition: service_healthy" in compose
|
||||
|
||||
|
||||
def test_deploy_waits_for_gateway_readiness_before_success(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "docker-args.txt"
|
||||
worktree, env = _deploy_fixture(
|
||||
tmp_path,
|
||||
docker_script=('#!/usr/bin/env sh\nprintf "%s\\n" "$@" > "$CAPTURE_DOCKER_ARGS"\nexit 0\n'),
|
||||
)
|
||||
env["CAPTURE_DOCKER_ARGS"] = str(capture)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(worktree / "scripts" / "deploy.sh"), "start"],
|
||||
cwd=worktree,
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
args = capture.read_text(encoding="utf-8").splitlines()
|
||||
assert "--wait" in args
|
||||
assert "--wait-timeout" in args
|
||||
assert "DeerFlow is running!" in result.stdout
|
||||
|
||||
|
||||
def test_deploy_failure_prints_gateway_diagnostics_and_never_claims_success(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "docker-calls.txt"
|
||||
worktree, env = _deploy_fixture(
|
||||
tmp_path,
|
||||
docker_script=('#!/usr/bin/env sh\nprintf "%s\\n" "$*" >> "$CAPTURE_DOCKER_CALLS"\ncase " $* " in\n *" up "*) exit 1 ;;\n *) exit 0 ;;\nesac\n'),
|
||||
)
|
||||
env["CAPTURE_DOCKER_CALLS"] = str(capture)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", str(worktree / "scripts" / "deploy.sh"), "start"],
|
||||
cwd=worktree,
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "DeerFlow is running!" not in result.stdout
|
||||
assert "DeerFlow services failed to become ready" in result.stderr
|
||||
assert "supports `docker compose up --wait`" in result.stderr
|
||||
calls = capture.read_text(encoding="utf-8")
|
||||
assert any(call.endswith(" ps") for call in calls.splitlines())
|
||||
assert " logs --no-color --tail 100 gateway" in calls
|
||||
@ -120,7 +120,7 @@ def test_official_entrypoints_route_pnpm_through_shared_runner():
|
||||
assert "PNPM = $(PYTHON) ../scripts/pnpm.py" in frontend_makefile
|
||||
assert '"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" install --silent' in serve_script
|
||||
assert 'DEERFLOW_PNPM_RUNNER="$REPO_ROOT/scripts/pnpm.py"' in serve_script
|
||||
assert 'FRONTEND_CMD=\'"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev\'' in serve_script
|
||||
assert 'FRONTEND_CMD=\'env PORT=3000 "$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev\'' in serve_script
|
||||
assert '"\\$DEERFLOW_PNPM_RUNNER\\" run preview"' in serve_script
|
||||
assert 'Path(__file__).resolve().with_name("pnpm.py")' in doctor_script
|
||||
assert 'project_root / "scripts" / "pnpm.py"' in support_bundle_script
|
||||
|
||||
@ -58,8 +58,10 @@ services:
|
||||
sh -c "cp /etc/nginx/nginx.conf.template /etc/nginx/nginx.conf
|
||||
&& nginx -g 'daemon off;'"
|
||||
depends_on:
|
||||
- frontend
|
||||
- gateway
|
||||
frontend:
|
||||
condition: service_started
|
||||
gateway:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- deer-flow
|
||||
restart: unless-stopped
|
||||
@ -144,6 +146,12 @@ services:
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; response = urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=3); raise SystemExit(0 if response.status == 200 else 1)"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 20s
|
||||
networks:
|
||||
- deer-flow
|
||||
restart: unless-stopped
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
## Service Startup Contracts
|
||||
|
||||
The root `PORT` value configures Docker's published nginx ingress only; local
|
||||
orchestration pins Next.js to `3000`. Runtime commands launch from the already
|
||||
synchronized environment with `uv run --no-sync`. Production Compose probes
|
||||
Gateway `/health`, and `deploy.sh` waits for all services before reporting
|
||||
success; failures print Compose status and recent Gateway logs.
|
||||
|
||||
## Backend Static Analysis Commands
|
||||
|
||||
The root `detect-thread-boundaries` target statically inventories execution
|
||||
|
||||
@ -386,17 +386,34 @@ echo ""
|
||||
|
||||
# ── Start / Up ───────────────────────────────────────────────────────────────
|
||||
|
||||
report_startup_failure() {
|
||||
echo -e "${RED}✗ DeerFlow services failed to become ready.${NC}" >&2
|
||||
echo ' If Docker Compose reports "unknown flag: --wait", upgrade to a version that' >&2
|
||||
echo ' supports `docker compose up --wait`.' >&2
|
||||
echo " Container status:" >&2
|
||||
"${COMPOSE_CMD[@]}" ps >&2 || true
|
||||
echo "" >&2
|
||||
echo " Recent Gateway logs:" >&2
|
||||
"${COMPOSE_CMD[@]}" logs --no-color --tail 100 gateway >&2 || true
|
||||
}
|
||||
|
||||
if [ "$CMD" = "start" ]; then
|
||||
echo "Starting containers (no rebuild)..."
|
||||
echo ""
|
||||
# shellcheck disable=SC2086
|
||||
"${COMPOSE_CMD[@]}" up -d --remove-orphans $services
|
||||
if ! "${COMPOSE_CMD[@]}" up -d --remove-orphans --wait --wait-timeout 180 $services; then
|
||||
report_startup_failure
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Default: build + start
|
||||
echo "Building images and starting containers..."
|
||||
echo ""
|
||||
# shellcheck disable=SC2086
|
||||
"${COMPOSE_CMD[@]}" up --build -d --remove-orphans $services
|
||||
if ! "${COMPOSE_CMD[@]}" up --build -d --remove-orphans --wait --wait-timeout 180 $services; then
|
||||
report_startup_failure
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
@ -304,9 +304,9 @@ export DEERFLOW_PNPM_PYTHON DEERFLOW_PNPM_RUNNER
|
||||
|
||||
# Frontend command
|
||||
if $DEV_MODE; then
|
||||
FRONTEND_CMD='"$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev'
|
||||
FRONTEND_CMD='env PORT=3000 "$DEERFLOW_PNPM_PYTHON" "$DEERFLOW_PNPM_RUNNER" run dev'
|
||||
else
|
||||
FRONTEND_CMD="env BETTER_AUTH_SECRET=$($DEERFLOW_PNPM_PYTHON -c 'import secrets; print(secrets.token_hex(16))') \"\$DEERFLOW_PNPM_PYTHON\" \"\$DEERFLOW_PNPM_RUNNER\" run preview"
|
||||
FRONTEND_CMD="env PORT=3000 BETTER_AUTH_SECRET=$($DEERFLOW_PNPM_PYTHON -c 'import secrets; print(secrets.token_hex(16))') \"\$DEERFLOW_PNPM_PYTHON\" \"\$DEERFLOW_PNPM_RUNNER\" run preview"
|
||||
fi
|
||||
|
||||
# Runtime path defaults. Local `make dev` launches Gateway from `backend/`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user