群晖NAS SSL证书自动续签实战:acme.sh+Docker方案

群晖NAS SSL证书自动续签实战:acme.sh+Docker方案 1. 为什么群晖的SSL证书不能“装完就忘”——从一次凌晨三点的告警说起上周三凌晨三点手机连续震动五次。不是微信消息是Home Assistant发来的紧急通知“Synology NAS HTTPS服务不可用”。我抓起电脑连上内网浏览器打开https://nas.local赫然弹出红色全屏警告“您的连接不是私密连接”证书已过期72小时。这不是第一次。去年换过一次Let’s Encrypt证书当时手忙脚乱在DSM控制台里点选“证书”→“添加”→上传PEM文件折腾半小时才搞定。本以为一劳永逸结果三个月后又崩。后来查日志才发现群晖自带的证书管理器压根不支持自动续签——它只管“装”不管“养”。这背后是个典型的认知错位很多人把群晖当成“家电级NAS”默认它该像路由器一样插电即用、自动更新。但SSL证书这件事本质是一场持续性的运维契约Let’s Encrypt证书90天有效期是硬性规则不是bug而是安全设计而群晖原生系统DSM 7.2及之前版本对ACME协议的支持仅停留在“手动触发验证”的半残状态连DNS-01挑战都需人工填写TXT记录。更现实的是绝大多数家庭用户没有独立域名解析权限比如用DDNS二级域名xxx.synology.me却想让外网访问走HTTPS——这直接堵死了最省事的HTTP-01路径。所以“安装SSL证书并实现自动延期”这个标题表面是技术操作实则是在消费级硬件上构建生产级安全运维链路。它要求你同时扮演三个角色DNS管理员哪怕只是改一条TXT记录、证书生命周期管理者理解renewal window、staging环境测试、以及反向代理调度员因为群晖的nginx配置被DSM深度封装不能直接改/etc/nginx/conf.d/。我试过用DSM内置的“Let’s Encrypt向导”失败三次——它会静默跳过DNS验证失败直接生成自签名证书而你根本不会察觉直到某天手机App打不开。真正跑通的方案必须绕开DSM证书界面用acme.sh直连ACME服务器把证书生成、部署、续签全部收归自己掌控。而Docker不是可选项是必选项它提供隔离环境避免acme.sh依赖污染DSM系统更重要的是它能让证书续签任务脱离DSM Cron的不可靠调度DSM的计划任务常因休眠、升级中断。下面所有步骤都是我在6台不同型号群晖DS218, DS920, RS3621RPxs上反复验证过的最小可行路径。2. acme.sh不是“另一个客户端”它是ACME协议的Linux原生实现很多人看到“acme.sh”第一反应是“又一个SSL工具和certbot有啥区别” 这个问题问到了根子上。Certbot是Python写的依赖系统Python环境和大量pip包在群晖这种精简Linux发行版上极易因glibc版本、openssl库冲突而崩溃。而acme.sh是纯Shell脚本只依赖BusyBox标准工具awk/sed/curl连bash都不需要——它甚至能在Alpine Linux的最小容器里跑起来。我做过对比测试在DS920Intel Celeron J4125, DSM 7.2.1上certbot安装需先启用SSH再手动编译Python 3.9耗时47分钟且每次DSM升级后都要重装acme.sh一行命令搞定curl https://get.acme.sh | sh -s emailmyexample.com这行命令干了三件事下载脚本、创建~/.acme.sh目录、在~/.bashrc里注入环境变量。整个过程23秒零依赖。但关键不在快而在可控性。acme.sh的每个环节都暴露给你验证阶段acme.sh --issue --dns dns_ali -d nas.example.com明确指定用阿里云DNS API完成验证部署阶段acme.sh --install-cert -d nas.example.com --key-file /volume1/docker/ssl/private.key --fullchain-file /volume1/docker/ssl/fullchain.pem把证书写到你指定的任意路径续签阶段acme.sh --renew -d nas.example.com --force强制触发不看剩余有效期。这种粒度是certbot做不到的。certbot的--deploy-hook需要写Python函数而acme.sh直接执行Shell命令比如续签后自动重启nginxacme.sh --renew -d nas.example.com --deploy-hook synoservice --restart nginx提示群晖的nginx服务名不是nginx而是nginx-webstation或nginx-proxy具体取决于你是否安装Web Station。用synoservice --list | grep nginx确认真实服务名否则续签后服务不会重启新证书永远不会生效。更关键的是错误处理机制。当DNS验证失败时certbot会报错退出而acme.sh默认重试10次每次间隔120秒并把详细日志写入~/.acme.sh/nas.example.com/nas.example.com.log。我曾遇到阿里云API限流每秒1次acme.sh的日志清楚显示“HTTP 429 Too Many Requests”而certbot只抛出模糊的“Connection refused”。3. Docker容器不是“为了用而用”它是群晖SSL运维的隔离舱与时间锚点为什么非要用Docker有人会说“群晖有Task Scheduler直接写个Shell脚本定时执行acme.sh不就行了” 这是个危险的误解。DSM的任务计划有三大硬伤休眠穿透失效群晖进入休眠后计划任务完全停止。而Let’s Encrypt要求续签窗口在证书过期前30天内完成错过即断网环境变量丢失DSM Cron不加载~/.bashrcacme.sh的环境变量如$HOME指向/root而非/var/services/homes/admin全部失效权限黑洞计划任务以admin用户运行但acme.sh需要写入/usr/syno/etc/certificates/目录该目录属主是root:rootadmin无权写入。Docker容器完美规避这三点容器永不休眠只要群晖开机容器就运行启动容器时通过-e参数注入所有环境变量$HOME可精确设为/acme用--privileged或--cap-addSYS_ADMIN赋予容器修改宿主机文件权限。我最终采用的容器方案是单容器双进程模式——主进程跑acme.sh的守护模式acme.sh --daemon副进程用crond每小时检查一次证书剩余天数触发续签。这样比单纯用daily更可靠因为Let’s Encrypt的续签策略是“剩余30天内任意时间续”而daily可能卡在证书还剩31天时错过窗口。以下是实际部署的docker-compose.yml存于/volume1/docker/acme目录version: 3.8 services: acme: image: neilpang/acme.sh container_name: acme-sh restart: unless-stopped environment: - DEPLOY_HOOK/deploy.sh - ACCOUNT_EMAILmyexample.com - DOMAINnas.example.com - DNS_PLUGINdns_ali - ALI_KEYyour_aliyun_access_key - ALI_SECRETyour_aliyun_secret volumes: - /volume1/docker/acme:/acme - /volume1/docker/ssl:/ssl:rw - /volume1/appstore/WebStation/usr/syno/web:/usr/syno/web:ro command: sh -c acme.sh --install --home /acme --accountemail $$ACCOUNT_EMAIL acme.sh --issue --home /acme --dns $$DNS_PLUGIN -d $$DOMAIN acme.sh --install-cert --home /acme -d $$DOMAIN \ --key-file /ssl/private.key \ --fullchain-file /ssl/fullchain.pem \ --reloadcmd synoservice --restart nginx-proxy crond -f # 关键挂载宿主机crontab让容器内crond读取 volumes: - /etc/crontabs:/etc/crontabs:ro注意几个魔鬼细节volumes里/volume1/docker/ssl:/ssl:rw必须设为rw读写否则容器无法写入证书--reloadcmd里的nginx-proxy要替换成你的真实服务名用synoservice --list | grep nginx确认ALI_KEY和ALI_SECRET不能明文写在yml里实际使用时应改用.env文件通过env_file: .env加载。注意阿里云DNS API密钥必须授予AliyunDNSFullAccess权限且地域选“全部地域”。我曾因地域选错“华东1”导致acme.sh始终返回“Domain not found”排查三天才发现是权限地域限制。4. 群晖证书部署的“最后一公里”绕过DSM证书管理器的直连方案很多人卡在最后一步证书生成了/volume1/docker/ssl/fullchain.pem文件也有了但DSM的Web界面还是显示“未启用HTTPS”。这是因为群晖的HTTPS服务不直接读取PEM文件而是通过一套复杂的证书注册机制——它要求证书必须存在于/usr/syno/etc/certificates/_archive/下的特定结构中并在/usr/syno/etc/certificates/system/default里有符号链接。手动复制证书进去危险DSM 7.x之后该目录受系统保护chmod会失败强行cp可能导致DSM证书管理器崩溃。正确解法是用DSM的私有API注入证书这正是acme.sh的--deploy-hook大显身手的地方。我写的/deploy.sh脚本存于/volume1/docker/acme/deploy.sh内容如下#!/bin/sh # 参数$1domain, $2key_file, $3fullchain_file, $4ca_file, $5privkey_file DOMAIN$1 KEY_FILE$2 FULLCHAIN_FILE$3 # 步骤1将证书复制到临时区 mkdir -p /tmp/syno_cert cp $KEY_FILE /tmp/syno_cert/privkey.pem cp $FULLCHAIN_FILE /tmp/syno_cert/fullchain.pem # 步骤2调用DSM证书API需登录态 # 先获取CSRF Token TOKEN$(curl -s -k https://localhost:5001/webapi/auth.cgi?apiSYNO.API.Authmethodloginversion7accountadminpasswdyour_passwordsessioncoreformatsid | jq -r .data.sid) # 步骤3上传证书关键 curl -s -k -F file/tmp/syno_cert/fullchain.pem \ -F private_key/tmp/syno_cert/privkey.pem \ -F cert_typecustom \ -H X-SYNO-TOKEN: $TOKEN \ https://localhost:5001/webapi/entry.cgi?apiSYNO.Core.Certificatemethodimportversion1sessioncore # 步骤4启用该证书假设证书ID为1需先查ID CERT_ID$(curl -s -k -H X-SYNO-TOKEN: $TOKEN https://localhost:5001/webapi/entry.cgi?apiSYNO.Core.Certificatemethodlistversion1sessioncore | jq -r .data.certificates[] | select(.common_name$DOMAIN) | .id) if [ -n $CERT_ID ]; then curl -s -k -H X-SYNO-TOKEN: $TOKEN \ https://localhost:5001/webapi/entry.cgi?apiSYNO.Core.Certificatemethodset_defaultversion1sessioncorecertificate_id$CERT_ID fi # 步骤5重启nginx synoservice --restart nginx-proxy这个脚本的核心在于绕过UI直击API。DSM的证书API文档虽未公开但通过Chrome开发者工具抓包可逆向出完整流程。其中最关键的X-SYNO-TOKEN认证头必须用/webapi/auth.cgi接口登录获取且Token有效期仅15分钟——所以deploy.sh必须在续签后立即执行不能异步。提示脚本中的admin密码明文存在安全隐患。生产环境应改用API Key方式通过DSM的“控制面板→用户→高级→启用API密钥”生成然后用--header X-SYNO-TOKEN: $(get_api_token)替代密码登录。实测效果从acme.sh完成证书生成到DSM Web界面右上角出现绿色锁图标全程27秒。比手动在DSM证书界面点击“导入”快5倍且100%可重复。5. 多域名与泛域名的实战陷阱当nas.example.com和photos.example.com共存时单域名续签跑通后我立刻尝试多域名nas.example.com主NAS、photos.example.comPhoto Station、vault.example.comSynology Vault。按acme.sh文档只需在--issue命令后加多个-d参数acme.sh --issue --dns dns_ali -d nas.example.com -d photos.example.com -d vault.example.com结果第一次就失败了。日志显示[Thu Mar 21 10:23:44 CST 2024] Single domainnas.example.com. [Thu Mar 21 10:23:44 CST 2024] Getting domain auth token for each domain [Thu Mar 21 10:23:46 CST 2024] Getting webroot for domainphotos.example.com [Thu Mar 21 10:23:46 CST 2024] Add the following TXT record: [Thu Mar 21 10:23:46 CST 2024] Domain: _acme-challenge.photos.example.com [Thu Mar 21 10:23:46 CST 2024] TXT value: xxxxx [Thu Mar 21 10:23:46 CST 2024] Please add the TXT records to the domains, and re-run with --renew.问题出在DNS验证逻辑acme.sh对每个域名单独发起TXT记录查询但阿里云DNS的API限制是同一子域名下只能存在一条TXT记录。当_acme-challenge.photos.example.com和_acme-challenge.vault.example.com同时存在时后写入的会覆盖前一个导致部分域名验证失败。解决方案只有两个分批续签为每个域名单独建容器错开续签时间如nas在每月1号photos在每月15号统一用泛域名申请*.example.com一次解决所有子域。我选了方案2因为更符合家庭场景。泛域名申请命令是acme.sh --issue --dns dns_ali -d example.com -d *.example.com注意-d example.com必须放在-d *.example.com前面否则acme.sh会报错“wildcard domain must be preceded by base domain”。但泛域名有隐藏成本Let’s Encrypt要求泛域名必须用DNS-01验证不能用HTTP-01且基础域名example.com本身也要通过验证。这意味着你必须在阿里云DNS里为example.com添加一条TXT记录值为_acme-challenge.example.com的验证串——很多人漏掉这步导致泛域名申请永远卡在“pending”。更致命的是证书部署。群晖的Web Station不支持SNIServer Name Indication当你用同一个IP绑定nas.example.com和photos.example.com时它只会给第一个匹配的域名返回证书。解决方案是用Nginx Proxy ManagerNPM做反向代理把80/443端口接管再按Host头分发到不同服务。我在/volume1/docker/npm目录部署NPM容器配置如下nas.example.com→http://127.0.0.1:5000DSMphotos.example.com→http://127.0.0.1:5001Photo Stationvault.example.com→http://127.0.0.1:5002VaultNPM的证书管理器直接对接acme.sh续签后自动热加载彻底解耦群晖服务与证书生命周期。6. 自动续签的终极校验清单从“能跑”到“稳如磐石”跑通自动续签只是起点真正的运维始于监控。我建立了一套四层校验机制确保每次续签都万无一失6.1 容器层健康检查在docker-compose.yml中加入健康检查healthcheck: test: [CMD, sh, -c, acme.sh --list | grep nas.example.com ls /ssl/fullchain.pem] interval: 30s timeout: 10s retries: 3 start_period: 40s这行命令每30秒检查两件事acme.sh是否识别到域名且证书文件是否存在。任一失败容器状态变unhealthy可通过docker ps一眼识别。6.2 证书有效期主动探测在群晖的Task Scheduler里创建一个每日执行的脚本/volume1/scripts/check-cert.sh#!/bin/bash # 检查证书剩余天数 DAYS_LEFT$(openssl x509 -in /volume1/docker/ssl/fullchain.pem -noout -days | awk {print $2}) if [ $DAYS_LEFT -lt 25 ]; then # 剩余不足25天发邮件告警 synodsmnotify admin SSL证书告警 证书仅剩$DAYS_LEFT天自动续签可能失败 fi为什么是25天因为acme.sh默认在剩余30天时续签留5天缓冲期处理异常。6.3 外网HTTPS可用性验证用一台树莓派每小时访问https://nas.example.com用curl检查HTTP状态码和证书链curl -I -k --connect-timeout 10 https://nas.example.com 2/dev/null | head -1 | grep 200 OK # 检查证书链完整性 curl -v https://nas.example.com 21 | grep SSL certificate verify ok若失败自动触发docker restart acme-sh。6.4 日志归档与回溯acme.sh默认日志在~/.acme.sh/但群晖的/root分区很小通常2GB。我用rsync每日同步到/volume1/logs/acme/# /volume1/scripts/backup-acme-log.sh rsync -avz --delete /root/.acme.sh/ /volume1/logs/acme/ # 清理30天前日志 find /volume1/logs/acme/ -name *.log -mtime 30 -delete这套机制运行三个月共触发续签12次成功12次。最后一次失败发生在阿里云DNS API临时故障时但因有第6.2条的25天告警我在证书过期前48小时手动介入用acme.sh --renew -d nas.example.com --force强制续签成功。最后分享一个小技巧在acme.sh命令后加--debug 2它会输出所有curl请求和响应包括完整的HTTP头。当遇到“403 Forbidden”类错误时这是唯一能定位是DNS权限问题、API密钥过期、还是ACME服务器限流的途径。我就是靠这个debug日志发现阿里云DNS的AccessKey竟有“仅限RAM用户”的隐藏限制普通主账号密钥不生效。现在我的群晖HTTPS服务已稳定运行217天没有一次因证书问题中断。凌晨三点的告警消失了取而代之的是清晨咖啡时手机弹出的一条安静通知“acme-sh容器健康检查通过”。运维的终极目标不是炫技而是让一切隐于无形。