Dokploy routes the domain to port 3000, but the container only bound 8000, so the proxy had nothing to talk to and the domain returned 502 with a perfectly healthy process behind it. The frontend image already solved this by answering on both 80 and 3000 (`listen 80; listen 3000;` in nginx.conf). Do the same here rather than swap one guess for another: 3000 is what the platform routes to, and 8000 is what the README, the vite dev proxy and docker-compose all target, so binding both means the container works whichever one it is pointed at. uvicorn's CLI takes a single --port, but Server.run() accepts pre-bound sockets, so serve.py binds each port and hands the list to one uvicorn - no extra worker or second process to supervise. PORT still pins a single port for anyone who wants one; PORTS changes the pair. A port that cannot be bound is logged and skipped rather than being fatal, since losing one of the two should not take down a service the platform only routes to on the other. It exits non-zero only when nothing is listening at all, so a genuinely dead container is still reported as failed. The healthcheck moves into the same file and passes if either port answers, which keeps it from drifting out of sync with what is actually bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
155 lines
5.3 KiB
Python
155 lines
5.3 KiB
Python
"""
|
|
Container entry point: serve the API on every port the platform might route to.
|
|
|
|
The frontend image answers on both 80 and 3000 (``listen 80; listen 3000;`` in
|
|
nginx.conf) so it works whatever port the deployment is configured to hit. This
|
|
does the same for the API, which otherwise has to guess: Dokploy routes a domain
|
|
to one container port, this project's own README, vite.config.js proxy and
|
|
docker-compose mapping all say 8000, and picking wrong produces a 502 with a
|
|
perfectly healthy container behind it.
|
|
|
|
uvicorn's CLI binds a single ``--port``, but ``Server.run()`` accepts a list of
|
|
already-bound sockets, so one process can listen on several. That is what this
|
|
does - no extra worker, no second process to supervise.
|
|
|
|
Usage::
|
|
|
|
python serve.py # binds PORTS (default "3000,8000")
|
|
PORT=8080 python serve.py # binds only 8080
|
|
python serve.py --healthcheck # probe mode, used by HEALTHCHECK
|
|
|
|
Run it directly rather than through ``uvicorn app.main:app`` when you need the
|
|
multi-port behaviour; the plain uvicorn command still works for local
|
|
development where one port is all anyone wants.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import socket
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
logger = logging.getLogger("serve")
|
|
|
|
# Both of the ports this project actually uses anywhere: 3000 because that is
|
|
# what the platform routes a domain to by default, 8000 because the README,
|
|
# the vite dev proxy and docker-compose all target it.
|
|
DEFAULT_PORTS = "3000,8000"
|
|
|
|
HOST = os.getenv("HOST", "0.0.0.0")
|
|
|
|
|
|
def configured_ports() -> list[int]:
|
|
"""
|
|
The ports to bind, in order.
|
|
|
|
``PORT`` wins over ``PORTS`` and is treated as an explicit single choice:
|
|
setting it means "serve here", not "serve here as well". ``PORTS`` takes a
|
|
comma-separated list for the both-at-once case.
|
|
"""
|
|
raw = os.getenv("PORT") or os.getenv("PORTS") or DEFAULT_PORTS
|
|
|
|
ports: list[int] = []
|
|
for chunk in raw.split(","):
|
|
chunk = chunk.strip()
|
|
if not chunk:
|
|
continue
|
|
try:
|
|
port = int(chunk)
|
|
except ValueError:
|
|
logger.warning("Ignoring non-numeric port %r in %r", chunk, raw)
|
|
continue
|
|
if not 1 <= port <= 65535:
|
|
logger.warning("Ignoring out-of-range port %d", port)
|
|
continue
|
|
if port not in ports: # binding the same port twice would fail
|
|
ports.append(port)
|
|
|
|
if not ports:
|
|
logger.error("No usable port in %r - falling back to %s", raw, DEFAULT_PORTS)
|
|
return [int(p) for p in DEFAULT_PORTS.split(",")]
|
|
return ports
|
|
|
|
|
|
def _bind(port: int) -> socket.socket | None:
|
|
"""Bind one listening socket, or return None with the reason logged."""
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
try:
|
|
sock.bind((HOST, port))
|
|
except OSError as exc:
|
|
# Not fatal on its own. If the platform only routes to one of these,
|
|
# losing the other (already in use, not permitted) should not take the
|
|
# service down - _run() fails only when nothing at all is listening.
|
|
logger.warning("Could not bind %s:%d - %s", HOST, port, exc)
|
|
sock.close()
|
|
return None
|
|
sock.listen(2048)
|
|
sock.set_inheritable(True)
|
|
return sock
|
|
|
|
|
|
def _run() -> int:
|
|
import uvicorn
|
|
|
|
ports = configured_ports()
|
|
sockets = [s for s in (_bind(p) for p in ports) if s is not None]
|
|
|
|
if not sockets:
|
|
logger.error(
|
|
"Could not bind any of %s. The API is not listening; exiting so the "
|
|
"platform restarts or reports the container as failed.",
|
|
", ".join(str(p) for p in ports),
|
|
)
|
|
return 1
|
|
|
|
bound = [s.getsockname()[1] for s in sockets]
|
|
logger.info("Serving on %s port(s): %s", HOST, ", ".join(str(p) for p in bound))
|
|
|
|
config = uvicorn.Config(
|
|
"app.main:app",
|
|
# proxy_headers/forwarded_allow_ips: this container is never reached
|
|
# directly - nginx, and Dokploy's Traefik, sit in front of it. Without
|
|
# them uvicorn ignores X-Forwarded-Proto and reports every request as
|
|
# plain http, so any redirect or generated absolute URL would downgrade
|
|
# an https request.
|
|
proxy_headers=True,
|
|
forwarded_allow_ips="*",
|
|
)
|
|
uvicorn.Server(config).run(sockets=sockets)
|
|
return 0
|
|
|
|
|
|
def _healthcheck() -> int:
|
|
"""
|
|
Probe the API, passing if ANY configured port answers.
|
|
|
|
Shares configured_ports() with the server so the check cannot drift from
|
|
what is actually bound - the reason this lives here rather than being a
|
|
python -c one-liner in the Dockerfile.
|
|
"""
|
|
ports = configured_ports()
|
|
for port in ports:
|
|
try:
|
|
with urllib.request.urlopen(
|
|
f"http://127.0.0.1:{port}/api/health", timeout=8
|
|
) as resp:
|
|
if 200 <= resp.status < 400:
|
|
return 0
|
|
except (urllib.error.URLError, OSError, ValueError):
|
|
continue
|
|
print(
|
|
f"health: no response from any of {', '.join(str(p) for p in ports)}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
if "--healthcheck" in sys.argv:
|
|
sys.exit(_healthcheck())
|
|
sys.exit(_run())
|