Docker Daemon 运维:systemd 集成、配置调优与日志治理

围绕 dockerd 进程模型与 docker.service 单元,拆解 Type=notify、Delegate、KillMode、LimitNOFILE 与 TasksMax 语义,讲 docker.socket 按需激活、daemon.json 常用项与校验、drop-in 覆盖与配置优先级、热重载边界,覆盖日志驱动选型与膨胀治理、journald 排障、内核参数、存储 GC 与安全加固。

前置阅读:建议先阅读 Docker 容器监控与日志实践:cgroups 资源限制、Prometheus 与日志驱动 与 Docker 容器排障与调试实战:退出码、OOM、exec 与调试工具链。本篇聚焦 daemon 侧的系统集成、配置调优与日志治理。

容器本身跑得稳不稳,一半取决于镜像与业务,另一半取决于 dockerd 这个常驻进程被系统怎么托管。很多人能熟练写 Dockerfile,却在 daemon.json 写错一个逗号后让整个节点的 Docker 起不来;也有人把日志驱动留在默认的 json-file 且不设上限,直到某天容器日志把根分区写满。本篇把 daemon 当作一个受 systemd 托管的普通服务来治理,逐层拆开它的启动、配置、日志与资源边界。

1. dockerd 进程模型与 docker.service 单元

1.1 从 docker 命令到容器进程的调用链

调用链是 docker CLI → dockerd → containerd → containerd-shim → runc → 容器进程。关键事实:dockerd 并不直接 fork 容器进程,它只负责 API、镜像、网络与卷的管理;真正的容器由 containerd-shim 收养,runc 在创建完命名空间与 cgroup 后就退出。这条链路的每一跳都影响 systemd 单元的写法——尤其是 KillMode 与 Delegate。

1.2 docker.service 关键字段逐项拆解

# /lib/systemd/system/docker.service(发行版自带,不建议直接改)
[Unit]
Description=Docker Application Container Engine
After=network-online.target docker.socket containerd.service
Wants=network-online.target
Requires=docker.socket containerd.service

[Service]
Type=notify
ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
StartLimitBurst=3
StartLimitIntervalSec=60
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
Delegate=yes
KillMode=process
OOMScoreAdjust=-500

[Install]
WantedBy=multi-user.target
  • Type=notify:dockerd 初始化完成后会通过 sd_notify 通知 systemd,systemd 才把服务标记为 active。这意味着 systemctl start docker 返回时 daemon 已经真正可用,脚本不需要再 sleep 轮询。配合 WatchdogSec 还能做健康探测。
  • -H fd://:监听文件描述符,由 docker.socket 传入,这是 socket activation 的接入点;如果写成 -H unix:///var/run/docker.sock 则变成 dockerd 自己监听,二者不要混用。
  • Delegate=yes:把 /sys/fs/cgroup/system.slice/docker.service/ 整棵子树委派给 dockerd,允许它自由创建、修改子 cgroup 的 controller。没有这一项,cgroup v2 下容器资源限制会出现 cannot set memory.max 之类的权限错误。
  • KillMode=process:systemctl stop docker 时只向主进程 dockerd 发信号,不会级联杀死 shim 与容器进程。这正是 live-restore 能生效的前提,也是为什么重启 daemon 不会顺带干掉业务容器。
  • LimitNOFILE=infinity 与 TasksMax=infinity:容器数量一多,每个容器都占用若干 fd 与线程,默认 1024 的 fd 上限远远不够;TasksMax 限制的是整个 service cgroup 的进程/线程总数,不放开会在批量启动容器时触发 pids.max 拒绝。
  • OOMScoreAdjust=-500:降低 dockerd 被 OOM Killer 选中的概率。daemon 被杀会导致所有容器失去管理入口,代价远高于牺牲一个业务进程。
  • StartLimitBurst=3 与 StartLimitIntervalSec=60:60 秒内启动失败 3 次就停止重试,避免配置错误时 systemd 无限重启刷日志。注意这两个键在较新的 systemd 中属于 [Unit] 段,老版本在 [Service] 段,写错位置会被静默忽略。

一句话:Type=notify 决定启动语义,Delegate=yes 决定 cgroup 权限,KillMode=process 决定重启时容器是否陪葬——这三项是 daemon 单元的地基。

2. docker.socket 与按需激活

2.1 socket activation 的工作方式

# /lib/systemd/system/docker.socket
[Socket]
ListenStream=/var/run/docker.sock
SocketMode=0660
SocketUser=root
SocketGroup=docker

[Install]
WantedBy=sockets.target

systemd 先创建 /var/run/docker.sock 并持有它,当第一个客户端连接进来时才启动 docker.service,并把监听 fd 通过 -H fd:// 传给 dockerd。好处有三:开机并行度提升,不必等 Docker 完全初始化;dockerd 重启期间 socket 文件始终存在,客户端不会遇到 Cannot connect to the Docker daemon,连接会排队等待;端口与权限(SocketMode、SocketGroup)由 systemd 统一管理,比让 dockerd 自己 chmod 更清晰。

2.2 什么时候该关掉它

若 ExecStart 里用的是 -H unix:///var/run/docker.sock 直连形式,则 docker.socket 属于冗余,应 systemctl disable --now docker.socket,否则会出现两个进程争抢同一个 socket 路径的诡异现象。另外 socket activation 与 live-restore 组合时要注意:socket 保持监听只是让客户端不报错,并不会让容器在 daemon 重启期间继续接受新指令。

systemctl status docker.socket && systemctl list-sockets | grep docker
ss -xlp | grep docker.sock    # 交叉验证监听进程是 systemd 还是 dockerd

3. daemon.json 核心配置项

/etc/docker/daemon.json 是 daemon 的主配置入口,修改后需重启(部分项可热重载)才生效。一份面向生产的配置骨架:

{
  "data-root": "/data/docker",
  "storage-driver": "overlay2",
  "log-driver": "local",
  "log-opts": { "max-size": "100m", "max-file": "5", "compress": "true" },
  "live-restore": true,
  "default-ulimits": {
    "nofile": { "Name": "nofile", "Hard": 65536, "Soft": 65536 }
  },
  "max-concurrent-downloads": 10,
  "max-concurrent-uploads": 10,
  "registry-mirrors": ["https://mirror.example.com"],
  "insecure-registries": ["registry.internal:5000"],
  "default-address-pools": [
    { "base": "172.17.0.0/16", "size": 24 }, { "base": "172.18.0.0/16", "size": 24 }
  ],
  "bip": "172.17.0.1/16",
  "fixed-cidr": "172.17.0.0/16",
  "mtu": 1450,
  "features": { "buildkit": true }
}
配置项作用默认值生产建议
data-root镜像与容器数据根目录/var/lib/docker指向独立数据盘,避免撑爆根分区
storage-driver存储驱动overlay2保持 overlay2,xfs 需 ftype=1
log-driver默认日志驱动json-file改为 local,自带轮转
log-opts日志驱动参数无必须设 max-size 与 max-file
live-restoredaemon 重启时保留容器false生产置 true
default-ulimits容器默认 ulimit继承 daemon显式声明 nofile
max-concurrent-downloads并发拉取层数3大带宽节点提到 10
max-concurrent-uploads并发推送层数5与 CI 带宽匹配
default-address-pools自定义网络地址池172.17 起自动显式规划,防与内网冲突
bip 与 fixed-cidrdocker0 网桥地址与容器段172.17.0.1/16与公司网段错开
mtu容器网络 MTU1500叠加 VXLAN 时降到 1450
registry-mirrors镜像加速地址无内网节点必配
insecure-registries允许 HTTP 的仓库无仅限内网可信仓库

3.1 语法校验先行

改完配置不要直接重启,先用内置校验:

dockerd --validate --config-file /etc/docker/daemon.json
python3 -m json.tool /etc/docker/daemon.json > /dev/null   # 纯 JSON 语法检查

常见坑:JSON 不允许尾随逗号与注释;log-opts 的值必须是字符串("max-size": 100m 会报类型错误);default-ulimits 的 nofile 必须写成带 Name 的对象形式,写成纯数字会被拒绝。

一句话:daemon.json 里最容易被忽略的不是语法,而是 log-opts 缺失——它决定了磁盘什么时候被写满。

4. 配置优先级、drop-in 覆盖与平滑重启

4.1 三层的优先级关系

dockerd 的最终配置由三个来源合并,优先级从高到低为:

  1. 命令行参数(ExecStart 里的标志)——最高优先级;
  2. /etc/docker/daemon.json——中间层;
  3. 内置默认值——兜底。

这意味着修改 /lib/systemd/system/docker.service 的 ExecStart 会压过 daemon.json,这是排查「明明改了配置却不生效」的第一怀疑对象。另一个高频现象是两者对同一项做了不同设置,dockerd 直接拒绝启动:

unable to configure the Docker daemon with file /etc/docker/daemon.json:
the following directives are specified both as a flag and in the configuration file

4.2 用 drop-in 而不是改原 unit

不要直接编辑 /lib/systemd/system/docker.service,包升级会覆盖它。正确做法是写 drop-in:

# /etc/systemd/system/docker.service.d/override.conf
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock \
  --log-level=warn
LimitNOFILE=1048576
Environment=DOCKER_TLS_CERTDIR=""

要点:先写一个空的 ExecStart= 清空原值,再写新值,否则 systemd 会报「cannot assign multiple ExecStart」;行尾续行反斜杠在 systemd 中合法,但后面不能有空格。改完执行:

systemctl daemon-reload && systemd-analyze verify docker.service
systemctl restart docker

daemon-reload 只重载 systemd 对单元文件的认知,不会重启服务;想让配置生效仍需 restart。反过来,只改 daemon.json 而不改 unit,也必须 restart(除非该项支持热重载)。

4.3 热重载与硬重启的边界

systemctl reload docker 等价于向主进程发 SIGHUP,只能重载下表中的部分项:

配置项可否 SIGHUP 热重载说明
debug可以即时切换调试日志
insecure-registries可以无需重启即可新增可信仓库
registry-mirrors可以加速地址即时生效
max-concurrent-downloads可以并发度即时调整
labels可以节点标签热更新
log-driver不可以需重启,且只影响新容器
data-root 与 storage-driver不可以迁移数据目录或换驱动需重启
live-restore不可以需重启 daemon

live-restore: true 时,systemctl restart docker 会保留所有运行中的容器,daemon 恢复后重新接管它们。但要注意:重启期间容器的 stdio 管道由 shim 持有,日志仍会写入;而依赖 daemon 的操作(exec、日志读取、网络变更)会短暂不可用。若同时开了 socket activation,客户端只会阻塞而不会报连接错误。

5. 日志驱动选型与日志膨胀治理

5.1 七种驱动横向对比

驱动轮转能力docker logs 可用适用场景
json-file需手配 log-opts可用单机小规模,务必配 max-size
local默认自动轮转可用单机生产首选
journald交给 journald可用已统一用 journald 采集的节点
syslog交给 syslog不可用对接传统 syslog 设施
fluentd由 fluentd 管理不可用需要结构化转发到 ES 等
gelf由 Graylog 管理不可用已用 Graylog 的团队
none不写日志不可用极高频日志且无需留存

json-file 是历史默认值,它不会自动轮转,单个容器的日志文件位于 /var/lib/docker/containers/<容器ID>/<容器ID>-json.log,长期运行必然膨胀。local 驱动在 18.09 引入,默认按 100MB × 5 轮转并支持压缩,写入格式更紧凑,是目前单机场景的推荐值。

一句话:把全局 log-driver 从 json-file 换成 local,一行配置就能消灭大部分「磁盘被日志写满」的故障。

5.2 定位与治理

docker system df && docker system df -v      # 概览后再下钻到单个容器与镜像
du -sh /var/lib/docker/containers/*/*-json.log | sort -rh | head -10
find /var/lib/docker/containers -name '*-json.log' -size +100M -exec ls -lh {} \;

治理手段按优先级排列:

  1. 改全局默认:log-opts 设 max-size 与 max-file,只影响之后新建的容器。
  2. 改单容器:docker run --log-opt max-size=50m --log-opt max-file=3,或 Compose 的 logging.options。
  3. 对存量容器:日志驱动与轮转参数在容器创建时固化,无法热改,只能重建容器;这也是为什么全局默认值必须一开始就设对。
  4. 兜底清理:truncate -s 0 <日志路径> 可立即释放空间而不中断容器(不要用 rm,会因 fd 未释放导致空间不回收)。

6. journald 集成与 journalctl 排障

6.1 daemon 自身日志

dockerd 的 stdout/stderr 被 systemd 捕获并送进 journald,所以 daemon 日志不在 /var/log/docker.log 里:

journalctl -u docker -f                       # 实时跟踪
journalctl -u docker -p err -b                # 本次启动以来的错误级别
journalctl -u docker -o json-pretty -n 200    # 结构化字段排查

journald 自身也要限容,否则它会成为新的磁盘黑洞:

# /etc/systemd/journald.conf
[Journal]
SystemMaxUse=2G
MaxRetentionSec=2week

改完 systemctl restart systemd-journald 生效。

6.2 容器日志走 journald

设置 log-driver: journald 后,容器日志带上 CONTAINER_NAME、CONTAINER_ID、IMAGE_NAME 等字段,可直接按容器名过滤,例如 journalctl CONTAINER_NAME=web -f。需要注意三点:journald 驱动忽略 max-size 与 max-file(轮转交给 journald);docker logs 依然可用,它是从 journal 反向读取的;高频日志场景下 journald 的写入锁可能成为瓶颈,压测时应关注 journalctl --disk-usage 与写入延迟。

7. daemon 级资源、网络与并发调优

7.1 内核参数

容器密度一上来,最先撞墙的往往是内核限制而非 CPU 内存:

# /etc/sysctl.d/99-docker.conf
fs.file-max = 2097152
fs.inotify.max_user_watches = 524288
net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
net.netfilter.nf_conntrack_max = 1048576
vm.max_map_count = 262144
vm.overcommit_memory = 1

sysctl -p /etc/sysctl.d/99-docker.conf 加载后,用 sysctl net.netfilter.nf_conntrack_count 观察连接跟踪表使用率;接近 nf_conntrack_max 时会出现 nf_conntrack: table full, dropping packet,表现为随机连接超时。vm.max_map_count 是 Elasticsearch 这类应用的硬性要求,容器内 mmap 数量超限会直接启动失败。

参数触发的问题建议值
fs.file-max打开文件数耗尽2097152
nf_conntrack_max连接跟踪表满、丢包1048576
vm.max_map_countmmap 失败、ES 启动异常262144
net.ipv4.ip_forward容器无法访问外网1
bridge-nf-call-iptables网桥流量绕过 iptables 规则1
inotify.max_user_watches文件监听失效524288

7.2 并发与 daemon 侧限流

max-concurrent-downloads 控制 docker pull 时并行拉取的层数,默认 3 在千兆带宽下明显偏低;max-concurrent-uploads 影响 docker push,CI 节点推送大镜像时值得提高。但两者都不能无脑拉满:层数过多会让镜像仓库与本地磁盘 IO 同时承压,建议按带宽与仓库限流能力取 8 到 16。max-download-attempts 决定失败重试次数,弱网环境可适当加大。改完用 docker info 过滤 concurrent、storage driver、logging driver、live restore 几项即可确认生效。

8. 存储 GC、启动排障与安全加固

8.1 分层清理与定时策略

docker system df                                       # 先看清占用分布
docker container prune --filter "until=24h"            # 清理 24 小时前的停止容器
docker image prune -a --filter "until=168h"            # 清理一周未用镜像
docker builder prune --filter "until=72h"              # 清理构建缓存
docker volume prune                                    # 清理无主卷,慎用
docker system prune -a --volumes --filter "until=168h" # 全量清理,最激进

--filter until 是安全网:没有它,prune 会清掉刚构建完、马上要部署的镜像。生产上建议用 systemd timer 定时执行中等激进度的组合(容器 + 构建缓存 + 7 天前镜像),并把 docker system df 的占用纳入监控。

# /etc/systemd/system/docker-gc.timer —— 配套 docker-gc.service(Type=oneshot)使用
[Unit]
Description=Run docker GC daily

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

8.2 启动失败定位四步法

daemon 起不来时按固定顺序排查,能覆盖九成场景:

systemctl status docker -l && journalctl -u docker -b -n 100   # 退出码与本次启动日志
dockerd --validate --config-file /etc/docker/daemon.json      # 配置合法性
dockerd --debug                                               # 前台运行,直接看崩溃点
systemd-analyze verify docker.service                         # 单元文件语法与依赖

常见根因与特征:

现象根因处置
启动即退出,日志含 invalid characterdaemon.json 语法错用 json.tool 校验后修正
failed to start daemon: error initializing graphdriverstorage-driver 与文件系统不匹配检查 xfs ftype,回退 overlay2
iptables: No chain/target/match内核模块缺失或 nft 后端冲突加载 br_netfilter,切换 iptables-legacy
permission denied on /data/dockerdata-root 权限或 SELinux 标签错修正属主并 restorecon
could not find an available, non-overlapping IPv4 address pool自定义网络地址池耗尽扩充 default-address-pools
Job for docker.service failed: start-limit-hit反复启动失败触发限流修复根因后 systemctl reset-failed

8.3 安全加固要点

daemon.json 中可加 "userns-remap": "default"、"no-new-privileges": true、"icc": false 三项:

  • userns-remap:把容器内 root 映射到宿主机的高位 UID,容器逃逸后拿到的也不是真 root。代价是数据卷属主会变成 100000:100000,存量卷需重新授权,且部分需要真实 root 的场景(如特权容器)无法使用。
  • no-new-privileges:禁止容器内进程通过 setuid 提权,几乎零成本,建议默认开启。
  • icc: false:关闭同一网桥内容器间的默认互通,配合自定义网络做最小连通。
  • 远程 API 必须走 TLS:绝不暴露 -H tcp://0.0.0.0:2375(无认证的裸端口等价于把宿主机 root 权限开放给整个网络)。正确做法是生成 CA 与服务端证书后,用 --tlsverify 配合 --tlscacert、--tlscert、--tlskey 三个参数监听 2376 端口启用双向校验。

8.4 生产踩坑表

坑后果规避方式
直接编辑 /lib/systemd/system/docker.service包升级后被覆盖用 /etc/systemd/system/docker.service.d 下的 drop-in
drop-in 中只写新 ExecStart 不写空的旧值报 cannot assign multiple ExecStart首行写 ExecStart= 清空
改 daemon.json 后只 daemon-reload配置未生效必须 systemctl restart docker
用 -H tcp://0.0.0.0:2375未认证的 root 级远程控制改 2376 并启用 tlsverify
日志驱动留 json-file 且无上限根分区被写满全局切 local 并配 max-size
对运行中容器改日志驱动报错或无效重建容器,日志配置创建时固化
prune 不带 until 过滤删掉待部署镜像一律加 –filter until
改了 ExecStart 却忘记 daemon-reload单元未重解析每次改 unit 都先 reload

9. 总结

主题关键结论一句话记忆
systemd 单元Type=notify 定义启动语义,Delegate 与 KillMode 决定 cgroup 与重启行为地基三件套不可少
socket 激活docker.socket 让客户端在 daemon 重启时不报错要么用 fd:// 要么直连,不可混用
daemon.json生产必备 data-root、log-opts、live-restore 与地址池规划改前先 validate
配置优先级命令行参数压过 daemon.json,drop-in 用于覆盖 unit不生效先查 ExecStart
热重载边界镜像仓库与并发项可 SIGHUP,日志与存储驱动必须重启reload 是特例不是常态
日志驱动local 自带轮转,json-file 必须手配且无法热改日志配置在创建时固化
journalddaemon 日志走 journal,容器日志可带容器名字段过滤journalctl -u docker 是第一现场
内核参数conntrack、max_map_count 与 fd 上限是高密度前提先调内核再调 Docker
GC 与安全prune 必带 until,远程 API 必走 TLS,userns-remap 权衡取舍清理要保守,暴露要加密

daemon 治理的本质,是把 Docker 还原成 systemd 眼里的普通服务:启动语义、配置来源、日志去向与资源边界都应可复现。建议先换 local 日志驱动并设 max-size 堵住磁盘膨胀,再用 drop-in 固化单元参数、理清优先级,接着补齐 conntrack 与 fd 等内核参数,最后收敛 GC 策略与安全加固。每次改动先 dockerd --validate,再 systemd-analyze verify,最后 systemctl restart。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「docker」更多文章

  1. 容器 CPU 调度与 NUMA:绑核、实时性与 QoS 保障
  2. OCI 镜像与工件规范:manifest、index 与 artifact 生态
  3. 构建缓存进阶:Buildx 远程缓存、CI 加速与缓存失效