mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-10 16:04:55 +00:00
feat: give every long-running compose service restart: unless-stopped and stop the tracking service exiting on a transient event-bus failure at boot, because the compose file had no restart policy on any service so Docker's default of no applied everywhere and a container that died stayed dead, which for tracking meant a startup DNS race against nats (its producer had no retry and exited immediately) left the service Exited(1) indefinitely with zero visible symptoms anywhere else since open and click tracking is fire-and-forget from the sender's side, so campaigns kept sending and nothing recorded an open or a click until someone thought to run docker compose ps -a, adding bounded retry with exponential backoff capped at ten seconds over eight attempts so the race self-heals without needing the restart policy as a safety net, excluding the one-shot seed job from the policy, and making the tracking host port overridable with TRACKING_PORT since 3000 is a very common default for other self-hosted software and a collision silently stops the container from publishing
This commit is contained in:
@@ -145,6 +145,11 @@ DEPLOYMENT_MODE=self_hosted
|
||||
# production and proxy it to the tracking service on :3000.
|
||||
# TRACKING_DOMAIN=localhost:3000
|
||||
|
||||
# Host port the tracking service is published on. 3000 is a very common default
|
||||
# for other self-hosted tools, so remap it here when it is already taken and
|
||||
# point your reverse proxy at the new port. The container port stays 3000.
|
||||
# TRACKING_PORT=3001
|
||||
|
||||
# Behind a reverse proxy with HTTPS, set each one explicitly instead:
|
||||
# APP_URL=https://app.example.com
|
||||
# API_PUBLIC_URL=https://api.example.com
|
||||
|
||||
+16
-1
@@ -202,6 +202,7 @@ services:
|
||||
# ─── infrastructure ───────────────────────────────────────────────────
|
||||
|
||||
postgres:
|
||||
restart: unless-stopped
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: warmbly
|
||||
@@ -217,6 +218,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
restart: unless-stopped
|
||||
image: redis:7-alpine
|
||||
ports: ["16379:6379"]
|
||||
volumes:
|
||||
@@ -228,6 +230,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
nats:
|
||||
restart: unless-stopped
|
||||
image: nats:2.10-alpine
|
||||
# -js: JetStream (durable streams). -m 8222: HTTP monitoring for healthcheck.
|
||||
command: ["-js", "-sd", "/data", "-m", "8222"]
|
||||
@@ -251,6 +254,7 @@ services:
|
||||
# publishing it would hand every captured message to anyone who can reach the
|
||||
# host. Set MAILPIT_BIND=0.0.0.0 if you deliberately want it reachable.
|
||||
mailpit:
|
||||
restart: unless-stopped
|
||||
image: axllent/mailpit:latest
|
||||
profiles: ["sandbox"]
|
||||
ports:
|
||||
@@ -267,6 +271,7 @@ services:
|
||||
# In the `sandbox` profile so the lean `make up` self-host never pulls it.
|
||||
# `make sandbox` / `make infra` start it by naming it explicitly.
|
||||
dovecot:
|
||||
restart: unless-stopped
|
||||
image: dovecot/dovecot:latest
|
||||
profiles: ["sandbox"]
|
||||
environment:
|
||||
@@ -283,6 +288,7 @@ services:
|
||||
# ─── application ──────────────────────────────────────────────────────
|
||||
|
||||
backend:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/docker/backend.Dockerfile
|
||||
@@ -341,6 +347,7 @@ services:
|
||||
start_period: 20s
|
||||
|
||||
consumer:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/docker/consumer.Dockerfile
|
||||
@@ -357,6 +364,7 @@ services:
|
||||
# (or run more on other machines via the SSH enrollment flow). Outbound IPs
|
||||
# belong to the mail provider, so more workers = more parallelism, not more IPs.
|
||||
worker:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/docker/worker.Dockerfile
|
||||
@@ -384,10 +392,14 @@ services:
|
||||
nats: { condition: service_healthy }
|
||||
|
||||
tracking:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: ./tracking
|
||||
dockerfile: Dockerfile
|
||||
ports: ["3000:3000"]
|
||||
# 3000 is a very common default for other self-hosted tools. Remap the host
|
||||
# side with TRACKING_PORT when it is already taken; the container port and
|
||||
# everything derived from TRACKING_DOMAIN stay as they are.
|
||||
ports: ["${TRACKING_PORT:-3000}:3000"]
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-dev}
|
||||
AWS_CONFIG_ENABLED: ${AWS_CONFIG_ENABLED:-false}
|
||||
@@ -409,6 +421,7 @@ services:
|
||||
nats: { condition: service_healthy }
|
||||
|
||||
realtime:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/docker/realtime.Dockerfile
|
||||
@@ -442,6 +455,7 @@ services:
|
||||
# the release publishes to GHCR is what runs here. For hot-reload development
|
||||
# use `make app` / docker-compose.dev.yml instead.
|
||||
web:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: Dockerfile
|
||||
@@ -458,6 +472,7 @@ services:
|
||||
backend: { condition: service_healthy }
|
||||
|
||||
admin:
|
||||
restart: unless-stopped
|
||||
build:
|
||||
context: ./admin
|
||||
dockerfile: Dockerfile
|
||||
|
||||
@@ -37,6 +37,8 @@ flowchart LR
|
||||
| 4 GB RAM | The running stack idles near 300 MB; the first build is the demanding part |
|
||||
| Free ports | 5173, 5174, 8080, 4000, 3000, 4222, 8222, 15432, 16379 |
|
||||
|
||||
`3000` is worth checking before you start: it is the default for a lot of other self-hosted software, and a collision there stops the tracking service from publishing. Set `TRACKING_PORT` to move the host side and point your reverse proxy at the new port.
|
||||
|
||||
The first build compiles Go, Rust, and Elixir services and builds two frontends. On a modern laptop that takes about **6 minutes**. Later builds reuse the cache and are much faster.
|
||||
|
||||
That is the whole list. No SMTP relay, no captcha keys, no cloud account, and no `.env` to write before the first run.
|
||||
|
||||
@@ -78,7 +78,8 @@ Newer builds return the invite-only refusal with its own machine code, `registra
|
||||
| Connecting a mailbox is rejected on the port | SMTP must be `587` or `465`. The Mailpit sink used by `make sandbox` listens on `1025` with no STARTTLS, so it cannot be used as a test mailbox |
|
||||
| A mailbox stalls after about an hour | The worker is missing `BOX_GOOGLE_*` or `BOX_OUTLOOK_*`. The backend starts the OAuth flow but each worker refreshes the token. Set them and restart the worker |
|
||||
| Scheduled sends never fire | Delayed sends run through the in-process Postgres task poller (`TASKS_PROVIDER=local`), so the backend must be running |
|
||||
| Opens and clicks never record | Check `TRACKING_DOMAIN` resolves and the tracking service answers on `/health`. If you overrode `KAFKA_TRACKING_TOPIC`, it has to be overridden for the Rust publisher and the Go subscriber together |
|
||||
| Opens and clicks never record | First check the container is actually up with `docker compose -p warmbly ps -a`: a dead `tracking` breaks nothing else, because sends do not wait on it. Then check `TRACKING_DOMAIN` resolves and the service answers on `/health`. If you overrode `KAFKA_TRACKING_TOPIC`, it has to be overridden for the Rust publisher and the Go subscriber together |
|
||||
| `tracking` exits immediately with `Bind for 0.0.0.0:3000 failed: port is already allocated` | Something else on the host owns port `3000`, a very common default. Set `TRACKING_PORT=3001` in `.env`, re-run `make up`, and point your reverse proxy's tracking host at the new port |
|
||||
| Seeding fails with `no migration found for version N` | The seed image is older than your schema. Re-run with `--build` |
|
||||
| Worker `install_state: error` | Test connection first (is the SSH key in `authorized_keys`?), then read `last_error` and Logs on the worker's detail page |
|
||||
| Worker heartbeat offline | Can the VPS reach the backend URL, NATS or Kafka, and Redis? Is the container running (Live status)? |
|
||||
|
||||
+41
-8
@@ -12,11 +12,12 @@ mod producer;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use tracing::info;
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::config::Config;
|
||||
@@ -24,6 +25,44 @@ use crate::handlers::{health, track_click, track_open, AppState};
|
||||
use crate::observability::report_error;
|
||||
use crate::producer::Producer;
|
||||
|
||||
/// Connects the event-bus producer, retrying transient failures.
|
||||
///
|
||||
/// The whole stack starts at once, so the first lookup of `nats` can fail with
|
||||
/// "DNS error: failed to lookup address information: Try again" purely because
|
||||
/// the other container is not up yet. Exiting on that leaves the service dead,
|
||||
/// and nothing else fails loudly when it is: sends still succeed, so the only
|
||||
/// symptom is opens and clicks silently never recording.
|
||||
async fn connect_producer(config: &Config) -> Producer {
|
||||
const MAX_ATTEMPTS: u32 = 8;
|
||||
const MAX_DELAY: Duration = Duration::from_secs(10);
|
||||
|
||||
let mut delay = Duration::from_millis(500);
|
||||
let mut attempt: u32 = 1;
|
||||
|
||||
loop {
|
||||
match Producer::from_config(config).await {
|
||||
Ok(producer) => {
|
||||
if attempt > 1 {
|
||||
info!("Tracking event producer connected after {attempt} attempts");
|
||||
}
|
||||
return producer;
|
||||
}
|
||||
Err(e) => {
|
||||
if attempt >= MAX_ATTEMPTS {
|
||||
report_error("Failed to create tracking event producer", e.as_ref());
|
||||
std::process::exit(1);
|
||||
}
|
||||
warn!(
|
||||
"Tracking event producer not ready (attempt {attempt}/{MAX_ATTEMPTS}), retrying in {delay:?}: {e}"
|
||||
);
|
||||
tokio::time::sleep(delay).await;
|
||||
delay = (delay * 2).min(MAX_DELAY);
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Initialize tracing
|
||||
@@ -48,13 +87,7 @@ async fn main() {
|
||||
|
||||
// Event-bus producer (NATS by default; Kafka when EVENTBUS_PROVIDER=kafka
|
||||
// and the `kafka` feature is compiled in).
|
||||
let producer = match Producer::from_config(&config).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report_error("Failed to create tracking event producer", e.as_ref());
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let producer = connect_producer(&config).await;
|
||||
|
||||
let state = AppState::new(producer, &config);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user