endpoint / Dockerfile
kdoherty's picture
Update Dockerfile
076ba93 verified
FROM ghcr.io/developmentseed/titiler:latest
# Python runtime optimizations
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONWARNINGS=ignore
# CORS settings for public access
ENV TITILER_API_CORS_ORIGINS=* \
TITILER_API_CORS_ALLOW_METHODS=GET,POST,OPTIONS
# Proxy settings
ENV FORWARDED_ALLOW_IPS=* \
TITILER_API_ROOT_PATH=""
# ============================================================================
# GDAL/RASTERIO TUNING PARAMETERS
# ============================================================================
# GDAL_CACHEMAX: GDAL block cache in MB (was 75% = ~24GB, now 4GB, orig 8GB)
# Higher = faster repeated reads, more memory used
# VSI_CACHE_SIZE: VSI cache size in bytes (1GB, was 512MB, your orig 512MB)
# This caches remote file chunks. Critical for S3/HTTP COGs
# CPL_VSIL_CURL_CACHE_SIZE: CURL cache in bytes (2GB, was 1GB, your orig 1GB)
# Caches HTTP range requests. Bigger = fewer re-fetches
# CPL_VSIL_CURL_CHUNK_SIZE: Size of chunks for range requests (10MB). Tune based on COG tile size
# GDAL_NUM_THREADS: Number of GDAL threads (was ALL_CPUS=8, conservative=2, now 6)
# More threads = faster parallel reads but more CPU contention
# GDAL_MAX_DATASET_POOL_SIZE: Max open datasets (was 450, now 300). Lower = less memory
ENV CPL_TMPDIR=/tmp \
GDAL_CACHEMAX=75% \
GDAL_INGESTED_BYTES_AT_OPEN=32768 \
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR \
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES \
GDAL_HTTP_MULTIPLEX=YES \
GDAL_HTTP_VERSION=2 \
VSI_CACHE=TRUE \
VSI_CACHE_SIZE=1073741824 \
CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif,.tiff,.vrt,.jp2,.png,.jpg \
CPL_VSIL_CURL_USE_HEAD=NO \
CPL_VSIL_CURL_CACHE_SIZE=2147483648 \
CPL_VSIL_CURL_CHUNK_SIZE=10485760 \
GDAL_HTTP_TIMEOUT=30 \
GDAL_HTTP_CONNECTTIMEOUT=10 \
GDAL_HTTP_MAX_RETRY=3 \
GDAL_HTTP_RETRY_DELAY=1 \
GDAL_NUM_THREADS=6 \
GDAL_MAX_DATASET_POOL_SIZE=300 \
PROJ_NETWORK=ON \
GDAL_ENABLE_WMS_CACHE=YES
# ============================================================================
# TITILER SPECIFIC SETTINGS
# ============================================================================
# MOSAIC_CONCURRENCY: Concurrent mosaic operations (was 4, now 3)
# RIO_TILER_MAX_THREADS: Rasterio tile generation threads per worker
ENV TITILER_API_DISABLE_MOSAIC=FALSE \
TITILER_API_ENABLE_TILES_CACHE=TRUE \
MOSAIC_CONCURRENCY=3 \
RIO_TILER_MAX_THREADS=4
# Install dependencies including rate limiting
RUN pip install gunicorn uvloop slowapi
# Set working directory
WORKDIR /app
# Create gunicorn config file
RUN printf '%s\n' \
'import multiprocessing' \
'import os' \
'' \
'# WORKER CONFIGURATION' \
'workers = 6' \
'worker_class = "uvicorn.workers.UvicornWorker"' \
'worker_connections = 500' \
'' \
'# REQUEST RECYCLING' \
'max_requests = 3000' \
'max_requests_jitter = 300' \
'' \
'# TIMEOUTS' \
'timeout = 60' \
'graceful_timeout = 30' \
'keepalive = 5' \
'' \
'# PERFORMANCE TUNING' \
'backlog = 1024' \
'limit_request_line = 4094' \
'limit_request_fields = 100' \
'limit_request_field_size = 8190' \
'' \
'# SERVER MECHANICS' \
'bind = "0.0.0.0:7860"' \
'daemon = False' \
'reuse_port = True' \
'preload_app = True' \
'' \
'# LOGGING' \
'accesslog = "-"' \
'errorlog = "-"' \
'loglevel = "info"' \
'' \
'def when_ready(server):' \
' server.log.info("Server ready. Spawning workers")' \
'' \
'def pre_fork(server, worker):' \
' server.log.info(f"Worker spawned (pid: {worker.pid})")' \
'' \
'def post_fork(server, worker):' \
' server.log.info(f"Worker initialized (pid: {worker.pid})")' \
'' \
'def worker_exit(server, worker):' \
' server.log.info(f"Worker exited (pid: {worker.pid})")' \
> /app/gunicorn_config.py
# Create custom app with rate limiting
RUN printf '%s\n' \
'from titiler.application.main import app' \
'from slowapi import Limiter, _rate_limit_exceeded_handler' \
'from slowapi.util import get_remote_address' \
'from slowapi.errors import RateLimitExceeded' \
'from slowapi.middleware import SlowAPIMiddleware' \
'from starlette.middleware.base import BaseHTTPMiddleware' \
'from starlette.responses import Response' \
'import time' \
'import asyncio' \
'from collections import defaultdict, deque' \
'import logging' \
'' \
'logging.basicConfig(level=logging.INFO)' \
'logger = logging.getLogger(__name__)' \
'' \
'# RATE LIMITING CONFIGURATION' \
'limiter = Limiter(' \
' key_func=get_remote_address,' \
' default_limits=["400 per minute", "5000 per hour"],' \
' storage_uri="memory://",' \
' swallow_errors=True' \
')' \
'' \
'app.state.limiter = limiter' \
'app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)' \
'app.add_middleware(SlowAPIMiddleware)' \
'' \
'# ADAPTIVE BURST PROTECTION' \
'class AdaptiveBurstProtection(BaseHTTPMiddleware):' \
' def __init__(self, app, burst_size=50, window=1.0, decay_rate=0.9):' \
' super().__init__(app)' \
' self.burst_size = burst_size' \
' self.window = window' \
' self.decay_rate = decay_rate' \
' self.requests = defaultdict(lambda: deque(maxlen=burst_size))' \
' self.delays = defaultdict(float)' \
' ' \
' async def dispatch(self, request, call_next):' \
' client_ip = request.client.host' \
' now = time.time()' \
' ' \
' request_times = self.requests[client_ip]' \
' request_times.append(now)' \
' ' \
' if len(request_times) >= 2:' \
' time_span = now - request_times[0]' \
' if time_span > 0:' \
' current_rate = len(request_times) / time_span' \
' ' \
' if current_rate > self.burst_size:' \
' self.delays[client_ip] = min(0.5, self.delays[client_ip] + 0.05)' \
' await asyncio.sleep(self.delays[client_ip])' \
' ' \
' if self.delays[client_ip] > 0.1:' \
' logger.warning(f"Rate limiting {client_ip}: {current_rate:.1f} req/s")' \
' ' \
' elif self.delays[client_ip] > 0:' \
' self.delays[client_ip] *= self.decay_rate' \
' ' \
' try:' \
' response = await call_next(request)' \
' return response' \
' except Exception as e:' \
' logger.error(f"Request failed: {e}")' \
' return Response(content="Internal error", status_code=500)' \
'' \
'app.add_middleware(AdaptiveBurstProtection, burst_size=75, window=1.0)' \
'' \
'# CACHE HEADERS' \
'@app.middleware("http")' \
'async def add_cache_headers(request, call_next):' \
' response = await call_next(request)' \
' path = str(request.url.path)' \
' ' \
' if "/tiles/" in path:' \
' response.headers["Cache-Control"] = "public, max-age=3600, stale-while-revalidate=7200"' \
' elif "/cog/statistics" in path or "/cog/info" in path:' \
' response.headers["Cache-Control"] = "public, max-age=600"' \
' elif "/cog/bounds" in path:' \
' response.headers["Cache-Control"] = "public, max-age=86400"' \
' ' \
' return response' \
'' \
'logger.info("Custom app with rate limiting initialized")' \
> /app/custom_app.py
# Create startup script
RUN printf '%s\n' \
'#!/bin/bash' \
'echo "==============================================="' \
'cd /app' \
'exec gunicorn custom_app:app -c gunicorn_config.py' \
> /app/start.sh
RUN chmod +x /app/start.sh
EXPOSE 7860
CMD ["/app/start.sh"]