前置阅读:建议先阅读 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-restore | daemon 重启时保留容器 | false | 生产置 true |
| default-ulimits | 容器默认 ulimit | 继承 daemon | 显式声明 nofile |
| max-concurrent-downloads | 并发拉取层数 | 3 | 大带宽节点提到 10 |
| max-concurrent-uploads | 并发推送层数 | 5 | 与 CI 带宽匹配 |
| default-address-pools | 自定义网络地址池 | 172.17 起自动 | 显式规划,防与内网冲突 |
| bip 与 fixed-cidr | docker0 网桥地址与容器段 | 172.17.0.1/16 | 与公司网段错开 |
| mtu | 容器网络 MTU | 1500 | 叠加 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 的最终配置由三个来源合并,优先级从高到低为:
- 命令行参数(
ExecStart里的标志)——最高优先级; /etc/docker/daemon.json——中间层;- 内置默认值——兜底。
这意味着修改 /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 {} \;
治理手段按优先级排列:
- 改全局默认:
log-opts设max-size与max-file,只影响之后新建的容器。 - 改单容器:
docker run --log-opt max-size=50m --log-opt max-file=3,或 Compose 的logging.options。 - 对存量容器:日志驱动与轮转参数在容器创建时固化,无法热改,只能重建容器;这也是为什么全局默认值必须一开始就设对。
- 兜底清理:
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_count | mmap 失败、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 character | daemon.json 语法错 | 用 json.tool 校验后修正 |
| failed to start daemon: error initializing graphdriver | storage-driver 与文件系统不匹配 | 检查 xfs ftype,回退 overlay2 |
| iptables: No chain/target/match | 内核模块缺失或 nft 后端冲突 | 加载 br_netfilter,切换 iptables-legacy |
| permission denied on /data/docker | data-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 必须手配且无法热改 | 日志配置在创建时固化 |
| journald | daemon 日志走 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。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。