Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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())