记录一次完整的黑群晖 NAS 部署过程,包含镜像加速、Alpine 源优化、权限治理、nginx 防护等 6 个真实踩坑点。
一、背景
手上有一套自研的「余哥礼簿」PHP 应用(PHP 8.2 + SQLite,无外部数据库依赖),准备部署到黑群晖上,供家庭局域网使用——婚丧嫁娶时手机、平板、大屏随时录入和查看。
选择 Docker 而非 Web Station 的理由:
- 环境隔离,不污染 DSM 系统
- 迁移方便,换 NAS 直接搬目录
- 可以锁定 PHP 版本和扩展,避免 DSM 升级打断
部署环境:
| 项目 | 值 |
|---|---|
| 群晖版本 | DSM 7.2+(Container Manager) |
| 存储卷 | volume5(docker 共享文件夹所在) |
| 项目路径 | /volume5/docker/giftbook/ |
| 访问端口 | 8080 |
| 内网 IP | 192.168.31.82 |
二、架构设计
采用单容器方案——Nginx + PHP-FPM 装在同一个容器里,用 supervisord 同时管理两个进程。
text
群晖 NAS
└── /volume5/docker/giftbook/
├── docker-compose.yml
├── Dockerfile
├── nginx/default.conf
├── php/uploads.ini
├── data/ ← SQLite + 二维码 + 草稿(持久化)
│ ├── giftbook.db
│ ├── upload_qrcode/
│ └── draft/
└── 所有 PHP 源码
为什么不拆成双容器(Nginx 容器 + FPM 容器): 双容器需要配置 FastCGI 网络通信 + 共享卷双重权限映射,在群晖 ACL 体系下极易出现 File not found 或 Permission denied。单容器把这些问题全部消除。
三、踩坑全记录
坑 1:群晖的 Docker 不是 systemd 服务
症状:
bash
$ systemctl restart docker
Failed to restart docker.service: Unit docker.service failed to load: No such file or directory.
原因: 群晖把 Docker 打包成 SPK 套件,不走 systemd。
正解:
bash
# DSM 7.2+ 用 ContainerManager
sudo synopkg restart ContainerManager
# 旧版用 Docker
sudo synopkg restart Docker
坑 2:镜像加速器配置位置不同于标准 Linux
症状: 改了 /etc/docker/daemon.json 加速器不生效。
原因: 群晖的 dockerd 读的是自己的配置文件,不是 /etc/docker/daemon.json。
正解: DSM 7.2+ 修改 /var/packages/ContainerManager/etc/dockerd.json:
bash
sudo tee /var/packages/ContainerManager/etc/dockerd.json > /dev/null <<'JSON'
{
"registry-mirrors": ["https://rif02pwzp51o4duzz2.xuanyuan.run"],
"insecure-registries": ["rif02pwzp51o4duzz2.xuanyuan.run"]
}
JSON
sudo chmod 644 /var/packages/ContainerManager/etc/dockerd.json
sudo synopkg restart ContainerManager
# 验证
docker info | grep -A 3 "Registry Mirrors"
坑 3:Alpine 的 SQLite 开发包名不是 libsqlite3-dev
症状: Dockerfile 构建时报:
text
ERROR: unable to select packages:
libsqlite3-dev (no such package)
原因: 从 Debian 系切到 Alpine 系,包名规则变了。Debian 叫 libsqlite3-dev,Alpine 叫 sqlite-dev。
正解:
dockerfile
RUN apk add --no-cache nginx supervisor sqlite-dev \
&& docker-php-ext-install pdo pdo_sqlite \
&& docker-php-ext-enable pdo_sqlite
坑 4:Alpine 官方源慢到离谱
症状: 一次构建 apk add 阶段耗时 966 秒(16 分钟)。
原因: Alpine 官方 CDN(dl-cdn.alpinelinux.org)在国内访问极慢。
正解: Dockerfile 里换清华源:
dockerfile
FROM php:8.2-fpm-alpine
# 换 Alpine 源为清华源
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories
RUN apk add --no-cache nginx supervisor sqlite-dev \
&& docker-php-ext-install pdo pdo_sqlite \
&& docker-php-ext-enable pdo_sqlite
# ...
效果: 重建从 16 分钟降到 2-4 分钟。
坑 5:SSH umask 077 导致上传的源码不可读
症状: 上传的所有 .php 文件是 -rw-------(600),容器内 www-data (UID 82) 读不到源码,访问返回 403。
原因: root 用户 SSH 会话的默认 umask 是 077,新建文件只有属主可读。
正解:
bash
cd /volume5/docker/giftbook/
# 所有文件 644,所有目录 755
find . -type f -exec chmod 644 {} \;
find . -type d -exec chmod 755 {} \;
# data 目录给容器完整写权限
chown -R 82:82 data
chmod -R 775 data
坑 6:nginx 屏蔽 data 目录只能靠配置,不能用 .htaccess
背景: 项目里原本在 data/ 和 data/upload_qrcode/ 放了 .htaccess 防下载/防执行。这套机制在 nginx 下完全无效。
正解: 在 nginx/default.conf 里显式写:
nginx
# 屏蔽 data 目录
location ^~ /data/ { deny all; return 404; }
# 屏蔽敏感 PHP 文件
location ~ ^/(config\.php|db\.php|lib_service\.php|migrate_json_to_sqlite\.php)$ {
deny all; return 404;
}
# 屏蔽 data 下的 .db/.json
location ~* /data/.*\.(db|db-wal|db-shm|json)$ { deny all; return 404; }
四、完整部署流程
4.1 目录准备
bash
sudo mkdir -p /volume5/docker/giftbook/{nginx,php,data/upload_qrcode,data/draft}
cd /volume5/docker/giftbook/
上传 PHP 源码,然后修复权限:
bash
find . -type f -exec chmod 644 {} \;
find . -type d -exec chmod 755 {} \;
chown -R 82:82 data
chmod -R 775 data
4.2 Dockerfile
dockerfile
FROM php:8.2-fpm-alpine
# 换 Alpine 源为清华源,避免 apk add 卡 16 分钟
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories
RUN apk add --no-cache nginx supervisor sqlite-dev \
&& docker-php-ext-install pdo pdo_sqlite \
&& docker-php-ext-enable pdo_sqlite
RUN php -m | grep -q pdo_sqlite
COPY nginx/default.conf /etc/nginx/http.d/default.conf
COPY php/uploads.ini /usr/local/etc/php/conf.d/uploads.ini
WORKDIR /var/www/html
EXPOSE 80
RUN printf '%s\n' \
'[supervisord]' \
'nodaemon=true' \
'user=root' \
'pidfile=/tmp/supervisord.pid' \
'logfile=/dev/null' \
'logfile_maxbytes=0' \
'' \
'[program:php-fpm]' \
'command=/usr/local/sbin/php-fpm -F' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
'' \
'[program:nginx]' \
'command=nginx -g "daemon off;"' \
'autostart=true' \
'autorestart=true' \
'stdout_logfile=/dev/stdout' \
'stdout_logfile_maxbytes=0' \
> /etc/supervisord.conf
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
4.3 nginx/default.conf
nginx
server {
listen 80;
server_name _;
root /var/www/html;
index gift_book_main.php index.php;
charset utf-8;
# 让 / 直接指向主页面
location = / {
rewrite ^ /gift_book_main.php last;
}
# 屏蔽 data 目录
location ^~ /data/ { deny all; return 404; }
# 屏蔽敏感 PHP 文件
location ~ ^/(config\.php|db\.php|lib_service\.php|migrate_json_to_sqlite\.php)$ {
deny all; return 404;
}
location ~* /data/.*\.(db|db-wal|db-shm|json)$ { deny all; return 404; }
# PHP 处理
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# 静态资源缓存
location ~* \.(jpg|jpeg|png|gif|webp|css|js|ttf|otf|woff2?)$ {
expires 7d; access_log off;
}
location ~ /\. { deny all; }
}
4.4 php/uploads.ini
ini
upload_max_filesize = 10M
post_max_size = 12M
max_execution_time = 120
date.timezone = Asia/Shanghai
注意:不要在这里写
extension=pdo_sqlite——镜像里已经通过docker-php-ext-enable加载了,重复加载会报Module already loaded警告。
4.5 docker-compose.yml
yaml
version: "3.8"
services:
giftbook:
build: .
container_name: giftbook
restart: unless-stopped
ports:
- "8080:80"
volumes:
# 源码只读挂载
- /volume5/docker/giftbook:/var/www/html:ro
# data 目录读写挂载
- /volume5/docker/giftbook/data:/var/www/html/data:rw
# nginx 配置单独挂载(改配置无需重建镜像)
- /volume5/docker/giftbook/nginx/default.conf:/etc/nginx/http.d/default.conf:ro
environment:
- TZ=Asia/Shanghai
关键点:
- 源码
:ro—— 容器内无法改代码,防止被入侵后篡改 data单独:rw—— SQLite 写库、上传二维码、写草稿nginx/default.conf单独挂载 —— 改完执行docker exec giftbook nginx -s reload即可生效
4.6 启动
bash
cd /volume5/docker/giftbook/
docker-compose down 2>/dev/null
docker-compose up -d --build
首次构建 2-4 分钟(走清华源)。构建成功后:
bash
docker ps | grep giftbook
# 应看到:0.0.0.0:8080->80/tcp
五、验证清单
bash
# 1) 主页面 200
curl -I http://localhost:8080/
curl -I http://localhost:8080/gift_book_main.php
# 2) data 目录 404
curl -I http://localhost:8080/data/giftbook.db
# 3) config.php 404
curl -I http://localhost:8080/config.php
# 4) pdo_sqlite 加载
docker exec -it giftbook php -m | grep -i sqlite
# 5) data 可写
docker exec -it giftbook sh -c 'touch /var/www/html/data/_writetest && echo OK && rm /var/www/html/data/_writetest'
期望结果:
| 验证项 | 状态 |
|---|---|
/ | 200 OK |
/gift_book_main.php | 200 OK |
/data/giftbook.db | 404 |
/config.php | 404 |
pdo_sqlite | 已加载 |
data 写入 | OK |
六、运维命令速查
bash
cd /volume5/docker/giftbook/
# 查看容器状态
docker-compose ps
# 查看日志(Ctrl+C 退出)
docker-compose logs -f giftbook
# 重启
docker-compose restart giftbook
# 停止
docker-compose down
# 启动
docker-compose up -d
# 改了源码后重建
docker-compose up -d --build
# 只改了 nginx 配置(挂载生效,无需重建)
docker exec -it giftbook nginx -s reload
七、备份策略
所有数据都在 /volume5/docker/giftbook/data/:
giftbook.db— SQLite 数据库(用户、事务、记录、日志)upload_qrcode/— 上传的收款二维码draft/— 临时草稿(可忽略)
用 Hyper Backup 定期备份:
打开 Hyper Backup → 新增 → 文件夹和套件 → 源选择 /volume5/docker/giftbook/data → 目标选另一个存储空间或外接盘。
手动备份:
bash
mkdir -p /volume1/backup/giftbook
cp -r /volume5/docker/giftbook/data /volume1/backup/giftbook/data_$(date +%Y%m%d)
八、经验总结
| 教训 | 建议 |
|---|---|
| 群晖 Docker ≠ 标准 Linux Docker | 配置、重启、加速器都走群晖自己的机制 |
| Alpine 包名与 Debian 系不同 | 装包前先查 Alpine 官方包名 |
| 容器内 apk 不受 registry 加速器影响 | 直接换 Alpine 镜像源 |
| root 用户 umask 077 会坑容器 | 部署前先 chmod 644 所有源码 |
.htaccess 在 nginx 下无效 | 必须显式写 location 规则 |
| 单容器比双容器省事 | PHP+SQLite 类应用建议单容器方案 |
| 挂载 nginx 配置而非重建镜像 | 改配置立即生效,开发体验提升明显 |
