diff --git a/backend/Dockerfile b/backend/Dockerfile index 3ba994e1d..8725033e0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -69,6 +69,9 @@ RUN python3 manage.py collectstatic --noinput --verbosity 2 # Expose ports EXPOSE 80 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \ + CMD ["python3", "/code/healthcheck.py"] + # Start with an entrypoint that runs init tasks then starts supervisord ENTRYPOINT ["/code/entrypoint.sh"] diff --git a/backend/server/healthcheck.py b/backend/server/healthcheck.py new file mode 100644 index 000000000..8c414f879 --- /dev/null +++ b/backend/server/healthcheck.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +import os +import sys +import urllib.error +import urllib.request + + +def main() -> int: + timeout = float(os.getenv('HEALTHCHECK_TIMEOUT_SECONDS', '3')) + urls = os.getenv( + 'HEALTHCHECK_URLS', + 'http://127.0.0.1:8000/healthz,http://127.0.0.1/healthz', + ) + + for raw_url in urls.split(','): + url = raw_url.strip() + if not url: + continue + try: + with urllib.request.urlopen(url, timeout=timeout) as response: + if response.status == 200: + return 0 + except (OSError, urllib.error.URLError): + continue + + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py index bc87bbe46..222b0bc77 100644 --- a/backend/server/main/urls.py +++ b/backend/server/main/urls.py @@ -2,7 +2,7 @@ from django.contrib import admin from django.views.generic import RedirectView, TemplateView from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView, DisablePasswordAuthenticationView -from .views import get_csrf_token, get_public_url, serve_protected_media +from .views import get_csrf_token, get_public_url, healthz, serve_protected_media from drf_yasg.views import get_schema_view from drf_yasg import openapi @@ -13,6 +13,7 @@ ) ) urlpatterns = [ + path('healthz/', healthz, name='healthz'), path('api/', include('adventures.urls')), path('api/', include('worldtravel.urls')), path("auth/", include("allauth.headless.urls")), @@ -49,4 +50,4 @@ path("api/integrations/", include("integrations.urls")), # Include the API endpoints: -] \ No newline at end of file +] diff --git a/backend/server/main/views.py b/backend/server/main/views.py index 3393e1370..9649d1e0c 100644 --- a/backend/server/main/views.py +++ b/backend/server/main/views.py @@ -6,6 +6,11 @@ from django.views.static import serve from adventures.utils.file_permissions import checkFilePermission + +def healthz(request): + return HttpResponse('ok', content_type='text/plain') + + def get_csrf_token(request): csrf_token = get_token(request) return JsonResponse({'csrfToken': csrf_token}) @@ -39,4 +44,4 @@ def serve_protected_media(request, path): response = HttpResponse() response['Content-Type'] = '' response['X-Accel-Redirect'] = '/protectedMedia/' + path - return response \ No newline at end of file + return response diff --git a/docker-compose-traefik.yaml b/docker-compose-traefik.yaml index 16a1805ca..4601ad81e 100644 --- a/docker-compose-traefik.yaml +++ b/docker-compose-traefik.yaml @@ -43,7 +43,14 @@ services: - "traefik.http.routers.adventurelogweb.tls=true" - "traefik.http.routers.adventurelogweb.tls.certresolver=letsencrypt" depends_on: - - server + server: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "/app/healthcheck.mjs"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s server: image: ghcr.io/seanmorley15/adventurelog-backend:latest @@ -71,6 +78,12 @@ services: - "traefik.http.routers.adventurelogserver.tls.certresolver=letsencrypt" depends_on: - db + healthcheck: + test: ["CMD", "python3", "/code/healthcheck.py"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s volumes: postgres-data: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a0da05336..f072ab1bb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,11 +12,18 @@ services: ports: - "${FRONTEND_PORT:-8015}:3000" depends_on: - - server + server: + condition: service_healthy volumes: - ./frontend:/app - pnpm_store:/pnpm-store command: sh -c "mkdir -p /pnpm-store && chown -R node:node /pnpm-store && su node -c 'pnpm config set store-dir /pnpm-store && pnpm install --frozen-lockfile && pnpm exec vite dev --host 0.0.0.0 --port 3000 --strictPort'" + healthcheck: + test: ["CMD", "node", "/app/healthcheck.mjs"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s db: image: postgis/postgis:16-3.5 @@ -52,6 +59,12 @@ services: python manage.py createsuperuser --noinput --username \"$$DJANGO_SUPERUSER_USERNAME\" --email \"$$DJANGO_SUPERUSER_EMAIL\" || true; fi; python manage.py runserver 0.0.0.0:8000" + healthcheck: + test: ["CMD", "python3", "/code/healthcheck.py"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s volumes: postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index 034ec065e..87da19042 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,14 @@ services: ports: - "${FRONTEND_PORT:-8015}:3000" depends_on: - - server + server: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "/app/healthcheck.mjs"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 30s db: image: postgis/postgis:16-3.5 @@ -30,6 +37,12 @@ services: - db volumes: - adventurelog_media:/code/media/ + healthcheck: + test: ["CMD", "python3", "/code/healthcheck.py"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s volumes: postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3b7fdc795..28a1a2efc 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -45,5 +45,8 @@ USER node:node # Expose the port that the app is listening on EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \ + CMD ["node", "/app/healthcheck.mjs"] + # Run startup.sh instead of the default command -CMD ["./startup.sh"] \ No newline at end of file +CMD ["./startup.sh"] diff --git a/frontend/healthcheck.mjs b/frontend/healthcheck.mjs new file mode 100644 index 000000000..18d9788a3 --- /dev/null +++ b/frontend/healthcheck.mjs @@ -0,0 +1,27 @@ +const timeoutMs = Number.parseInt(process.env.HEALTHCHECK_TIMEOUT_MS ?? '3000', 10); +const urls = (process.env.HEALTHCHECK_URLS ?? 'http://127.0.0.1:3000/healthz') + .split(',') + .map((url) => url.trim()) + .filter(Boolean); + +for (const url of urls) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + method: 'GET', + signal: controller.signal, + cache: 'no-store' + }); + if (response.status === 200) { + process.exit(0); + } + } catch (_) { + // Continue to the next URL. + } finally { + clearTimeout(timer); + } +} + +process.exit(1); diff --git a/frontend/src/routes/healthz/+server.ts b/frontend/src/routes/healthz/+server.ts new file mode 100644 index 000000000..638ef3261 --- /dev/null +++ b/frontend/src/routes/healthz/+server.ts @@ -0,0 +1,10 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async () => { + return new Response('ok', { + status: 200, + headers: { + 'Content-Type': 'text/plain' + } + }); +};