mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 16:04:55 +00:00
Hetzner CX32 + 16 Primary IPs becomes 16 sending identities with one
install command, without expanding ops complexity.
cmd/worker/main.go: WORKER_ID now resolves via 4-tier precedence:
1. WORKER_ID env (explicit UUID)
2. WORKER_BIND_IP env (derive UUIDv5 from the bound IP)
3. hostname-as-UUID (legacy single-IP VPS)
4. generated UUID (local dev fallback)
Boot also constructs the chosen Codec + EventBus + EncryptedKeyStore
via the FromEnv factories from earlier commits, so a worker process is
fully configured by its envelope env file plus the runtime config it
pulls from the backend on first boot.
scripts/install-worker.sh gains --ips <ipv4,ipv4,...> which:
- writes a warmbly-worker@.service systemd template
- drops a per-instance env file at /etc/warmbly/instances/<dashed-ip>.env
with WORKER_BIND_IP and WORKER_ID
- shares one /etc/warmbly/worker.env for the common config
- --status, --update, --uninstall now multi-IP aware
- single-IP mode preserved when --ips is absent
5 worker tests pin the UUIDv5 derivation against the installer's
uuidgen --sha1 output so the two never drift.
docs/MULTI_IP_WORKERS.md is the operator runbook with the Hetzner
recipe, OS-level IP attachment, rDNS automation, day-2 ops, and the
25%-of-fleet blast-radius rule.
206 lines
6.8 KiB
Markdown
206 lines
6.8 KiB
Markdown
# Multi-IP Worker Deployment
|
|
|
|
This runbook covers the hosted Warmbly deployment pattern where one physical
|
|
box runs many worker processes, one per IP. The intent is to spread sending
|
|
across many sender identities (one worker per IP) without exploding the
|
|
number of VMs we operate.
|
|
|
|
## Why one worker per IP
|
|
|
|
Each row in the `workers` table is effectively an *egress*: a unique
|
|
`(worker_id, ip_addr)` that the control plane assigns mailboxes to and
|
|
routes Kafka work toward. The worker process binds outbound SMTP/IMAP/HTTPS
|
|
to that IP via `WORKER_BIND_IP` and the `internal/client/netbind` helper.
|
|
So:
|
|
|
|
- 1 IP per process keeps reputation per-IP measurable and isolatable
|
|
- N processes per box keeps ops density high without inflating VM count
|
|
- A bad IP can be quarantined by stopping a single instance, not the box
|
|
|
|
## Reference recipe: Hetzner CX32 + 16 Primary IPs
|
|
|
|
CX32 = 4 vCPU, 8GB RAM, 80GB disk. Comfortable for 16 lightweight worker
|
|
processes plus a Docker daemon. If you need more headroom, step up to CX42.
|
|
|
|
### 1. Order the IPs
|
|
|
|
In the Hetzner Cloud console, allocate 16 Primary IPv4 addresses in the
|
|
same datacenter as the server and attach all of them to the CX32.
|
|
|
|
### 2. Configure them at the OS level
|
|
|
|
Hetzner only auto-configures the first Primary IP. The others must be added
|
|
explicitly. With `iproute2`:
|
|
|
|
```bash
|
|
# eth0 is the public interface; check `ip link` to confirm.
|
|
for ip in 5.6.7.11 5.6.7.12 5.6.7.13 5.6.7.14 5.6.7.15 5.6.7.16 \
|
|
5.6.7.17 5.6.7.18 5.6.7.19 5.6.7.20 5.6.7.21 5.6.7.22 \
|
|
5.6.7.23 5.6.7.24 5.6.7.25 5.6.7.26; do
|
|
ip addr add "${ip}/32" dev eth0
|
|
done
|
|
```
|
|
|
|
To make that survive reboot, use netplan (Ubuntu) or systemd-networkd. A
|
|
netplan example:
|
|
|
|
```yaml
|
|
# /etc/netplan/60-extra-ips.yaml
|
|
network:
|
|
version: 2
|
|
ethernets:
|
|
eth0:
|
|
addresses:
|
|
- 5.6.7.11/32
|
|
- 5.6.7.12/32
|
|
# ...one entry per extra IP
|
|
```
|
|
|
|
Run `netplan apply` and confirm with `ip -4 addr show eth0` that all 16
|
|
addresses are bound.
|
|
|
|
### 3. Set rDNS per IP
|
|
|
|
Reverse DNS (PTR) records help inbox providers identify and trust your
|
|
sending. Set the PTR for each IP via the Hetzner API:
|
|
|
|
```bash
|
|
HCLOUD_TOKEN="..."
|
|
for ip in 5.6.7.11 5.6.7.12 5.6.7.13 5.6.7.14 5.6.7.15 5.6.7.16 \
|
|
5.6.7.17 5.6.7.18 5.6.7.19 5.6.7.20 5.6.7.21 5.6.7.22 \
|
|
5.6.7.23 5.6.7.24 5.6.7.25 5.6.7.26; do
|
|
hostname="mail-$(echo "$ip" | tr . -).send.warmbly.com"
|
|
curl -fsS -X POST "https://api.hetzner.cloud/v1/primary_ips/<PRIMARY_IP_ID>/actions/change_dns_ptr" \
|
|
-H "Authorization: Bearer ${HCLOUD_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
-d "{\"ip\":\"${ip}\",\"dns_ptr\":\"${hostname}\"}"
|
|
done
|
|
```
|
|
|
|
Then add matching forward A records for those hostnames so rDNS + forward
|
|
DNS agree. SPF, DKIM, and DMARC alignment for the sending domain still
|
|
applies at the mailbox level, not the IP level.
|
|
|
|
### 4. Install the workers
|
|
|
|
One command brings up 16 worker processes, each bound to a distinct IP:
|
|
|
|
```bash
|
|
sudo ./install-worker.sh \
|
|
--kafka kafka.warmbly.com:9092 \
|
|
--schema-registry https://schema.warmbly.com \
|
|
--redis redis://cache.warmbly.com:6379 \
|
|
--aws-key AKIA... --aws-secret ... --aws-region us-east-1 \
|
|
--ips 5.6.7.11,5.6.7.12,5.6.7.13,5.6.7.14,5.6.7.15,5.6.7.16,5.6.7.17,5.6.7.18,5.6.7.19,5.6.7.20,5.6.7.21,5.6.7.22,5.6.7.23,5.6.7.24,5.6.7.25,5.6.7.26
|
|
```
|
|
|
|
Under the hood:
|
|
|
|
- A `warmbly-worker@.service` systemd template unit is written once
|
|
- Per-instance env files land at `/etc/warmbly/instances/<dashed-ip>.env`
|
|
each carrying `WORKER_BIND_IP` and the derived `WORKER_ID`
|
|
- 16 instances are enabled and started:
|
|
`warmbly-worker@5-6-7-11`, `warmbly-worker@5-6-7-12`, ...
|
|
- All instances share `/etc/warmbly/worker.env` for Kafka, KMS, Redis, etc.
|
|
|
|
The worker IDs are deterministic UUIDv5 values derived from the IP, so
|
|
reinstalling with the same `--ips` set produces the same IDs and reuses the
|
|
existing `workers` rows / reputation. Adding or removing an IP only
|
|
affects that instance; the rest keep sending.
|
|
|
|
## Day-2 operations
|
|
|
|
### Check status across all instances
|
|
|
|
```bash
|
|
sudo ./install-worker.sh --status
|
|
```
|
|
|
|
Prints one row per instance with IP, instance name, systemd state, and
|
|
worker ID.
|
|
|
|
### Tail one instance
|
|
|
|
```bash
|
|
journalctl -u warmbly-worker@5-6-7-11 -f
|
|
```
|
|
|
|
### Restart one instance
|
|
|
|
```bash
|
|
sudo systemctl restart warmbly-worker@5-6-7-11
|
|
```
|
|
|
|
### Quarantine one IP
|
|
|
|
If an IP's reputation tanks, take just that instance down. The rest of the
|
|
box keeps sending and the control plane will stop assigning new work to the
|
|
silent worker via the existing heartbeat-based assignment logic.
|
|
|
|
```bash
|
|
sudo systemctl stop warmbly-worker@5-6-7-11
|
|
sudo systemctl disable warmbly-worker@5-6-7-11
|
|
```
|
|
|
|
When the IP is recovered (rDNS fixed, mailbox health restored, etc.):
|
|
|
|
```bash
|
|
sudo systemctl enable --now warmbly-worker@5-6-7-11
|
|
```
|
|
|
|
### Update all instances to a new image tag
|
|
|
|
```bash
|
|
sudo WARMBLY_WORKER_IMAGE=ghcr.io/warmbly/worker:v1.42.0 \
|
|
./install-worker.sh --update
|
|
```
|
|
|
|
`--update` detects multi-IP installs automatically and restarts every
|
|
instance.
|
|
|
|
### Uninstall
|
|
|
|
```bash
|
|
sudo ./install-worker.sh --uninstall
|
|
```
|
|
|
|
Stops every instance, removes the template unit and per-instance env
|
|
files, and keeps the shared config under `/etc/warmbly/` so a reinstall is
|
|
fast. Use `--purge` to also delete the shared config.
|
|
|
|
## Health-check model
|
|
|
|
Each worker process runs its own heartbeat goroutine
|
|
(`WorkerService.Heartbeat`). The control plane sees N independent heartbeats
|
|
from one box and treats each one as a distinct sending identity:
|
|
|
|
- one IP down -> one missed heartbeat -> control plane stops routing
|
|
campaign/warmup work to that worker only
|
|
- box down -> all N heartbeats stop -> control plane reassigns affected
|
|
mailboxes to other workers per the existing tier rules
|
|
|
|
There is no box-level health check: the unit of health is the worker
|
|
process, which is also the unit of sending identity. This is intentional.
|
|
|
|
## Blast radius guidance
|
|
|
|
Do not concentrate too much of your fleet on one box. If a box goes hard
|
|
down, you lose every IP it was hosting at once. Rule of thumb:
|
|
|
|
- never put more than ~25% of your total fleet IPs on a single box
|
|
- spread boxes across at least two Hetzner datacenters (e.g. `nbg1` + `fsn1`)
|
|
- keep premium tier and free tier on different boxes so a free-tier
|
|
abuse incident cannot starve premium customers of capacity
|
|
|
|
For the default CX32 + 16 IP recipe, that means: don't run hosted Warmbly
|
|
on fewer than 4 such boxes once you're past the pilot stage.
|
|
|
|
## Compatibility
|
|
|
|
`install-worker.sh` without `--ips` is unchanged: one `warmbly-worker.service`
|
|
unit, IP auto-detected or supplied via `--ip`. Existing single-IP
|
|
deployments keep working with no migration needed. The control-plane
|
|
schema (`workers` table) and assignment logic
|
|
(`internal/app/worker/assignment.go`) are not affected by this change;
|
|
each per-IP process simply registers itself as a normal worker.
|