Recently Written · git

subplz-web

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

Log | Files | Refs


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.