优雅地部署 Mastodon 实例

准备工作

接下来,我将基于 Mastodon 这一开源项目从头讲解如何自行部署一个接入 Fediverse 的实例。

在本教学内容中,我将会使用以下环境:

  • 处理器:2C AMD64(理论在 ARM64v8 上操作也完全一致)
  • 运行内存:3GiB
  • 磁盘空间:50GiB
  • 操作系统:AlmaLinux 9(or RockyLinux 9)
  • 容器环境:Podman 5
  • 反向代理:Caddy 2
  • CDN/WAF:Cloudflare
  • 主程序:Mastodon 4.3
  • 数据库:PostgreSQL 17
  • KV 缓存:Redis 7
  • 任务对列:Sidekiq
  • 服务器搜索:ElasticSearch 7.17

:::tip

以上程序看上去非常复杂,但是我已经对于所需要进行的操作进行了精简,全程无需动脑即可完成所有配置项。

:::

在开始之前,首先需要准备好一台位于中国大陆境外的符合上述最低配置要求的服务器,并在其上安装 AlmaLinux 9 操作系统。除此外,还需要拥有一个完全管理权限的域名,这个域名将会负责所有前端服务的访问定位,如果可以的话,请将其绑定到 Cloudflare 平台上,这有助于完全跟随接下来的配置,而不需要去完全理解为什么(当然,我会尽可能将这部分内容也解释清楚,以便于域名放在其它平台上的朋友自助部署服务)。

参考资料

以下是可能使用到的参考资料:

第一步:服务器基础配置

AlmaLinux 9 是基于 Fedora 的 Linux 分支,它创建目的,是接替 CentOS 成为 RHEL 操作系统的下游,提供无与伦比的企业级稳定性和实用性,我是 CentOS 难民,在 2021 年将手下的几乎所有服务器都迁移到了 AlmaLinux 操作系统,我的所有自动化运维程序都在这里创建,所以接下来,我也将在这个操作系统下展开。

不想自定义进行各种配置,只希望快速完成基础设置的朋友可以使用以下快速脚本一键(按照我的最优实践)初始化服务器:

wget -O ~/AlmaLinux9Podman.sh https://sh.soraharu.com/ServerMaintenance/FirstInstallation/AlmaLinux9Podman.sh \
  && sh ~/AlmaLinux9Podman.sh \
    "${sshPublicKey}" \
    "${prettyHostname}" \
    "${staticHostname}" \
  && rm -f ~/AlmaLinux9Podman.sh

这个脚本中有三个需要替换的变量:

  • ${sshPublicKey} - SSH 公钥,没有可以在线生成(不安全的操作)
  • ${prettyHostname} - 友好主机名称,例如 XiaoXi's Workstation
  • ${staticHostname} - 主机名称,例如 xiaoxis-workstation

针对于高级用户的自定义配置分解

系统更新

dnf clean all
dnf makecache
dnf update -y

安装依赖程序

dnf install -y glibc-common langpacks-zh_CN dnf-automatic kpatch kpatch-dnf passwd wget net-tools firewalld git cockpit cockpit-packagekit cockpit-storaged cockpit-podman zsh util-linux-user

# 启用服务
systemctl enable --now podman.socket
systemctl enable --now cockpit.socket

# 移除 Cockpit Web Console 提示
rm -f /etc/motd.d/cockpit

启用系统自动更新(每周一凌晨 1:30 执行)

systemctl enable --now dnf-automatic-install.timer
mkdir -p /etc/systemd/system/dnf-automatic-install.timer.d/
echo "[Timer]" >/etc/systemd/system/dnf-automatic-install.timer.d/time.conf
echo "OnBootSec=" >>/etc/systemd/system/dnf-automatic-install.timer.d/time.conf
echo "OnCalendar=mon 01:30" >>/etc/systemd/system/dnf-automatic-install.timer.d/time.conf
systemctl daemon-reload

安全和性能配置(SSH 端口 51200、Cockpit 端口 51201)

# 启用内核实时补丁
dnf kpatch auto

# 移除 Virtio-Balloon 驱动
rmmod virtio_balloon

# 允许 root 用户登录 Cockpit
echo "# List of users which are not allowed to login to Cockpit" >/etc/cockpit/disallowed-users

# 修改 Cockpit 端口
mkdir -p /etc/systemd/system/cockpit.socket.d/
echo "[Socket]" >/etc/systemd/system/cockpit.socket.d/override.conf
echo "ListenStream=" >>/etc/systemd/system/cockpit.socket.d/override.conf
echo "ListenStream=51201" >>/etc/systemd/system/cockpit.socket.d/override.conf
systemctl daemon-reload
systemctl restart cockpit.socket

# 修改 SSH 端口
sed -i 's/#Port 22/Port 51200/g' /etc/ssh/sshd_config
sed -i 's/Port 22/Port 51200/g' /etc/ssh/sshd_config

# 配置 SSH 公钥
mkdir -p /root/.ssh/
echo "${sshPublicKey}" >/root/.ssh/authorized_keys

# 配置 SSH 禁止密码登录
if [[ "$(grep -c 'PasswordAuthentication ' '/etc/ssh/sshd_config')" -eq '1' && "$(grep -c '#PasswordAuthentication yes' '/etc/ssh/sshd_config')" -eq '1' ]]; then
    sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config
else
    sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/g' /etc/ssh/sshd_config
fi
systemctl restart sshd.service

# 关闭 SELinux
setenforce 0
sed -i 's/SELINUX=enforcing/SELINUX=disabled/g' /etc/selinux/config

中文化

# 设置系统语言为简体中文
localectl set-locale "zh_CN.utf8"

# 设置时区
timedatectl set-timezone 'Asia/Shanghai'

防火墙规则

systemctl enable --now firewalld.service
firewall-cmd --permanent --zone=public --add-service=cockpit
firewall-cmd --permanent --zone=public --add-service=http
firewall-cmd --permanent --zone=public --add-service=https
firewall-cmd --permanent --zone=public --add-service=http3
firewall-cmd --permanent --zone=public --new-service=podman-container
firewall-cmd --permanent --service=podman-container --add-port=51300-51399/tcp --add-port=51300-51399/udp
firewall-cmd --permanent --service=ssh --add-port=51200/tcp
firewall-cmd --permanent --service=ssh --remove-port=22/tcp
firewall-cmd --permanent --service=cockpit --add-port=51201/tcp
firewall-cmd --permanent --service=cockpit --remove-port=9090/tcp
firewall-cmd --reload

Podman 日志限制

# 进行 Podman 设置
cp /usr/share/containers/containers.conf /etc/containers/containers.conf

# 设置 Podman 最大日志大小为 10MiB
sed -i 's/#log_size_max = -1/log_size_max = 10485760/g' /etc/containers/containers.conf
systemctl restart podman.socket

Podman 支持 IPv6

# Podman 新建 IPv6 网关
podman network create --ipv6 --gateway fd00::1:8:1 --subnet fd00::1:8:0/112 --gateway 10.90.0.1 --subnet 10.90.0.0/16 podman1

远程 Shell 进程获取当前目录路径(方便使用 SFTP)

if [ "$(grep -c 'export PS1=' '/root/.bash_profile')" -eq '0' ]; then
    printf "export PS1=\"\$PS1\\[\\\e]1337;CurrentDir=\"'\$(pwd)\\\a\\]'" >>/root/.bash_profile
fi
if [ "$(grep -c 'precmd () { echo -n \"\\\x1b]1337;CurrentDir=\$(pwd)\\\x07\" }' '/root/.zshrc')" -eq '0' ]; then
    echo "" >>/root/.zshrc
    echo "# 使用 OSC 1337 协议向远程 shell 报告 CWD" >>/root/.zshrc
    echo "precmd () { echo -n \"\\\x1b]1337;CurrentDir=\$(pwd)\\\x07\" }" >>/root/.zshrc
fi

创建容器目录(后续容器命令的默认数据存储路径都在此)

mkdir -p /podmandirectory/

使用 zsh 代替 bash

# 将默认 Shell 设置为 Zsh
chsh -s $(which zsh)

# 安装 Oh My Zsh
sh -c "$(wget -O- https://install.ohmyz.sh)" "" --unattended

# 开启 Oh My Zsh 自动更新
sed -i "s/# zstyle ':omz:update' mode auto/zstyle ':omz:update' mode auto/g" /root/.zshrc
zsh

第二步:为容器运行作准备

所有的容器都需要运行在预先设定好的『框架』内,你需要为其指定数据存储的目录、虚拟网络、IP 地址、环境变量等数据,这些『框架』在手,可以保证你的容器拥有完好的可复制性和可迁移性,我们今天需要在不使用额外容器编排技术(如 podman-compose)的情况下,使用最清晰的方式理解并运行容器。

创建目录

首先,需要新建以下本地目录:

# Caddy
mkdir -p /podmandirectory/caddy2/config/cert/
mkdir -p /podmandirectory/caddy2/config/site/
mkdir -p /podmandirectory/caddy2/data/

# Mastodon
mkdir -p /podmandirectory/mastodon/public/system/

# PostgreSQL
mkdir -p /podmandirectory/mastodon/postgres17/

# Redis
mkdir -p /podmandirectory/mastodon/redis/

# ElasticSearch
mkdir -p /podmandirectory/mastodon/elasticsearch/config/
mkdir -p /podmandirectory/mastodon/elasticsearch/data/

:::tip

以上所有目录,均为各程序所使用的数据、配置目录。容器就像是一个个盒子,在使用的时候将盒子放在你的机器上,盒子里所变更的一切文件都不会与你的宿主机交互,只有当你为该盒子开了一个洞,指定的文件才会从洞里出来,放置在指定的地方。你应该为容器内所有需要永久保留的文件配置一个位于宿主机的映射路径,这样才能够永久地保留部分文件,而抛弃其它大多数无关的复杂度。文件映射的内容在下文中还会讲到。

:::

创建 Mastodon 配置

接下来,我们需要进行 Mastodon 的基础配置,它的配置文件应该存储在 /podmandirectory/mastodon/env.production,你可以打开它:

vi /podmandirectory/mastodon/env.production

以下是我的配置文件供参考:

# Federation
# ----------
# This identifies your server and cannot be changed safely later
# ----------
# 本地域(服务器在联邦内的唯一、不可更改标识符)
LOCAL_DOMAIN=szdiy.social
# 主网页域(默认使用本地域)
WEB_DOMAIN=szdiy.social
# 别名域
ALTERNATE_DOMAINS=szdiy.social,assets.szdiy.social
# 安全模式
# AUTHORIZED_FETCH=true
# 有限联邦模式
# LIMITED_FEDERATION_MODE=true
# 单用户模式
SINGLE_USER_MODE=false
# 默认显示语言
DEFAULT_LOCALE=zh-CN

# Redis
# -----
# Redis 主机
REDIS_HOST=10.90.0.48
# Redis 端口
REDIS_PORT=6379
# Redis 密码
REDIS_PASSWORD=
# 使用 Redis URL 形式连接 Redis
# REDIS_URL=redis://user:password@localhost:6379
# Redis 命名空间
REDIS_NAMESPACE=mastodon
# Sidekiq 使用的 Redis 的 Redis URL
# SIDEKIQ_REDIS_URL=

# Cache Redis
# -----------
# CACHE_REDIS_HOST=
# CACHE_REDIS_PORT=
# CACHE_REDIS_URL=
# CACHE_REDIS_NAMESPACE=

# PostgreSQL
# ----------
# 数据库主机
DB_HOST=10.90.0.47
# 数据库用户名
DB_USER=postgres
# DB_USER=mastodon
# 数据库名
DB_NAME=mastodon_production
# 数据库用户密码
DB_PASS=
# 数据库端口
DB_PORT=5432
# 数据库连接池数量(默认等于 MAX_THREADS)
# DB_POOL=5
# 数据库安全模式
# DB_SSLMODE=prefer
# 使用数据库 URL 形式连接数据库
# DATABASE_URL=postgresql://user:password@localhost:5432

# PostgreSQL replica
# ------------------
# REPLICA_DB_HOST=
# REPLICA_DB_PORT=
# REPLICA_DB_NAME=
# REPLICA_DB_USER=
# REPLICA_DB_PASS=
# REPLICA_DATABASE_URL=

# Elasticsearch
# -------------
# 启用 ES
ES_ENABLED=true
# ES 实现方式
ES_PRESET=single_node_cluster
# ES 主机
ES_HOST=10.90.0.49
# ES 端口
ES_PORT=9200
# ES 用户名
# ES_USER=elastic
# ES 密码
# ES_PASS=password
# ES 前缀(默认等于 REDIS_NAMESPACE)
# ES_PREFIX=mastodon

# Secrets
# -------
# Make sure to use `bundle exec rails secret` to generate secrets
# -------
SECRET_KEY_BASE=『需要填写』
OTP_SECRET=『需要填写』

# Encryption secrets
# ------------------
# Must be available (and set to same values) for all server processes
# These are private/secret values, do not share outside hosting environment
# Use `bin/rails db:encryption:init` to generate fresh secrets
# Do NOT change these secrets once in use, as this would cause data loss and other issues
# ------------------
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=『需要填写』
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=『需要填写』
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=『需要填写』

# Web Push
# --------
# Generate with `bundle exec rails mastodon:webpush:generate_vapid_key`
# --------
VAPID_PRIVATE_KEY=『需要填写』
VAPID_PUBLIC_KEY=『需要填写』

# Sending mail
# ------------
# SMTP 服务器
SMTP_SERVER=smtphk.qiye.aliyun.com
# SMTP 端口
SMTP_PORT=465
# SMTP 域(默认等于 SMTP_SERVER)
# SMTP_DOMAIN=
# SMTP 登录用户名
SMTP_LOGIN=szdiy-social@notice.karula.org
# SMTP 登录密码
SMTP_PASSWORD=『需要填写你的 SMTP 信息』
# SMTP 发件人名称
SMTP_FROM_ADDRESS=SZDIY Social <szdiy-social@notice.karula.org>
# SMTP 送达方式
# SMTP_DELIVERY_METHOD=smtp
# SMTP 验证方式
SMTP_AUTH_METHOD=plain
# SMTP OpenSSL 验证模式(用于接受自签名证书)
SMTP_OPENSSL_VERIFY_MODE=none
# SMTP TLS 安全
SMTP_TLS=true
# SMTP SSL 安全
# SMTP_SSL=true
# SMTP 自动启用 StartTLS
# SMTP_ENABLE_STARTTLS_AUTO=true

# File storage
# ------------
# 静态资源 CDN 地址
CDN_HOST=https://assets.szdiy.social
# 启用 S3
S3_ENABLED=true
# S3_REGION=auto
S3_ENDPOINT=『需要填写你的 Cloudflare R2 信息』
S3_BUCKET=szdiy-social-media
AWS_ACCESS_KEY_ID=『需要填写你的 Cloudflare R2 信息』
AWS_SECRET_ACCESS_KEY=『需要填写你的 Cloudflare R2 信息』
# S3_SIGNATURE_VERSION=v4
# S3_OVERRIDE_PATH_STYLE=false
S3_PROTOCOL=https
# S3_HOSTNAME=media.szdiy.social
# S3_ALIAS_HOST=media.szdiy.social
# S3_ALIAS_HOST=gcore.cdn.media.szdiy.social
S3_ALIAS_HOST=media.szdiy.social
# S3_OPEN_TIMEOUT=5
# S3_READ_TIMEOUT=5
# S3 强制单请求(启用以修复 S3 422 错误)
# https://github.com/mastodon/mastodon/issues/18778
# S3_FORCE_SINGLE_REQUEST=true
# S3_ENABLE_CHECKSUM_MODE=false
# S3_STORAGE_CLASS=
# S3_MULTIPART_THRESHOLD=15
# Cloudflare R2 不支持 ACL
S3_PERMISSION=
# S3_BATCH_DELETE_LIMIT=1000
# S3_BATCH_DELETE_RETRY=3

# OpenID Connect
# --------------
# OIDC_ENABLED=true
# OIDC_DISPLAY_NAME=XiaoXi's OpenID Connect
# OIDC_ISSUER=https://sso.soraharu.com/realms/sora
# OIDC_DISCOVERY=true
# OIDC_CLIENT_AUTH_METHOD=
# OIDC_SCOPE=openid,profile,email
# OIDC_RESPONSE_TYPE=
# OIDC_RESPONSE_MODE=
# OIDC_DISPLAY=
# OIDC_PROMPT=
# OIDC_SEND_NONCE=
# OIDC_SEND_SCOPE_TO_TOKEN_ENDPOINT=
# OIDC_IDP_LOGOUT_REDIRECT_URI=
# OIDC_UID_FIELD=preferred_username
# OIDC_CLIENT_ID=szdiy-social
# OIDC_CLIENT_SECRET=『需要填写你的 OIDC 信息,此处示例配置为我的自建认证中心方案』
# OIDC_REDIRECT_URI=https://szdiy.social/auth/auth/openid_connect/callback
# OIDC_HTTP_SCHEME=
# OIDC_HOST=
# OIDC_PORT=
# OIDC_AUTH_ENDPOINT=https://sso.soraharu.com/realms/sora/.well-known/openid-configuration
# OIDC_TOKEN_ENDPOINT=
# OIDC_USER_INFO_ENDPOINT=
# OIDC_JWKS_URI=
# OIDC_END_SESSION_ENDPOINT=
# OIDC_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true

# Cache buster
# ------------
# CACHE_BUSTER_ENABLED=true
# CACHE_BUSTER_HTTP_METHOD=GET
# CACHE_BUSTER_SECRET_HEADER=
# CACHE_BUSTER_SECRET=

# IP and session retention
# -----------------------
# Make sure to modify the scheduling of ip_cleanup_scheduler in config/sidekiq.yml
# to be less than daily if you lower IP_RETENTION_PERIOD below two days (172800).
# -----------------------
# IP_RETENTION_PERIOD=31556952
# SESSION_RETENTION_PERIOD=31556952

# Deployment
# ----------
# 使用 Rails 提供静态文件
# RAILS_SERVE_STATIC_FILES=true
# Rails 日志级别
RAILS_LOG_LEVEL=warn
# 日志级别(Mastodon 为 streaming 进程生成的日志)
# LOG_LEVEL=info
# 可信代理 IP
# TRUSTED_PROXY_IP=

# Scaling
# -------
# Sidekiq 分支数
# SIDEKIQ_CONCURRENCY=5
# Puma 分支数
# WEB_CONCURRENCY=2
# Puma 线程数
# MAX_THREADS=5
# Puma 关闭连接等待秒数
# PERSISTENT_TIMEOUT=20
# 外部 streaming API 地址
STREAMING_API_BASE_URL=wss://streaming.szdiy.social

# Tor
# ---
# http_proxy=
# http_hidden_proxy=http://10.90.0.5:8118
# ALLOW_ACCESS_TO_HIDDEN_SERVICE=true

# Anti Spam / Abuse
# -----------------
HCAPTCHA_SITE_KEY=『需要填写你的 hCaptcha 信息』
HCAPTCHA_SECRET_KEY=『需要填写你的 hCaptcha 信息』

# Sessions
# --------
# 最大活跃会话数
# MAX_SESSION_ACTIVATIONS=10

# Home feeds
# ----------
# 活跃用户的 feed 在内存中保存的周期
# USER_ACTIVE_DAYS=7

# DeepL
# -----
# DEEPL_API_KEY=
# DEEPL_PLAN=

# Open source
# -----------
# GitHub 项目
# GITHUB_REPOSITORY=yanranxiaoxi/Mastodon-Sora
# 源代码仓库地址
# SOURCE_BASE_URL=https://gitlab.soraharu.com/XiaoXi/Mastodon-Sora
# GitHub API Token(用于从 GitHub 提交历史生成 AUTHORS.md)
# GITHUB_API_TOKEN=

:::info

在配置中有很多需要修改的地方,比如所有的域名都以 szdiy.social 结尾,你的自部署实例应该将其修改为你自己的域名。

:::

Secrets、Encryption secrets 和 Web Push

在 Secrets、Encryption secrets 和 Web Push 部分,这里的所有内容都需要依靠 Mastodon 和 Ruby 的能力生成:

# Secrets
# -------
# Make sure to use `bundle exec rails secret` to generate secrets
# -------
SECRET_KEY_BASE=『需要填写』
OTP_SECRET=『需要填写』

# Encryption secrets
# ------------------
# Must be available (and set to same values) for all server processes
# These are private/secret values, do not share outside hosting environment
# Use `bin/rails db:encryption:init` to generate fresh secrets
# Do NOT change these secrets once in use, as this would cause data loss and other issues
# ------------------
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=『需要填写』
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=『需要填写』
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=『需要填写』

# Web Push
# --------
# Generate with `bundle exec rails mastodon:webpush:generate_vapid_key`
# --------
VAPID_PRIVATE_KEY=『需要填写』
VAPID_PUBLIC_KEY=『需要填写』

请在宿主机内执行以下命令(这会用到我编译的客制化镜像,后续会描述它),并将生成的密钥填入指定位置:

# 创建 Secrets(你需要执行两次以生成两个不同的密钥填入 SECRET_KEY_BASE 和 OTP_SECRET)
podman container run --rm -it docker.io/yanranxiaoxi/mastodon-sora:stable bundle exec rails secret

# 创建 Encryption secrets
podman container run --rm -it docker.io/yanranxiaoxi/mastodon-sora:stable bundle exec rails db:encryption:init

# 创建 Web Push
podman container run --rm -it docker.io/yanranxiaoxi/mastodon-sora:stable bundle exec rails mastodon:webpush:generate_vapid_key

# 每个 `podman container run` 命令都创建了一个容器,
# 并在容器内执行了 `bundle exec` 命令,因为这是临时操作,
# 所以每个容器都带有 `--rm` 标签,它们会在执行完成后立刻销毁

Sending mail

在 Sending mail 部分,我使用的是阿里企业邮箱,你也可以灵活配置为其它邮件提供商:

# Sending mail
# ------------
# SMTP 服务器
SMTP_SERVER=smtphk.qiye.aliyun.com
# SMTP 端口
SMTP_PORT=465
# SMTP 域(默认等于 SMTP_SERVER)
# SMTP_DOMAIN=
# SMTP 登录用户名
SMTP_LOGIN=szdiy-social@notice.karula.org
# SMTP 登录密码
SMTP_PASSWORD=『需要填写你的 SMTP 信息』
# SMTP 发件人名称
SMTP_FROM_ADDRESS=SZDIY Social <szdiy-social@notice.karula.org>
# SMTP 送达方式
# SMTP_DELIVERY_METHOD=smtp
# SMTP 验证方式
SMTP_AUTH_METHOD=plain
# SMTP OpenSSL 验证模式(用于接受自签名证书)
SMTP_OPENSSL_VERIFY_MODE=none
# SMTP TLS 安全
SMTP_TLS=true
# SMTP SSL 安全
# SMTP_SSL=true
# SMTP 自动启用 StartTLS
# SMTP_ENABLE_STARTTLS_AUTO=true

:::tip

我没有使用自己的邮件服务器邮局,是因为在当前互联网垃圾横行的环境下,一个没有大企业背书的电子邮局几乎不会被任何邮件安全组织信任,也就意味着所发出的邮件有很大可能被送进垃圾信箱。为了减少这种可能以及过度发信对个人邮局造成的不利影响,我推荐持有个人邮件服务器邮局的朋友,使用公共发信平台来处理自动化邮件,虽然这可能会降低对个人信息的掌控程度。

:::

File storage

在 File storage 部分,我推荐使用 Cloudflare R2 作为媒体文件的存储位置,它只收取 API 请求费用和存储空间费用,不计流量费。

# File storage
# ------------
# 静态资源 CDN 地址
CDN_HOST=https://assets.szdiy.social
# 启用 S3
S3_ENABLED=true
# S3_REGION=auto
S3_ENDPOINT=『需要填写你的 Cloudflare R2 信息』
S3_BUCKET=szdiy-social-media
AWS_ACCESS_KEY_ID=『需要填写你的 Cloudflare R2 信息』
AWS_SECRET_ACCESS_KEY=『需要填写你的 Cloudflare R2 信息』
# S3_SIGNATURE_VERSION=v4
# S3_OVERRIDE_PATH_STYLE=false
S3_PROTOCOL=https
# S3_HOSTNAME=media.szdiy.social
# S3_ALIAS_HOST=media.szdiy.social
# S3_ALIAS_HOST=gcore.cdn.media.szdiy.social
S3_ALIAS_HOST=media.szdiy.social
# S3_OPEN_TIMEOUT=5
# S3_READ_TIMEOUT=5
# S3 强制单请求(启用以修复 S3 422 错误)
# https://github.com/mastodon/mastodon/issues/18778
# S3_FORCE_SINGLE_REQUEST=true
# S3_ENABLE_CHECKSUM_MODE=false
# S3_STORAGE_CLASS=
# S3_MULTIPART_THRESHOLD=15
# Cloudflare R2 不支持 ACL
S3_PERMISSION=
# S3_BATCH_DELETE_LIMIT=1000
# S3_BATCH_DELETE_RETRY=3

你需要创建一个 Cloudflare R2 Bucket,其名称对应 S3_BUCKET 参数。

:::tip

这里也支持任何 S3 服务,只需要你能够正确配置其参数。

:::

Anti Spam / Abuse

如需启用 hCaptcha 验证码服务,你需要去 hcaptcha.com 注册一个账号并生成以下密钥:

# Anti Spam / Abuse
# -----------------
HCAPTCHA_SITE_KEY=『需要填写你的 hCaptcha 信息』
HCAPTCHA_SECRET_KEY=『需要填写你的 hCaptcha 信息』

它可以比较有效地防止你的站点账号被人暴力破解。

:::info

如果你不希望启用 hCaptcha,可以安全地注释这两行配置。

:::

创建 ElasticSearch 插件配置

ElasticSearch 的 data 目录需要有容器内写入权限,你需要修改该目录的权限:

chown -R 1000:0 /podmandirectory/mastodon/elasticsearch/data/

# 或是(可能不安全)
chmod -R 777 /podmandirectory/mastodon/elasticsearch/data/

ElasticSearch 插件的配置文件应该存储在 /podmandirectory/mastodon/elasticsearch/config/

目录下,你可以创建这个文件:

vi /podmandirectory/mastodon/elasticsearch/config/elasticsearch-plugins.yml

在文件内粘贴以下内容:

plugins:
  - id: analysis-ik
    location: https://get.infini.cloud/elasticsearch/analysis-ik/7.17.28
  - id: analysis-stconvert
    location: https://get.infini.cloud/elasticsearch/analysis-stconvert/7.17.28

:::info

这代表着安装了 analysis-ikanalysis-stconvert 两个插件的 7.17.28 版本,如果你以后要更新 ElasticSearch 的版本,也需要在这里同时修改插件的版本。

:::

:::tip

这两个插件是为了更加友好地支持中文搜索环境,我们应该大部分都是中文作为基本母语的朋友,所以此处也将中文搜索优化的配置放在基本流程中。

:::

:::tip

为了让 Mastodon 能够完全支持中文搜索分词,我在 mastodon-sora 镜像中也有针对于此的二次开发,如若有兴趣,你也可以查看 我的 mastodon-sora 源代码仓库 以获取更多信息。

:::

创建 Caddy 配置

Caddy 的配置文件为 /podmandirectory/caddy2/Caddyfile,你需要创建这个文件:

vi /podmandirectory/caddy2/Caddyfile

并在文件内粘贴以下内容:

{
        email letsencrypt@szdiy.social
        servers {
                import /config/reuse/trusted_proxies.cloudflare.Caddyfile
        }
}

import /config/site/*.Caddyfile

:::info

你需要把 letsencrypt@szdiy.social 修改为你的电子邮箱地址,这是用于接收自动化安全证书签发问题(虽然我们本次案例中并没有使用到该功能)的唯一渠道。

:::

我在进行 Caddy 配置的时候,使用到了我的集成化配置开源项目,所以,你需要继续在宿主机执行以下命令以激活我的自动化配置脚本:

wget -O ~/autoSyncNiceCaddyfile.sh https://sh.soraharu.com/Container/caddy2/autoSyncNiceCaddyfile.sh && sh ~/autoSyncNiceCaddyfile.sh "podman" "firstRun" && rm -f ~/autoSyncNiceCaddyfile.sh

:::tip

这个自动化配置脚本将会克隆我的 Nice Caddyfile 项目到 /podmandirectory/caddy2/config/reuse/ 目录下,并保持每日自动更新,里面有许多常用的 Caddyfile 配置文件可供调用。

:::

创建所有需要公开的站点配置

对 Mastodon 和 Caddy 的配置结束后,我们需要创建所有需要公开的站点配置。

回顾前文,我们大致需要以下域名接入访问:

  1. Mastodon 接入主域名(szdiy.social
  2. Mastodon streaming 串流域名(streaming.szdiy.social
  3. Mastodon assets 静态文件分发域名(assets.szdiy.social
  4. Mastodon media 媒体 S3 存储域名(media.szdiy.social

其中 1-3 需要在宿主机进行配置并允许公开访问,4 需要在 Cloudflare R2(或其他 S3 供应商)内绑定到 Bucket 并允许公开访问。

所有需要在宿主机进行的配置都建议存放在 /podmandirectory/caddy2/config/site/ 目录下,名称规则为 域名 + .Caddyfile,以下给定这三个文件的内容:

  1. szdiy.social.Caddyfile
szdiy.social {
        tls /config/cert/@.szdiy.social.pem /config/cert/@.szdiy.social.key

        import /config/reuse/encode.Caddyfile
        import /config/reuse/header.secure-portal.Caddyfile

        reverse_proxy http://10.90.0.44:3000 {
                import /config/reuse/header_up.follow.Caddyfile
        }
}
  1. streaming.szdiy.social.Caddyfile
streaming.szdiy.social {
        tls /config/cert/@.szdiy.social.pem /config/cert/@.szdiy.social.key

        import /config/reuse/encode.Caddyfile
        import /config/reuse/header.content-distribution.Caddyfile

        reverse_proxy http://10.90.0.45:4000 {
                import /config/reuse/header_up.follow.Caddyfile
        }
}
  1. assets.szdiy.social.Caddyfile
assets.szdiy.social {
        tls /config/cert/@.szdiy.social.pem /config/cert/@.szdiy.social.key

        import /config/reuse/encode.Caddyfile
        import /config/reuse/header.content-distribution.Caddyfile

        reverse_proxy http://10.90.0.44:3000 {
                import /config/reuse/header_up.follow.Caddyfile
        }

        @matchpath {
                not path /assets/* /avatars/* /emoji/* /headers/* /packs/* /sounds/* /inert.css
        }
        handle @matchpath {
                respond "This is a static resource CDN service for Mastodon operated by @szdiy.social" 403
        }
}

在 Cloudflare 上进行 DNS/TLS 配置

为了让你的用户能够安全地访问到你的 Mastodon 实例,你需要在 Cloudflare 上正确地配置安全规则,并将你的域名正确指向你的 Mastodon 所在的服务器。

创建 DNS 记录

你需要创建以下 DNS 记录:

类型名称IPv4 地址代理状态TTL
Aszdiy.social你的服务器 IPv4 地址自动
Astreaming.szdiy.social你的服务器 IPv4 地址自动
Aassets.szdiy.social你的服务器 IPv4 地址自动

DNS记录 中按以下方式填入即可:

Cloudflare DNS 记录

生成源安全证书

你需要在 SSL/TLS源服务器 页面创建 源证书

Cloudflare 源服务器证书

请参照以下配置填写(域名修改为你自己的):

配置源服务器证书

然后创建你的源证书和私钥,并将他们存储到 /podmandirectory/caddy2/config/cert/ 目录下。其中,源证书存储为 @.szdiy.social.pem 文件,私钥存储为 @.szdiy.social.key 文件。这两个文件与在 创建所有需要公开的站点配置 中的 tls 内容相匹配。

进行系统配置

接下来是我和我的同好们踩过的坑的集锦内容。

ElasticSearch 的地址映射大小

我们使用的 ElasticSearch 17 版本有一个系统 CTL 硬性要求,就是 vm.max_map_count 不能低于 262144,你可以编辑 /etc/sysctl.conf 文件并添加以下内容:

vm.max_map_count = 262144

Redis 的 OverCommit 内存限制

Redis 官方文档中描述,Redis 需要启用 OverCommit 内存以获得更好的性能,你可以编辑 /etc/sysctl.conf 文件并添加以下内容:

vm.overcommit_memory = 1

在以上配置完成后,你需要重启你的服务器以应用配置。请执行:

init 6

:::success

至此,你已完成了所有需要的配置。

:::

第三步:启动服务

拉取镜像

我们今天所有的服务,都会在容器中运行。使用容器可以极大地减少服务部署的难度,并且在后期维护和迁移上都非常方便。包括现在大热的各种开源 AI 模型,你如果想要在自己的服务器上运行,基本都脱不开容器的使用。

不过容器的具体定义和使用并不在今天的讨论范围之内,今天我们只需要使用到 Podman 这一容器引擎的 container runpull 命令即可。

:::tip

前面我有提到,我使用的 Mastodon 容器镜像是我的客制化版本。这是得益于 Mastodon 是一个完全开源的项目,我可以在它的源代码的基础上实现我自己想要的功能。比如在这里,我将图片、视频的单文件大小上限和分辨率上限调高了许多,并且还支持发表万字长文(Mastodon 原先的限制为 500 字)。

:::

为了后续部署阶段能够流畅进行,我们可以首先拉取所有需要的镜像到本地,这需要用到 podman pull 命令:

podman pull docker.io/library/caddy:2-alpine
podman pull docker.io/yanranxiaoxi/mastodon-sora:v4.3.7-a
podman pull ghcr.io/mastodon/mastodon-streaming:v4.3.7
podman pull docker.io/library/postgres:17-alpine
podman pull docker.io/library/redis:7-alpine
podman pull docker.elastic.co/elasticsearch/elasticsearch:7.17.28

:::info

虽然不推荐,但是如果你希望在国内的服务器上部署 Mastodon 服务,你在从 docker.io 注册表拉取镜像时可能会遇到网络问题,这是因为从今年(2025 年)2 月开始,中国大陆已经阻断对于大部分公共 Docker 注册表的访问。你可以使用以下命令登录我个人的 docker.io 注册表镜像服务:

podman login -u=xiaoxis-registry -p=9d4AQF2evRsF5dNSroV58sg3J5HfH4CwsrFsjbvy9E5gHusTSqVSwsMbEbnJ9aPS docker.mirror.soraharu.com

然后将所有 podman pullpodman container run 命令内包含的 docker.io 都替换为 docker.mirror.soraharu.com

:::

使用 Podman 部署服务

接下来,一切都变得很简单了,你只需要使用以下命令部署所有的容器:

mastodon-db

podman container run \
    --cpu-shares=1024 \
    --detach \
    --env=POSTGRES_HOST_AUTH_METHOD=trust \
    --health-cmd='pg_isready -U postgres' \
    --ip=10.90.0.47 \
    --ip6=fd00::1:8:47 \
    --label=io.containers.autoupdate=registry \
    --name=mastodon-db \
    --network=podman1 \
    --quiet \
    --replace \
    --restart=always \
    --shm-size=256m \
    --tls-verify \
    --volume=/podmandirectory/mastodon/postgres17/:/var/lib/postgresql/data/ \
    docker.io/library/postgres:17-alpine

数据库服务启动完成后,需要创建数据库格式:

podman container run --rm -it --env-file=/podmandirectory/mastodon/env.production docker.io/yanranxiaoxi/mastodon-sora:v4.3.7-a bundle exec rails db:create
podman container run --rm -it --env-file=/podmandirectory/mastodon/env.production docker.io/yanranxiaoxi/mastodon-sora:v4.3.7-a bundle exec rails db:migrate

mastodon-redis

podman container run \
    --cpu-shares=1024 \
    --detach \
    --health-cmd='redis-cli ping' \
    --ip=10.90.0.48 \
    --ip6=fd00::1:8:48 \
    --label=io.containers.autoupdate=registry \
    --name=mastodon-redis \
    --network=podman1 \
    --quiet \
    --replace \
    --restart=always \
    --tls-verify \
    --volume=/podmandirectory/mastodon/redis/:/data/ \
    docker.io/library/redis:7-alpine

mastodon-es

podman container run \
    --cpu-shares=1024 \
    --detach \
    --env=bootstrap.memory_lock=true \
    --env=cluster.name=es-mastodon \
    --env=discovery.type=single-node \
    --env=ES_JAVA_OPTS="-Xms512m -Xmx512m -Des.enforce.bootstrap.checks=true" \
    --env=thread_pool.write.queue_size=1000 \
    --env=xpack.graph.enabled=false \
    --env=xpack.license.self_generated.type=basic \
    --env=xpack.ml.enabled=false \
    --env=xpack.security.enabled=false \
    --env=xpack.watcher.enabled=false \
    --health-cmd='curl --silent --fail localhost:9200/_cluster/health || exit 1' \
    --ip=10.90.0.49 \
    --ip6=fd00::1:8:49 \
    --name=mastodon-es \
    --network=podman1 \
    --quiet \
    --replace \
    --restart=always \
    --tls-verify \
    --ulimit=memlock=-1:-1 \
    --ulimit=nofile=65536:65536 \
    --volume=/podmandirectory/mastodon/elasticsearch/config/elasticsearch-plugins.yml:/usr/share/elasticsearch/config/elasticsearch-plugins.yml \
    --volume=/podmandirectory/mastodon/elasticsearch/data/:/usr/share/elasticsearch/data/ \
    docker.elastic.co/elasticsearch/elasticsearch:7.17.28

mastodon-web

podman container run \
    --cpu-shares=1024 \
    --detach \
    --env-file=/podmandirectory/mastodon/env.production \
    --health-cmd='wget -q --spider --proxy=off localhost:3000/health || exit 1' \
    --ip=10.90.0.44 \
    --ip6=fd00::1:8:44 \
    --label=io.containers.autoupdate=registry \
    --name=mastodon-web \
    --network=podman1 \
    --quiet \
    --replace \
    --restart=always \
    --tls-verify \
    --volume=/podmandirectory/mastodon/public/system/:/mastodon/public/system/ \
    docker.io/yanranxiaoxi/mastodon-sora:v4.3.7-a \
    bundle exec puma -C config/puma.rb

mastodon-streaming

podman container run \
    --cpu-shares=1024 \
    --detach \
    --env-file=/podmandirectory/mastodon/env.production \
    --health-cmd='wget -q --spider --proxy=off localhost:4000/api/v1/streaming/health || exit 1' \
    --ip=10.90.0.45 \
    --ip6=fd00::1:8:45 \
    --label=io.containers.autoupdate=registry \
    --name=mastodon-streaming \
    --network=podman1 \
    --quiet \
    --replace \
    --restart=always \
    --tls-verify \
    ghcr.io/mastodon/mastodon-streaming:v4.3.7 \
    node ./streaming/index.js

mastodon-sidekiq

podman container run \
    --cpu-shares=1024 \
    --detach \
    --env-file=/podmandirectory/mastodon/env.production \
    --health-cmd="ps aux | grep '[s]idekiq\ 6' || false" \
    --ip=10.90.0.46 \
    --ip6=fd00::1:8:46 \
    --label=io.containers.autoupdate=registry \
    --name=mastodon-sidekiq \
    --network=podman1 \
    --quiet \
    --replace \
    --restart=always \
    --tls-verify \
    --volume=/podmandirectory/mastodon/public/system/:/mastodon/public/system/ \
    docker.io/yanranxiaoxi/mastodon-sora:v4.3.7-a \
    bundle exec sidekiq

caddy2

podman container run \
    --cpu-shares=1024 \
    --detach \
    --ip=10.90.0.2 \
    --ip6=fd00::1:8:2 \
    --label=io.containers.autoupdate=registry \
    --memory=1g \
    --memory-reservation=256m \
    --name=caddy2 \
    --network=podman1 \
    --publish=80:80/tcp \
    --publish=443:443/tcp \
    --publish=443:443/udp \
    --quiet \
    --replace \
    --restart=always \
    --tls-verify \
    --volume=/podmandirectory/caddy2/Caddyfile:/etc/caddy/Caddyfile \
    --volume=/podmandirectory/caddy2/config/:/config/ \
    --volume=/podmandirectory/caddy2/data/:/data/ \
    docker.io/library/caddy:2-alpine

Podman 参数的简要描述

在每一个 podman container run 命令中,我都配置了许多启动参数,以下是这些启动参数的简要描述:

参数描述
cpu-shares处理器积分数,数值越高代表抢占 CPU 时间的优先级越高
detach容器将在后台运行
env容器运行时的环境变量
env-file从文件读取容器运行时的环境变量
health-cmd容器健康检查的心跳命令
ip容器在 Podman 虚拟网络中的 IPv4 地址
ip6容器在 Podman 虚拟网络中的 IPv6 地址
label=io.containers.autoupdate=registry启用容器自动更新后,指定容器需要从注册表内拉取更新
memory容器能够占用的最大运行内存
memory-reservation为容器保留的内存
name容器名称
network=podman1容器网络为 podman1,这个网络是我们在 Podman 支持 IPv6 中创建的
publish容器内部端口与宿主机端口的映射关系
quiet容器静态执行,不返回数据
replace当存在同名容器时覆盖运行
restart=always在容器被关闭后自动重启
shm-size容器内不同进程的共享内存大小
tls-verify拉取容器镜像时需要 TLS 验证
ulimit容器内的 ulimit 系统限制
volume容器内文件目录与宿主机文件目录的映射关系

这基本包括了你接下来需要运行其它容器时会用到的所有常用参数。

以上,你已经完成了所有部署步骤,接下来我们需要测试我们的部署成果。

测试部署状态

你首先需要创建一个管理员账号:

podman exec -it mastodon-web \
  bin/tootctl accounts create \
    XiaoXi \
    --email admin@soraharu.com \
    --confirmed \
    --role Owner

修改上述命令中的用户名和电子邮箱,然后执行,程序会自动创建密码并打印出来。

此时,你应该访问你的实例主域名(例如此时我访问 https://szdiy.social/),并使用上文创建的密码登录账号。

如果你的实例处于审核模式,你还需要手动审批你自己的注册:

podman exec -it mastodon-web \
  bin/tootctl accounts approve XiaoXi

:::success
恭喜你,你已经拥有了自己的 Fediverse 实例!

:::

第四步:互联、优化、备份,以及更多

将容器交予 systemd 管理并自动更新

将容器交予 systemd 管理后,可以做到开机自动启动容器,并且能够自动进行容器更新。

:::tip

在本教程中,我们的 Mastodon 版本是 v4.3.7,这是详细的补丁版本,不存在自动更新的可能,但是 Caddy、PostgreSQL 的版本并没有完全限制,它们还是可以接收到新的修复。

:::

这是一个我个人推荐的通用方案,可以极大地简化运维的难度,你可以使用以下我预编写的脚本进行快速自动化配置:

wget -O ~/newAutoUpdateContainer.sh https://sh.soraharu.com/ServerMaintenance/Podman/newAutoUpdateContainer.sh \
  && sh ~/newAutoUpdateContainer.sh "mastodon-db" \
  && sh ~/newAutoUpdateContainer.sh "mastodon-redis" \
  && sh ~/newAutoUpdateContainer.sh "mastodon-es" \
  && sh ~/newAutoUpdateContainer.sh "mastodon-web" \
  && sh ~/newAutoUpdateContainer.sh "mastodon-streaming" \
  && sh ~/newAutoUpdateContainer.sh "mastodon-sidekiq" \
  && sh ~/newAutoUpdateContainer.sh "caddy2" \
  && rm -f ~/newAutoUpdateContainer.sh

:::info
Podman 官方提示需要使用 Quadlets 来管理 systemd 配置,我认为这又增加了复杂度,所以此处没有使用这个方案。

:::

构建 ElasticSearch 索引

为了能够正确进行搜索,我们需要在每次进行版本更新后(特指 Mastodon 版本),重新构建 ElasticSearch 的索引:

podman exec -it mastodon-web \
  bin/tootctl search deploy --only=tags statuses public_statuses

:::tip

请记住在每次进行更新后都执行此命令,才能获得更好的使用体验。

:::

自动清除过期数据

Mastodon 是去中心化的社交平台,自部署的 Mastodon 实例会接收到许多不来自于本地的外部实例的帖子、媒体文件等数据,并缓存在数据库和对象存储中,其中大多数的数据在一段时间后就变得不那么重要了(很少有人会去浏览和查阅),我们可以清除这部分数据以减少在服务器和对象存储上的资费。

你可以在 /etc/crontab 内添加以下定时任务:

# 每日 2:10 清理 14 天前的外部实例链接卡片预览图
10 2 * * * root podman exec -t mastodon-web tootctl preview_cards remove --days 14
# 每日 2:30 清理 7 天前的外部实例媒体数据
30 2 * * * root podman exec -t mastodon-web tootctl media remove --days 7
# 每日 2:00 清理缓存存储区
0 2 * * * root podman exec -t mastodon-web tootctl cache clear
# 每月 1 日 3:00 扫描不属于现有媒体附件的文件(游离碎片文件)并清理,这会造成大量对象存储 API 调用,可能产生计费
0 3 1 * * root podman exec -t mastodon-web tootctl media remove-orphans

通过中继与其它实例主动交换数据

一个实例建立的初期,只有内部用户可以互相查看到所发布的帖子,时间线上会显得非常空旷,这时候,我们可以通过订阅 ActivityPub 中继的方式,来主动与其它实例交换用户和帖子数据,这能够快速扩充你的跨站信息流。

当前中文区推荐的跨站中继:

https://relay.nya.one/inbox
https://relay.mstdn.one/inbox
https://relay.dragon-fly.club/inbox
https://relay.acg.mn/inbox
https://relay.isle.moe/inbox

其它语言推荐的跨站中继:

https://relay.toot.io/inbox

你需要在 Mastodon 后台导航到 管理中继站 并添加以上部分或全部中继。

Mastodon 中继站配置页

添加新评论 取消回复

仅有一条评论

  1. Test comment OωO