最近在维护一套基于 Docker 部署的禅道开源版时,遇到了一个比较典型的问题:随着使用时间增长,附件、图片和备份文件不断增多,本地服务器磁盘空间压力越来越大。由于禅道开源版本身并没有提供直接将附件存储到 S3、OSS、COS 等对象存储的配置项,因此我尝试使用 s3fs 将 S3 bucket 挂载到宿主机目录,再通过 Docker Compose 将对应目录挂载进禅道容器。
最终方案已经跑通,过程中也踩了一些坑,尤其是文件 owner、s3fs 性能、systemd 启动顺序和 Docker 自动创建目录的问题。这里做一次完整记录。
背景
当前禅道运行在 Docker 中,基础服务类似这样:
services:
zentao:
container_name: zentao
image: hub.zentao.net/app/zentao:21.7.4
restart: always
ports:
- "80:80"
- "8306:3306"
volumes:
- /home/ubuntu/deploy/data:/data
environment:
MYSQL_INTERNAL: "true"
禅道的数据目录通过宿主机目录 /home/ubuntu/deploy/data 持久化。随着附件和备份越来越多,我希望把这两类数据迁移到 S3:
附件目录 -> S3 /upload
备份目录 -> S3 /backup
最终采用的方式是:
S3 bucket
└── zentao-biceek
├── mounted.check
├── upload/
└── backup/
宿主机
└── /home/ubuntu/deploy/s3fs/mount/root
├── mounted.check
├── upload/
└── backup/
Docker 容器
├── /home/ubuntu/deploy/s3fs/mount/root/upload -> 禅道附件目录
└── /home/ubuntu/deploy/s3fs/mount/root/backup -> 禅道备份目录
也就是说,宿主机只挂载一次 bucket 根目录,然后在 Docker Compose 中分别把 upload 和 backup 子目录挂载到禅道容器内。
为什么选择 s3fs
禅道开源版没有原生 S3 附件存储配置。如果不改代码,比较现实的方案有两个:
- 使用
s3fs、rclone mount等工具把对象存储挂载成本地目录; - 二次开发禅道附件模块,上传时直接调用 S3 SDK。
第二种方式更彻底,但维护成本更高,后续禅道升级也要重新适配。对于中小规模使用场景,附件主要是上传、下载、预览,读写模式相对简单,因此先采用 s3fs 作为低侵入方案。
需要明确的是,S3 不是 POSIX 文件系统,s3fs 只是把对象存储模拟成本地文件系统。它适合附件类场景,但不适合数据库、日志、缓存、session 等高频随机读写场景。
安装 s3fs
Ubuntu 上安装很简单:
sudo apt update
sudo apt install -y s3fs
准备凭证文件:
sudo mkdir -p /home/ubuntu/deploy/s3fs
sudo tee /home/ubuntu/deploy/s3fs/passwd_file >/dev/null <<'EOF'
ACCESS_KEY_ID:SECRET_ACCESS_KEY
EOF
sudo chmod 600 /home/ubuntu/deploy/s3fs/passwd_file
由于使用了 allow_other,还需要确保 /etc/fuse.conf 中启用了:
sudo grep -q '^user_allow_other' /etc/fuse.conf || echo 'user_allow_other' | sudo tee -a /etc/fuse.conf
创建挂载目录:
sudo mkdir -p /home/ubuntu/deploy/s3fs/mount/root
sudo chmod 755 /home/ubuntu/deploy/s3fs/mount/root
最终可用的 s3fs 挂载命令
最初我使用的是:
sudo s3fs zentao-biceek /home/ubuntu/deploy/s3fs/mount/root \
-o passwd_file=/home/ubuntu/deploy/s3fs/passwd_file \
-o endpoint=us-east-1 \
-o allow_other \
-o uid=0 \
-o gid=0 \
-o umask=0022 \
-o multipart_size=64 \
-o parallel_count=10 \
-o retries=5 \
-o stat_cache_expire=60 \
-o max_stat_cache_size=10000
但这里有一个关键问题:uid=0、gid=0 会让 s3fs 挂载出来的文件显示为:
root root
而禅道原始上传目录的 owner 是:
nobody nogroup
也就是 UID/GID 通常为:
65534:65534
因此最终需要改成:
sudo s3fs zentao-biceek /home/ubuntu/deploy/s3fs/mount/root \
-o passwd_file=/home/ubuntu/deploy/s3fs/passwd_file \
-o endpoint=us-east-1 \
-o allow_other \
-o uid=65534 \
-o gid=65534 \
-o umask=0022 \
-o multipart_size=64 \
-o parallel_count=10 \
-o retries=5 \
-o stat_cache_expire=5 \
-o max_stat_cache_size=10000 \
-o disable_noobj_cache
其中几个参数比较关键:
uid=65534 / gid=65534
让挂载目录在容器内呈现为 nobody:nogroup,避免禅道启动时反复检查或修正目录 owner。
umask=0022
让目录和文件权限大致表现为 755 / 644。
multipart_size=64
parallel_count=10
用于优化大文件上传。
stat_cache_expire=5
disable_noobj_cache
减少“写入后立即读取却看不到文件”的概率。附件上传后经常会立即预览或校验文件存在性,所以不建议设置过长的元数据缓存。
我没有启用 use_cache。原因是运行机器本地磁盘空间较小,如果启用内容缓存,s3fs 可能长期占用本地磁盘。对于这个场景,宁可牺牲一部分重复读取性能,也要避免缓存把磁盘打满。
Docker Compose 挂载方式
Docker Compose 中建议使用长语法,并设置 create_host_path: false,避免 s3fs 挂载失败时 Docker 自动创建空目录,导致禅道误把附件写入宿主机本地目录。
示例:
services:
zentao:
container_name: zentao
image: hub.zentao.net/app/zentao:21.7.4
restart: "no"
networks:
zentao_network:
ipv4_address: 172.18.0.10
mac_address: 02:42:ac:11:ab:cd
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "3"
ports:
- "80:80"
- "8306:3306"
volumes:
- type: bind
source: /home/ubuntu/deploy/data
target: /data
- type: bind
source: /home/ubuntu/deploy/s3fs/mount/root/upload
target: /data/zentao/www/data/upload/1
bind:
create_host_path: false
- type: bind
source: /home/ubuntu/deploy/s3fs/mount/root/backup
target: /data/backup
bind:
create_host_path: false
environment:
MYSQL_INTERNAL: "true"
networks:
zentao_network:
driver: bridge
ipam:
config:
- subnet: 172.18.0.0/24
这里有两个注意点。
第一,附件目录 /data/zentao/www/data/upload/1 需要根据实际容器确认:
docker exec -it zentao sh -c "find /data /opt /apps -type d -path '*/www/data/upload/1' 2>/dev/null"
第二,不建议再使用:
restart: always
因为 Docker daemon 启动后可能绕过 systemd 的挂载检查,自动拉起禅道容器。这里更推荐:
restart: "no"
让禅道容器完全由 systemd 控制启动顺序。
使用 systemd 确保 s3fs 先挂载
如果只是手工挂载 s3fs,然后启动 Docker,重启机器后容易出问题。更稳妥的方式是用 systemd 管理挂载和 Docker Compose 启动顺序。
s3fs 挂载服务
创建:
sudo nano /etc/systemd/system/zentao-s3fs.service
内容:
[Unit]
Description=Mount S3 bucket root for Zentao
Wants=network-online.target
After=network-online.target
Before=zentao-compose.service
[Service]
Type=forking
User=root
Group=root
ExecStartPre=/usr/bin/mkdir -p /home/ubuntu/deploy/s3fs/mount/root
ExecStartPre=/bin/sh -c '! /usr/bin/mountpoint -q /home/ubuntu/deploy/s3fs/mount/root'
ExecStart=/usr/bin/s3fs zentao-biceek /home/ubuntu/deploy/s3fs/mount/root -o passwd_file=/home/ubuntu/deploy/s3fs/passwd_file -o endpoint=us-east-1 -o allow_other -o uid=65534 -o gid=65534 -o umask=0022 -o multipart_size=64 -o parallel_count=10 -o retries=5 -o stat_cache_expire=5 -o max_stat_cache_size=10000 -o disable_noobj_cache
ExecStartPost=/bin/sh -c 'for i in $(seq 1 30); do /usr/bin/mountpoint -q /home/ubuntu/deploy/s3fs/mount/root && test -f /home/ubuntu/deploy/s3fs/mount/root/mounted.check && test -d /home/ubuntu/deploy/s3fs/mount/root/upload && test -d /home/ubuntu/deploy/s3fs/mount/root/backup && exit 0; sleep 1; done; exit 1'
ExecStop=/bin/fusermount -u /home/ubuntu/deploy/s3fs/mount/root
ExecStopPost=/bin/sh -c '/usr/bin/mountpoint -q /home/ubuntu/deploy/s3fs/mount/root && /bin/umount -l /home/ubuntu/deploy/s3fs/mount/root || true'
Restart=on-failure
RestartSec=10
TimeoutStartSec=60
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
这里使用了一个健康检查文件:
mounted.check
它需要放在 bucket 根目录。systemd 启动后会检查:
挂载点存在
mounted.check 可见
upload 目录可见
backup 目录可见
只有这些条件都满足,挂载服务才算成功。
Docker Compose 启动服务
创建:
sudo nano /etc/systemd/system/zentao-compose.service
内容:
[Unit]
Description=Zentao Docker Compose Service
Requires=docker.service
Requires=zentao-s3fs.service
After=docker.service
After=zentao-s3fs.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/ubuntu/deploy
ExecStartPre=/usr/bin/mountpoint -q /home/ubuntu/deploy/s3fs/mount/root
ExecStartPre=/usr/bin/test -f /home/ubuntu/deploy/s3fs/mount/root/mounted.check
ExecStartPre=/usr/bin/test -d /home/ubuntu/deploy/s3fs/mount/root/upload
ExecStartPre=/usr/bin/test -d /home/ubuntu/deploy/s3fs/mount/root/backup
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=120
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
这样可以保证:
Docker daemon 启动
-> s3fs 挂载成功
-> 检查 S3 目录和 mounted.check
-> docker compose up -d
启用服务:
sudo systemctl daemon-reload
sudo systemctl enable zentao-s3fs.service
sudo systemctl enable zentao-compose.service
sudo systemctl start zentao-s3fs.service
sudo systemctl start zentao-compose.service
查看状态:
systemctl status zentao-s3fs.service
systemctl status zentao-compose.service
查看日志:
journalctl -u zentao-s3fs.service -e
journalctl -u zentao-compose.service -e
遇到的关键问题:owner 必须是 nobody:nogroup
这次实践中最关键的坑就是 owner。
禅道原始上传目录显示类似:
drwxr-xr-x 2 nobody nogroup
而 s3fs 默认或设置 uid=0,gid=0 后会显示:
drwxr-xr-x 1 root root
这会导禅道上传文件失败,并且启动时在日志里长时间停留在:
Check zentao data owner
由于 s3fs 上的 stat、chown、目录遍历都比本地文件系统慢得多,禅道启动脚本如果尝试递归检查或修正 owner,就会非常慢。
最终解决方法是把 s3fs 参数改成:
-o uid=65534
-o gid=65534
也就是让 s3fs 下的文件在容器里显示为:
nobody:nogroup
修改后,禅道启动检查恢复正常。
确认方式:
ls -ld /home/ubuntu/deploy/s3fs/mount/root/upload
stat -c '%U:%G %u:%g %n' /home/ubuntu/deploy/s3fs/mount/root/upload
容器内也可以确认:
docker exec -it zentao sh -c "stat -c '%U:%G %u:%g %n' /data/zentao/www/data/upload/1"
期望输出中 UID/GID 为:
65534:65534
最终方案总结
最终采用的方案是:
1. s3fs 挂载 S3 bucket 根目录到宿主机;
2. bucket 下包含 upload、backup 和 mounted.check;
3. systemd 管理 s3fs 挂载;
4. systemd 在启动禅道 Docker Compose 前检查挂载状态;
5. Docker Compose 使用 bind mount 将 upload 和 backup 子目录挂入禅道容器;
6. s3fs 必须设置 uid=65534,gid=65534;
7. 不启用 use_cache,避免占用小磁盘;
8. 使用 stat_cache_expire=5 和 disable_noobj_cache 降低写后立即读的问题;
9. 禅道容器 restart 设置为 "no",由 systemd 控制启动顺序。
最终挂载参数如下:
sudo s3fs zentao-biceek /home/ubuntu/deploy/s3fs/mount/root \
-o passwd_file=/home/ubuntu/deploy/s3fs/passwd_file \
-o endpoint=us-east-1 \
-o allow_other \
-o uid=65534 \
-o gid=65534 \
-o umask=0022 \
-o multipart_size=64 \
-o parallel_count=10 \
-o retries=5 \
-o stat_cache_expire=5 \
-o max_stat_cache_size=10000 \
-o disable_noobj_cache
这套方案不需要修改禅道源码,也不需要自定义禅道镜像,适合作为禅道开源版附件和备份迁移到 S3 的低侵入方案。
当然,它仍然有局限:s3fs 不是本地文件系统,也不是完整的 POSIX 语义。如果附件规模非常大、并发上传下载很高,或者对一致性和性能要求很严,长期来看更好的方案仍然是对禅道附件模块做 S3 SDK 级别的存储改造。