From 8f2455026da139914c2ec724600ae40d5bcbbc7b Mon Sep 17 00:00:00 2001 From: Ruben Fischer Date: Tue, 21 Apr 2026 12:22:49 +0200 Subject: [PATCH] fixed coolify health checks --- DEPLOYMENT.md | 2 +- Dockerfile | 2 +- docker-compose.coolify.yml | 10 +++++++++- docker-compose.simple.yml | 10 +++++++++- docker-compose.ssl.yml | 2 +- docker-compose.yml | 2 +- src/services/scheduler_runner.py | 19 +++++++++++++++++++ src/web/app.py | 11 +++++++++++ 8 files changed, 52 insertions(+), 6 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index fd04982..2638557 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -325,7 +325,7 @@ docker compose restart # health-check.sh erstellen cat > health-check.sh << 'EOF' #!/bin/bash -if curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/login | grep -q "200"; then +if curl -s -o /dev/null -w "%{http_code}" http://localhost:8001/health | grep -q "200"; then echo "$(date): OK" else echo "$(date): FEHLER - Neustart..." diff --git a/Dockerfile b/Dockerfile index e096569..e66473c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,7 +41,7 @@ EXPOSE 8001 # Health check (uses PORT env var with 8001 fallback) HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD python -c "import os, httpx; port = os.getenv('PORT', '8001'); httpx.get(f'http://localhost:{port}/login', timeout=5)" || exit 1 + CMD python -c "import os, httpx; port = os.getenv('PORT', '8001'); r = httpx.get(f'http://127.0.0.1:{port}/health', timeout=5); raise SystemExit(0 if r.status_code == 200 else 1)" || exit 1 # Run the application (uses PORT env var with 8001 fallback) CMD sh -c "python -m uvicorn src.web.app:app --host 0.0.0.0 --port ${PORT:-8001}" diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 7b3838c..39833e9 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -27,6 +27,7 @@ services: interval: 10s timeout: 5s retries: 3 + start_period: 5s linkedin-scheduler: build: . @@ -34,10 +35,17 @@ services: command: python -m src.services.scheduler_runner labels: - traefik.enable=false + healthcheck: + test: ["CMD", "python", "-c", "import os, time; p=os.getenv('SCHEDULER_HEALTH_FILE','/tmp/scheduler_heartbeat'); raise SystemExit(0 if os.path.exists(p) and time.time() - os.path.getmtime(p) < 120 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s environment: - PYTHONPATH=/app - SCHEDULER_ENABLED=true - REDIS_URL=redis://redis:6379/0 + - SCHEDULER_HEALTH_FILE=/tmp/scheduler_heartbeat volumes: - logs:/app/logs depends_on: @@ -61,7 +69,7 @@ services: redis: condition: service_healthy healthcheck: - test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8001/login', timeout=5)"] + test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://127.0.0.1:8001/health', timeout=5); raise SystemExit(0 if r.status_code == 200 else 1)"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index fa1c989..2b21a5c 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -11,17 +11,25 @@ services: interval: 10s timeout: 5s retries: 3 + start_period: 5s linkedin-scheduler: build: . container_name: linkedin-scheduler restart: unless-stopped command: sh -c "python -m src.services.scheduler_runner" + healthcheck: + test: ["CMD", "python", "-c", "import os, time; p=os.getenv('SCHEDULER_HEALTH_FILE','/tmp/scheduler_heartbeat'); raise SystemExit(0 if os.path.exists(p) and time.time() - os.path.getmtime(p) < 120 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s env_file: .env environment: - PYTHONPATH=/app - SCHEDULER_ENABLED=true - REDIS_URL=redis://redis:6379/0 + - SCHEDULER_HEALTH_FILE=/tmp/scheduler_heartbeat volumes: - ./logs:/app/logs depends_on: @@ -47,7 +55,7 @@ services: redis: condition: service_healthy healthcheck: - test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8001/login', timeout=5)"] + test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://127.0.0.1:8001/health', timeout=5); raise SystemExit(0 if r.status_code == 200 else 1)"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.ssl.yml b/docker-compose.ssl.yml index 011c032..6b663e2 100644 --- a/docker-compose.ssl.yml +++ b/docker-compose.ssl.yml @@ -18,7 +18,7 @@ services: volumes: - ./logs:/app/logs healthcheck: - test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8001/login', timeout=5)"] + test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://127.0.0.1:8001/health', timeout=5); raise SystemExit(0 if r.status_code == 200 else 1)"] interval: 30s timeout: 10s retries: 3 diff --git a/docker-compose.yml b/docker-compose.yml index 7581e75..d5c4f05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,7 +16,7 @@ services: # Optional: Mount logs directory - ./logs:/app/logs healthcheck: - test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8001/login', timeout=5)"] + test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://127.0.0.1:8001/health', timeout=5); raise SystemExit(0 if r.status_code == 200 else 1)"] interval: 30s timeout: 10s retries: 3 diff --git a/src/services/scheduler_runner.py b/src/services/scheduler_runner.py index d8a3054..de86da3 100644 --- a/src/services/scheduler_runner.py +++ b/src/services/scheduler_runner.py @@ -8,16 +8,29 @@ SchedulerService so it can run in its own container without duplicating work in the main web-worker containers (which set SCHEDULER_ENABLED=false). """ import asyncio +import os import signal +from pathlib import Path from loguru import logger from src.database.client import DatabaseClient from src.services.scheduler_service import init_scheduler +async def heartbeat_loop(path: Path, interval_seconds: int = 30): + """Write a heartbeat file so Docker can healthcheck the scheduler process.""" + while True: + try: + path.write_text(str(asyncio.get_running_loop().time()), encoding="utf-8") + except Exception as exc: + logger.warning(f"Failed to write scheduler heartbeat: {exc}") + await asyncio.sleep(interval_seconds) + + async def main(): db = DatabaseClient() scheduler = init_scheduler(db, check_interval=60) + health_file = Path(os.getenv("SCHEDULER_HEALTH_FILE", "/tmp/scheduler_heartbeat")) stop_event = asyncio.Event() @@ -30,10 +43,16 @@ async def main(): loop.add_signal_handler(sig, handle_signal) await scheduler.start() + heartbeat_task = asyncio.create_task(heartbeat_loop(health_file)) logger.info("Scheduler started (dedicated process)") await stop_event.wait() + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass await scheduler.stop() logger.info("Scheduler stopped") diff --git a/src/web/app.py b/src/web/app.py index cf1601a..f11ec05 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -116,6 +116,17 @@ app.add_middleware(GZipMiddleware, minimum_size=500) app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") +@app.api_route("/health", methods=["GET", "HEAD"], include_in_schema=False) +async def health(): + """Container health endpoint for Docker/Coolify. + + Keep this endpoint intentionally shallow: it verifies the ASGI app is + serving requests without depending on auth, templates, Redis, Supabase or + external APIs. + """ + return {"status": "ok"} + + def _load_env_file(path: Path) -> dict: """Load key=value pairs from a simple .env file.""" data = {}