infra/burst/README.md (13365 bytes)
1 # Burst mode: more workers, on the cheapest cloud, when paid jobs wait 2 3 Burst mode is **off**. Nothing here runs, and nothing here costs money, until 4 the owner turns it on with the procedure below. 5 6 ## What it does 7 8 Today the web server does each server job itself, one at a time. When many 9 paid jobs wait (a 500-book pack, an unlimited plan), buyers wait for hours. 10 11 In burst mode: 12 13 - The web server keeps the data: Postgres and Redis on its own 127.0.0.1, and 14 its own worker, which takes paid jobs first and then free ones. 15 - The files go to a Cloudflare R2 bucket. R2 charges no transfer fee, to any 16 cloud or to a customer who downloads a result. 17 - Once a minute the burst controller (`python -m tools.burst`) reads the paid 18 queue. When paid jobs wait, it reads the live offers of each cloud that has 19 keys: the newest AWS spot prices, and the Hetzner prices of the server types 20 that are in stock. A new worker goes to the offer with the lowest cost per 21 book, inside the spend cap. 22 - A worker reaches Postgres and Redis through an SSH tunnel to the web server. 23 The tunnel user can forward those two ports and do nothing else. A worker 24 takes no inbound connection. 25 - A worker with no job for 10 minutes stops after its job, and Terraform 26 destroys its machine. A scale-in never kills a job. When a cloud takes a spot 27 machine back during a job, the job runs once more on another worker. 28 - Free credits never start a machine: burst workers take paid jobs only. 29 30 ``` 31 web server (RackNerd, US west) 32 Postgres + Redis on 127.0.0.1, own worker, controller 33 ^ | 34 SSH tunnel | | terraform apply 35 (2 ports) | v 36 burst workers on AWS spot (us-west-2) or Hetzner, 0..N 37 | 38 v 39 Cloudflare R2 bucket (inputs, results, app releases, backups) 40 ``` 41 42 ## Money 43 44 **Spend cap.** The controller keeps the cost of the burst machines of the last 45 30 days under a cap: 46 47 cap = min($100, max($5, 30% of the net server revenue of the last 30 days)) 48 49 The net revenue counts each pack for 30 days after its purchase, and each 50 active subscription, after the Stripe fee (2.9% + 30 cents, and 0.7% more for 51 a subscription). Free credits count for nothing. The $5 floor lets the first 52 buyer have a burst worker. The controller also keeps 2.5 hours of each running 53 worker in reserve. At the cap it starts no new machine, and running jobs 54 finish. Change the numbers with `SUBPLZ_BURST_CAP_SHARE`, 55 `SUBPLZ_BURST_CAP_FLOOR_USD` and `SUBPLZ_BURST_CAP_CEILING_USD`. 56 57 **Other limits.** `SUBPLZ_BURST_MAX_WORKERS` (default 1). The AWS spot price 58 limit (`max_price`, $0.0625 an hour). The AWS spot limit of a new account (5 59 vCPU: one worker). The Hetzner server limit. A machine powers itself off after 60 90 idle minutes, if the controller is gone. 61 62 **Prices** (financial advocate, 2026-09-22; the controller reads the live ones): 63 64 | Offer | Per hour | Compute per 10-hour book | Note | 65 |---|---|---|---| 66 | AWS spot, 4-vCPU x86 (c6a, c7a, m6a) | about $0.06, + $0.007 IPv4 and disk | about $0.14 | billed per second | 67 | AWS spot, 4-vCPU Graviton (c7g) | $0.041-0.056 | $0.10-0.14 | off until one test book: add to `SUBPLZ_BURST_AWS_TYPES` | 68 | Hetzner EU cx33 | about $0.016 | about $0.05 | often sold out; billed per started hour | 69 | Hetzner EU cpx32 | about $0.065 | $0.14-0.20 | 155 ms from the web server | 70 | Hetzner US cpx31 | about $0.115 | $0.25-0.35 | | 71 | R2 bucket | $0.015 per GB-month | | no transfer fee; 10 GB free | 72 73 While burst mode is on and idle, only the R2 storage costs money. AWS charges 74 $0.09/GB for the results that its workers send to R2, above 100 GB a month: 75 when you pass that, set `SUBPLZ_BURST_AWS_EGRESS_USD_PER_BOOK` (about 0.09 x 76 the GB of a result), so that the price comparison includes it. 77 78 ## Parts 79 80 | Path | What | 81 |---|---| 82 | `state/` | The web server's Postgres, Redis and tunnel user (`setup-web-server.sh`), the R2 bucket and its token. Apply once, on the web server. | 83 | `workers/aws/` | One spot instance for each AWS worker. Only the controller applies it. `iam-policy.json` is the policy of the controller's AWS key. | 84 | `workers/hetzner/` | One server for each Hetzner worker. Only the controller applies it. | 85 | `cloud-init.yaml.tftpl` | What a new machine does at first boot, on either cloud. | 86 | `worker-constraints.txt` | The package versions of the web server, so that a worker computes the same result. | 87 | `systemd/` | Units for the web server: the controller timer and the web server's own worker. | 88 | `../../tools/burst/` | The controller: `core.py` decides, `offers.py` reads the prices, `revenue.py` reads the revenue. | 89 | `../../tools/copy_database.py` | Copies SQLite to Postgres (once, when burst mode goes on) and back. | 90 91 ## Accounts (owner steps, once) 92 93 Only the owner does these steps. Claude never types a key. 94 95 1. **Cloudflare**: an account with R2 turned on (done on 2026-09-22). At 96 activation, make an API token for Terraform (My Profile, API Tokens, Create 97 Custom Token) with two permissions: Account, Workers R2 Storage, Edit; and 98 User, API Tokens, Edit. Write down the account ID (R2 page, right side). 99 2. **AWS**, for spot workers: an account on the Paid plan (the Free plan blocks 100 xlarge machines), with MFA on the root user. Make an IAM user 101 `subplz-burst` with the policy in `workers/aws/iam-policy.json`, and an 102 access key for it. Sign up: [AWS sign-up](https://portal.aws.amazon.com/billing/signup). 103 3. **Hetzner**, for Hetzner workers: an account, a project `subplz-burst` with 104 nothing else in it, and an API token (Read & Write) in that project. Check 105 the server limit of the project (Limits). Sign up: 106 [Hetzner sign-up](https://accounts.hetzner.com/signUp). 107 108 The controller uses each cloud that has keys. One cloud is enough; two let it 109 switch to the cheaper one. 110 111 ## Turn burst mode on 112 113 Do these steps as root on the web server. The site is down from step 8 to 114 step 12, about 10 minutes. 115 116 1. Install Terraform: 117 118 ```bash 119 wget -qO- https://apt.releases.hashicorp.com/gpg | gpg --dearmor -o /usr/share/keyrings/hashicorp.gpg 120 echo "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com bookworm main" > /etc/apt/sources.list.d/hashicorp.list 121 apt-get update && apt-get install -y terraform 122 ``` 123 124 2. Put the keys in `/etc/subplz-burst.env`. Each command asks for the value 125 with the input hidden. Leave out the lines of a cloud that you do not use: 126 127 ```bash 128 cd /opt/subplz-web 129 tools/set-secret.sh CLOUDFLARE_API_TOKEN /etc/subplz-burst.env 130 tools/set-secret.sh SUBPLZ_BURST_AWS_ACCESS_KEY_ID /etc/subplz-burst.env 131 tools/set-secret.sh SUBPLZ_BURST_AWS_SECRET_ACCESS_KEY /etc/subplz-burst.env 132 tools/set-secret.sh HCLOUD_TOKEN /etc/subplz-burst.env 133 echo "SUBPLZ_BURST_MAX_WORKERS=1" >> /etc/subplz-burst.env 134 ``` 135 136 3. Install the queue, storage and Postgres clients into the app, and make the 137 folder of the Terraform state (it holds passwords): 138 139 ```bash 140 .venv/bin/pip install -c infra/burst/worker-constraints.txt redis rq boto3 "psycopg[binary]" 141 install -d -m 700 /var/lib/subplz-burst 142 ``` 143 144 4. Make the shared state. This installs Postgres, Redis and the tunnel user on 145 this machine, and makes the bucket and its token. Read the plan before you 146 type `yes`: 147 148 ```bash 149 set -a; . /etc/subplz-burst.env; set +a 150 S=/opt/subplz-web/infra/burst/state 151 terraform -chdir=$S init -backend-config=path=/var/lib/subplz-burst/state.tfstate 152 terraform -chdir=$S apply -var cloudflare_account_id=YOUR_ACCOUNT_ID 153 ``` 154 155 5. Write the storage keys and the backup settings: 156 157 ```bash 158 (umask 077 159 terraform -chdir=$S output -raw storage_env > /etc/subplz-storage.env 160 terraform -chdir=$S output -raw rclone_env > /etc/subplz-rclone.env 161 echo "BUCKET=$(terraform -chdir=$S output -raw bucket)" > /etc/subplz-backup.env) 162 ``` 163 164 6. Make the backups: 165 166 ```bash 167 cp .env .env.bak-before-burst 168 cp data/subplz.db data/subplz.db.bak-before-burst 169 ``` 170 171 7. Read the new database address: 172 173 ```bash 174 DB=$(terraform -chdir=$S output -raw web_env | sed -n 's/^SUBPLZ_WEB_DATABASE_URL=//p') 175 ``` 176 177 8. Stop the site: 178 179 ```bash 180 systemctl stop subplz-web 181 ``` 182 183 9. Copy the database to Postgres, and the files to the bucket: 184 185 ```bash 186 .venv/bin/python -m tools.copy_database sqlite:////opt/subplz-web/data/subplz.db "$DB" 187 env $(cat /etc/subplz-rclone.env) rclone copy --progress data/artifacts "r2:$(terraform -chdir=$S output -raw bucket)/jobs" 188 ``` 189 190 10. Tell the app to use the queue, Postgres and the bucket: 191 192 ```bash 193 terraform -chdir=$S output -raw web_env >> .env 194 install -d /etc/systemd/system/subplz-web.service.d 195 printf '[Service]\nEnvironmentFile=/etc/subplz-storage.env\n' > /etc/systemd/system/subplz-web.service.d/storage.conf 196 cp infra/burst/systemd/subplz-worker.service infra/burst/systemd/subplz-burst.* /etc/systemd/system/ 197 systemctl daemon-reload 198 ``` 199 200 11. Start the site, the web server's own worker, and the nightly backup: 201 202 ```bash 203 systemctl start subplz-web 204 systemctl enable --now subplz-worker subplz-pgdump.timer 205 ``` 206 207 12. Do a test job on the site with "Convert in this browser" off. Make sure 208 that it succeeds and that its files download. If it fails, turn burst mode 209 off (below) before you look for the cause. 210 211 13. Prepare the worker modules of each cloud that has keys: 212 213 ```bash 214 for c in aws hetzner; do 215 terraform -chdir=infra/burst/workers/$c init -backend-config=path=/var/lib/subplz-burst/workers-$c.tfstate 216 done 217 ``` 218 219 14. Start the controller. Do a dry run first: it logs its decision, the offers 220 and the spend cap, and changes nothing: 221 222 ```bash 223 set -a; . ./.env; . /etc/subplz-storage.env; . /etc/subplz-burst.env; set +a 224 .venv/bin/python -m tools.burst --dry-run 225 systemctl enable --now subplz-burst.timer 226 ``` 227 228 15. Watch it: `journalctl -u subplz-burst -f`. A new worker needs about 10 229 minutes to install before it takes a job. 230 231 16. Measure one book on each cloud that the controller used. If a 10-hour book 232 takes much more or less than 2 hours on 4 vCPU, the price comparison is 233 off: tell Claude the times. 234 235 ## While burst mode is on 236 237 - **Deploy**: after `git pull`, restart `subplz-web` and `subplz-worker`. The 238 controller sees the new commit. It stops the workers of the old commit after 239 their jobs and starts new ones. You do nothing else. 240 - **Workers**: change `SUBPLZ_BURST_MAX_WORKERS` in `/etc/subplz-burst.env`. 241 The next run uses it. With `0`, each burst worker stops after its job and its 242 machine goes. 243 - **AWS spot limit**: when paid jobs often wait for more than an hour, ask AWS 244 for more spot vCPU (Service Quotas, "All Standard Spot Instance Requests"), 245 then raise `SUBPLZ_BURST_AWS_VCPU_LIMIT` and `SUBPLZ_BURST_MAX_WORKERS`. 246 - **Keys**: do not change the passwords, the tunnel key or the bucket token 247 while a worker runs. Set the workers to 0 first, and wait until 248 `terraform -chdir=infra/burst/workers/<cloud> state list` shows nothing. 249 - **State**: `/var/lib/subplz-burst` is the only record of what Terraform made. 250 If it is lost, the machines still run and cost money: find them in the AWS 251 and Hetzner consoles (tag or label `subplz-burst-worker`) and delete them. 252 - **Storage**: nothing deletes files. R2 costs $0.015 per GB-month; watch the 253 bucket size in the Cloudflare dashboard. 254 255 ## Turn burst mode off 256 257 1. Set `SUBPLZ_BURST_MAX_WORKERS=0`. Wait until the `state list` of each worker 258 module shows nothing. Then: 259 `systemctl disable --now subplz-burst.timer subplz-worker subplz-pgdump.timer`. 260 2. Stop the site. Copy Postgres to a new SQLite file, and the bucket to the 261 disk: 262 263 ```bash 264 systemctl stop subplz-web 265 .venv/bin/python -m tools.copy_database "$DB" sqlite:////opt/subplz-web/data/subplz-from-postgres.db 266 env $(cat /etc/subplz-rclone.env) rclone copy --progress "r2:$(terraform -chdir=$S output -raw bucket)/jobs" data/artifacts 267 mv data/subplz.db data/subplz.db.bak-burst && mv data/subplz-from-postgres.db data/subplz.db 268 ``` 269 270 3. Remove the burst lines from `.env` (the six lines that step 10 added), and 271 remove `/etc/systemd/system/subplz-web.service.d/storage.conf`. Then 272 `systemctl daemon-reload && systemctl start subplz-web`, and do a test job. 273 4. Only after the test job succeeds: remove the bucket and its token with 274 `terraform -chdir=$S destroy`. It deletes the bucket with its files. 275 Postgres and Redis stay installed on the web server, unused. 276 277 ## Security 278 279 - Postgres and Redis listen on 127.0.0.1 only. A worker reaches them through 280 an SSH tunnel as `subplz-tunnel`, a user with no shell whose key can forward 281 those two ports and nothing else. The worker checks the web server's host key. 282 - A worker accepts no inbound connection (AWS security group and Hetzner 283 firewall without inbound rules). 284 - The bucket token can read and write that one bucket only. The AWS key can end 285 only instances tagged `subplz-burst-worker`. 286 - The state files and `/etc/subplz-*.env` hold secrets: root only, mode 0600. 287 A machine's user data holds the tunnel key and the database passwords; only 288 the cloud account and the machine itself can read it.