179 lines
6.9 KiB
Python
179 lines
6.9 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="*",
|
|
# Request line + headers ceiling. h11's default is 16 KB, and the GET
|
|
# form of the image search (GET /api/search/image-vector) carries a
|
|
# 1024-float vector in the query string: 5.5 KB as base64, 8-10 KB
|
|
# comma-separated - which with a browser's cookies and headers can
|
|
# cross 16 KB and be dropped before the app sees it. 64 KB keeps that
|
|
# working and is still a tight bound against header abuse. (The
|
|
# keyword is uvicorn's; a rename would fail loudly here at boot.)
|
|
h11_max_incomplete_event_size=64 * 1024,
|
|
)
|
|
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.
|
|
|
|
Probes "/" rather than /api/health, and the distinction matters. This is a
|
|
LIVENESS check: the only question it may ask is "is this process still
|
|
serving HTTP", because the platform's answer to "no" is to stop routing
|
|
traffic to the container.
|
|
|
|
/api/health is a READINESS report - it dials Postgres and Ollama to say
|
|
whether they are reachable. Using it here couples the container's existence
|
|
to its dependencies: an unreachable database made /api/health block for the
|
|
OS TCP timeout, the check timed out, the container was marked unhealthy, and
|
|
a service that was running perfectly well returned Bad Gateway on every
|
|
route - including the ones that never touch the database. "/" is served from
|
|
memory and does no I/O at all, so it can only fail if the app really is gone.
|
|
"""
|
|
ports = configured_ports()
|
|
for port in ports:
|
|
try:
|
|
with urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=8) as resp:
|
|
if 200 <= resp.status < 500:
|
|
return 0
|
|
except urllib.error.HTTPError as exc:
|
|
# An HTTP status - even 404 - means something is listening and
|
|
# routing. That is exactly what liveness asks.
|
|
if exc.code < 500:
|
|
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())
|