scripts: add chat relay docker setup

This commit is contained in:
shum
2026-07-28 11:50:43 +00:00
parent 7c931d08ea
commit 1fa7e5b226
5 changed files with 261 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# The Dockerfile clones the source itself and only needs entrypoint.py from the
# build context. Exclude everything else, especially .env (secrets) and out/.
*
!entrypoint.py
+13
View File
@@ -0,0 +1,13 @@
# Copy to .env and edit per server (relay1 / relay2 / relay3).
# Relay identity.
RELAY_NAME="My Relay"
RELAY_WEB_DOMAIN=relay1.example.com
# PostgreSQL credentials (used by both the db service and the relay's DB_CONN).
POSTGRES_USER=simplex
POSTGRES_DB=simplex_chat_relay
POSTGRES_PASSWORD=change-me
# Source tag to build (relay mode requires v7.0.0-beta.4 or later).
CHAT_REF=v7.0.0-beta.5
+70
View File
@@ -0,0 +1,70 @@
# syntax=docker/dockerfile:1
# Build simplex-chat in relay mode with PostgreSQL support, then ship it on a
# slim runtime with only the libraries the binary links against. A bare
# `scratch` image cannot run this dynamically-linked binary.
ARG CHAT_REF=v7.0.0-beta.5
ARG GHC=9.6.3
ARG CABAL=3.10.1.0
# ---- build ----------------------------------------------------------------
FROM ubuntu:22.04 AS build
ARG GHC
ARG CABAL
ENV DEBIAN_FRONTEND=noninteractive \
PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Build toolchain and simplex-chat dependencies (libpq-dev for client_postgres).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl git build-essential \
libpq-dev libgmp3-dev zlib1g-dev libnuma-dev libssl-dev \
llvm-12 llvm-12-dev \
&& rm -rf /var/lib/apt/lists/*
# GHC + cabal via ghcup, then fetch the package index into this layer.
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org \
| BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \
BOOTSTRAP_HASKELL_GHC_VERSION="${GHC}" \
BOOTSTRAP_HASKELL_CABAL_VERSION="${CABAL}" sh \
&& ghcup set ghc "${GHC}" \
&& ghcup set cabal "${CABAL}" \
&& cabal update
# CHAT_REF is declared here, after the toolchain layers, so changing the tag
# reuses everything above and rebuilds only the clone + compile below.
ARG CHAT_REF
RUN git clone --depth 1 --branch "${CHAT_REF}" \
https://github.com/simplex-chat/simplex-chat /project
WORKDIR /project
# Compile the executable with PostgreSQL persistence and strip it. The cabal
# store (compiled dependencies) and dist-newstyle are cache mounts, so a
# different tag recompiles only changed code, not every dependency.
RUN --mount=type=cache,target=/root/.cabal/store,sharing=locked \
--mount=type=cache,target=/project/dist-newstyle,sharing=locked \
cp scripts/cabal.project.local.linux cabal.project.local \
&& cabal build -fclient_postgres exe:simplex-chat \
&& bin=$(find dist-newstyle -name simplex-chat -type f -executable | head -n1) \
&& install -m 0755 "$bin" /simplex-chat-relay \
&& strip /simplex-chat-relay
# ---- runtime --------------------------------------------------------------
FROM debian:stable-slim AS runtime
# python3 runs the entrypoint; the rest are the binary's shared libraries.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates python3 \
libpq5 libgmp10 libssl3 zlib1g libnuma1 libffi8 \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -u 1000 relay
COPY --from=build /simplex-chat-relay /usr/local/bin/simplex-chat-relay
COPY entrypoint.py /usr/local/bin/entrypoint.py
# Run as non-root; host bind-mounts must be writable by this UID.
USER relay
# The relay (GHC runtime) shuts down cleanly on SIGINT, not SIGTERM.
STOPSIGNAL SIGINT
ENTRYPOINT ["python3", "/usr/local/bin/entrypoint.py"]
+74
View File
@@ -0,0 +1,74 @@
name: simplex-chat-relay
services:
relay:
build:
context: .
args:
CHAT_REF: ${CHAT_REF:-v7.0.0-beta.5}
image: chat-relay:latest # set your image/registry name
container_name: chat-relay # optional: rename
depends_on:
db:
condition: service_healthy
environment:
RELAY_NAME: ${RELAY_NAME}
RELAY_WEB_DOMAIN: ${RELAY_WEB_DOMAIN}
# Password comes from PGPASSWORD (libpq reads it), so it stays out of the
# process argv and needs no URL-encoding.
DB_CONN: postgresql://${POSTGRES_USER}@db:5432/${POSTGRES_DB}
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
# Channel previews + CORS config, served by the host's system Caddy.
- /var/www/relay-web-channels:/var/www/relay-web-channels
# Relay address is written here: `cat out/relay-address.txt`.
- ./out:/out
restart: unless-stopped
db:
image: postgres:18
container_name: chat-relay-db # optional: rename
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
# Parallel workers use POSIX shared memory; the default 64 MB /dev/shm can
# be too small and cause "could not resize shared memory segment" errors.
shm_size: 256mb
volumes:
# PG18+ uses a version-specific PGDATA (/var/lib/postgresql/18/docker) and
# a VOLUME at /var/lib/postgresql. Mount there so major-version upgrades
# can use pg_upgrade --link.
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 10
# Tuning for ~5 GB RAM / 4 cores. Match to the actual server.
# `--name=value` is postgres's equivalent of `-c name=value`.
command:
- postgres
- --max_connections=100
- --shared_buffers=1280MB
- --effective_cache_size=3840MB
- --maintenance_work_mem=320MB
- --checkpoint_completion_target=0.9
- --wal_buffers=16MB
- --default_statistics_target=100
- --random_page_cost=1.1
- --effective_io_concurrency=200
- --work_mem=6301kB
- --huge_pages=off
- --jit=off
- --wal_compression=lz4
- --min_wal_size=1GB
- --max_wal_size=4GB
- --max_worker_processes=4
- --max_parallel_workers_per_gather=2
- --max_parallel_workers=4
- --max_parallel_maintenance_workers=2
restart: unless-stopped
volumes:
pgdata:
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Record the relay address, then run the relay with no wrapper in the way.
On the first start (or whenever /out/relay-address.txt is missing) a short
one-shot invocation creates the profile and address if needed, prints the
address, and saves it. Then the real relay is exec'd, so it runs as PID 1 with
no Python left in the process tree. Steady-state starts skip straight to the
exec.
The address is not stored in the database as a ready-to-use string (it is a
binary conn-req blob plus short-link data, re-encoded on display), so the
relay's own output is the canonical source.
"""
import os
import subprocess
import sys
BIN = "simplex-chat-relay"
WEB_ROOT = "/var/www/relay-web-channels"
ADDR_FILE = "/out/relay-address.txt"
CAPTURE_TIMEOUT = 180 # seconds; first address creation involves an SMP round-trip
def require(name):
value = os.environ.get(name)
if not value:
sys.exit(f"{name} is required")
return value
def find_address(text):
for token in text.split():
if token.startswith("https://") or token.startswith("simplex:"):
return token
return None
def capture_address(oneshot):
"""One-shot: create the profile/address if needed, print it, and save it.
Runs before the real relay starts, so there is never a second agent
subscribing to the same queues. If it can't capture (e.g. SMP briefly
unreachable), the relay still creates and serves the address, and the next
start retries because the file is still missing.
"""
cmd = oneshot + ["--create-schema", "-t", "0", "-e", "/sa"]
try:
out = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
errors="replace",
timeout=CAPTURE_TIMEOUT,
).stdout
except subprocess.TimeoutExpired as exc:
out = exc.stdout or ""
sys.stdout.write(out) # keep it in the container logs
sys.stdout.flush()
address = find_address(out)
if address:
with open(ADDR_FILE, "w") as f:
f.write(address + "\n")
else:
sys.stderr.write("entrypoint: relay address not captured; will retry next start\n")
def main():
name = require("RELAY_NAME")
domain = require("RELAY_WEB_DOMAIN")
conn = require("DB_CONN")
image_file = os.environ.get("RELAY_IMAGE_FILE")
os.makedirs(f"{WEB_ROOT}/channel", exist_ok=True)
os.makedirs("/out", exist_ok=True)
common = [BIN, "--relay", "--headless", "--user-display-name", name]
# First start (or the file was removed): bootstrap and capture the address,
# then the real relay reuses it. The avatar only applies at profile
# creation, so it goes on the one-shot, not the long-running relay.
if not os.path.exists(ADDR_FILE):
oneshot = common + (["--user-image-file", image_file] if image_file else []) + ["-d", conn]
capture_address(oneshot)
# Replace this process with the relay: PID 1, native signals, no wrapper.
relay = common + [
"--relay-web-domain", domain,
"--relay-web-dir", f"{WEB_ROOT}/channel",
"--relay-web-cors-file", f"{WEB_ROOT}/cors.conf",
"--relay-web-interval", "30",
"-d", conn,
"--create-schema",
]
os.execvp(relay[0], relay)
if __name__ == "__main__":
main()