将禅道开源版附件和备份存放到 S3:一次基于 Docker + s3fs 的实践记录

最近在维护一套基于 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 中分别把 uploadbackup 子目录挂载到禅道容器内。

为什么选择 s3fs

禅道开源版没有原生 S3 附件存储配置。如果不改代码,比较现实的方案有两个:

  1. 使用 s3fsrclone mount 等工具把对象存储挂载成本地目录;
  2. 二次开发禅道附件模块,上传时直接调用 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=0gid=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 上的 statchown、目录遍历都比本地文件系统慢得多,禅道启动脚本如果尝试递归检查或修正 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 级别的存储改造。