我是如何写作本文的:在应用部署平台和 VPS 上各自建立了一个测试用的 Mastodon 站点后,作技术笔记。自评可靠程度:可靠性中等,全面性中等。
本文适合谁看/怎么看:了解 Mastodon 的设计和使用,并对建立自己的 Mastodon 站点感兴趣的读者。单纯想看关于 Mastodon 的小介绍、VPS 评测、或 Nginx 笔记、 Docker 教程 亦可。
更新策略:我可能会直接修改本文内容,而不标注”X月X日更新“
我对 Mastodon 的理解
Mastodon,直译为”乳齿象”,是一个去中心化的、开放源代码的社交平台,是由一个个自主运营、规则各异的站点形成的邦联。虽然理念背道而驰,但 Mastodon 的用法比较像 Twitter (X),所以某种程度上和 Twitter (X) 形成竞争用户的关系。
了解 Mastodon 运行机制的最好办法是加入一个 Mastodon 站点(又称成为“象友”)。通过发布嘟文(Toot),你可以了解到 Mastodon 的诸多规则,比如“跨站可见性”的含义、站点一般默认禁用全局文本搜索的选择、打Hash Tag的作用等等。
从 Managed Service 到 Self Hosting 的光谱
假设某一天,你想要搭建、维护一个自己的 Mastodon 站点,服务一个用户群体,成为一个自主运营的站点邦联中的一个,暂时忽略设定站点规则等社会方面,在技术的层面如何实施?你会发现有很多技术路线可选。一端是开箱即用、简化建站维护流程的托管服务(Managed Service),一端是自主可控、支持定制的自托管(Self Hosting)。
最省事的办法是把搭建和维护站点的责任委托给服务商。这篇博文详细介绍了托管站服务,Mastodon的官方文档中列出了一些托管商,如 masto.host 等,这些托管商的共同特点是它们都专注于提供 Mastodon 等类似的社交平台建站服务。
稍微灵活一些的托管方案是使用一些通用的应用部署平台,比如 railway.app,比如 cloud.sealos.io。在这些平台上,你可以依据一个 docker image、一个 GitHub 仓库或一个本地代码仓库来建立应用,从 ChatGPT 的网页 UI,到依赖 PostgreSQL 和 Redis 的 Mastodon 应用,不一而足。例如 railway 就会通过你提供的代码来建立一个符合 OCI 标准的镜像。这一方案允许你对站点进行代码级更改,但仍有一些限制,如 railway 就不支持 docker-compose。
比较经典的自托管解决方案是租用服务器 VPS,建立站点。你能够通过命令行接入服务器,从操作系统的层面全面控制服务器,从而定制你的站点。具体到 Mastodon 上,可以依照官方文档的教程一步步操作安装软件,也可以尝试使用 Docker 镜像简化流程。总体而言,一个 Mastodon 实例需要 2G 往上的内存和 25G 往上的硬盘空间。
自托管的终极方案是使用自己手头的主机作为服务器,你将需要负责所有技术细节,对站点有完全的掌控。
在应用部署平台试水
Disclaimer:我之所以尝试使用 railway 部署,是因为我之前订阅了 railway 的服务,不代表 railway 是干这件事情的一个好的/成熟的解决方案。
搜索 “deploy mastodon to railway”,得到这个现成的仓库。按照仓库的说明填上用户名 (不要选为 “admin”,会和保留字段冲突) 和邮箱。如果出现问题,就用重启解决一切问题(实际上是各个服务启动的顺序很关键,重启 mastodon 服务之前/同时可能需要重启其它服务)。最终可以成功进入 Mastodon 站点。
部署结束后反思,这个方案有诸多缺点:提供的控制不够,对服务器没有命令行控制,不支持docker-compose。况且,该平台按CPU、内存、出站流量按量加收使用费,由于Mastodon占用大量内存,平台的预计收费为$10每月,性价比很低。
VPS 的选择
既然应用部署平台不是好的解决方案,考虑租用VPS。
这里有一个有意思的对比:Amazon, Microsoft 和 Google 分别推出的 AWS, Azure 和 GCP(合称 hyperscaler cloud)和其它 VPS(如 Digital Ocean, Vulture 等)。前者可以定性为“云服务”,扩展性极强,面向企业提供高品质服务,资费较贵;后者可以定性为“租用服务器”,面向个人和小商户,性价比高。
具体而言,前者对出站流量(Egress traffic)累计计费,收费项目名目繁多,金额上不封顶,采用后付费的账单制,可能出现天价账单。AWS EC2 出站流量收费 $0.09/GB,即 $90/TB。与之相对的,一些专门的VPS如DigitalOcean, Contabo, 等等都采取“包月”制,每月预付费固定的金额,如果出站流量超限则限速,不会出现天价账单。如果硬要比较出站流量的价格,Contabo每月$5.5的套餐允许高达32TB的出站流量,要便宜数个数量级。
在 VPS 上部署 Mastodon 的技术路线
就我目前了解,有两条技术路线:一条是按照官网的教程一步一步走;另一条是在 Docker 中部署,这个官网的技术支持可能不多。出于个人原因,我选择了第二条路线。
本篇博文的余下部分会先概述用到的两个工具 Nginx 和 Docker,再给出一个大致的部署步骤和运维方法。
相关技术笔记其一:Nginx 的配置
静态网页
服务器nginx的基本配置可以参考其教程。也最好参考/etc/nginx/sites-available/default中的内容。关于设置每个端口的default_server,见这里。
对于静态网页的服务,基本的原理为:
server {
location / {
root /data/www;
}
location /images/ {
root /data;
}
}
# request example.com/images/01.png will be mapped to /data/images/01.png
# request example.com/index.html will be mapped to /data/www/index.html on the server's filesystem
简而言之,用户请求的URI会被用来和每个location做前缀匹配,选出最长者,把URI中的path加到root后面。
这个配置里之所以不用listen,是因为使用默认的80端口。
进行代理
server {
location / {
proxy_pass http://localhost:8080/;
}
location ~ \.(gif|jpg|png)$ {
root /data/images;
}
}
# preceded with ~ is regular expression
在这个例子中,所有义.gif .jpf .png为后缀的文件请求会被映射到/data/images,其余的请求会被转发给8080端口上被代理的服务器。
相关技术笔记其二:Docker 的使用
基本概念:image和container
首先,需要区分镜像image和容器container。
镜像是一个快照或者模板,定义了如何创造对应的容器。一个容器可以包含很多“层”,进行修改相当于在顶上添加一层
容器是镜像的实例,依据一个镜像可以创造出多个对应的容器(docker run 命令)。
有若干方法创造新的镜像,比如 1) 使用 Dockerfile,使用命令如 docker build -t test:latest .
;2) 利用对容器所做的变化定义镜像,使用 docker commit <container_id> <new_image_name>
# syntax=docker/dockerfile:1
FROM ubuntu:22.04
# install app dependencies
RUN apt-get update && apt-get install -y python3 python3-pip
RUN pip install flask==3.0.*
# install app
COPY hello.py /
# final configuration
ENV FLASK_APP=hello
EXPOSE 8000
CMD ["flask", "run", "--host", "0.0.0.0", "--port", "8000"]
在上面的示例中,如果使用docker build -t test:latest .
来构建镜像,则要求该 Dockerfile 本身和 hello.py 在当前文件夹中。
基础用法
通过镜像创建新容器:docker run --name NEW-CONTAINER IMAGE [COMMAND] [ARG...]
暂停既有容器:docker stop CONTAINER
让退出的容器继续执行:docker start CONTAINER
docker在列出所有容器/镜像:
docker ps -a
docker image list
docker在运行中的容器里执行一个新命令:
docker exec CONTAINER COMMAND [ARG...]
docker查看容器log:
docker logs --tail 50 --follow --timestamps CONTAIER
docker删除容器/镜像:
docker container rm CONTAINER
docker container prune # removes all stopped containers
docker image rm IMAGE
附加:如何重定向输入输出?
run、exec 和 start 命令中可以用 -i 指明 interactive,即将当前命令行的 stdin 重定向到容器内的 stdin,从而得到交互式命令行的效果。
例如,docker start -i sandbox-container > sandbox/output.txt 2> sandbox/error.txt
命令,加了 -i,output.txt 中就是 container 默认命令的输出;不加 -i,output.txt 中就是 docker start 的输出(“sandbox-container”)。
深入理解容器的命令、主进程和生命周期
从 docker run 说起
Docker run 需要提供 COMMAND [ARG…]。其含义为,从镜像创建容器后,设立一个主进程(Main Process)并在主进程中运行上述命令。如果用户没有提供命令,则默认执行 Dockerfile CMD 中的命令。如果 Dockerfile 设置了 Entrypoint,则上述两种情况的命令都会作为 Entrypoint 命令的参数。实际的主进程命令可以在 docker ps -a
的 COMMAND 列中查询到。
当命令执行完毕,主进程退出,容器也会退出,容器的 exit status 就是命令的 exit status。
关于 docker exec
Docker exec 同样需要提供 COMMAND [ARG…],但其含义与 docker run 大不相同。它在一个已经运行的容器中创建一个新进程,执行用户传入的命令。这个命令会原样执行,不受 Dockerfile 的 CMD 和 Entrypoint 的影响。
由于命令原样、与主进程命令同时执行,docker exec 常被用于辅助和调试。最常用地,docker exec -it CONTAINER /bin/sh
。
docker compose 的用法
这里列举一个 docker compose 的一个基础使用示例,请详细研读,了解docker compose 在干什么。注:后文将compose中的服务和容器交换使用了,但服务和容器不一定一一对应,请注意甄别。
在docker-compose.yaml的Service元素中,每个service都要么有一个build章节定义如何构建,要么有一个image章节标明使用哪个现成的镜像。
按顺序构建并运行所有服务所需的容器:docker compose up -d
停止所有容器:docker compose stop
删除所有用到的容器:docker compose down
在新创建一个服务,并在新创建的服务上运行命令:docker compose run SERVICE
深入理解 Docker 中的网络机制
network是docker中的一等公民。可以通过 docker network ls
来查看所有网络,通过 docker network inspect NAME
来检查其中某一个网络。
docker-compose.yaml中network含义的文档。默认地,compose会为你的app创建一个network,连接到同一个Network的服务可以互相通信。具体如何通信?前面提到的基础使用示例中,服务web想要找服务redis通信,在网络的层面使用hostname为”redis”(其它的选择包括”localhost”, “example.com”等等),可见dockers的每个network中实际上有一个DNS系统,把服务名映射到主机名。
=== docker_compose.yaml ===
services:
web:
build: .
ports:
- "8000:5000"
redis:
image: "redis:alpine"
=== app.py for web ===
import redis
cache = redis.Redis(host='redis', port=6379)
后续填写 Mastodon 的 .env.production 时,会需要填写”Name of PostgreSQL database:“、”Redis host:“等字段。这其实和刚才的例子是相通的,填写 docker-compose.yaml 里数据库、Redis服务的名称即可。
深入理解 Dockerfile 的命令
RUN 命令:构建镜像过程中执行的命令。如果想要看到它的输出,在 docker build 命令中加命令行参数 --progress=plain
CMD 命令:定义了依据该镜像启动容器时,容器中第一时间执行的命令。如果在 docker run 时传入了 [COMMAND] 参数,则 CMD 定义的命令会被覆盖。
ENTRYPOINT 命令:与 CMD 命令类似,但是不会被覆盖,运行时传入的命令会被追加(append)在这里定义的命令之后执行(参考)。例如,这个 Dockerfile 中有一行 ENTRYPOINT ["/usr/bin/tini", "--"]
,而对应的 docker-compose.yaml 中 web 服务的命令为 command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
,这就导致 docker ps -a 检查得到的 web 服务正在运行的命令为 /usr/bin/tini -- ba…
Docker 容器内外映射
Docker 所有带冒号的映射关系都是按照 “容器外 : 容器内” 的格式,如
miniflux-db:/var/lib/postgresql/data
将容器内的 /var/lib/… 映射到 miniflux-db,一个 Docker 软件管理的全局 named volume./postgres14:/var/lib/postgresql/data
将容器内的 /var/lib/… 映射到容器外当前文件夹(与 docker-compose.yaml 同文件夹)下的 postgres14/"8081:8080"
将容器内的 8080 端口映射到容器外的 8081 端口
Q: 容器内外的文件映射,访问权限问题怎么处理?
访问权限问题首先涉及文件的权限位。对于容器内外映射的文件/文件夹,包括权限位的全部属性显然应该是一样的,因为它们本质上是 Host 磁盘上的同一个 inode 上的数据域。假设有如下映射 ./html:/var/www/html
,则如果把 ./html/wp-content
的所有者从 root 变为 www-data,则进入容器内观察,/var/www/html/wp-content
的所有者也从 root 变成了 www-data。
关于访问的主体,即进程的身份(UID、GID),Docker 内进程访问映射的文件夹时,其 UID、GID 被保留,并对照文件的权限位做鉴权。换言之,整个访问(包括鉴权)的过程可以视为全部在容器内完成的。
搭建过程概述
Mastodon 的代码仓库有不少分支,比如 glitch-soc,这里出于个人原因尝试部署一个名为”闭社“的代码仓库,它是 glitch-soc 的分支(所以是分支的分支),不过不论部署哪个分支,其原理是相通的。
配置 Nginx
参考上面的 Nginx 笔记和搭建博客来配置和调试,不在这里详细描述了。
配置Postgres数据库
注
不做这步,在docker-compose.yml的db service下写如下配置
environment:
POSTGRES_DB: mastodon_production
POSTGRES_USER: mastodon
POSTGRES_PASSWORD: change_me
可能也行
试着连接数据库的命令行
在下载完毕所需软件后(假设闭社的源码解压在了/opt/mastodon/下),首先利用容器在/opt/mastodon/postgres14路径下创建一个数据库:
docker run --name mypostgres14 -v /opt/mastodon/postgres14:/var/lib/postgresql/data -e POSTGRES_PASSWORD=password --rm -d postgres:14-alpine
-v: bind mount a volume
-e: set environment variables
-d: detach, run container in background
–rm: Automatically remove the container and its associated anonymous volumes when it exits
命令会立刻返回,此时多出一个名为mypostgres14的容器,–rm是指容器自己退出时删除。接着,
docker exec -it mypostgres14 psql -U postgres
这里 psql -U postgres
是在容器内执行的命令,postgres是默认的super user。按下回车键后,会进入容器内的Postgres的命令行。
如果 /opt/mastodon/postgres14 处已经有了数据库,那么可以尝试输入 \l
(相当于MySQL的 show databases;) 来列出所有数据库,\c database_name
来连接到任意一个数据库,\dt
来列出该数据库的所有表 (相当于MySQL的 show tables;),通过这种方式检查mastodon的数据库使用情况。
如果是第一次搭建,则可以执行 CREATE USER mastodon WITH PASSWORD 'password' CREATEDB;
来为Mastodon创建一个用户,这里 CREATEDB 指赋予新建用户自主创建数据库的权限。 然后 \q
退出,退出命令行后容器仍未退出(因为postgres的镜像中已经定义了一个独立于用户的server process在始终运行)。
数据是存在容器之外的
这里需要注意的是,数据是存在容器之外的(即 /opt/mastodon/postgres14 路径下),不会随容器的销毁而销毁。事实上,这里创造的mypostgres14这个容器不会再被使用了,观察 docker-compose.yml 中的 db Service,它也是把容器之外的路径 /opt/mastodon/postgres14 映射到容器内文件系统的。
db:
restart: always
image: postgres:14-alpine
shm_size: 256mb
networks:
- internal_network
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres']
volumes:
- ./postgres14:/var/lib/postgresql/data
environment:
- 'POSTGRES_HOST_AUTH_METHOD=trust'
填写 Mastodon 的配置文件 .env.production
依据上面所做的数据库配置(Mastodon用户的用户名和密码)、观察docker-compose.yaml的内容,以及对docker网络机制的了解,你应该可以填写 .env.production 了。关于发送邮件,目前可以先填写从localhost发送,后续等站点上线后再更改。
创建自定义的Mastodon镜像
仔细观察docker-compose.yml,发现需要启动5个容器/服务:
- db、redis:直接拉取镜像。文件定义了它们的healthcheck(健康检查方法),environment(环境变量), volumes(文件系统映射)等等。如前所述,镜像中已经定义了一个server process,因此我们不需要定义command来覆盖镜像中默认执行的CMD(参考)。
- Web、Streaming、Sidekiq:它们的执行环境基于本地的 Dockerfile 构建(build .),鉴于Dockerfile中有复制源码到容器中的操作,因此它们相当于基于本地的代码。另外,每个服务中需要定义一个特有的command,来规定容器被创建时该执行哪个命令,真正启动该服务。
根据我的记忆,有一次服务崩溃时,web, streaming和sidekiq会显示exited,而db和redis仍在running,印证了上面的说法(退出的是mastodon相关的命令)。
如果让Web、Streaming、Sidekiq三个服务其中的每一个都构建自己的镜像(在docker-compose.yaml中,使用列出 build .
而不列出 image:
)则每个服务都会产生一个自己的镜像,一来花费三倍的源码编译时间,二来总镜像文件大小要乘以三。不如手动构建并在本地保存三者共用的镜像:
docker build -t my-closed-social .
在docker-compose.yaml的web, streaming, sidekiq三个服务中,进行如下改动
<<<<<<<
build: .
image: ghcr.io/mastodon/mastodon
=======
image: my-closed-social
>>>>>>>
三个服务利用同一个本地构建的镜像。如果不做这个改动,行为上可能相似,但配置文件中既有build又有image,compose,按照 Docker 的文档,其行为不好预期。
至此,你的 Mastodon 站点应该可以通过域名访问了,而你应该可以作为管理员登入你的 Mastodon 站点。
开放新用户注册与运维
参考:
配置邮件服务帮助新用户注册
新用户注册时,Mastodon 站点需要向用户提供的邮箱发送验证邮件,这需要一个邮件服务。
首先,作为管理员登入系统,在Administration>Server Settings>Registrations 中,控制是否允许新用户注册。
然后,创建一个邮件服务。为了方便,可以考虑使用第三方邮件服务器,如Zoho Mail,然后在 .env.production 中填写 SMTP_ 开头的配置,重新构建镜像、上线服务。
运维:使用 tootctl 命令
docker compose run --rm web bin/tootctl
创建tootctl.sh文件,使用alias将tootctl设置为tootctl.sh的别名。在tootctl.sh文件中将传入的参数传入容器中的bin/tootctl即可。具体需要执行的命令参考这个文档。
Leave a Reply