tools/burst/__main__.py (13597 bytes)
1 """Burst controller: add workers on the cheapest cloud when paid jobs wait. 2 3 The web server runs `python -m tools.burst` once a minute (a systemd timer, 4 see infra/burst/README.md). One run does this: 5 6 1. Read the paid queue in Redis, and the state of each burst worker. 7 2. Read the live offers of each cloud that has credentials (AWS spot prices, 8 Hetzner prices and stock), and the net revenue of the last 30 days. 9 3. Decide (core.decide): new workers go to the offer with the lowest cost per 10 book, inside the spend cap. Idle workers get the rq shutdown command: rq 11 lets a worker finish its job first, so a scale-in never kills a paid job. 12 4. For each cloud whose set of workers changed: write its Terraform variable 13 file and run `terraform apply` on infra/burst/workers/<cloud>. If the apply 14 fails, that cloud rests for 30 minutes, and the next run uses the next 15 cheapest offer. 16 17 The host name of a worker is its machine name, and rq records the host name of 18 each worker. That is how a machine and its rq worker are matched. 19 """ 20 21 from __future__ import annotations 22 23 import argparse 24 import json 25 import logging 26 import os 27 import subprocess 28 import sys 29 import tempfile 30 import time 31 from datetime import datetime, timedelta, timezone 32 from pathlib import Path 33 34 from .core import NAME_PREFIX, Config, Memory, Offer, decide, spend_30d, spend_cap_usd 35 36 log = logging.getLogger("subplz.burst") 37 38 QUEUE_NAME = "subplz-jobs-paid" # backend.queue.PAID_QUEUE: burst workers take paid jobs only 39 RELEASE_PREFIX = "releases/" 40 PROVIDERS = ("aws", "hetzner") 41 COOLDOWN_S = 30 * 60 42 43 44 # --- Redis --------------------------------------------------------------------- 45 46 def read_queue(conn) -> tuple[int, dict[str, str], dict[str, str]]: 47 """Waiting paid jobs, the state of each burst worker, and its rq worker name.""" 48 from rq import Queue, Worker 49 50 waiting = Queue(QUEUE_NAME, connection=conn).count 51 live: dict[str, str] = {} 52 rq_names: dict[str, str] = {} 53 for worker in Worker.all(connection=conn): 54 host = worker.hostname or "" 55 if not host.startswith(NAME_PREFIX): 56 continue # the web server's own worker, or one that this code does not manage 57 live[host] = "busy" if worker.get_state() == "busy" else "idle" 58 rq_names[host] = worker.name 59 return waiting, live, rq_names 60 61 62 def send_drain(conn, rq_names: dict[str, str], names: frozenset[str]) -> None: 63 from rq.command import send_shutdown_command 64 65 for name in sorted(names): 66 if name in rq_names: 67 send_shutdown_command(conn, rq_names[name]) 68 log.info("told %s to stop after its current job", name) 69 70 71 # --- files --------------------------------------------------------------------- 72 73 def load_memory(path: Path) -> Memory: 74 if not path.exists(): 75 return Memory() 76 return Memory.from_json(json.loads(path.read_text(encoding="utf-8"))) 77 78 79 def save_json(path: Path, data: dict) -> None: 80 """Write `data` so that a crash never leaves half a file.""" 81 path.parent.mkdir(parents=True, exist_ok=True) 82 fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp") 83 with os.fdopen(fd, "w", encoding="utf-8") as out: 84 json.dump(data, out, indent=2, sort_keys=True) 85 os.replace(tmp, path) 86 87 88 def provider_vars(memory: Memory, provider: str) -> dict: 89 """The Terraform variables of one cloud: its workers.""" 90 return {"workers": { 91 name: {"ref": w["ref"], "machine": w["machine"], "zone": w["zone"]} 92 for name, w in sorted(memory.workers.items()) if w["provider"] == provider 93 }} 94 95 96 # --- clouds -------------------------------------------------------------------- 97 98 def env_float(name: str, default: float) -> float: 99 return float(os.environ.get(name, default)) 100 101 102 def env_list(name: str, default: str) -> list[str]: 103 return [v.strip() for v in os.environ.get(name, default).split(",") if v.strip()] 104 105 106 def vcpus_in_use(memory: Memory, provider: str) -> int: 107 from .offers import AWS_VCPUS 108 109 return sum(AWS_VCPUS.get(w["machine"], 4) for w in memory.workers.values() 110 if w["provider"] == provider) 111 112 113 def gather_offers(memory: Memory) -> list[Offer]: 114 """The live offers of each cloud that has credentials. A cloud whose API 115 fails gives no offers this run; the others still do.""" 116 from . import offers 117 118 found: list[Offer] = [] 119 if os.environ.get("SUBPLZ_BURST_AWS_ACCESS_KEY_ID"): 120 try: 121 import boto3 122 123 ec2 = boto3.client( 124 "ec2", region_name=os.environ.get("SUBPLZ_BURST_AWS_REGION", "us-west-2"), 125 aws_access_key_id=os.environ["SUBPLZ_BURST_AWS_ACCESS_KEY_ID"], 126 aws_secret_access_key=os.environ["SUBPLZ_BURST_AWS_SECRET_ACCESS_KEY"], 127 ) 128 machines = env_list("SUBPLZ_BURST_AWS_TYPES", "c6a.xlarge,c7a.xlarge,m6a.xlarge") 129 since = datetime.now(timezone.utc) - timedelta(hours=1) 130 limit = int(os.environ.get("SUBPLZ_BURST_AWS_VCPU_LIMIT", "5")) 131 capacity = max(0, limit - vcpus_in_use(memory, "aws")) // 4 132 found += offers.aws_offers( 133 offers.fetch_aws_spot_prices(ec2, machines, since), 134 max_price=env_float("SUBPLZ_BURST_AWS_MAX_PRICE", 0.0625), 135 # A public IPv4 address ($0.005/h) and a 20 GB disk ($0.0022/h). 136 extra_usd_per_hour=env_float("SUBPLZ_BURST_AWS_EXTRA_USD_PER_HOUR", 0.0072), 137 # AWS gives 100 GB of transfer out each month for free. Above it, 138 # each result that a worker sends to R2 costs $0.09/GB. 139 extra_usd_per_book=env_float("SUBPLZ_BURST_AWS_EGRESS_USD_PER_BOOK", 0.0), 140 capacity=capacity, 141 ) 142 except Exception: # noqa: BLE001 - one cloud failing must not stop the others 143 log.exception("AWS offers failed") 144 if os.environ.get("HCLOUD_TOKEN"): 145 try: 146 import requests 147 148 def get_json(path: str) -> dict: 149 r = requests.get(f"https://api.hetzner.cloud/v1{path}", timeout=20, 150 headers={"Authorization": f"Bearer {os.environ['HCLOUD_TOKEN']}"}) 151 r.raise_for_status() 152 return r.json() 153 154 limit = int(os.environ.get("SUBPLZ_BURST_HETZNER_SERVER_LIMIT", "5")) 155 in_use = sum(1 for w in memory.workers.values() if w["provider"] == "hetzner") 156 found += offers.hetzner_offers( 157 get_json, 158 machines=set(env_list("SUBPLZ_BURST_HETZNER_TYPES", "cx33,cx43,cpx31,cpx32,ccx13")), 159 usd_per_eur=env_float("SUBPLZ_BURST_USD_PER_EUR", 1.15), 160 # A primary IPv4 address: 0.50 EUR a month. 161 ipv4_eur_per_hour=env_float("SUBPLZ_BURST_HETZNER_IPV4_EUR_PER_HOUR", 0.0007), 162 capacity=max(0, limit - in_use), 163 ) 164 except Exception: # noqa: BLE001 165 log.exception("Hetzner offers failed") 166 return found 167 168 169 def revenue_30d() -> float: 170 from backend.db import SessionLocal 171 172 from .revenue import revenue_30d_usd 173 174 with SessionLocal() as session: 175 return revenue_30d_usd(session, datetime.now(timezone.utc)) 176 177 178 def terraform_env(provider: str) -> dict[str, str]: 179 """The environment of one `terraform apply`. The storage keys of the app are 180 also AWS_* variables (R2 speaks the S3 protocol), so they must not reach the 181 AWS provider: it gets the burst keys instead.""" 182 env = {k: v for k, v in os.environ.items() 183 if not (k.startswith("AWS_") or k.startswith("SUBPLZ_WEB_"))} 184 if provider == "aws": 185 env["AWS_ACCESS_KEY_ID"] = os.environ.get("SUBPLZ_BURST_AWS_ACCESS_KEY_ID", "") 186 env["AWS_SECRET_ACCESS_KEY"] = os.environ.get("SUBPLZ_BURST_AWS_SECRET_ACCESS_KEY", "") 187 env["AWS_REGION"] = os.environ.get("SUBPLZ_BURST_AWS_REGION", "us-west-2") 188 return env 189 190 191 def terraform_apply(terraform: list[str], module_dir: Path, var_file: Path, env: dict) -> bool: 192 cmd = [*terraform, f"-chdir={module_dir}", "apply", "-auto-approve", 193 "-input=false", "-no-color", f"-var-file={var_file}"] 194 result = subprocess.run(cmd, capture_output=True, text=True, env=env) 195 if result.returncode != 0: 196 log.error("terraform apply failed in %s (%s):\n%s", module_dir, result.returncode, 197 result.stderr[-4000:]) 198 return False 199 return True 200 201 202 def ensure_release(app_dir: Path, ref: str, bucket: str) -> None: 203 """Put the app at `ref` in the bucket, where new workers download it: the 204 repository is private, and a worker must run the web server's commit.""" 205 import boto3 206 from botocore.exceptions import ClientError 207 208 s3 = boto3.client("s3") 209 key = f"{RELEASE_PREFIX}{ref}.tar.gz" 210 try: 211 s3.head_object(Bucket=bucket, Key=key) 212 return 213 except ClientError as exc: 214 if exc.response.get("Error", {}).get("Code") not in ("404", "NoSuchKey", "NotFound"): 215 raise 216 with tempfile.TemporaryDirectory() as tmp: 217 archive = Path(tmp) / "app.tar.gz" 218 subprocess.run(["git", "-C", str(app_dir), "archive", "--format=tar.gz", 219 "-o", str(archive), ref], check=True) 220 s3.upload_file(str(archive), bucket, key) 221 log.info("uploaded %s", key) 222 223 224 def rollback(provider: str, old: Memory, new: Memory, now: float) -> Memory: 225 """Undo one cloud's part of a decision after its apply failed, and rest it.""" 226 workers = {n: w for n, w in new.workers.items() if w["provider"] != provider} 227 workers.update({n: w for n, w in old.workers.items() if w["provider"] == provider}) 228 new.workers = workers 229 new.ledger = [e for e in new.ledger 230 if not (e.get("provider") == provider and e["end"] == now)] 231 new.draining &= set(workers) 232 new.cooldown = {**new.cooldown, provider: now + COOLDOWN_S} 233 return new 234 235 236 # --- one run --------------------------------------------------------------------- 237 238 def run(args: argparse.Namespace) -> int: 239 from redis import Redis 240 241 now = time.time() 242 config = Config(max_workers=args.max_workers) 243 ref = subprocess.run(["git", "-C", str(args.app_dir), "rev-parse", "HEAD"], 244 check=True, capture_output=True, text=True).stdout.strip() 245 conn = Redis.from_url(args.redis_url) 246 waiting, live, rq_names = read_queue(conn) 247 state_file = args.state_dir / "controller.json" 248 memory = load_memory(state_file) 249 offers = gather_offers(memory) if waiting else [] 250 cap = spend_cap_usd(revenue_30d(), k=args.cap_share, floor=args.cap_floor, ceiling=args.cap_ceiling) 251 decision, new_memory = decide(now, waiting, live, memory, config, ref, offers, cap) 252 log.info("waiting=%d live=%s spend30=$%.2f cap=$%.2f add=%s drain=%s remove=%s", 253 waiting, live, spend_30d(now, memory), cap, 254 [(n, o.provider, o.machine, o.zone, round(o.usd_per_book(config), 3)) 255 for n, o in decision.add], 256 sorted(decision.drain), sorted(decision.remove)) 257 if waiting and not decision.add and not live: 258 log.warning("paid jobs wait, and no burst worker can start (spend cap, or no offer)") 259 if args.dry_run: 260 return 0 261 262 send_drain(conn, rq_names, decision.drain) 263 if decision.add: 264 ensure_release(args.app_dir, ref, args.bucket) 265 status = 0 266 for provider in PROVIDERS: 267 if provider_vars(memory, provider) == provider_vars(new_memory, provider): 268 continue 269 var_file = args.state_dir / f"workers-{provider}.tfvars.json" 270 save_json(var_file, provider_vars(new_memory, provider)) 271 if not terraform_apply(args.terraform, args.modules_dir / provider, var_file, 272 terraform_env(provider)): 273 new_memory = rollback(provider, memory, new_memory, now) 274 save_json(var_file, provider_vars(new_memory, provider)) 275 status = 1 276 save_json(state_file, new_memory.to_json()) 277 return status 278 279 280 def main(argv: list[str] | None = None) -> int: 281 logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 282 p = argparse.ArgumentParser(prog="python -m tools.burst", description=__doc__.splitlines()[0]) 283 p.add_argument("--max-workers", type=int, 284 default=int(os.environ.get("SUBPLZ_BURST_MAX_WORKERS", "1"))) 285 p.add_argument("--cap-share", type=float, default=env_float("SUBPLZ_BURST_CAP_SHARE", 0.30), 286 help="share of the net revenue of 30 days that burst workers may spend") 287 p.add_argument("--cap-floor", type=float, default=env_float("SUBPLZ_BURST_CAP_FLOOR_USD", 5.0)) 288 p.add_argument("--cap-ceiling", type=float, default=env_float("SUBPLZ_BURST_CAP_CEILING_USD", 100.0)) 289 p.add_argument("--redis-url", default=os.environ.get("SUBPLZ_WEB_REDIS_URL", "")) 290 p.add_argument("--bucket", default=os.environ.get("SUBPLZ_WEB_S3_BUCKET", "")) 291 p.add_argument("--app-dir", type=Path, default=Path("/opt/subplz-web")) 292 p.add_argument("--modules-dir", type=Path, default=Path("/opt/subplz-web/infra/burst/workers")) 293 p.add_argument("--state-dir", type=Path, default=Path("/var/lib/subplz-burst")) 294 p.add_argument("--terraform", nargs="+", default=["terraform"]) 295 p.add_argument("--dry-run", action="store_true", help="decide and log, change nothing") 296 args = p.parse_args(argv) 297 if not args.redis_url or not args.bucket: 298 p.error("set SUBPLZ_WEB_REDIS_URL and SUBPLZ_WEB_S3_BUCKET (or --redis-url, --bucket)") 299 return run(args) 300 301 302 if __name__ == "__main__": 303 sys.exit(main())