基于 cturra/docker-ntp 的 NTP 容器 chronyd.pid Permission Denied 问题排查与迁移方案
最近在一个 NTP 容器部署中,我们遇到了一个比较典型、但又比较迷惑的问题:同样一份 ymir 配置,在部分节点上可以正常启动,在另一些节点上却会持续重启,日志反复报错:
Fatal error : Could not open /var/run/chrony/chronyd.pid : Permission denied
这个问题一开始很容易被理解成“某些节点权限没配对”或者“宿主机环境脏了”,但继续往下追会发现,真正的问题并不只是某一个目录的 owner 或 mode,而是 cturra/docker-ntp 这条启动链在容器场景下对运行时环境比较敏感。本文把这次问题的背景、排查过程、根因分析和最终方案整理出来。
背景
原始 NTP 服务使用的是 cturra/docker-ntp 镜像,部署方式大致如下:
services:
ntp:
image: cturra/ntp:latest
tmpfs:
- /etc/chrony:rw,mode=1750
- /run/chrony:rw,mode=1750
- /var/lib/chrony:rw,mode=1750
network_mode: host
privileged: true
这个镜像的启动逻辑比较简单:
- 由
/opt/startup.sh动态生成/etc/chrony/chrony.conf - 调整部分运行时目录权限
- 通过
/usr/sbin/chronyd -u chrony -d -x启动chronyd
从设计意图看,这条链路没有明显问题:容器主进程先以 root 启动,完成配置文件生成和目录准备,再通过 -u chrony 降权运行 chronyd。
问题在于,这套设计对 /etc/chrony、/run/chrony、/var/lib/chrony 的存在方式和权限模型有一组隐含前提,而这些前提在不同节点上并不总是稳定成立。
故障现象
失败节点上的容器日志大致如下:
2026-04-20T07:45:58Z chronyd version 4.6.1 starting (+CMDMON +NTP +REFCLOCK +RTC +PRIVDROP +SCFILTER +SIGND +ASYNCDNS +NTS +SECHASH +IPV6 -DEBUG)
2026-04-20T07:45:58Z Fatal error : Could not open /var/run/chrony/chronyd.pid : Permission denied
在尝试把 pidfile 挪到 /var/lib/chrony/chronyd.pid 时,错误只是跟着路径一起变化:
Fatal error : Could not open /var/lib/chrony/chronyd.pid : Permission denied
这里有两个值得注意的点:
- 报错位置会跟着
pidfile路径变化,说明问题并不局限于/run/chrony这一处。 - 同样的配置并不是所有节点都会失败,说明问题不太像单纯的静态配置写错,更像是运行时环境敏感型问题。
排查过程
1. 检查镜像里的运行时目录
先直接进入镜像检查最基础的目录状态:
docker run --rm --entrypoint sh hub.fastonetech.com/tools/cturra/ntp:latest -c '
id chrony;
ls -ld /run /var/run /run/chrony /var/run/chrony 2>/dev/null || true
'
结果显示:
chrony用户存在,uid=100 gid=101/run存在,/var/run是/run的符号链接- 镜像里默认并没有
/run/chrony
继续对照 upstream 的 startup.sh 可以看到,它的逻辑是:
- 如果
/run/chrony已存在,则去chown - 如果
/var/lib/chrony已存在,则去chown - 然后生成
/etc/chrony/chrony.conf - 最后执行
chronyd -u chrony -d -x
也就是说,它会“修正已有目录”,但不会“保证目录存在”。
2. 检查 tmpfs owner 是否足够
既然镜像本身不保证 /run/chrony 存在,那么最自然的思路就是在 Docker tmpfs 层显式指定 uid=100,gid=101:
tmpfs:
- /etc/chrony:rw,mode=1750
- /run/chrony:rw,mode=1750,uid=100,gid=101
- /var/lib/chrony:rw,mode=1750,uid=100,gid=101
为了确认这条思路是否成立,我们又做了一次手工验证:
docker run --rm --user 100:101 --tmpfs /etc/chrony:rw,mode=1750 --tmpfs /run/chrony:rw,mode=1750,uid=100,gid=101 --tmpfs /var/lib/chrony:rw,mode=1750,uid=100,gid=101 --entrypoint /bin/sh hub.fastonetech.com/tools/cturra/ntp:latest -ec '
id
stat -Lc "%u:%g %a %n" /run/chrony /var/lib/chrony /etc/chrony
touch /run/chrony/chrony.pid /var/lib/chrony/chrony.pid
ls -ln /run/chrony/chrony.pid /var/lib/chrony/chrony.pid
'
验证结果说明:
chrony用户可以在/run/chrony下创建文件chrony用户也可以在/var/lib/chrony下创建文件
这一步很关键,因为它基本排除了“tmpfs owner 完全没生效”这种最简单的解释。
3. 检查是否可以直接让容器以 chrony 用户运行
既然 chrony 用户自己可以写这些目录,那么另一个直觉上的方案就是直接把容器主进程改成:
user: "100:101"
但这个方案很快也被证伪了。实际日志会变成:
/opt/startup.sh: line 69: can't create /etc/chrony/chrony.conf: Permission denied
Fatal error : Not superuser
原因并不复杂:
/opt/startup.sh需要先生成/etc/chrony/chrony.conf- 然后再执行
chronyd -u chrony ... -u chrony的语义是“先以 root 启动,再降权到 chrony”
所以一旦容器主进程本身就不是 root,就会同时撞上两类问题:
- 无法按原方式生成
chrony.conf chronyd -u chrony因为“不是 superuser”而拒绝启动
这说明:cturra/docker-ntp 的官方启动脚本和直接使用 user: "100:101" 并不兼容。
根因分析
1. 这不是单纯的“chmod 不对”
如果问题只是某个目录缺少写权限,那么在给 tmpfs 明确加上 uid=100,gid=101 后,问题通常就应该结束了。
但这里的实际情况是:
- 手工验证表明
chrony用户自己可以写/run/chrony和/var/lib/chrony - 官方启动链仍然会在部分节点上报
chronyd.pid Permission denied
这说明问题并不只是静态 DAC 权限,而更像是:
cturra/docker-ntp这条“root 启动 -> 生成配置 ->chronyd -u chrony降权”的链路,在不同节点上的表现并不完全稳定
2. 问题点跟着 pidfile 走,而不是跟着某个固定目录走
最初报错是:
Could not open /var/run/chrony/chronyd.pid : Permission denied
当我们把 pidfile 改到 /var/lib/chrony/chronyd.pid 后,错误也一起变成:
Could not open /var/lib/chrony/chronyd.pid : Permission denied
这类现象通常意味着:
chronyd的启动流程中,PID 文件处理本身就是一个敏感点- 问题更像是“当前运行模型与容器环境不兼容”,而不只是某个特定路径写错了
3. 为什么同样配置在不同节点表现不一致
这也是整个问题最容易让人误判的地方。
如果只看 ymir 配置,会觉得所有节点都一样;但从实际结果看,cturra/docker-ntp 这条链路对宿主机环境差异比较敏感,常见的影响因素包括:
- Docker / containerd / runc / 内核版本差异
- 节点上已有容器的重建历史不同,残留 pid 或旧挂载状态不同
latest镜像在不同节点的缓存时间点不一致- tmpfs / ownership / security policy 在不同宿主机上的细微行为差异
- 某些节点“刚好满足”旧镜像的隐含假设,而另一些节点不会
因此,“有些节点正常、有些节点异常”并不说明配置本身没问题,反而更可能说明镜像的启动模型对节点环境差异过于敏感。
社区里的常见 workaround
查社区资料后,可以看到几类常见 workaround:
- 修正
/run/chrony或/var/lib/chrony的 owner/mode - 强制重建容器,避免残留 pid 或旧挂载状态影响:
docker compose up -d --force-recreate
- 某些 Ubuntu 宿主机上检查或调整 AppArmor 规则
- 直接切换到容器权限模型更清晰的镜像,例如
simonrupf/docker-chronyd
前两种手段在某些节点上可以缓解问题,但并不能保证跨节点一致性;而第 4 种方案更像是“从镜像设计上规避问题”。
解决方案
方案 A:继续修补 cturra/docker-ntp
理论上,继续沿着这条路往下做也不是完全不行,例如:
- 自定义 wrapper,自己生成
chrony.conf - 自己创建并修正
/run/chrony、/var/lib/chrony - 改写
pidfile路径 - 甚至直接绕过原始
/opt/startup.sh
但这条路的问题也很明显:
- 需要自己维护一层与 upstream 不完全一致的启动逻辑
- 后续镜像升级时还要继续验证或搬运 patch
- 排障和交接成本会越来越高
如果目标只是“让当前某一批节点先起来”,它可以作为临时方案;但如果目标是“长期稳定运行”,性价比并不高。
方案 B:切换到 simonrupf/docker-chronyd(最终采用)
最终采用的方案是直接切换到 simonrupf/docker-chronyd。
原因主要有几点:
- 它明确按
chrony:chrony用户运行,权限模型更一致。 - 官方 compose 示例直接要求三个 tmpfs 都带
uid=100,gid=101。 - 它更明确地处理
/etc/chrony、/run/chrony、/var/lib/chrony这几个关键目录。 - 它有固定版本 tag,可直接 pin 版本,而不是继续依赖漂移的
latest。
最终配置调整为:
services:
ntp:
image: simonrupf/chronyd:0.7.1
container_name: ntp
restart: always
read_only: true
tmpfs:
- /etc/chrony:rw,mode=1750,uid=100,gid=101
- /run/chrony:rw,mode=1750,uid=100,gid=101
- /var/lib/chrony:rw,mode=1750,uid=100,gid=101
network_mode: host
cap_add:
- CAP_NET_BIND_SERVICE
environment:
- NTP_SERVERS=ntp1.aliyun.com,ntp2.aliyun.com,ntp3.aliyun.com
- LOG_LEVEL=0
相对于旧配置,这里有两个明显变化:
- 不再依赖
privileged: true - 不再依赖
cturra/docker-ntp那条“root 启动 ->-u chrony降权”的脚本链路
一个顺手修掉的小问题:127.0.0.1 不是理想 fallback
排查过程中还顺手发现,原来的 fallback NTP 地址写成了:
127.0.0.1
如果它的语义是“本地离线时钟”,更常见、也更明确的写法其实是:
127.127.1.1
这不是这次 PID 权限问题的直接根因,但既然已经调整 NTP 组件,顺手改掉会更稳妥。
验证方式
镜像切换完成后,可以通过下面几项做基本验证:
docker logs -f ntp
docker exec ntp chronyc tracking
docker exec ntp chronyc sources
重点关注:
- 容器是否还会反复重启
chronyc tracking的Stratum是否正常Leap status是否为Normalsources中是否能看到上游时间源并建立同步关系
小结
这次问题表面上看是:
chronyd无法打开 PID 文件,像是一个目录权限问题
但继续往下追会发现,更本质的问题其实是:
cturra/docker-ntp在容器场景下的启动 / 降权 / 运行时目录模型不够稳,对节点运行环境差异比较敏感
这也是为什么同样配置在不同节点上表现不一致。
对于希望在多节点环境中稳定运行 NTP 容器的场景,直接切换到 simonrupf/docker-chronyd,通常比继续在 cturra/docker-ntp 上叠更多 workaround 更像一个长期可维护的方案。