Improve Modal startup reliability and ClickHouse request resilience
78ee9e9 parent: 5f7ef9d modified
atop_analyze.py +32 -9 | @@ -23,11 +23,13 @@ from __future__ import annotations | ||
| 23 | 23 | import argparse |
| 24 | 24 | import base64 |
| 25 | 25 | import json |
| 26 | +import logging | |
| 26 | 27 | import os |
| 27 | 28 | import re |
| 28 | 29 | import shutil |
| 29 | 30 | import subprocess |
| 30 | 31 | import sys |
| 32 | +import time | |
| 31 | 33 | import urllib.error |
| 32 | 34 | import urllib.parse |
| 33 | 35 | import urllib.request |
| @@ -39,7 +41,11 @@ DEFAULT_LOGDIR = "/var/log/atop" | ||
| 39 | 41 | CLAUDE_BIN = "claude" |
| 40 | 42 | CLAUDE_MODEL = "opus" |
| 41 | 43 | CLICKHOUSE_URL_ENV = "CLICKHOUSE_URL" |
| 44 | +CLICKHOUSE_REQUEST_TIMEOUT = 120 | |
| 45 | +CLICKHOUSE_COLD_START_RETRIES = 2 | |
| 42 | 46 | IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") |
| 47 | +logger = logging.getLogger(__name__) | |
| 48 | +logger.setLevel(logging.INFO) | |
| 43 | 49 | |
| 44 | 50 | WANTED_LABELS = {"CPU", "CPL", "PRC"} |
| 45 | 51 | DATE_RE = re.compile(r"^\d{4}/\d{2}/\d{2}$") |
| @@ -226,15 +232,32 @@ def clickhouse_request( | ||
| 226 | 232 | if modal_key is not None: |
| 227 | 233 | request.add_header("Modal-Key", modal_key) |
| 228 | 234 | request.add_header("Modal-Secret", modal_secret or "") |
| 229 | - try: | |
| 230 | - with urllib.request.urlopen(request, timeout=30) as response: | |
| 231 | - if response.status >= 300: | |
| 232 | - raise AtopError(f"ClickHouse returned HTTP {response.status}") | |
| 233 | - except urllib.error.HTTPError as e: | |
| 234 | - detail = e.read().decode(errors="replace").strip() | |
| 235 | - raise AtopError(f"ClickHouse returned HTTP {e.code}: {detail}") from e | |
| 236 | - except urllib.error.URLError as e: | |
| 237 | - raise AtopError(f"could not reach ClickHouse at {url}: {e.reason}") from e | |
| 235 | + for attempt in range(CLICKHOUSE_COLD_START_RETRIES + 1): | |
| 236 | + try: | |
| 237 | + logger.info( | |
| 238 | + "clickhouse_request operation=%s attempt=%d body_bytes=%d", | |
| 239 | + query.split(None, 1)[0], attempt + 1, len(body.encode()), | |
| 240 | + ) | |
| 241 | + # A scale-to-zero Modal Web Function can take longer than a normal | |
| 242 | + # HTTP request to boot ClickHouse and attach its Volume. | |
| 243 | + with urllib.request.urlopen(request, timeout=CLICKHOUSE_REQUEST_TIMEOUT) as response: | |
| 244 | + if response.status >= 300: | |
| 245 | + raise AtopError(f"ClickHouse returned HTTP {response.status}") | |
| 246 | + logger.info("clickhouse_request_complete operation=%s", query.split(None, 1)[0]) | |
| 247 | + return | |
| 248 | + except urllib.error.HTTPError as e: | |
| 249 | + detail = e.read().decode(errors="replace").strip() | |
| 250 | + raise AtopError(f"ClickHouse returned HTTP {e.code}: {detail}") from e | |
| 251 | + except (TimeoutError, urllib.error.URLError) as e: | |
| 252 | + if attempt == CLICKHOUSE_COLD_START_RETRIES: | |
| 253 | + reason = getattr(e, "reason", str(e)) | |
| 254 | + raise AtopError(f"could not reach ClickHouse at {url}: {reason}") from e | |
| 255 | + delay = 5 * (attempt + 1) | |
| 256 | + logger.warning( | |
| 257 | + "clickhouse_request_retry operation=%s delay_seconds=%d reason=%s", | |
| 258 | + query.split(None, 1)[0], delay, getattr(e, "reason", str(e)), | |
| 259 | + ) | |
| 260 | + time.sleep(delay) | |
| 238 | 261 | |
| 239 | 262 | |
| 240 | 263 | def write_clickhouse( |
| @@ -23,11 +23,13 @@ from __future__ import annotations | |||
| 23 | import argparse | 23 | import argparse |
| 24 | import base64 | 24 | import base64 |
| 25 | import json | 25 | import json |
| 26 | +import logging | ||
| 26 | import os | 27 | import os |
| 27 | import re | 28 | import re |
| 28 | import shutil | 29 | import shutil |
| 29 | import subprocess | 30 | import subprocess |
| 30 | import sys | 31 | import sys |
| 32 | +import time | ||
| 31 | import urllib.error | 33 | import urllib.error |
| 32 | import urllib.parse | 34 | import urllib.parse |
| 33 | import urllib.request | 35 | import urllib.request |
| @@ -39,7 +41,11 @@ DEFAULT_LOGDIR = "/var/log/atop" | |||
| 39 | CLAUDE_BIN = "claude" | 41 | CLAUDE_BIN = "claude" |
| 40 | CLAUDE_MODEL = "opus" | 42 | CLAUDE_MODEL = "opus" |
| 41 | CLICKHOUSE_URL_ENV = "CLICKHOUSE_URL" | 43 | CLICKHOUSE_URL_ENV = "CLICKHOUSE_URL" |
| 44 | +CLICKHOUSE_REQUEST_TIMEOUT = 120 | ||
| 45 | +CLICKHOUSE_COLD_START_RETRIES = 2 | ||
| 42 | IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") | 46 | IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") |
| 47 | +logger = logging.getLogger(__name__) | ||
| 48 | +logger.setLevel(logging.INFO) | ||
| 43 | 49 | ||
| 44 | WANTED_LABELS = {"CPU", "CPL", "PRC"} | 50 | WANTED_LABELS = {"CPU", "CPL", "PRC"} |
| 45 | DATE_RE = re.compile(r"^\d{4}/\d{2}/\d{2}$") | 51 | DATE_RE = re.compile(r"^\d{4}/\d{2}/\d{2}$") |
| @@ -226,15 +232,32 @@ def clickhouse_request( | |||
| 226 | if modal_key is not None: | 232 | if modal_key is not None: |
| 227 | request.add_header("Modal-Key", modal_key) | 233 | request.add_header("Modal-Key", modal_key) |
| 228 | request.add_header("Modal-Secret", modal_secret or "") | 234 | request.add_header("Modal-Secret", modal_secret or "") |
| 229 | - try: | 235 | + for attempt in range(CLICKHOUSE_COLD_START_RETRIES + 1): |
| 230 | - with urllib.request.urlopen(request, timeout=30) as response: | 236 | + try: |
| 231 | - if response.status >= 300: | 237 | + logger.info( |
| 232 | - raise AtopError(f"ClickHouse returned HTTP {response.status}") | 238 | + "clickhouse_request operation=%s attempt=%d body_bytes=%d", |
| 233 | - except urllib.error.HTTPError as e: | 239 | + query.split(None, 1)[0], attempt + 1, len(body.encode()), |
| 234 | - detail = e.read().decode(errors="replace").strip() | 240 | + ) |
| 235 | - raise AtopError(f"ClickHouse returned HTTP {e.code}: {detail}") from e | 241 | + # A scale-to-zero Modal Web Function can take longer than a normal |
| 236 | - except urllib.error.URLError as e: | 242 | + # HTTP request to boot ClickHouse and attach its Volume. |
| 237 | - raise AtopError(f"could not reach ClickHouse at {url}: {e.reason}") from e | 243 | + with urllib.request.urlopen(request, timeout=CLICKHOUSE_REQUEST_TIMEOUT) as response: |
| 244 | + if response.status >= 300: | ||
| 245 | + raise AtopError(f"ClickHouse returned HTTP {response.status}") | ||
| 246 | + logger.info("clickhouse_request_complete operation=%s", query.split(None, 1)[0]) | ||
| 247 | + return | ||
| 248 | + except urllib.error.HTTPError as e: | ||
| 249 | + detail = e.read().decode(errors="replace").strip() | ||
| 250 | + raise AtopError(f"ClickHouse returned HTTP {e.code}: {detail}") from e | ||
| 251 | + except (TimeoutError, urllib.error.URLError) as e: | ||
| 252 | + if attempt == CLICKHOUSE_COLD_START_RETRIES: | ||
| 253 | + reason = getattr(e, "reason", str(e)) | ||
| 254 | + raise AtopError(f"could not reach ClickHouse at {url}: {reason}") from e | ||
| 255 | + delay = 5 * (attempt + 1) | ||
| 256 | + logger.warning( | ||
| 257 | + "clickhouse_request_retry operation=%s delay_seconds=%d reason=%s", | ||
| 258 | + query.split(None, 1)[0], delay, getattr(e, "reason", str(e)), | ||
| 259 | + ) | ||
| 260 | + time.sleep(delay) | ||
| 238 | 261 | ||
| 239 | 262 | ||
| 240 | def write_clickhouse( | 263 | def write_clickhouse( |
modified
modal_atop_analyze.py +25 -2 | @@ -12,24 +12,46 @@ write run in Modal; the ClickHouse endpoint and Proxy Token live in the | ||
| 12 | 12 | from __future__ import annotations |
| 13 | 13 | |
| 14 | 14 | import os |
| 15 | +import logging | |
| 15 | 16 | |
| 16 | 17 | import modal |
| 17 | 18 | |
| 18 | 19 | from atop_analyze import AtopError, log_path, parse, run_atop, write_clickhouse |
| 19 | 20 | |
| 21 | +# Modal captures container stderr. Configure it explicitly because the runtime | |
| 22 | +# does not install an INFO-level handler for application loggers by default. | |
| 23 | +logging.basicConfig( | |
| 24 | + level=logging.INFO, | |
| 25 | + format="%(asctime)s %(levelname)s %(name)s %(message)s", | |
| 26 | + force=True, | |
| 27 | +) | |
| 20 | 28 | app = modal.App("atop-analyze") |
| 29 | +logger = logging.getLogger(__name__) | |
| 30 | +logger.setLevel(logging.INFO) | |
| 31 | +# ``modal run`` mounts this entrypoint automatically, but imports are not | |
| 32 | +# automatically included in the remote container. Bake the shared parser into | |
| 33 | +# the function image so the remote ingest function can import it. | |
| 34 | +image = modal.Image.debian_slim(python_version="3.12").add_local_file( | |
| 35 | + "atop_analyze.py", "/root/atop_analyze.py", copy=True | |
| 36 | +) | |
| 21 | 37 | clickhouse_secret = modal.Secret.from_name( |
| 22 | 38 | "atop-clickhouse-proxy", |
| 23 | 39 | required_keys=["CLICKHOUSE_URL", "MODAL_KEY", "MODAL_SECRET"], |
| 24 | 40 | ) |
| 25 | 41 | |
| 26 | 42 | |
| 27 | -@app.function(secrets=[clickhouse_secret], timeout=15 * 60) | |
| 43 | +@app.function(image=image, secrets=[clickhouse_secret], timeout=15 * 60) | |
| 28 | 44 | def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]: |
| 29 | 45 | """Parse an atop export and persist its samples through private ClickHouse.""" |
| 46 | + logger.info("ingest_started raw_bytes=%d initialize=%s", len(raw_atop.encode()), initialize) | |
| 30 | 47 | samples = parse(raw_atop) |
| 31 | 48 | if not samples: |
| 32 | 49 | raise RuntimeError("no usable atop samples in the supplied log range") |
| 50 | + process_rows = sum(len(sample.procs) for sample in samples) | |
| 51 | + logger.info( | |
| 52 | + "atop_parsed samples=%d process_rows=%d first_epoch=%d last_epoch=%d", | |
| 53 | + len(samples), process_rows, samples[0].epoch, samples[-1].epoch, | |
| 54 | + ) | |
| 33 | 55 | |
| 34 | 56 | write_clickhouse( |
| 35 | 57 | samples, |
| @@ -43,9 +65,10 @@ def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]: | ||
| 43 | 65 | os.environ["MODAL_SECRET"], |
| 44 | 66 | initialize, |
| 45 | 67 | ) |
| 68 | + logger.info("clickhouse_write_complete samples=%d process_rows=%d", len(samples), process_rows) | |
| 46 | 69 | return { |
| 47 | 70 | "samples": len(samples), |
| 48 | - "process_rows": sum(len(sample.procs) for sample in samples), | |
| 71 | + "process_rows": process_rows, | |
| 49 | 72 | } |
| 50 | 73 | |
| 51 | 74 | |
| @@ -12,24 +12,46 @@ write run in Modal; the ClickHouse endpoint and Proxy Token live in the | |||
| 12 | from __future__ import annotations | 12 | from __future__ import annotations |
| 13 | 13 | ||
| 14 | import os | 14 | import os |
| 15 | +import logging | ||
| 15 | 16 | ||
| 16 | import modal | 17 | import modal |
| 17 | 18 | ||
| 18 | from atop_analyze import AtopError, log_path, parse, run_atop, write_clickhouse | 19 | from atop_analyze import AtopError, log_path, parse, run_atop, write_clickhouse |
| 19 | 20 | ||
| 21 | +# Modal captures container stderr. Configure it explicitly because the runtime | ||
| 22 | +# does not install an INFO-level handler for application loggers by default. | ||
| 23 | +logging.basicConfig( | ||
| 24 | + level=logging.INFO, | ||
| 25 | + format="%(asctime)s %(levelname)s %(name)s %(message)s", | ||
| 26 | + force=True, | ||
| 27 | +) | ||
| 20 | app = modal.App("atop-analyze") | 28 | app = modal.App("atop-analyze") |
| 29 | +logger = logging.getLogger(__name__) | ||
| 30 | +logger.setLevel(logging.INFO) | ||
| 31 | +# ``modal run`` mounts this entrypoint automatically, but imports are not | ||
| 32 | +# automatically included in the remote container. Bake the shared parser into | ||
| 33 | +# the function image so the remote ingest function can import it. | ||
| 34 | +image = modal.Image.debian_slim(python_version="3.12").add_local_file( | ||
| 35 | + "atop_analyze.py", "/root/atop_analyze.py", copy=True | ||
| 36 | +) | ||
| 21 | clickhouse_secret = modal.Secret.from_name( | 37 | clickhouse_secret = modal.Secret.from_name( |
| 22 | "atop-clickhouse-proxy", | 38 | "atop-clickhouse-proxy", |
| 23 | required_keys=["CLICKHOUSE_URL", "MODAL_KEY", "MODAL_SECRET"], | 39 | required_keys=["CLICKHOUSE_URL", "MODAL_KEY", "MODAL_SECRET"], |
| 24 | ) | 40 | ) |
| 25 | 41 | ||
| 26 | 42 | ||
| 27 | -@app.function(secrets=[clickhouse_secret], timeout=15 * 60) | 43 | +@app.function(image=image, secrets=[clickhouse_secret], timeout=15 * 60) |
| 28 | def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]: | 44 | def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]: |
| 29 | """Parse an atop export and persist its samples through private ClickHouse.""" | 45 | """Parse an atop export and persist its samples through private ClickHouse.""" |
| 46 | + logger.info("ingest_started raw_bytes=%d initialize=%s", len(raw_atop.encode()), initialize) | ||
| 30 | samples = parse(raw_atop) | 47 | samples = parse(raw_atop) |
| 31 | if not samples: | 48 | if not samples: |
| 32 | raise RuntimeError("no usable atop samples in the supplied log range") | 49 | raise RuntimeError("no usable atop samples in the supplied log range") |
| 50 | + process_rows = sum(len(sample.procs) for sample in samples) | ||
| 51 | + logger.info( | ||
| 52 | + "atop_parsed samples=%d process_rows=%d first_epoch=%d last_epoch=%d", | ||
| 53 | + len(samples), process_rows, samples[0].epoch, samples[-1].epoch, | ||
| 54 | + ) | ||
| 33 | 55 | ||
| 34 | write_clickhouse( | 56 | write_clickhouse( |
| 35 | samples, | 57 | samples, |
| @@ -43,9 +65,10 @@ def ingest(raw_atop: str, initialize: bool = False) -> dict[str, int]: | |||
| 43 | os.environ["MODAL_SECRET"], | 65 | os.environ["MODAL_SECRET"], |
| 44 | initialize, | 66 | initialize, |
| 45 | ) | 67 | ) |
| 68 | + logger.info("clickhouse_write_complete samples=%d process_rows=%d", len(samples), process_rows) | ||
| 46 | return { | 69 | return { |
| 47 | "samples": len(samples), | 70 | "samples": len(samples), |
| 48 | - "process_rows": sum(len(sample.procs) for sample in samples), | 71 | + "process_rows": process_rows, |
| 49 | } | 72 | } |
| 50 | 73 | ||
| 51 | 74 | ||
modified
modal_grafana.py +20 -15 | @@ -7,6 +7,7 @@ with ``modal deploy modal_grafana.py``. | ||
| 7 | 7 | """ |
| 8 | 8 | |
| 9 | 9 | import os |
| 10 | +import socket | |
| 10 | 11 | import subprocess |
| 11 | 12 | import time |
| 12 | 13 | |
| @@ -14,9 +15,6 @@ import modal | ||
| 14 | 15 | |
| 15 | 16 | APP_NAME = "atop-grafana" |
| 16 | 17 | IDLE_SECONDS = 300 |
| 17 | -GRAFANA_UID = 472 | |
| 18 | -GRAFANA_GID = 0 | |
| 19 | - | |
| 20 | 18 | app = modal.App(APP_NAME) |
| 21 | 19 | grafana_secret = modal.Secret.from_name( |
| 22 | 20 | "atop-grafana", |
| @@ -41,6 +39,9 @@ image = ( | ||
| 41 | 39 | scaledown_window=IDLE_SECONDS, |
| 42 | 40 | timeout=15 * 60, |
| 43 | 41 | env={ |
| 42 | + # Modal's web-server proxy connects over IPv4; Grafana otherwise binds | |
| 43 | + # to [::], which can leave the public endpoint waiting indefinitely. | |
| 44 | + "GF_SERVER_HTTP_ADDR": "0.0.0.0", | |
| 44 | 45 | "GF_AUTH_DISABLE_LOGIN_FORM": "true", |
| 45 | 46 | "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", |
| 46 | 47 | "GF_AUTH_GENERIC_OAUTH_NAME": "Pocket ID", |
| @@ -52,21 +53,25 @@ image = ( | ||
| 52 | 53 | "GF_AUTH_GENERIC_OAUTH_VALIDATE_ID_TOKEN": "true", |
| 53 | 54 | }, |
| 54 | 55 | ) |
| 56 | +@modal.concurrent(max_inputs=100) | |
| 55 | 57 | @modal.web_server(3000, startup_timeout=120) |
| 56 | 58 | def grafana() -> None: |
| 57 | 59 | """Run a public Grafana login page; its data-source credentials stay server-side.""" |
| 58 | - def drop_privileges() -> None: | |
| 59 | - # Modal ignores the Docker image's USER instruction for Functions. | |
| 60 | - # The upstream Grafana image uses unprivileged UID 472 and group 0. | |
| 61 | - os.setgid(GRAFANA_GID) | |
| 62 | - os.setuid(GRAFANA_UID) | |
| 63 | - | |
| 64 | 60 | process = subprocess.Popen( |
| 65 | 61 | ["grafana", "server", "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini"], |
| 66 | - preexec_fn=drop_privileges, | |
| 62 | + # Pass this through the spawned process as well as Modal's function | |
| 63 | + # environment, since the web-server proxy reaches the service over IPv4. | |
| 64 | + env={**os.environ, "GF_SERVER_HTTP_ADDR": "0.0.0.0"}, | |
| 67 | 65 | ) |
| 68 | - # modal.web_server waits for port 3000. Catch an immediate startup failure | |
| 69 | - # early so its cause appears in Modal logs rather than as a proxy timeout. | |
| 70 | - time.sleep(1) | |
| 71 | - if process.poll() is not None: | |
| 72 | - raise RuntimeError(f"Grafana exited during startup ({process.returncode})") | |
| 66 | + # Grafana takes a few seconds to bind. Do not return from the Modal web | |
| 67 | + # function until its port is accepting connections; otherwise the proxy can | |
| 68 | + # route the first request before a backend exists and leave it hanging. | |
| 69 | + for _ in range(60): | |
| 70 | + if process.poll() is not None: | |
| 71 | + raise RuntimeError(f"Grafana exited during startup ({process.returncode})") | |
| 72 | + with socket.socket() as probe: | |
| 73 | + probe.settimeout(0.25) | |
| 74 | + if probe.connect_ex(("127.0.0.1", 3000)) == 0: | |
| 75 | + return | |
| 76 | + time.sleep(0.5) | |
| 77 | + raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds") | |
| @@ -7,6 +7,7 @@ with ``modal deploy modal_grafana.py``. | |||
| 7 | """ | 7 | """ |
| 8 | 8 | ||
| 9 | import os | 9 | import os |
| 10 | +import socket | ||
| 10 | import subprocess | 11 | import subprocess |
| 11 | import time | 12 | import time |
| 12 | 13 | ||
| @@ -14,9 +15,6 @@ import modal | |||
| 14 | 15 | ||
| 15 | APP_NAME = "atop-grafana" | 16 | APP_NAME = "atop-grafana" |
| 16 | IDLE_SECONDS = 300 | 17 | IDLE_SECONDS = 300 |
| 17 | -GRAFANA_UID = 472 | ||
| 18 | -GRAFANA_GID = 0 | ||
| 19 | - | ||
| 20 | app = modal.App(APP_NAME) | 18 | app = modal.App(APP_NAME) |
| 21 | grafana_secret = modal.Secret.from_name( | 19 | grafana_secret = modal.Secret.from_name( |
| 22 | "atop-grafana", | 20 | "atop-grafana", |
| @@ -41,6 +39,9 @@ image = ( | |||
| 41 | scaledown_window=IDLE_SECONDS, | 39 | scaledown_window=IDLE_SECONDS, |
| 42 | timeout=15 * 60, | 40 | timeout=15 * 60, |
| 43 | env={ | 41 | env={ |
| 42 | + # Modal's web-server proxy connects over IPv4; Grafana otherwise binds | ||
| 43 | + # to [::], which can leave the public endpoint waiting indefinitely. | ||
| 44 | + "GF_SERVER_HTTP_ADDR": "0.0.0.0", | ||
| 44 | "GF_AUTH_DISABLE_LOGIN_FORM": "true", | 45 | "GF_AUTH_DISABLE_LOGIN_FORM": "true", |
| 45 | "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", | 46 | "GF_AUTH_GENERIC_OAUTH_ENABLED": "true", |
| 46 | "GF_AUTH_GENERIC_OAUTH_NAME": "Pocket ID", | 47 | "GF_AUTH_GENERIC_OAUTH_NAME": "Pocket ID", |
| @@ -52,21 +53,25 @@ image = ( | |||
| 52 | "GF_AUTH_GENERIC_OAUTH_VALIDATE_ID_TOKEN": "true", | 53 | "GF_AUTH_GENERIC_OAUTH_VALIDATE_ID_TOKEN": "true", |
| 53 | }, | 54 | }, |
| 54 | ) | 55 | ) |
| 56 | +@modal.concurrent(max_inputs=100) | ||
| 55 | @modal.web_server(3000, startup_timeout=120) | 57 | @modal.web_server(3000, startup_timeout=120) |
| 56 | def grafana() -> None: | 58 | def grafana() -> None: |
| 57 | """Run a public Grafana login page; its data-source credentials stay server-side.""" | 59 | """Run a public Grafana login page; its data-source credentials stay server-side.""" |
| 58 | - def drop_privileges() -> None: | ||
| 59 | - # Modal ignores the Docker image's USER instruction for Functions. | ||
| 60 | - # The upstream Grafana image uses unprivileged UID 472 and group 0. | ||
| 61 | - os.setgid(GRAFANA_GID) | ||
| 62 | - os.setuid(GRAFANA_UID) | ||
| 63 | - | ||
| 64 | process = subprocess.Popen( | 60 | process = subprocess.Popen( |
| 65 | ["grafana", "server", "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini"], | 61 | ["grafana", "server", "--homepath=/usr/share/grafana", "--config=/etc/grafana/grafana.ini"], |
| 66 | - preexec_fn=drop_privileges, | 62 | + # Pass this through the spawned process as well as Modal's function |
| 63 | + # environment, since the web-server proxy reaches the service over IPv4. | ||
| 64 | + env={**os.environ, "GF_SERVER_HTTP_ADDR": "0.0.0.0"}, | ||
| 67 | ) | 65 | ) |
| 68 | - # modal.web_server waits for port 3000. Catch an immediate startup failure | 66 | + # Grafana takes a few seconds to bind. Do not return from the Modal web |
| 69 | - # early so its cause appears in Modal logs rather than as a proxy timeout. | 67 | + # function until its port is accepting connections; otherwise the proxy can |
| 70 | - time.sleep(1) | 68 | + # route the first request before a backend exists and leave it hanging. |
| 71 | - if process.poll() is not None: | 69 | + for _ in range(60): |
| 72 | - raise RuntimeError(f"Grafana exited during startup ({process.returncode})") | 70 | + if process.poll() is not None: |
| 71 | + raise RuntimeError(f"Grafana exited during startup ({process.returncode})") | ||
| 72 | + with socket.socket() as probe: | ||
| 73 | + probe.settimeout(0.25) | ||
| 74 | + if probe.connect_ex(("127.0.0.1", 3000)) == 0: | ||
| 75 | + return | ||
| 76 | + time.sleep(0.5) | ||
| 77 | + raise RuntimeError("Grafana did not listen on port 3000 within 30 seconds") | ||
added
modal_pocket_id.py +56 -0 | new file mode 100644 | ||
| @@ -0,0 +1,56 @@ | ||
| 1 | +"""Persistent Pocket ID deployment for Modal. | |
| 2 | + | |
| 3 | +The ``pocket-id`` secret must contain ``ENCRYPTION_KEY``. Pocket ID keeps its | |
| 4 | +SQLite database, signing keys, and uploads in ``/app/data``, mounted here as a | |
| 5 | +Modal Volume so they survive container replacement and scale-to-zero. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +import subprocess | |
| 9 | +import time | |
| 10 | + | |
| 11 | +import modal | |
| 12 | + | |
| 13 | +APP_NAME = "pocket-id" | |
| 14 | +APP_URL = "https://codegod100--pocket-id-serve.modal.run" | |
| 15 | +DATA_VOLUME_NAME = "pocket-id-data" | |
| 16 | + | |
| 17 | +app = modal.App(APP_NAME) | |
| 18 | +data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True) | |
| 19 | +pocket_id_secret = modal.Secret.from_name("pocket-id", required_keys=["ENCRYPTION_KEY"]) | |
| 20 | +image = ( | |
| 21 | + modal.Image.debian_slim(python_version="3.12") | |
| 22 | + .apt_install("ca-certificates", "curl") | |
| 23 | + .run_commands( | |
| 24 | + "mkdir -p /app " | |
| 25 | + "&& curl --fail --location --silent --show-error " | |
| 26 | + "https://github.com/pocket-id/pocket-id/releases/latest/download/pocket-id_linux_amd64 " | |
| 27 | + "--output /app/pocket-id " | |
| 28 | + "&& chmod 0755 /app/pocket-id" | |
| 29 | + ) | |
| 30 | +) | |
| 31 | + | |
| 32 | + | |
| 33 | +@app.function( | |
| 34 | + image=image, | |
| 35 | + secrets=[pocket_id_secret], | |
| 36 | + volumes={"/app/data": data_volume}, | |
| 37 | + min_containers=0, | |
| 38 | + max_containers=1, | |
| 39 | + scaledown_window=300, | |
| 40 | + timeout=15 * 60, | |
| 41 | + env={ | |
| 42 | + "APP_URL": APP_URL, | |
| 43 | + "ALLOW_INSECURE_CALLBACK_URLS": "false", | |
| 44 | + # Modal Volumes are network filesystems; SQLite's WAL mode is unsafe | |
| 45 | + # there. Keep a single writer and use DELETE journaling instead. | |
| 46 | + "DB_CONNECTION_STRING": "data/pocket-id.db?_journal_mode=DELETE", | |
| 47 | + }, | |
| 48 | +) | |
| 49 | +@modal.concurrent(max_inputs=100) | |
| 50 | +@modal.web_server(1411, startup_timeout=120) | |
| 51 | +def serve() -> None: | |
| 52 | + """Start Pocket ID and retain the web-server Function for its lifetime.""" | |
| 53 | + process = subprocess.Popen(["/app/pocket-id"]) | |
| 54 | + time.sleep(1) | |
| 55 | + if process.poll() is not None: | |
| 56 | + raise RuntimeError(f"Pocket ID exited during startup ({process.returncode})") | |
| new file mode 100644 | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | +"""Persistent Pocket ID deployment for Modal. | ||
| 2 | + | ||
| 3 | +The ``pocket-id`` secret must contain ``ENCRYPTION_KEY``. Pocket ID keeps its | ||
| 4 | +SQLite database, signing keys, and uploads in ``/app/data``, mounted here as a | ||
| 5 | +Modal Volume so they survive container replacement and scale-to-zero. | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +import subprocess | ||
| 9 | +import time | ||
| 10 | + | ||
| 11 | +import modal | ||
| 12 | + | ||
| 13 | +APP_NAME = "pocket-id" | ||
| 14 | +APP_URL = "https://codegod100--pocket-id-serve.modal.run" | ||
| 15 | +DATA_VOLUME_NAME = "pocket-id-data" | ||
| 16 | + | ||
| 17 | +app = modal.App(APP_NAME) | ||
| 18 | +data_volume = modal.Volume.from_name(DATA_VOLUME_NAME, create_if_missing=True) | ||
| 19 | +pocket_id_secret = modal.Secret.from_name("pocket-id", required_keys=["ENCRYPTION_KEY"]) | ||
| 20 | +image = ( | ||
| 21 | + modal.Image.debian_slim(python_version="3.12") | ||
| 22 | + .apt_install("ca-certificates", "curl") | ||
| 23 | + .run_commands( | ||
| 24 | + "mkdir -p /app " | ||
| 25 | + "&& curl --fail --location --silent --show-error " | ||
| 26 | + "https://github.com/pocket-id/pocket-id/releases/latest/download/pocket-id_linux_amd64 " | ||
| 27 | + "--output /app/pocket-id " | ||
| 28 | + "&& chmod 0755 /app/pocket-id" | ||
| 29 | + ) | ||
| 30 | +) | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +@app.function( | ||
| 34 | + image=image, | ||
| 35 | + secrets=[pocket_id_secret], | ||
| 36 | + volumes={"/app/data": data_volume}, | ||
| 37 | + min_containers=0, | ||
| 38 | + max_containers=1, | ||
| 39 | + scaledown_window=300, | ||
| 40 | + timeout=15 * 60, | ||
| 41 | + env={ | ||
| 42 | + "APP_URL": APP_URL, | ||
| 43 | + "ALLOW_INSECURE_CALLBACK_URLS": "false", | ||
| 44 | + # Modal Volumes are network filesystems; SQLite's WAL mode is unsafe | ||
| 45 | + # there. Keep a single writer and use DELETE journaling instead. | ||
| 46 | + "DB_CONNECTION_STRING": "data/pocket-id.db?_journal_mode=DELETE", | ||
| 47 | + }, | ||
| 48 | +) | ||
| 49 | +@modal.concurrent(max_inputs=100) | ||
| 50 | +@modal.web_server(1411, startup_timeout=120) | ||
| 51 | +def serve() -> None: | ||
| 52 | + """Start Pocket ID and retain the web-server Function for its lifetime.""" | ||
| 53 | + process = subprocess.Popen(["/app/pocket-id"]) | ||
| 54 | + time.sleep(1) | ||
| 55 | + if process.poll() is not None: | ||
| 56 | + raise RuntimeError(f"Pocket ID exited during startup ({process.returncode})") | ||