将Burningboard.net Mastodon实例迁移至多Jail FreeBSD设置
Migrating Burningboard.net Mastodon Instance to a Multi-Jail FreeBSD Setup

原始链接: https://blog.hofstede.it/migrating-burningboardnet-mastodon-instance-to-a-multi-jail-freebsd-setup/index.html

## Mastodon 在 FreeBSD Jails 上的部署:总结 本文详细介绍了将 Mastodon 实例 (burningboard.net) 迁移到基于 FreeBSD jails 和 BastilleBSD 的强大、模块化架构的过程。目标是创建一个易于维护、生产就绪的系统,具有明确的关注点分离和简化的网络管理。 该设置使用了四个 jails – 分别用于 Nginx (反向代理/TLS)、Mastodon Web 后端 (Puma/Rails)、Sidekiq (后台任务) 和数据库 (PostgreSQL/Valkey)。主机上的中央 PF 防火墙处理所有过滤、NAT 和路由,为每个 jail 提供清晰的网络视图。 主要特性包括通过 `nullfs` 挂载的共享源代码树,方便更新;干净的双栈 (IPv4/IPv6) 网络设计;以及通过专用桥接和私有地址空间为每个功能组提供可预测的网络。标准 FreeBSD `rc.d` 脚本管理每个 jail 内的服务,避免了对 Docker 或 systemd 等容器化技术的需求。 这种方法优先考虑简单性、可观察性和可靠性,提供集中控制、使用 ZFS 轻松快照以及完全基于原生 FreeBSD 工具构建的“令人信赖”的基础设施。

一个黑客新闻的讨论围绕着一篇博客文章,详细介绍了Burningboard.net Mastodon实例迁移到FreeBSD上的多jail设置(hofstede.it)。作者借鉴了FediMeteo项目的经验,并在文章中表示感谢。 用户们争论了使用jail与将所有服务运行在单台机器上的优缺点。Jail的主要优势包括隔离——限制潜在安全漏洞的影响——独立的依赖管理,以及共享基础系统的能力。Jail与Docker容器进行了比较,但指出了它们在安全和网络方面的不同方法。 一位评论者开玩笑地说,使用jail在使用*BSD时就是“规定”。有人澄清了该服务器是一个Fediverse实例,而不是WoltLab Burning Board论坛软件。 许多用户表达了对FreeBSD Jails作为一种更优越的虚拟化方法的赞赏。
相关文章

原文

Over the last few weeks, I’ve been working on migrating our Mastodon instance burningboard.net from its current Linux host to a modular FreeBSD jail-based setup powered by BastilleBSD.

This post walks through the architecture and design rationale of my new multi-jail Mastodon system, with aggressive separation of concerns, centralized firewalling, and a fully dual-stack network design.

Acknowledgements

This work is based on the excellent post by Stefano Marinelli:

Installing Mastodon on a FreeBSD jail”

Stefano’s article inspired me to try Mastodon on FreeBSD. My implementation takes that foundation and extends it for a more maintainable, production-ready architecture.

Design Goals

The motivation behind this move:

  1. Central PF firewall – all filtering, NAT, and routing are handled by the host only. Jails see a clean, local L2 view - no PF inside jails, no double NAT.
  2. Separation of concerns – every jail runs exactly one functional service:
    • nginx — reverse proxy + TLS termination
    • mastodonweb — Puma / Rails web backend
    • mastodonsidekiq — background jobs
    • database — PostgreSQL and Valkey (Redis fork)
  3. Host‑managed source – Mastodon source tree shared via nullfs between web and sidekiq jails. Common .env.production, shared dependencies, single codebase to maintain.
  4. Clean dual‑stack (IPv4 + IPv6) – every component visible under both protocols; no NAT66 or translation hacks.
  5. Predictable networking – each functional group lives on its own bridge with private address space.

Jail and Network Overview

Example address plan (using RFC 5737 and 3849 documentation spaces):

Jail Purpose IPv4 IPv6
nginx Reverse proxy 192.0.2.13 2001:db8:8000::13
mastodonweb Rails backend 198.51.100.9 2001:db8:9000::9
mastodonsidekiq Workers 198.51.100.8 2001:db8:b000::8
database PostgreSQL + Valkey 198.51.100.6 2001:db8:a000::6
Host “burningboard.example.net” 203.0.113.1 2001:db8::f3d1

Each functional bucket gets its own bridge(4) interface on the host (bastille0..bastille3) and its own /24 and /64 subnet.
Jails are created and attached to the corresponding bridge.

Schematic diagram

[ Internet ]
     |
     v
 [ PF Host ]
     ├── bridge0 — nginx (192.0.2.13 / 2001:db8:8000::13)
     ├── bridge1 — mastodonweb (198.51.100.9 / 2001:db8:9000::9)
     ├── bridge2 — database (198.51.100.6 / 2001:db8:a000::6)
     └── bridge3 — sidekiq (198.51.100.8 / 2001:db8:b000::8)

With the address plan established, the next step is creating the individual jails and assigning virtual network interfaces.

Jail Creation and Per‑Jail Configuration

Each jail was created directly through Bastille using VNET support, attaching it to its respective bridge.
For example, creating the nginx frontend jail on the bastille0 bridge:

bastille create -B nginx 14.3-RELEASE 192.0.2.13 bastille0

Bastille automatically provisions a VNET interface inside the jail (vnet0) and associates it with the corresponding bridge on the host.
Inside each jail, the /etc/rc.conf defines its own network interface, IPv4/IPv6 addresses, default routes, and any service daemons enabled for that jail.

Example configuration for the database jail (substituted with documentation addresses):

ifconfig_e0b_database_name="vnet0"
ifconfig_vnet0="inet 198.51.100.6 netmask 255.255.255.0"
ifconfig_vnet0_ipv6="inet6 2001:db8:a000::6/64"
ifconfig_vnet0_descr="database jail interface on bastille2"
defaultrouter="198.51.100.1"
ipv6_defaultrouter="2001:db8:a000::1"
syslogd_flags="-ss"
sendmail_enable="NO"
sendmail_submit_enable="NO"
sendmail_outbound_enable="NO"
sendmail_msp_queue_enable="NO"
cron_flags="-J 60"
valkey_enable="YES"
postgresql_enable="YES"

Each jail is therefore a fully self‑contained FreeBSD environment with native rc(8) configuration, its own routing table, and service definition. Bastille’s role ends at boot‑time network attachment - the rest is standard FreeBSD administration.

Host /etc/rc.conf

Below is a simplified version of the host configuration that ties everything together.
Each jail bridge subnet is assigned both IPv4 and IPv6 space; the host acts as gateway.

# Basic host config
hostname="burningboard.example.net"
keymap="us.kbd"

# Networking
ifconfig_vtnet0="inet 203.0.113.1 netmask 255.255.255.255"
ifconfig_vtnet0_ipv6="inet6 2001:db8::f3d1/64"
defaultrouter=="203.0.113.254"
ipv6_defaultrouter="2001:db8::1"

# Bridges for jails
cloned_interfaces="bridge0 bridge1 bridge2 bridge3"
ifconfig_bridge0_name="bastille0"
ifconfig_bridge1_name="bastille1"
ifconfig_bridge2_name="bastille2"
ifconfig_bridge3_name="bastille3"

# Bridge interfaces for individual networks

# Frontend (nginx)
ifconfig_bastille0="inet 192.0.2.1/24"
ifconfig_bastille0_ipv6="inet6 2001:db8:8000::1/64"

# Mastodon Web (Rails / Puma)
ifconfig_bastille1="inet 198.51.100.1/24"
ifconfig_bastille1_ipv6="inet6 2001:db8:9000::1/64"

# Database
ifconfig_bastille2="inet 198.51.100.2/24"
ifconfig_bastille2_ipv6="inet6 2001:db8:a000::1/64"

# Sidekiq (workers)
ifconfig_bastille3="inet 198.51.100.3/24"
ifconfig_bastille3_ipv6="inet6 2001:db8:b000::1/64"

gateway_enable="YES"
ipv6_gateway_enable="YES"

# Services
pf_enable="YES"
pflog_enable="YES"
bastille_enable="YES"
zfs_enable="YES"
sshd_enable="YES"
ntpd_enable="YES"
ntpd_sync_on_start="YES"

This provides proper L3 separation for each functional group.
In this layout, bastille0 → frontend, bastille1 → app, bastille2DB, bastille3 → worker pool.

/etc/pf.conf

The host firewall serves the dual purpose of NAT gateway and service ingress controller.

Below is an anonymized but structurally identical configuration.

# --- Macros ---
ext_if = "vtnet0"
jail_net = "198.51.100.0/20"
jail_net6 = "2001:db8:8000::/64"

host_ipv6 = "2001:db8::f3d1"
frontend_v4 = "192.0.2.13"
frontend_v6 = "2001:db8:8000::13"

# Trusted management networks (example)
trusted_v4 = "{ 203.0.113.42, 192.0.2.222 }"
trusted_v6 = "{ 2001:db8:beef::/64 }"

table <bruteforce> persist

set skip on lo0
set block-policy drop
set loginterface $ext_if

scrub in all fragment reassemble
scrub out all random-id max-mss 1500

# --- NAT ---
# Jails -> egress internet (IPv4)
nat on $ext_if inet from $jail_net to any -> ($ext_if)

# --- Port redirection ---
# Incoming HTTP/HTTPS -> nginx jail
rdr on $ext_if inet  proto tcp to ($ext_if) port {80,443} -> $frontend_v4
rdr on $ext_if inet6 proto tcp to $host_ipv6 port {80,443} -> $frontend_v6

# --- Filtering policy ---

# Default deny (log for audit)
block in log all
block out log all

# Allow existing stateful flows out
pass out all keep state

# Allow management SSH (example port 30822) only from trusted subnets
pass in quick on $ext_if proto tcp from $trusted_v4 to ($ext_if) port 30822 \
    flags S/SA keep state (max-src-conn 5, max-src-conn-rate 3/30, overload <bruteforce> flush global)

pass in quick on $ext_if inet6 proto tcp from $trusted_v6 to $host_ipv6 port 30822 \
    flags S/SA keep state (max-src-conn 5, max-src-conn-rate 3/30, overload <bruteforce> flush global)

# Block all other SSH
block in quick on $ext_if proto tcp to any port 30822 label "ssh_blocked"

# ICMP/ICMPv6 essentials
pass in inet proto icmp icmp-type { echoreq, unreach }
pass in inet6 proto ipv6-icmp icmp6-type { echoreq, echorep, neighbrsol, neighbradv, toobig, timex, paramprob }

# Inter-jail traffic
# nginx -> mastodonweb
pass in quick on bastille0 proto tcp from 192.0.2.13 to 198.51.100.9 port {3000,4000} keep state
pass in quick on bastille0 proto tcp from 2001:db8:8000::13 to 2001:db8:9000::9 port {3000,4000} keep state

# mastodonweb -> database (Postgres + Valkey)
pass in quick on bastille1 proto tcp from 198.51.100.9 to 198.51.100.6 port {5432,6379} keep state
pass in quick on bastille1 proto tcp from 2001:db8:9000::9 to 2001:db8:a000::6 port {5432,6379} keep state

# sidekiq -> database
pass in quick on bastille3 proto tcp from 198.51.100.8 to 198.51.100.6 port {5432,6379} keep state
pass in quick on bastille3 proto tcp from 2001:db8:b000::8 to 2001:db8:a000::6 port {5432,6379} keep state

# Optional: temporary egress blocking during testing
block in quick on { bastille0, bastille1, bastille2, bastille3 } from $jail_net to any
block in quick on { bastille0, bastille1, bastille2, bastille3 } inet6 from $jail_net6 to any

# External access
pass in quick on $ext_if inet  proto tcp to $frontend_v4 port {80,443} keep state
pass in quick on $ext_if inet6 proto tcp to $frontend_v6 port {80,443} keep state

This PF configuration centralizes control at the host. The jails have no firewall logic - just clean IP connectivity.

Shared Source Design

Both mastodonweb and mastodonsidekiq jails mount /usr/local/mastodon from the host:

/usr/local/mastodon -> /usr/local/bastille/jails/mastodonweb/root/usr/home/mastodon

/usr/local/mastodon -> /usr/local/bastille/jails/mastodonsidekiq/root/usr/home/mastodon

Example fstab entry:

/usr/local/mastodon /usr/local/bastille/jails/mastodonweb/root/usr/home/mastodon nullfs rw 0 0

That way, only one source tree needs updates after a git pull or bundle/yarn operation. The jails simply see the current state of that directory.

Logs and tmp directories are symlinked to /var/log/mastodon and /var/tmp/mastodon inside each jail for persistence and cleanup.

Service Boot Integration

Each Mastodon jail defines lightweight /usr/local/etc/rc.d scripts:

#!/bin/sh
# PROVIDE: mastodon_web
# KEYWORD: shutdown

. /etc/rc.subr

name="mastodon_web"
rcvar=mastodon_web_enable
pidfile="/var/run/mastodon/${name}.pid"

start_cmd="mastodon_web_start"
stop_cmd="mastodon_web_stop"

mastodon_web_start() {
    mkdir -p /var/run/mastodon
    chown mastodon:mastodon /var/run/mastodon
    su mastodon -c "export PATH=/usr/local/bin:/usr/bin:/bin; \
        export RAILS_ENV=production; export PORT=3000; \
        cd /home/mastodon/live && \
        /usr/sbin/daemon -T ${name} -P /var/run/mastodon/${name}_supervisor.pid \
        -p /var/run/mastodon/${name}.pid -f -S -r \
        /usr/local/bin/bundle exec puma -C config/puma.rb"
}

mastodon_web_stop() {
    kill -9 `cat /var/run/mastodon/${name}_supervisor.pid` 2>/dev/null
    kill -15 `cat /var/run/mastodon/${name}.pid` 2>/dev/null
}

load_rc_config $name
run_rc_command "$1"

Equivalent scripts exist for mastodon_streaming and the Sidekiq worker.

Everything integrates seamlessly with FreeBSD’s native service management:

service mastodon_web start
service mastodon_streaming restart
service mastodonsidekiq status

No Docker, no systemd, no exotic process supervisors.


Why It Matters

The resulting system is simple, observable, and robust:

  • Firewall rules are centralized and auditable.
  • Each jail is a clean service container (pure FreeBSD primitives, no overlay complexity).
  • IPv4/IPv6 connectivity is symmetrical and clear.
  • Source and configs are under full administrator control, not hidden in containers.

It’s also easy to snapshot with ZFS or promote new releases jail-by-jail using Bastille’s clone/deploy model.

Summary

In short:

  • Host does PF, routing, NAT, bridges
  • Each jail has exactly one purpose
  • Source code lives once on the host
  • Dual-stack networking, no translation
  • Everything FreeBSD-native

This structure makes it easy to reason about - each moving part has one job.

That’s how I like my infrastructure: boringly reliable.

References

联系我们 contact @ memedata.com