Docker 基础与进阶

本文合并自:01-Docker基础.md、03-Docker基础与进阶.md

一、容器技术与 Docker 概述

1.1 虚拟化技术的演进:物理机 → 虚拟机 → 容器

运维环境的交付形态经历了三个阶段:

阶段 形态 特点 典型问题
第一阶段 物理机 一台服务器跑一套或几套应用 资源利用率低(常年 5%~15%)、上线周期以天/周计、应用之间互相干扰
第二阶段 虚拟机(KVM/VMware/Xen) Hypervisor 把一台物理机切成多台虚拟机,每台虚拟机有完整的 Guest OS 资源利用率提升,但每台虚拟机要额外付出一份内核与系统进程的开销,启动以分钟计,镜像以 GB 计
第三阶段 容器(LXC → Docker) 多个容器共享宿主机内核,只隔离进程与文件系统视图 启动以秒/毫秒计,镜像以 MB 计,密度高;但隔离性弱于虚拟机,且不能跨内核(Linux 容器不能直接跑 Windows 应用)

容器技术本身并不是 Docker 发明的。Linux 内核早在 2.6.x 时代就提供了 Namespace 与 Cgroups,LXC(Linux Container)已经能做出容器。Docker 的贡献在于:

  1. 提出了镜像(Image)这种分层、可复用、可分发的打包格式;
  2. 提供了 Dockerfile 这种声明式、可版本化的构建方式;
  3. 提供了 Registry 这种集中分发渠道,让"构建一次、到处运行"真正落地。

Docker 是一个开源的应用容器引擎,基于 Go 语言开发,遵从 Apache 2.0 协议开源。它允许开发者将应用及其依赖打包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上运行,也可以实现虚拟化。容器采用沙箱机制,相互之间不会有任何接口。

容器技术经历了从 LXC 到 Docker 的发展。Docker 镜像是容器的静态模板,容器是镜像运行起来的实例。

1.2 容器与虚拟机的对比

架构对比(自下而上):

【虚拟机】                          【容器】
+---------------------------+      +---------------------------+
| App A | App B | App C     |      | App A | App B | App C     |
+-------+-------+-----------+      +-------+-------+-----------+
| Libs  | Libs  | Libs      |      | Libs  | Libs  | Libs      |
+-------+-------+-----------+      +-------+-------+-----------+
|GuestOS|GuestOS|GuestOS    |  <-- 这一层被省掉了 -->
+-------+-------+-----------+      +---------------------------+
|      Hypervisor           |      |    Docker Engine          |
+---------------------------+      +---------------------------+
|      Host OS / Kernel     |      |    Host OS / Kernel       |
+---------------------------+      +---------------------------+
|      物理硬件              |      |    物理硬件               |
+---------------------------+      +---------------------------+

关键差别只有一处:虚拟机每个实例带一个完整的 Guest OS 内核,容器共享宿主机内核。由此派生出下面所有的性能与特性差异。

详细对比表:

对比项 虚拟机 容器
隔离层级 硬件级虚拟化(Hypervisor 模拟 CPU/内存/磁盘/网卡) 操作系统级虚拟化(Namespace + Cgroups)
是否有独立内核 有,每个 VM 一个完整内核 无,共享宿主机内核
启动速度 分钟级(需完整走内核引导 + init + 服务启动) 秒级甚至毫秒级(只是起一个进程)
镜像体积 GB 级(几百 MB 起,常见 1~20 GB) MB 级(alpine 约 5 MB,ubuntu 约 100 MB)
内存占用 每个实例数百 MB 到数 GB 的固定开销 只有应用进程本身的开销,接近 0 额外开销
单机可运行密度 数个到数十个 数十到数百甚至上千个
CPU/内存性能损耗 约 5%~15%(有 Hypervisor 转译与二次调度) 接近 0(进程直接跑在宿主内核上,只是被 Cgroups 限额)
磁盘 I/O 性能 有虚拟磁盘一层损耗 联合文件系统有一定写放大,绑定挂载几乎无损耗
隔离级别(安全性) 强。逃逸需突破 Hypervisor 较弱。共享内核,内核漏洞可导致逃逸;root 权限需谨慎
能否跑异构系统 能。Linux 宿主上可跑 Windows/BSD 不能。Linux 容器只能跑 Linux 应用(Windows 上靠内嵌 Linux 虚拟机实现)
迁移与快照 成熟的热迁移、内存快照 一般是"销毁重建",靠外部数据卷保存状态
适用场景 需要强隔离的多租户、需要跑不同操作系统、传统单体应用整体搬迁、数据库等有状态核心服务 微服务、无状态应用、CI/CD 构建环境、需要快速弹性伸缩的场景、开发测试环境标准化

结论不是"容器取代虚拟机",而是"容器跑在虚拟机里":生产上常见的形态是用虚拟机(或云主机)做资源与安全边界,在虚拟机内部用容器做应用交付。

详见 15-01-KVM虚拟化.md

1.3 容器的底层原理

容器不是一个内核对象,它是"一堆内核特性组合出来的效果"。三大支柱:

1.3.1 Namespace:提供隔离

Namespace 让进程只能看见属于自己那一份的系统资源视图。Linux 提供六种(后期又增加了 cgroup、time namespace):

Namespace 隔离内容 说明与在容器中的体现
PID 进程 ID 空间 容器内 ps 只能看到自己的进程,且容器主进程 PID 为 1。宿主机上能看到这个进程的真实 PID(docker top 显示的就是宿主机 PID)
NET 网络设备、IP、端口、路由表、iptables 规则 每个容器有自己独立的网卡(eth0)、独立 IP、独立端口空间。所以两个容器都可以监听 80 端口而不冲突
IPC System V IPC、POSIX 消息队列 容器内的共享内存、信号量、消息队列与宿主机及其他容器互不可见
MNT 文件系统挂载点 容器有自己的根文件系统视图,/ 是镜像解出来的目录,看不到宿主机的 /
UTS 主机名与域名 容器可以有自己的 hostname(默认是容器 ID 前 12 位),--hostname 可指定
USER 用户与用户组 ID 可以把容器内的 root(UID 0)映射为宿主机上的普通用户 UID,这是 rootless 容器的基础。默认 Docker 不启用 user namespace,所以容器内 root 就是宿主机 root

1.3.2 Cgroups:提供资源限制

Namespace 解决"看不见",Cgroups(Control Groups)解决"用不多"。Cgroups 按子系统对资源做限额与统计:

子系统 作用 对应 docker run 选项
cpu / cpuacct CPU 时间片配额与统计 --cpus、--cpu-shares、--cpu-period、--cpu-quota
cpuset 绑定到指定 CPU 核心与内存节点 --cpuset-cpus、--cpuset-mems
memory 内存与 swap 上限,超限触发 OOM Kill -m / --memory、--memory-swap
blkio 块设备 I/O 带宽与 IOPS --device-read-bps、--device-write-iops
pids 进程数上限,防 fork 炸弹 --pids-limit
devices 允许访问哪些设备文件 --device

没有 Cgroups 限制的容器,可以把宿主机的 CPU 或内存吃光,把同宿主机的其他容器一起拖死。生产环境必须给容器设置资源限制。

1.3.3 UnionFS:联合文件系统与分层镜像

UnionFS(联合文件系统)可以把多个目录"叠"成一个目录来看。Docker 用它实现镜像分层:

容器可写层(Container Layer, 读写)      <- 容器运行时产生的所有修改都在这里
------------------------------------
镜像层 4:CMD/配置                 只读
镜像层 3:COPY 应用代码            只读     <- 这几层共享给所有基于该镜像的容器
镜像层 2:RUN apt-get install      只读
镜像层 1:FROM ubuntu 基础层        只读
  • 分层:Dockerfile 里每一条会改变文件系统的指令产生一层,层之间可以复用。10 个容器基于同一个 nginx 镜像,磁盘上只有一份镜像层。
  • 写时复制(CoW,Copy-on-Write):容器读文件时从下层直接读;一旦要修改下层的文件,先把该文件整体复制到最上面的可写层再改。所以修改一个大文件的第一次写会有明显延迟。
  • 删除:删除下层文件并不会真的删掉,而是在可写层打一个 whiteout 标记,所以"在后一层 RUN 里 rm 掉前一层装的东西"并不能让镜像变小。
  • 容器可写层的生命周期与容器一致:docker rm 容器,可写层随之删除,数据全部丢失。这就是必须用数据卷做持久化的根本原因。

常见存储驱动:overlay2(当前默认,推荐)、devicemapper(CentOS 7 早期默认,direct-lvm 模式才可用于生产)、aufs(旧版 Ubuntu)、btrfs、zfs。早期文档中提到的 "容器使用 AUFS 这类文件系统,不能持久化数据,容器关闭后所有更改都会丢失",指的就是这一点。

1.4 Docker 的架构

1.4.1 Client / Daemon / Registry 三方关系

Docker 采用 C/S 架构:

   docker CLI (Client)
        |  REST API(默认走 unix:///var/run/docker.sock,也可走 tcp://)
        v
   dockerd (Daemon)  <-------- pull/push -------->  Registry(Docker Hub / 私有仓库)
        |
   本地镜像存储 + 容器运行 + 网络 + 数据卷

一个完整的 Docker 由以下几个部分组成:

组成部分 说明
Docker Client 客户端,即 docker 命令,负责把用户命令翻译成 REST API 请求
Docker Daemon 守护进程 dockerd,真正干活的一方:管镜像、管容器、管网络、管卷
Docker Image 镜像,只读模板
Docker Container 容器,镜像的运行实例
Docker Registry 镜像仓库,存放与分发镜像

因为 Client 与 Daemon 通过 API 通信,所以 docker 命令可以管理远程主机上的 Docker(设置 DOCKER_HOST 环境变量或 -H 参数)。也正因如此,把 Docker API 暴露到公网是极高危的行为。

1.4.2 dockerd / containerd / runc 的分层演进与 OCI 标准

早期 Docker 是一个大而全的单体二进制。随着 OCI(Open Container Initiative)标准的建立,被拆成了分层结构:

docker CLI
   |
dockerd            —— 高层功能:镜像构建、网络、卷、API
   |  gRPC
containerd         —— 容器生命周期管理、镜像拉取解压、快照(符合 CRI 标准,可被 K8s 直接调用)
   |
containerd-shim    —— 每个容器一个 shim 进程,让容器脱离 containerd 独立存活
   |
runc               —— 真正调用 Namespace/Cgroups 创建容器的低层运行时(OCI Runtime 标准实现)
   |
Linux Kernel(Namespace / Cgroups / UnionFS)

OCI 定义了两个规范:

  • OCI Image Spec:镜像格式规范。让 Docker 构建的镜像可以被 podman、containerd、CRI-O 使用。
  • OCI Runtime Spec:运行时规范。runc、crun、kata-runtime、gVisor 都是其实现。

有了 shim 层,systemctl restart docker 时正在运行的容器可以不被杀掉(需要开启 live-restore)。也正因为 K8s 可以直接对接 containerd,从 K8s 1.24 起移除了 dockershim,但镜像格式是通用的,用 Docker 构建的镜像照样能在 K8s 上跑。

1.4.3 版本分支

从 2017 年 3 月开始,Docker 在原有基础上分为两个分支版本:

  • Docker CE(Community Edition):社区免费版。
  • Docker EE(Enterprise Edition):企业版,强调安全,需付费使用。

本文介绍 Docker CE 的安装与使用。版本号从 2017 年起改为 YY.MM 形式(如 18.09、19.03、20.10、24.0)。

1.5 Docker 的核心概念

1.5.1 镜像、容器、仓库三者关系

  • image(镜像):只读模板,类似于安装光盘 / 程序包,例如 docker.io/ubuntu。它包含了运行应用所需的文件系统内容与元数据(启动命令、环境变量、暴露端口等),但不包含内核。
  • container(容器):镜像运行起来的实例,一个镜像可以启动多个容器。容器 = 镜像的只读层 + 一个可写层 + Namespace/Cgroups 隔离环境。
  • registry(仓库):集中存放镜像的服务。公共的有 Docker Hub、阿里云容器镜像服务、网易云;私有的可以用 registry 或 Harbor 自建。

用面向对象来类比:镜像相当于类,容器相当于对象(实例),仓库相当于类库。三者的操作关系:

Dockerfile --build--> 镜像 --run--> 容器 --commit--> 新镜像
                       |  ^
                    push| |pull
                       v  |
                      Registry

1.5.2 镜像分层、写时复制与容器可写层

前面 1.3.3 已展开原理,这里强调三条实践结论:

  1. 镜像是只读的,任何容器内的修改都发生在可写层,不影响镜像本身,也不影响其他容器。
  2. 容器删除即数据丢失。要保存数据必须用数据卷(Volume)或绑定挂载(Bind Mount)。
  3. 要把容器内的修改固化成镜像,只能靠 docker commit 或重新构建。

一个经典踩坑(来自源讲义):

踩坑:在交互式容器里 touch 创建一个文件后退出,再次 docker run -it 会发现文件没了。原因是退出再登录其实是又产生了一个新的容器,而不是回到之前的容器。

这个坑对应两个解法,后面第三章与第七章分别展开:继续连接原来的容器(start + attach),或者把容器 commit 成新镜像。

1.6 Docker 的优势与局限

优势:

优势 说明
环境一致性 "在我机器上是好的"这一类问题被消除,镜像把依赖一并打包
启动快、密度高 秒级启动,单机可跑几十上百个容器
交付标准化 一个镜像即交付物,开发、测试、预发、生产用同一个镜像,只换配置
易于弹性伸缩与回滚 拉起新容器即扩容,切回旧镜像 tag 即回滚
天然适配 CI/CD 与微服务 构建产物不可变,配合编排系统可做滚动升级
资源隔离与限额 Cgroups 可精确限制 CPU/内存/IO

局限:

局限 说明
隔离性弱于虚拟机 共享内核,内核漏洞可能被利用做容器逃逸;多租户强隔离场景仍需虚拟机或 Kata
不能跨内核 Linux 容器不能跑 Windows 程序,反之亦然
有状态服务复杂 数据库等有状态应用需要额外解决存储持久化、数据一致性与备份
网络与存储引入复杂度 多层 NAT、跨主机网络、卷插件等增加了排错难度
日志与监控需重新建设 容器随时销毁重建,日志必须外送,不能只留在容器里
单容器单进程模型的约束 传统一台机器上多个服务的部署方式需要拆分改造

1.7 安装部署

1.7.1 内核版本要求与 CentOS 6/7 的区别

Docker 依赖较新的内核特性(overlayfs、cgroups、namespace 完整支持):

项目 CentOS 6(内核 2.6.32) CentOS 7(内核 3.10+)
Docker 官方要求 内核 3.10 以上,2.6.32 不满足;官方仅在很早期通过 EPEL 提供过 docker-io 1.7 之前的版本,overlay2 存储驱动不可用 满足要求,Docker CE 官方仓库直接支持(要求 CentOS 7.x,且建议 7.4 以上)
是否推荐 不推荐。即使强行用 EPEL 装上旧版 docker,也存在:内核缺少 overlayfs 只能用 devicemapper loop-lvm(生产不可用)、cgroups 支持不完整导致资源限制失效、大量镜像因 glibc/内核 syscall 不兼容而无法运行、官方与社区均已停止支持 推荐。生产主流
服务管理 SysV init:service docker start / chkconfig docker on systemd:systemctl start docker / systemctl enable docker
官方 CE 仓库 无 el6 包 有 docker-ce.repo(centos/7)

CentOS 6 上若必须尝试(仅实验用途):

# CentOS 6 —— 仅实验,生产禁用
yum install -y epel-release
yum install -y docker-io
service docker start
chkconfig docker on
service docker status

CentOS 7 上使用 systemd:

# CentOS 7 及以上
systemctl start docker
systemctl enable docker
systemctl status docker
systemctl restart docker
systemctl daemon-reload

查看当前内核版本,确认是否满足 3.10 以上:

uname -r
cat /etc/redhat-release

1.7.2 环境准备

在宿主机(或虚拟机)上操作,本文以宿主机为例。

  1. 配置 CentOS 163 软件源;
  2. 保证时间同步;
  3. 能访问公网;
  4. 关闭 iptables、selinux、firewalld(实验环境;生产环境见第五章 Docker 与 firewalld 的冲突处理)。

注意:CentOS 7.3 已经不被 Docker 官方支持,需要在本地执行升级:

yum -y upgrade

1.7.3 卸载旧版本

较旧版本的 Docker 被称为 docker 或 docker-engine。如果已安装这些版本,请先卸载:

yum remove -y docker \
                  docker-client \
                  docker-client-latest \
                  docker-common \
                  docker-latest \
                  docker-latest-logrotate \
                  docker-logrotate \
                  docker-engine

卸载不会删除 /var/lib/docker(镜像、容器、卷都在里面),如需彻底清理:

rm -rf /var/lib/docker
rm -rf /var/lib/containerd

1.7.4 方式一:使用 CentOS 自带源安装 docker(旧版,快速上手)

yum install docker -y

编辑 /etc/sysconfig/docker,将 --selinux-enabled 修改为 --selinux-enabled=false:

vim /etc/sysconfig/docker
# Modify string from "--selinux-enabled" to "--selinux-enabled=false"

启动、查看状态并设置开机自启:

systemctl start docker
systemctl status docker
systemctl enable docker

这种方式装的是 CentOS extras 源里的 docker 1.13.1,版本很旧,镜像名会带 docker.io/ 前缀(如 docker.io/ubuntu)。仅适合教学快速演示,生产请用下面的 Docker CE 官方源。

1.7.5 方式二:使用 yum 安装 Docker CE(推荐)

  1. 安装所需的依赖包:
yum install -y yum-utils device-mapper-persistent-data lvm2
  1. 设置 Docker 的 yum 存储库(官方源):
yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo

国内网络建议改用阿里云镜像源,速度快很多:

yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
# 若已添加官方源,也可把域名整体替换为阿里云
sed -i 's+download.docker.com+mirrors.aliyun.com/docker-ce+' /etc/yum.repos.d/docker-ce.repo
yum makecache fast
  1. 查看可安装的版本:
yum list docker-ce --showduplicates | sort -r
yum list docker-ce-cli --showduplicates | sort -r
  1. 安装最新版 Docker CE:
yum install -y docker-ce

新版本推荐三个包一起装(docker-ce 是 daemon,docker-ce-cli 是客户端,containerd.io 是低层运行时):

yum install -y docker-ce docker-ce-cli containerd.io
# 需要 compose 与 buildx 插件时追加
yum install -y docker-buildx-plugin docker-compose-plugin

安装指定版本(版本号从上面 yum list 的输出里取):

yum install -y docker-ce-19.03.9-3.el7 docker-ce-cli-19.03.9-3.el7 containerd.io

1.7.6 排错:container-selinux 依赖

若安装时报错提示缺少 container-selinux:

错误:软件包:3:docker-ce-18.09.6-3.el7.x86_64 (docker-ce-stable)
          需要:container-selinux >= 2.9

按以下步骤处理:

  1. 下载 container-selinux 的 RPM 包:
wget http://mirror.centos.org/centos/7/extras/x86_64/Packages/container-selinux-2.95-2.el7_6.noarch.rpm
  1. 安装该 RPM 包:
rpm -ivh container-selinux-2.95-2.el7_6.noarch.rpm
  1. 再次执行 Docker CE 安装命令:
yum install -y docker-ce

根因是 CentOS 的 extras 源被禁用或未同步到足够新的 container-selinux。也可以直接启用 extras 源解决:yum install -y container-selinux --enablerepo=extras。

1.7.7 启动 Docker 与开机自启

systemctl daemon-reload
systemctl start docker
systemctl enable docker
# 一步完成启动 + 自启(新版 systemd 支持)
systemctl enable --now docker

检查状态与开机自启是否生效:

systemctl status docker
systemctl is-enabled docker
ps -ef | grep dockerd

1.7.8 查看版本与详细信息

查看 Docker 版本:

docker version
Client:
 Version:           18.09.6
 API version:       1.39
 Go version:        go1.10.8
 Git commit:        481bc77156
 Built:             Sat May  4 02:34:58 2019
 OS/Arch:           linux/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          18.09.6
  API version:      1.39 (minimum version 1.12)
  Go version:       go1.10.8
  Git commit:       481bc77
  Built:            Sat May  4 02:02:43 2019
  OS/Arch:          linux/amd64
  Experimental:     false

输出解读:

  • 分成 Client 与 Server 两段。如果只显示 Client 段并报错,说明 daemon 没起来或当前用户无权访问 socket。
  • API version 客户端与服务端要兼容;旧客户端连新服务端一般可以,反之可能报 API 版本过低。
  • minimum version 是服务端能接受的最低客户端 API 版本。

查看更详细的信息:

docker info

docker info 关键字段解读:

字段 含义与关注点
Containers / Running / Paused / Stopped 容器总数与各状态数量
Images 本地镜像数量
Server Version 服务端版本
Storage Driver 存储驱动,应为 overlay2。若显示 devicemapper 且 Data loop file 有值,说明是 loop-lvm 模式,生产环境严重不可用,必须改配置
Backing Filesystem 底层文件系统,overlay2 推荐 xfs(且需 ftype=1)或 ext4
Cgroup Driver cgroup 驱动,cgroupfs 或 systemd。与 K8s 配合时必须与 kubelet 一致,否则 kubelet 起不来
Cgroup Version 1 或 2
Logging Driver 日志驱动,默认 json-file
Docker Root Dir 数据目录,默认 /var/lib/docker,磁盘占用主要在这里
Registry Mirrors 已生效的镜像加速器列表,配置加速器后应能在这里看到
Insecure Registries 允许 HTTP 访问的仓库列表,搭私有仓库必看
Kernel Version 内核版本,需 3.10 以上
Total Memory / CPUs 宿主机资源
WARNING: No swap limit support 提示内核未开启 swap 限额,需要在 GRUB 加 cgroup_enable=memory swapaccount=1
Live Restore Enabled 重启 daemon 时是否保持容器运行

1.7.9 配置镜像加速器与守护进程参数 /etc/docker/daemon.json

国内直接拉 Docker Hub 极慢甚至超时,必须配置镜像加速器。加速器与存储驱动、cgroup driver、日志限制一起写在 /etc/docker/daemon.json(该文件默认不存在,需手动创建):

mkdir -p /etc/docker
vim /etc/docker/daemon.json

完整推荐配置:

{
  "registry-mirrors": [
    "https://docker.m.daocloud.io",
    "https://dockerproxy.com",
    "https://docker.nju.edu.cn",
    "https://docker.mirrors.ustc.edu.cn",
    "https://hub-mirror.c.163.com",
    "https://mirror.baidubce.com"
  ],
  "insecure-registries": ["192.168.1.100:5000"],
  "storage-driver": "overlay2",
  "storage-opts": ["overlay2.override_kernel_check=true"],
  "exec-opts": ["native.cgroupdriver=systemd"],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "3"
  },
  "data-root": "/data/docker",
  "live-restore": true,
  "default-ulimits": {
    "nofile": { "Name": "nofile", "Hard": 65536, "Soft": 65536 }
  },
  "max-concurrent-downloads": 10,
  "max-concurrent-uploads": 5,
  "dns": ["223.5.5.5", "114.114.114.114"]
}

常用国内加速地址(可用性随时间变化,建议配多个做备份):

提供方 地址
DaoCloud https://docker.m.daocloud.io
中科大 USTC https://docker.mirrors.ustc.edu.cn
南京大学 https://docker.nju.edu.cn
网易 https://hub-mirror.c.163.com
百度云 https://mirror.baidubce.com
腾讯云(仅腾讯云内网) https://mirror.ccs.tencentyun.com
阿里云(需登录控制台获取专属地址) https://<你的ID>.mirror.aliyuncs.com

各配置项说明:

配置项 作用
registry-mirrors 镜像加速器。只对 Docker Hub 官方镜像生效,拉 gcr.io、quay.io 的镜像不走它
insecure-registries 允许通过 HTTP(或自签证书 HTTPS)访问的仓库,搭私有 registry 必配
storage-driver 存储驱动,固定 overlay2
exec-opts: native.cgroupdriver cgroup 驱动。与 K8s 配套时必须设为 systemd,否则 kubelet 报 cgroup driver 不一致
log-opts.max-size / max-file 单个日志文件大小与轮转个数,防止日志把磁盘写满(详见第三章)
data-root Docker 数据目录,用于迁移到大盘
live-restore 重启 dockerd 时不杀掉运行中的容器(与 Swarm 模式不兼容)
default-ulimits 容器默认的文件句柄上限,解决 too many open files
max-concurrent-downloads 并发下载层数,提高拉取速度
dns 容器默认 DNS。宿主机 /etc/resolv.conf 里只有 127.0.0.1 时必须显式配置

配置后必须重载并重启:

systemctl daemon-reload
systemctl restart docker
# 验证加速器与驱动是否生效
docker info | grep -A 6 "Registry Mirrors"
docker info | grep -E "Storage Driver|Cgroup Driver|Docker Root Dir"

注意:daemon.json 是严格的 JSON,不允许注释、不允许尾随逗号。写错会导致 dockerd 启动失败,用 journalctl -u docker --no-pager -n 50 查看原因。同一参数不能同时出现在 daemon.json 和 systemd 的 ExecStart 里(如 -H),否则报冲突。

1.7.10 把普通用户加入 docker 组

默认 /var/run/docker.sock 属主是 root:docker、权限 660,所以普通用户执行 docker ps 会报:

Got permission denied while trying to connect to the Docker daemon socket at
unix:///var/run/docker.sock

做法是把用户加入 docker 组:

groupadd docker              # 组一般安装时已自动创建
usermod -aG docker tom       # 把用户 tom 追加进 docker 组,务必用 -aG,漏掉 -a 会清掉其他附加组
id tom                       # 确认
# 重新登录或执行下面命令让新组生效
newgrp docker
docker ps                    # 验证

安全含义(必须点明):加入 docker 组等同于获得 root 权限。 因为通过 Docker API 可以做到:

# 任何 docker 组成员都能这样拿到宿主机 root 文件系统的完整读写权
docker run -it --rm -v /:/host alpine chroot /host sh
# 或直接以特权容器方式操作宿主机
docker run -it --rm --privileged --pid=host alpine nsenter -t 1 -m -u -n -i sh

因此:

  • 不要把 docker 组当成"降权方案",它没有任何降权效果;
  • 审计上要把 docker 组成员视同 sudoers;
  • 真正需要非 root 使用容器时,应使用 rootless 模式(见第五章安全一节)。

1.7.11 测试运行 hello-world

docker run hello-world

Docker CE 版输出:

Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
1b930d010525: Pull complete
Digest: sha256:92695bc579f31df7a63da6922075d0666e565ceccad16b59c3374d2cf4e8e50e
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://hub.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/get-started/

CentOS 自带 docker 1.13.1 版的输出(镜像名带 docker.io/ 前缀):

Unable to find image 'hello-world:latest' locally
Trying to pull repository docker.io/library/hello-world ...
latest: Pulling from docker.io/library/hello-world
b04784fba78d: Pull complete
Digest: sha256:f3b3b28a45160805bb16542c9531888519430e9e6d6ffc09d72261b0d26ff74f

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://cloud.docker.com/

For more examples and ideas, visit:
 https://docs.docker.com/engine/userguide/

执行 docker run 命令后,如果本地存在所需镜像,会直接使用该镜像;如果没有,则会自动从 Docker 仓库下载并运行。这条输出里的四步,正好把 1.4.1 的 Client / Daemon / Registry 三方关系走了一遍。

1.7.12 帮助的获取

在终端直接输入 docker 即可列出所有用法和命令,也可使用帮助:

docker --help
docker run --help          # 查看子命令帮助
man docker-images          # 旧版 docker 提供 man 手册
man docker-ps
man docker-search
man docker-pull
man docker-run
man docker-logs
man docker-attach
man dockerfile

二、镜像管理

2.1 镜像的命名规范

完整的镜像引用格式:

[registry[:port]/][namespace/]repository[:tag|@digest]

示例:
nginx                                     -> docker.io/library/nginx:latest
nginx:1.16                                -> docker.io/library/nginx:1.16
docker.io/ubuntu                          -> 旧版 docker 的显示形式
tx/ubuntu:v1                              -> 个人命名空间 tx 下的 ubuntu 镜像,tag v1
registry.cn-hangzhou.aliyuncs.com/ns/app:1.0
192.168.1.100:5000/myapp:v2               -> 私有仓库(带端口)
nginx@sha256:f950ede81138cd1d94d9d...     -> 按 digest 精确引用

各段说明:

组成 说明
registry 仓库地址。省略时默认 docker.io。含 . 或 : 或等于 localhost 的第一段才会被识别为 registry 地址,否则会被当成 namespace
namespace 命名空间 / 用户名。Docker Hub 官方镜像的隐含命名空间是 library,可以省略
repository 仓库名,即镜像名
tag 标签,通常表示版本。省略时默认 latest
digest 内容摘要 sha256:...,唯一且不可变

latest 标签的陷阱:

  1. latest 不代表"最新版",它只是一个普通的、默认的标签名。维护者完全可以让 latest 指向一个旧版本,或干脆不发布 latest。
  2. latest 是可变引用。今天拉到的 nginx:latest 和一个月后拉到的可能是完全不同的镜像,导致"同一份 Dockerfile 构建结果不一致",问题极难复现。
  3. 使用 latest 时,docker run 的默认拉取策略(本地有就不拉)会让你一直用着旧镜像而不自知。
  4. 生产环境规则:Dockerfile 的 FROM 与部署清单里的镜像引用一律写明确的 tag,关键场景用 digest 锁定。
# 反例
FROM ubuntu
# 正例
FROM ubuntu:22.04
# 最严格:锁定 digest,内容级不可变
FROM ubuntu@sha256:d26d529daa4d8567167181d9d569f2a85da3c5ecaf539cace2c6223355d69981

查看本地镜像的 digest:

docker images --digests
docker inspect --format='{{index .RepoDigests 0}}' nginx:1.16

同一仓库源可以有多个 TAG,代表不同版本。使用 REPOSITORY:TAG 来定义不同镜像。若不指定版本标签(例如只写 ubuntu),Docker 默认使用 ubuntu:latest。

2.2 镜像命令逐项详解

2.2.1 docker search 搜索镜像

从 Docker 仓库搜索镜像,一般下载 STARS 最多的镜像:

docker search ubuntu
NAME                                                   DESCRIPTION                                     STARS               OFFICIAL            AUTOMATED
ubuntu                                                 Ubuntu is a Debian-based Linux operating sys…   9512                [OK]
dorowu/ubuntu-desktop-lxde-vnc                         Docker image to provide HTML5 VNC interface …   299                                     [OK]
rastasheep/ubuntu-sshd                                 Dockerized SSH service, built on top of offi…   213                                     [OK]
consol/ubuntu-xfce-vnc                                 Ubuntu container with "headless" VNC session…   177                                     [OK]
ubuntu-upstart                                         Upstart is an event-based replacement for th…   97                  [OK]
ansible/ubuntu14.04-ansible                            Ubuntu 14.04 LTS with ansible                   96                                      [OK]

搜索 CentOS 相关镜像:

docker search centos7.3
docker search httpd
NAME                                    DESCRIPTION                                     STARS               OFFICIAL            AUTOMATED
httpd                                   The Apache HTTP Server Project                  2464                [OK]
hypriot/rpi-busybox-httpd               Raspberry Pi compatible Docker Image with a …   46
centos/httpd                                                                            23                                      [OK]
centos/httpd-24-centos7                 Platform for running Apache httpd 2.4 or bui…   22
tplatform/aws-linux-httpd24-php70       aws-linux-httpd24-php70                         3                                       [OK]

搜索结果字段说明:

字段 说明
NAME 镜像仓库源的名称
DESCRIPTION 镜像的描述
STARS 收藏、点赞数量
OFFICIAL 是否为 Docker 官方发布
AUTOMATED 是否自动构建

常用选项:

选项 说明
--limit N 限制返回结果条数(默认 25,最大 100)
-f stars=100 / --filter stars=100 只显示 stars 大于等于 100 的
-f is-official=true 只显示官方镜像
-f is-automated=true 只显示自动构建的镜像
--no-trunc 不截断 DESCRIPTION,完整显示
--format 自定义输出格式,如 --format "{{.Name}}\t{{.StarCount}}"
docker search --limit 5 --filter is-official=true nginx
docker search --no-trunc --filter stars=500 mysql
docker search --format "table {{.Name}}\t{{.StarCount}}\t{{.IsOfficial}}" redis

docker search 只能搜 Docker Hub,且搜不到 tag 列表。要查某个镜像有哪些 tag,只能上 Docker Hub 网页,或调用 registry API: curl -s https://registry.hub.docker.com/v2/repositories/library/nginx/tags/?page_size=50 | python -m json.tool

2.2.2 docker pull 拉取镜像

下载 Ubuntu 镜像到本地:

docker pull ubuntu
Using default tag: latest
latest: Pulling from library/ubuntu
f476d66f5408: Pull complete
8882c27f669e: Pull complete
d9af21273955: Pull complete
f5029279ec12: Pull complete
Digest: sha256:d26d529daa4d8567167181d9d569f2a85da3c5ecaf539cace2c6223355d69981
Status: Downloaded newer image for ubuntu:latest

旧版 docker(1.13.1)写法,需要带 docker.io/ 前缀(镜像约 200 多 MB,需要较好的网络):

docker pull docker.io/ubuntu

可在镜像名后加 :版本号 下载指定版本,不指定则默认为最新版 :latest:

docker pull nginx:1.16
1.16: Pulling from library/nginx
743f2d6c1f65: Pull complete
375084df5778: Pull complete
6976b5e9656a: Pull complete
Digest: sha256:f950ede81138cd1d94d9d46505678c1046c007d949f9006430752615deb5008a
Status: Downloaded newer image for nginx:1.16

拉取官方 httpd 镜像:

docker pull httpd
Using default tag: latest
latest: Pulling from library/httpd
743f2d6c1f65: Already exists
c92eb69846a6: Pull complete
2211b052800a: Pull complete
aed180197314: Pull complete
7c472a4980a7: Pull complete
Digest: sha256:680657b49788fc643cda8cbdcf0564474cb78c0d5ed53a3ea9cb3d56f9aacfc8
Status: Downloaded newer image for httpd:latest

注意输出里的 743f2d6c1f65: Already exists —— httpd 与前面拉的 nginx 共用了同一个基础层(都基于 debian),这一层不会重复下载,这就是镜像分层复用的直接体现。

按 digest 精确拉取(内容不可变,CI/CD 中推荐):

docker pull nginx@sha256:f950ede81138cd1d94d9d46505678c1046c007d949f9006430752615deb5008a

常用选项:

选项 说明
-a / --all-tags 拉取该仓库下所有 tag。慎用,可能几十 GB
-q / --quiet 静默模式,不显示逐层进度
--platform 指定平台架构,如 --platform linux/amd64、linux/arm64、linux/arm/v7。在 ARM 机器上拉 amd64 镜像或反之时必须显式指定
--disable-content-trust=false 启用内容信任校验(默认关闭校验)
docker pull --platform linux/amd64 nginx:1.25
docker pull -a alpine            # 慎用
docker pull -q redis:7

Status 行的两种取值:

  • Downloaded newer image for xxx:本地没有或有更新,实际下载了。
  • Image is up to date for xxx:本地已是最新,未下载。

2.2.3 docker images 列出本地镜像

docker images

Docker CE 输出:

REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
nginx               1.16                18ff9f3390d6        2 days ago          109MB
ubuntu              latest              d131e0fa2585        13 days ago         102MB
hello-world         latest              fce289e99eb9        4 months ago        1.84kB

仅装了 hello-world 时:

REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-world         latest              fce289e99eb9        4 months ago        1.84kB

旧版 docker 1.13.1 输出(REPOSITORY 带 docker.io/ 前缀,SIZE 带空格):

REPOSITORY                               TAG                 IMAGE ID            CREATED             SIZE
docker.io/hello-world                    latest              1815c82652c0        10 weeks ago        1.84 kB
docker.io/ubuntu                         latest              ae904afa83bb        4 months ago        288.3 MB

字段说明:

字段 说明
REPOSITORY 镜像的仓库源
TAG 镜像的标签
IMAGE ID 镜像 ID(默认显示 sha256 的前 12 位)
CREATED 镜像创建时间(是镜像被构建的时间,不是被拉到本地的时间)
SIZE 镜像大小(各层解压后的累计大小,不等于下载流量,也不等于多镜像共存时的实际磁盘占用,因为层会共享)

常用选项:

选项 说明
-a / --all 显示所有镜像,包括中间层镜像(intermediate images)
-q / --quiet 只显示镜像 ID,便于配合批量操作
--no-trunc 显示完整的镜像 ID(含 sha256: 前缀)
--digests 额外显示 DIGEST 列
-f / --filter 按条件过滤
--format Go 模板自定义输出

--filter 支持的条件:

docker images -f "dangling=true"                 # 悬空镜像(<none>:<none>)
docker images -f "dangling=false"                # 非悬空
docker images -f "before=nginx:1.16"             # 在指定镜像之前创建的
docker images -f "since=ubuntu:latest"           # 在指定镜像之后创建的
docker images -f "label=maintainer=tx"           # 按 LABEL 过滤
docker images -f "reference=nginx:*"             # 按名称通配过滤
docker images nginx                              # 只看某个仓库的所有 tag

--format 实用写法:

# 精简三列表格
docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"
# 名称:标签 一行一个,便于脚本处理
docker images --format "{{.Repository}}:{{.Tag}}"
# 按大小排查体积大户
docker images --format "{{.Size}}\t{{.Repository}}:{{.Tag}}" | sort -rh | head -20
# 输出 JSON
docker images --format "{{json .}}"

可用的模板字段:.ID、.Repository、.Tag、.Digest、.CreatedSince、.CreatedAt、.Size。

批量操作组合:

docker rmi $(docker images -q -f "dangling=true")   # 删除所有悬空镜像
docker rmi $(docker images -q)                      # 删除所有镜像(慎用)

2.2.4 docker inspect 查看镜像详情

docker inspect nginx:1.16

返回一个 JSON 数组。镜像的关键字段:

字段 含义
Id 完整镜像 ID
RepoTags 该镜像拥有的所有标签。同一镜像可以有多个 tag
RepoDigests 内容摘要,用于精确引用
Created 构建时间
Architecture / Os 架构与操作系统,排查"exec format error"时看这里
Size / VirtualSize 体积
Config.Env 镜像内置的环境变量(Dockerfile 的 ENV)
Config.Cmd 默认启动命令(CMD)
Config.Entrypoint 入口程序(ENTRYPOINT)
Config.WorkingDir 工作目录(WORKDIR)
Config.ExposedPorts 声明暴露的端口(EXPOSE)
Config.Volumes 声明的匿名卷挂载点(VOLUME)
Config.User 运行用户(USER)
Config.Labels 标签元数据(LABEL)
Config.Healthcheck 健康检查配置
RootFS.Layers 各层的 sha256 列表,用于比对两个镜像共享了哪些层
GraphDriver.Data 存储驱动层的实际磁盘路径

用 --format 精确提取,避免翻几百行 JSON:

docker inspect --format '{{.Config.Cmd}}' nginx:1.16
docker inspect --format '{{.Config.Entrypoint}}' nginx:1.16
docker inspect --format '{{.Config.WorkingDir}}' nginx:1.16
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' nginx:1.16
docker inspect --format '{{.Architecture}}/{{.Os}}' nginx:1.16
docker inspect --format '{{len .RootFS.Layers}}' nginx:1.16      # 层数
docker inspect --format '{{json .Config.ExposedPorts}}' nginx:1.16
# 只看某一段,配合 jq 更方便
docker inspect nginx:1.16 | jq '.[0].Config'

docker inspect 对镜像、容器、网络、数据卷都通用,也可以显式指定类型:

docker image inspect nginx:1.16
docker container inspect nginx
docker network inspect bridge
docker volume inspect myvol

2.2.5 docker history 查看分层与每层大小

docker history nginx:1.16
IMAGE          CREATED       CREATED BY                                      SIZE      COMMENT
18ff9f3390d6   2 days ago    /bin/sh -c #(nop)  CMD ["nginx" "-g" "daemon…   0B
<missing>      2 days ago    /bin/sh -c #(nop)  STOPSIGNAL SIGTERM           0B
<missing>      2 days ago    /bin/sh -c #(nop)  EXPOSE 80                    0B
<missing>      2 days ago    /bin/sh -c ln -sf /dev/stdout /var/log/nginx…   22B
<missing>      2 days ago    /bin/sh -c set -x  && apt-get update  && apt…   54MB
<missing>      2 days ago    /bin/sh -c #(nop)  ENV NJS_VERSION=0.3.2        0B
<missing>      2 days ago    /bin/sh -c #(nop)  ENV NGINX_VERSION=1.16.0…    0B
<missing>      2 days ago    /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      2 days ago    /bin/sh -c #(nop) ADD file:e9c5b4a...in /       69.2MB

要点:

  • 从上到下是从最新层到最底层,正好是 Dockerfile 指令的倒序。
  • <missing> 不是错误。表示该中间层是从远端拉取的、本地没有独立的镜像 ID。
  • #(nop) 表示这条指令只改元数据,不产生文件系统内容,SIZE 为 0B。
  • 哪一层大,优化就从哪一层下手。上例中 apt-get update && apt-get install 那一层 54MB 是主要优化对象。

常用选项:

选项 说明
--no-trunc 完整显示 CREATED BY(构建命令),排查镜像是怎么来的必用
-H=false / --human=false SIZE 显示为原始字节数
-q / --quiet 只显示镜像 ID
--format 自定义输出
docker history --no-trunc nginx:1.16
docker history --format "table {{.Size}}\t{{.CreatedBy}}" --no-trunc myapp:v1

安全提示:docker history --no-trunc 能看到构建时的完整命令。如果你在 Dockerfile 的 RUN 里写了密码或 token,任何拿到镜像的人都能从这里读出来,即使你在后一层把文件删了也没用。

2.2.6 docker tag 打标签

docker tag 源镜像[:TAG] 目标镜像[:TAG]

示例:

docker tag nginx:1.16 mynginx:v1
docker tag nginx:1.16 192.168.1.100:5000/nginx:1.16      # 推送私有仓库前的必要步骤
docker tag 18ff9f3390d6 myrepo/nginx:stable              # 用镜像 ID 打标签

本质:docker tag 只是给同一个镜像 ID 增加一个"别名(引用)",不复制任何数据,不产生新层,秒级完成,不占额外磁盘。

验证一下——两个名字的 IMAGE ID 完全相同:

docker images | grep -E "nginx|mynginx"
mynginx             v1                  18ff9f3390d6        2 days ago          109MB
nginx               1.16                18ff9f3390d6        2 days ago          109MB

由此推出两个重要结论:

  1. 推送到私有仓库前,必须先 docker tag 成 仓库地址/命名空间/镜像名:tag 的形式,因为推送目标是由镜像名本身决定的,docker push 没有"指定目标仓库"的参数。
  2. docker rmi mynginx:v1 只是删掉这个别名(输出 Untagged:),镜像数据仍在,因为还有 nginx:1.16 引用它。只有当最后一个引用被删除时,镜像层才真正被删除(输出 Deleted:)。

2.2.7 docker rmi 删除镜像

docker rmi <镜像名或ID>
docker rmi nginx:1.16
docker rmi 18ff9f3390d6

删除镜像(必须先删掉基于该镜像的所有容器,才能删除镜像)。

常用选项:

选项 说明
-f / --force 强制删除。会先解除所有 tag 再删镜像;但仍然删不掉被运行中容器使用的镜像
--no-prune 不删除该镜像的无 tag 父层

删不掉的常见原因与处置:

报错 原因 处置
image is being used by running container xxx 有运行中的容器在用 docker stop xxx 后再删,或 docker rm -f xxx
image is being used by stopped container xxx 有已停止的容器在用 docker rm xxx 删掉容器,或 docker rmi -f 强制
image is referenced in multiple repositories 该镜像 ID 有多个 tag 用 仓库名:tag 而不是镜像 ID 来删;或 -f 强制解除所有 tag
image has dependent child images 有其他镜像以它为基础层(如你 build 出来的镜像) 先删子镜像,或 -f
No such image 名字或 ID 写错,或该 tag 已被 untag docker images -a 核对

批量操作:

# 找出哪些容器在用某镜像
docker ps -a --filter ancestor=nginx:1.16
# 先删容器再删镜像
docker rm -f $(docker ps -aq --filter ancestor=nginx:1.16)
docker rmi nginx:1.16
# 删除所有镜像
docker rmi -f $(docker images -q)
# 删除所有 <none> 悬空镜像
docker rmi $(docker images -q -f dangling=true)

输出的两种前缀含义:

Untagged: nginx:1.16                       <- 仅解除了引用
Untagged: nginx@sha256:f950ede81138...     <- 解除了 digest 引用
Deleted: sha256:18ff9f3390d6...            <- 真正删除了镜像层,磁盘空间被释放

2.2.8 docker save / docker load 镜像导出与导入

用于离线环境分发镜像(内网机器无法访问仓库时的标准做法)。

# 导出单个镜像
docker save -o nginx-1.16.tar nginx:1.16
docker save nginx:1.16 > nginx-1.16.tar          # 等价的重定向写法
# 一次导出多个镜像到一个包
docker save -o images.tar nginx:1.16 ubuntu:latest redis:7
# 导出并压缩(体积可显著减小)
docker save nginx:1.16 | gzip > nginx-1.16.tar.gz

# 导入
docker load -i nginx-1.16.tar
docker load < nginx-1.16.tar
gunzip -c nginx-1.16.tar.gz | docker load
docker load -q -i nginx-1.16.tar                 # 静默模式

docker load 的输出:

Loaded image: nginx:1.16

与 export / import 的区别(重点对照表):

对比项 docker save / docker load docker export / docker import
操作对象 镜像(image) 容器(container)
命令形式 docker save -o x.tar 镜像名:tag docker export -o x.tar 容器名/ID
导入命令 docker load -i x.tar docker import x.tar 新镜像名:tag
是否保留分层 保留全部层,导入后 docker history 完整 不保留,所有层被压平成一层
是否保留元数据 保留 CMD、ENTRYPOINT、ENV、EXPOSE、VOLUME、LABEL 等全部配置 全部丢失。导入后启动必须手工指定命令
是否保留多 tag 保留(一个包可含多个镜像与多个 tag) 不涉及,导入时必须自己指定镜像名
导入时能否改名 不能(名字写在包里) 必须指定名字,可任意改
是否包含容器数据卷内容 不涉及 不包含卷里的数据,只有容器可写层与镜像内容
包体积 较大(各层独立存放) 通常较小(层被压平、去掉了历史)
典型用途 离线分发镜像、镜像备份、CI 产物传递 把一个改动过的运行中容器做成"快照镜像"、给镜像做瘦身压层

export / import 用法示例:

docker export -o ubuntu-snapshot.tar 088dc45a5319
cat ubuntu-snapshot.tar | docker import - myubuntu:snapshot
# 导入时补充元数据
docker import -c 'CMD ["/bin/bash"]' -m "from container" ubuntu-snapshot.tar myubuntu:snapshot
# 也支持从 URL 导入
docker import http://example.com/rootfs.tar.gz myimg:v1

踩坑:用 import 导入的镜像直接 docker run 会报 No command specified,因为 CMD/ENTRYPOINT 都丢了。必须在 run 时显式给命令:docker run -it myubuntu:snapshot /bin/bash。

2.2.9 docker image prune 清理悬空镜像

悬空镜像(dangling image)就是 docker images 里 REPOSITORY 与 TAG 都显示 <none> 的镜像。它们的主要来源是:重复构建同名同 tag 的镜像,新镜像抢走了 tag,旧镜像失去引用。

docker images -f dangling=true            # 先看看有哪些
docker image prune                        # 删除所有悬空镜像(会交互确认)
docker image prune -f                     # 不确认直接删
docker image prune -a                     # 删除所有"没有被任何容器使用"的镜像,杀伤力大得多
docker image prune -a --filter "until=168h"   # 只删 7 天前的
docker image prune --filter "label=stage=builder"  # 按标签删(多阶段构建的中间镜像)

-a 要格外小心:它会删掉所有当前没有容器在用的镜像,包括你辛苦拉了半天的基础镜像。生产上建议加 --filter until= 限定时间。

2.2.10 docker system df 与 docker system prune 空间清理

查看 Docker 各类对象的磁盘占用:

docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          15        4         3.921GB   2.874GB (73%)
Containers      8         2         184.2MB   142.1MB (77%)
Local Volumes   6         2         1.203GB   865.4MB (71%)
Build Cache     42        0         1.856GB   1.856GB

字段含义:

字段 含义
TOTAL 对象总数
ACTIVE 正在被使用的数量(镜像有容器在用、容器在运行、卷被挂载)
SIZE 实际占用磁盘
RECLAIMABLE 可回收空间,也就是清理后能腾出来多少

看详细明细,定位到具体哪个镜像/容器/卷占地方:

docker system df -v

统一清理:

docker system prune            # 删除:停止的容器 + 未被使用的网络 + 悬空镜像 + 构建缓存
docker system prune -f         # 跳过确认
docker system prune -a         # 追加删除所有未被容器使用的镜像
docker system prune -a --volumes   # 再追加删除未被使用的数据卷 —— 极度危险,可能删掉数据库数据
docker system prune --filter "until=24h"

分类清理命令一览:

命令 清理对象
docker container prune 所有已停止的容器
docker image prune 悬空镜像(加 -a 为所有未使用镜像)
docker volume prune 未被任何容器使用的数据卷(有数据丢失风险)
docker network prune 未被使用的自定义网络
docker builder prune 构建缓存(BuildKit 缓存常常是最大的一块)
docker system prune 前面几项的组合(默认不含 volume)

生产建议:写成定时任务,只做安全的那几项。 0 4 * * * /usr/bin/docker system prune -f --filter "until=168h" >> /var/log/docker-prune.log 2>&1 绝对不要在生产 crontab 里加 --volumes。

2.3 Dockerfile 完整详解

创建镜像有两种方式:

  1. 从已经创建的容器中更新镜像,并提交该镜像(docker commit)。
  2. 使用 Dockerfile 指令来构建新镜像。

使用 Dockerfile 可以自定义运行应用的容器镜像,比手动进入容器安装再 commit 更可重复、更适合自动化。参考 man dockerfile。

2.3.1 构建上下文与 .dockerignore

docker build -t myapp:v1 .
                          ^
                          这个点是【构建上下文】路径,不是"当前目录的 Dockerfile"

构建上下文(build context)是 docker build 最容易被误解的概念:

  1. 执行 build 时,Docker CLI 会把上下文目录整个打包发送给 dockerd(因为真正干活的是 daemon,可能在远端主机上)。
  2. 所以上下文目录越大,构建启动越慢。在 / 或家目录下执行 docker build . 会导致 CLI 试图打包整个磁盘,出现长时间卡在 Sending build context to Docker daemon。
  3. COPY 与 ADD 的源路径只能是上下文内的相对路径,不能写 ../xxx 或绝对路径 /etc/xxx,否则报 forbidden path outside the build context。

用 .dockerignore 排除不需要的文件(语法类似 .gitignore,放在上下文根目录):

# .dockerignore
.git
.gitignore
.svn
node_modules
*.log
*.tmp
*.md
!README.md
Dockerfile
docker-compose.yml
.env
.idea/
.vscode/
target/
build/
dist/
**/*.pyc
__pycache__/
tests/
docs/
*.key
*.pem
id_rsa

.dockerignore 的三重收益:

  • 构建上下文变小,发送更快;
  • 避免把 .git、node_modules 这类大目录 COPY 进镜像,镜像更小;
  • 避免把 .env、私钥这类敏感文件误打包进镜像层。

2.3.2 docker build 命令与选项

docker build -f dockerfile -t my_httpd:0.1 .

注意命令最后有一个点 .,表示 Dockerfile 所在路径(构建上下文)。构建会分步骤执行,如果某步出错,修改 Dockerfile 后重新执行该命令,已经成功的步骤会走缓存。

常用选项:

选项 说明
-t / --tag 指定镜像名与 tag。可重复使用,一次打多个标签:-t app:v1 -t app:latest
-f / --file 指定 Dockerfile 路径。默认是上下文根目录下名为 Dockerfile 的文件。文件名可以任意,如 Dockerfile.prod
--no-cache 完全不使用缓存,从第一条指令开始重建
--build-arg 传入 ARG 定义的构建参数:--build-arg VERSION=1.2.3,可重复
--pull 每次都尝试拉取更新的基础镜像(防止 FROM 的本地旧镜像被一直复用)
--rm 构建成功后删除中间容器(默认 true)
--force-rm 无论成败都删除中间容器
--target 多阶段构建中只构建到指定阶段,如 --target builder
--platform 指定目标平台:--platform linux/arm64
--network 构建过程中 RUN 使用的网络模式,如 --network host
--label 给镜像添加标签
--squash 把所有层压成一层(实验特性)
--progress=plain BuildKit 下输出完整日志(排错必用,默认 auto 会折叠输出)
- 从标准输入读 Dockerfile:docker build -t app:v1 - < Dockerfile(此时没有上下文,不能 COPY)
--secret BuildKit 特性,安全地传入密钥且不留在镜像层里
--cache-from 用指定镜像作为缓存源,CI 中跨机器复用缓存

示例:

docker build -t myapp:v1 -t myapp:latest .
docker build -f Dockerfile.prod -t myapp:prod .
docker build --no-cache -t myapp:v1 .
docker build --build-arg APP_VERSION=2.1.0 --build-arg HTTP_PROXY=http://10.0.0.1:8080 -t myapp:v1 .
docker build --target builder -t myapp:builder .
docker build --pull --progress=plain -t myapp:v1 . 2>&1 | tee build.log
docker build -t myapp:v1 https://github.com/user/repo.git#main    # 直接从 Git 构建

2.3.3 构建缓存的命中规则

理解缓存规则,是把构建从 10 分钟降到 10 秒的关键。

规则:

  1. Docker 逐条执行指令。对每条指令,先在缓存中找"父镜像相同 + 本条指令字符串完全相同"的已有层,命中则直接复用,输出 ---> Using cache。
  2. 一旦某一条指令缓存未命中,它之后的所有指令都不再使用缓存(缓存链断裂)。
  3. 对 RUN 指令,只比对命令字符串本身,不会去检查命令执行的结果是否变了。所以 RUN apt-get update 命中缓存后,即使上游软件源更新了,也不会重新执行 —— 这就是著名的 apt 缓存陈旧问题。
  4. 对 COPY 与 ADD,除了比对指令字符串,还会比对被复制文件的内容校验和(以及元数据)。文件内容一变,缓存立刻失效。
  5. ARG 的值变化会让引用了该 ARG 的后续指令缓存失效。

由此得出的层顺序黄金法则:把变动最少的放前面,变动最频繁的放最后。

# 反例:代码一改,依赖就要重装一遍,每次构建 5 分钟
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm ci --production
CMD ["node", "server.js"]
# 正例:只有 package.json 变化时才重装依赖,改代码构建只需几秒
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --production
COPY . .
CMD ["node", "server.js"]

避免 apt 缓存陈旧的写法 —— update 与 install 必须写在同一条 RUN 里:

# 反例:install 那层命中缓存时,用的还是几个月前的包索引,会报 404 Not Found
RUN apt-get update
RUN apt-get install -y nginx curl

# 正例
RUN apt-get update && apt-get install -y --no-install-recommends \
        nginx \
        curl \
    && rm -rf /var/lib/apt/lists/*

查看某次构建的缓存命中情况(BuildKit):

DOCKER_BUILDKIT=1 docker build --progress=plain -t myapp:v1 .
docker builder prune          # 缓存过大时清理

2.4 Dockerfile 指令逐项详解

2.4.1 FROM —— 指定基础镜像

FROM 用于指定构建新镜像所使用的基础镜像,必须是 Dockerfile 中的首条命令(ARG 是唯一可以出现在 FROM 之前的指令)。基础镜像可以来自远程官方仓库,也可以位于本地仓库。tag 或 digest 可选(类似版本号),不指定时使用 latest。

FROM <image>
FROM <image>:<tag>
FROM <image>:<digest>
FROM <image>[:<tag>] AS <阶段名>

示例:

FROM ubuntu:22.04
FROM docker.io/ubuntu
FROM nginx:1.25-alpine
FROM ubuntu@sha256:d26d529daa4d8567167181d9d569f2a85da3c5ecaf539cace2c6223355d69981
FROM golang:1.21 AS builder
FROM 192.168.1.100:5000/base/centos7:v2

FROM scratch:空白基础镜像

scratch 是一个特殊的保留名,代表"什么都没有的零字节镜像"。它没有任何文件,没有 shell,没有 libc。适合完全静态编译的程序(Go、Rust):

FROM scratch
COPY hello /hello
CMD ["/hello"]

用 scratch 的注意点:

  • 不能用 RUN(没有 shell);
  • CMD/ENTRYPOINT 必须用 exec 形式(["..."]),不能用 shell 形式;
  • 需要 HTTPS 时要自己 COPY 一份 CA 证书(/etc/ssl/certs/ca-certificates.crt);
  • 需要时区要自己 COPY /usr/share/zoneinfo;
  • 容器里没有 sh,所以 docker exec -it xxx sh 无法进入,排错只能靠日志。

一个 Dockerfile 中可以有多个 FROM,这就是多阶段构建(见 2.5.2)。

2.4.2 MAINTAINER 与 LABEL —— 元数据

MAINTAINER 指定作者,已废弃(deprecated),应改用 LABEL:

# 旧写法,已废弃
MAINTAINER tx <tx@example.com>

# 推荐写法
LABEL maintainer="tx <tx@example.com>"

LABEL 以键值对形式给镜像添加任意元数据,可以写多个键值对(写在一条里只占一层):

LABEL maintainer="ops@example.com" \
      version="1.2.3" \
      description="订单服务" \
      org.opencontainers.image.source="https://github.com/org/repo" \
      org.opencontainers.image.revision="a1b2c3d" \
      stage="production"

查看与过滤:

docker inspect --format '{{json .Config.Labels}}' myapp:v1
docker images --filter "label=stage=production"

2.4.3 RUN —— 构建时执行命令

RUN 用于在镜像构建过程中执行命令,有两种形式:

  • shell 形式(Linux 下默认使用 /bin/sh -c):
RUN <command>
  • exec 形式(类似函数调用):
RUN ["executable", "param1", "param2"]

两种写法对比:

RUN /bin/bash -c source $HOME/.bashrc; echo $HOME
RUN ["/bin/bash", "-c", "echo hello"]

两种形式的差异:

形式 是否经过 shell 变量替换 / 管道 / 重定向 / && 说明
shell 形式 RUN cmd 是,走 /bin/sh -c 支持 常用形式。要求镜像内有 /bin/sh(scratch 不行)
exec 形式 RUN ["cmd","a"] 否,直接 exec 不支持,$HOME 不会被展开,| > 只是普通字符串 需要精确控制参数、或没有 shell 时使用。注意必须用双引号(这是 JSON 数组)

注意:多行命令不要写多个 RUN。Dockerfile 中每一个指令都会建立一层镜像,多少个 RUN 就构建多少层,会造成镜像臃肿、增加构建部署时间,还容易出错。RUN 书写时换行符为 \。

合并 RUN 减少层数的标准写法:

# 反例:4 层,且删除操作无效(前一层的文件已经固化在镜像里,rm 只是加了 whiteout 标记,体积不减)
RUN apt-get update
RUN apt-get install -y nginx curl vim
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*

# 正例:1 层,清理在同一层内完成,体积真正减小
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        nginx \
        curl \
        vim \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

CentOS / RHEL 系的等价写法:

RUN yum install -y --setopt=tsflags=nodocs \
        httpd \
        curl \
    && yum clean all \
    && rm -rf /var/cache/yum

Alpine 的写法(--no-cache 免去手动清理):

RUN apk add --no-cache nginx curl
# 编译型依赖用虚拟包,装完整包删除
RUN apk add --no-cache --virtual .build-deps gcc musl-dev make \
    && make install \
    && apk del .build-deps

合并的另一层意义:让"下载—编译—删除源码"发生在同一层,否则源码 tar 包会永久留在中间层里:

RUN curl -fsSL https://example.com/app-1.0.tar.gz -o /tmp/app.tar.gz \
    && tar -xzf /tmp/app.tar.gz -C /usr/local/src \
    && cd /usr/local/src/app-1.0 && ./configure && make && make install \
    && rm -rf /tmp/app.tar.gz /usr/local/src/app-1.0

但也不要极端到把所有东西塞进一条 RUN —— 那样任何微小改动都会导致整条重新执行,失去缓存价值。平衡点是:按"逻辑阶段"分组(装系统依赖一条、装语言依赖一条、编译一条)。

2.4.4 CMD —— 指定容器默认启动命令

CMD 用于指定容器启动时要执行的命令,有三种格式:

CMD ["executable","param1","param2"]     # exec 形式,推荐
CMD ["param1","param2"]                  # 仅作为 ENTRYPOINT 的默认参数
CMD command param1 param2                # shell 形式

CMD 与 RUN 的区别:CMD 在容器启动时执行,RUN 在镜像构建时执行。

要点:

  1. 一个 Dockerfile 里可以写多条 CMD,但只有最后一条生效。
  2. docker run 镜像 <命令> 传入的命令会完全覆盖 CMD。
  3. shell 形式会被包装成 /bin/sh -c "命令",导致实际的 PID 1 是 sh 而不是你的程序,信号无法传递到应用(docker stop 会等 10 秒后被 SIGKILL)。所以生产一律用 exec 形式。
# 反例(shell 形式):PID 1 是 sh,nginx 收不到 SIGTERM
CMD nginx -g "daemon off;"
# 正例(exec 形式):PID 1 是 nginx
CMD ["nginx", "-g", "daemon off;"]

源讲义中的 httpd 示例:

CMD ["/usr/sbin/apache2","-D","FOREGROUND"]

注意:容器内的主进程必须在前台运行。 若写成 CMD ["/etc/init.d/apache2","start"],脚本把 apache 放到后台后自己退出,PID 1 结束,容器立刻退出。这就是"容器启动后马上 Exited (0)"最常见的原因。各服务的前台运行参数:

服务 前台运行写法
nginx nginx -g "daemon off;"
apache2 / httpd apache2 -D FOREGROUND / httpd -D FOREGROUND
php-fpm php-fpm -F
mysqld mysqld(默认前台)
redis redis-server --daemonize no
sshd /usr/sbin/sshd -D
tomcat catalina.sh run(不是 startup.sh)

2.4.5 ENTRYPOINT —— 配置容器入口程序

ENTRYPOINT 用于给容器配置一个可执行程序。每次使用镜像创建容器时,ENTRYPOINT 指定的程序都会被设置为默认程序。有两种形式:

ENTRYPOINT ["executable", "param1", "param2"]    # exec 形式,推荐
ENTRYPOINT command param1 param2                 # shell 形式

与 CMD 的异同:

  • 相同点:都只能写一条,写多条时只有最后一条生效;都是容器启动时才运行。
  • 不同点:通过 docker run 传入的命令不会覆盖 ENTRYPOINT,而会覆盖 CMD。
  • 如果同时写了 ENTRYPOINT 和 CMD,且 CMD 不是一个完整的可执行命令,那么 CMD 的内容会作为 ENTRYPOINT 的参数。
  • 如果同时写了 ENTRYPOINT 和 CMD,且 CMD 是一条完整指令,那么两者会互相覆盖,谁在最后谁生效。

要覆盖 ENTRYPOINT,必须显式使用 docker run --entrypoint。

2.4.6 CMD 与 ENTRYPOINT 四种组合行为对照表(重点)

设想 Dockerfile 中的不同写法,容器实际执行的命令如下:

情况 Dockerfile 写法 docker run 镜像(不带命令) docker run 镜像 ls -l(带命令)
① 都没有 无 CMD 无 ENTRYPOINT 报错 No command specified(若基础镜像有继承则用继承的) ls -l
② 只有 CMD CMD ["nginx","-g","daemon off;"] nginx -g "daemon off;" ls -l(CMD 被完全覆盖)
③ 只有 ENTRYPOINT ENTRYPOINT ["nginx","-g","daemon off;"] nginx -g "daemon off;" nginx -g "daemon off;" ls -l(命令被当成参数追加,通常会导致启动失败)
④ 两者都有(CMD 为参数) ENTRYPOINT ["nginx"]
CMD ["-g","daemon off;"]
nginx -g "daemon off;" nginx ls -l(CMD 被替换为 ls -l 并作为 ENTRYPOINT 的参数)

更细的组合矩阵(exec 与 shell 形式混用):

ENTRYPOINT 写法 CMD 写法 实际执行
无 CMD ["echo","hi"] echo hi
无 CMD echo hi /bin/sh -c "echo hi"
ENTRYPOINT ["echo"] 无 echo
ENTRYPOINT ["echo"] CMD ["hi"] echo hi
ENTRYPOINT ["echo"] CMD hi echo /bin/sh -c hi (混用陷阱,几乎一定不是你想要的)
ENTRYPOINT echo CMD ["hi"] /bin/sh -c "echo" (shell 形式的 ENTRYPOINT 会让 CMD 完全失效)
ENTRYPOINT echo CMD hi /bin/sh -c "echo" (CMD 同样失效)

记忆口诀:

  • ENTRYPOINT = "这个镜像是干什么的"(不可变的主体),CMD = "默认怎么干"(可被覆盖的参数)。
  • 想让镜像"像一个命令行工具"用,就用 ENTRYPOINT + CMD 提供默认参数;
  • 想让镜像"像一台可以随便进去执行东西的机器"用,就只用 CMD;
  • 两者都写 exec 形式,绝不混用 shell 形式。

实用示例 —— 把镜像做成命令行工具:

FROM alpine:3.18
RUN apk add --no-cache curl
ENTRYPOINT ["curl"]
CMD ["--help"]
docker run --rm mycurl                          # 等价于 curl --help
docker run --rm mycurl -I https://example.com   # 等价于 curl -I https://example.com
docker run --rm --entrypoint sh -it mycurl      # 需要进容器排错时覆盖 entrypoint

配合入口脚本做初始化(生产最常见的模式):

COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
#!/bin/sh
set -e
# 这里做配置渲染、等待依赖服务、初始化数据库等前置工作
envsubst < /etc/nginx/conf.d/app.conf.template > /etc/nginx/conf.d/app.conf
# 关键:用 exec 把 PID 1 交给真正的主进程,保证信号能被正确接收
exec "$@"

exec "$@" 这一行是重点:不用 exec 的话,脚本自己占着 PID 1,docker stop 的 SIGTERM 只会发给脚本,应用收不到,只能被强杀。

2.4.7 COPY 与 ADD —— 复制文件

ADD 是复制命令,把文件复制到镜像中。如果把虚拟机与容器想象成两台 Linux 服务器,它类似于 scp,只是 scp 需要用户名密码验证,而 ADD 不需要。

ADD <src>... <dest>
ADD ["<src>",... "<dest>"]
  • <src> 可以是本地文件、本地压缩文件,也可以是 URL(写成 URL 时 ADD 类似于 wget);
  • <dest> 可以是容器内绝对路径,也可以是相对于工作目录的相对路径。

COPY:

COPY <src>... <dest>
COPY ["<src>",... "<dest>"]

与 ADD 的区别:COPY 的 <src> 只能是本地文件,其他用法一致。

完整区别对照表:

能力 COPY ADD
复制本地文件 / 目录 支持 支持
源支持通配符 支持 支持
自动解压本地 tar 归档(.tar、.tar.gz、.tgz、.tar.bz2、.tar.xz) 不支持,原样复制 支持,自动解包到目标目录
从 URL 下载 不支持 支持(但下载的文件权限固定 600,且不会解压,也无法用 - 续传,还会白白增大镜像层)
--chown=user:group 支持 支持
--chmod= 支持(BuildKit) 支持(BuildKit)
--from=<阶段> 跨阶段复制 支持 支持
语义清晰度 高,行为可预测 低,有隐式解压/下载行为

何时用哪个(官方建议):

  • 默认一律用 COPY。它的行为透明可预测。
  • 只有一种情况用 ADD:需要自动解压本地 tar 包时。
  • 绝不要用 ADD <URL>。应改用 RUN curl/wget && 解压 && 删除,这样下载物不会残留在镜像层里,且能校验 checksum:
# 反例
ADD https://example.com/app-1.0.tar.gz /tmp/
RUN tar -xzf /tmp/app-1.0.tar.gz -C /opt && rm /tmp/app-1.0.tar.gz   # 删了也白删,前一层已固化

# 正例
RUN curl -fsSL https://example.com/app-1.0.tar.gz -o /tmp/app.tar.gz \
    && echo "abc123...  /tmp/app.tar.gz" | sha256sum -c - \
    && tar -xzf /tmp/app.tar.gz -C /opt \
    && rm -f /tmp/app.tar.gz

各种用法示例:

COPY index.html /var/www/html/
COPY src/ /app/src/                       # 注意:复制的是目录【内容】,不是目录本身
COPY *.conf /etc/nginx/conf.d/
COPY --chown=www-data:www-data app/ /var/www/app/
COPY --chmod=755 entrypoint.sh /usr/local/bin/
COPY --from=builder /build/bin/app /usr/local/bin/app     # 多阶段构建
ADD app-1.0.tar.gz /opt/                  # 自动解压到 /opt/

路径规则(易错点):

  • <dest> 以 / 结尾表示目录,不存在时会自动创建;不以 / 结尾且源是单个文件时,表示目标文件名。
  • COPY src/ /app/ 复制的是 src 目录下的内容,不会在 /app 下建 src 子目录。要保留目录名需写 COPY src /app/src。
  • <src> 必须在构建上下文内,不能用 ../。

2.4.8 WORKDIR —— 设置工作目录

设置工作目录指令,可以多次切换(相当于 cd 命令),对 RUN、CMD、ENTRYPOINT、COPY、ADD 生效。

WORKDIR /path/to/workdir

示例:

WORKDIR /app
COPY . .                       # 相当于 COPY . /app/
RUN npm install                # 在 /app 下执行
CMD ["node", "server.js"]      # 在 /app 下执行

# 可以多次切换,支持相对路径(相对于上一个 WORKDIR)
WORKDIR /usr
WORKDIR src
WORKDIR app                    # 最终为 /usr/src/app
# 支持使用 ENV 变量
ENV APP_HOME=/opt/app
WORKDIR $APP_HOME

要点:

  • WORKDIR 指定的目录不存在时会自动创建,不需要先 RUN mkdir -p。
  • 不要用 RUN cd /app 代替 WORKDIR。每条 RUN 都是独立的 shell 进程,cd 的效果不会延续到下一条 RUN:
# 无效
RUN cd /app
RUN pwd            # 输出 / ,不是 /app

# 正确
WORKDIR /app
RUN pwd            # 输出 /app
# 或在同一条 RUN 内 cd
RUN cd /app && make
  • 运行时可用 docker run -w /other 覆盖。

2.4.9 ENV 与 ARG —— 环境变量与构建参数

ENV 设置环境变量,有两种语法:

ENV <key> <value>
ENV <key>=<value> ...

第一种一次设置一个(该语法已不推荐),第二种一次设置多个。设置后,后续的 RUN 都可以使用;容器启动后可通过 docker inspect 查看,也可在 docker run --env key=value 时设置或修改。

例如安装了 JAVA 程序需要设置 JAVA_HOME:

ENV JAVA_HOME /usr/local/jdkxxxx/

推荐写法(一条 ENV 设置多个变量,只占一层):

ENV JAVA_HOME=/usr/local/jdk1.8.0_291 \
    CATALINA_HOME=/usr/local/tomcat \
    PATH=$JAVA_HOME/bin:$PATH \
    LANG=C.UTF-8 \
    TZ=Asia/Shanghai

源讲义的 Apache 环境变量示例:

ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data
ENV APACHE_LOG_DIR /var/log/apache2
ENV APACHE_PID_FILE /var/run/apache2.pid
ENV APACHE_RUN_DIR /var/run/apache2
ENV APACHE_LOCK_DIR /var/lock/apache2

ARG 定义构建时参数:

ARG <name>[=<默认值>]
ARG APP_VERSION=1.0.0
ARG BUILD_DATE
ARG TARGETARCH                 # buildx 自动注入的预定义参数
RUN echo "building version $APP_VERSION for $TARGETARCH"
docker build --build-arg APP_VERSION=2.1.0 --build-arg BUILD_DATE=$(date -u +%Y-%m-%d) -t app:2.1.0 .

ENV 与 ARG 的区别与作用域(重点):

对比项 ENV ARG
生效阶段 构建时 + 运行时 仅构建时
容器内能否读到 能,docker exec xxx env 可见 不能,容器运行时不存在该变量
是否留在镜像元数据里 是,docker inspect 的 Config.Env 可见 否(但会留在 docker history 里)
构建时能否从命令行传值 不能(--build-arg 只对 ARG 生效) 能,--build-arg KEY=VAL
运行时能否覆盖 能,docker run -e KEY=VAL 不涉及
能否在 FROM 之前使用 不能 能(唯一可以出现在第一条 FROM 之前的指令)
优先级 同名时 ENV 覆盖 ARG 被 ENV 覆盖

作用域细节:

ARG BASE_TAG=22.04              # FROM 之前的 ARG:只对 FROM 行可见
FROM ubuntu:${BASE_TAG}
ARG BASE_TAG                    # 想在 FROM 之后继续用,必须【重新声明】一次
RUN echo $BASE_TAG

FROM alpine AS stage2
RUN echo $BASE_TAG              # 空!ARG 的作用域限于单个构建阶段,跨 FROM 需重新声明

把 ARG 转成 ENV 的常用手法:

ARG APP_VERSION=1.0.0
ENV APP_VERSION=${APP_VERSION}  # 这样运行时容器内也能读到

安全警告:不要用 ARG 传递密码、token、私钥。 虽然它不进 Config.Env,但会明文出现在 docker history 中。要传密钥请用 BuildKit 的 --secret:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=$HOME/.npmrc -t app .

2.4.10 EXPOSE —— 声明端口

EXPOSE 用于指定容器运行时监听的端口:

EXPOSE <port> [<port>...]
EXPOSE <port>/<protocol>
EXPOSE 80
EXPOSE 80 443
EXPOSE 8080/tcp
EXPOSE 53/udp
EXPOSE 80/tcp 80/udp

EXPOSE 的实际作用:仅仅是声明(文档化)。 它不会做任何端口映射,不会打开防火墙,不写 iptables 规则。它的真实价值只有三条:

  1. 告诉使用者这个镜像该访问哪个端口(可用 docker inspect 或 docker image inspect 读到);
  2. 让 docker run -P(大写)能自动把这些端口随机映射到宿主机高位端口;
  3. 某些编排工具与反向代理(如 Traefik)会读取它作为默认目标端口。

EXPOSE 只是声明,并不会让容器端口直接被宿主机访问。要使其可访问,需要在 docker run 时通过 -p 发布端口,或通过 -P 发布所有由 EXPOSE 导出的端口。

# 映射一个端口
EXPOSE port1
# 运行容器时
docker run -p port1 image

# 映射多个端口
EXPOSE port1 port2 port3
docker run -p port1 -p port2 -p port3 image
# 也可以指定映射到宿主机的具体端口
docker run -p host_port1:port1 -p host_port2:port2 -p host_port3:port3 image

端口映射是 Docker 中很重要的功能。每次运行容器时,容器 IP 不能指定,而是在桥接网卡地址范围内随机生成;宿主机 IP 固定,把容器端口映射到宿主机端口,就免去每次访问容器服务都要查容器 IP 的麻烦。对于运行中的容器,可以使用 docker port 加上容器端口和容器 ID 查看其映射到宿主机的端口。

反过来也要记住:没写 EXPOSE 的端口照样可以用 -p 映射。EXPOSE 不是映射的前提条件。

2.4.11 VOLUME —— 声明匿名数据卷

VOLUME ["<mountpoint>"]
VOLUME <mountpoint> [<mountpoint2>...]

容器使用 AUFS 这类文件系统,不能持久化数据,容器关闭后所有更改都会丢失。当容器中应用有持久化数据需求时,可以在 Dockerfile 中使用该指令。该目录可被容器本身使用,也可共享给其他容器使用。

VOLUME ["/var/lib/mysql"]
VOLUME /data /logs
VOLUME ["/var/log", "/var/db"]

行为说明:

  1. 容器启动时,Docker 会自动为该路径创建一个匿名数据卷并挂载上去。
  2. 匿名卷的名字是一串随机 hash,位于 /var/lib/docker/volumes/<hash>/_data。
  3. 如果 docker run 时用 -v myvol:/var/lib/mysql 显式指定,则用你指定的卷,不再创建匿名卷。
  4. 镜像里该目录下原有的文件,会在首次挂载时被复制到新建的空卷里(这是 Volume 独有的行为,Bind Mount 没有)。

副作用与注意:

  • 声明了 VOLUME 之后,该目录在后续 Dockerfile 指令中的写入都会失效(写在临时卷里,构建结束即丢弃)。所以 VOLUME /data 一定要放在所有对 /data 的 RUN/COPY 操作之后。
  • 匿名卷会随着容器的反复创建不断堆积,docker system df 里 Local Volumes 越来越大。要定期 docker volume prune,或用 docker run --rm(会同时删掉匿名卷)。
  • 生产上更推荐不在 Dockerfile 里写 VOLUME,而是在运行时用 -v/--mount 显式指定,控制权在部署方。

2.4.12 USER —— 指定运行用户

设置启动容器的用户(例如 Hadoop 需要 hadoop 用户、Oracle 需要 oracle 用户),可以是用户名或 UID:

USER daemo
USER UID
USER <user>[:<group>]
USER <UID>[:<GID>]

注意:如果设置了容器以 daemon 用户运行,那么 RUN、CMD、ENTRYPOINT 都会以这个用户运行。镜像构建完成后,通过 docker run 运行容器时,可以通过 -u 参数覆盖指定的用户。

标准用法 —— 创建专用用户并降权(安全最佳实践):

FROM alpine:3.18
# 先用 root 做需要特权的事
RUN apk add --no-cache nginx \
    && addgroup -g 1001 -S appgroup \
    && adduser -u 1001 -S appuser -G appgroup
COPY --chown=appuser:appgroup app/ /app/
# 最后切换为非 root
USER appuser
WORKDIR /app
CMD ["./start.sh"]

Debian/Ubuntu 系写法:

RUN groupadd -g 1001 appgroup && useradd -u 1001 -g appgroup -m -s /bin/bash appuser
USER appuser
# 或使用镜像里已有的用户
USER www-data
USER nobody

要点:

  • 推荐用 UID 数字而不是用户名。用数字时即使镜像里没有 /etc/passwd 条目也能工作,且便于与宿主机 UID 对应解决挂载权限问题。
  • USER 之后的 RUN 若需要装软件会因权限不足失败,所以 USER 通常写在 Dockerfile 靠后位置。
  • 非 root 用户无法绑定 1024 以下的特权端口。所以降权镜像通常让应用监听 8080,再用 -p 80:8080 映射出去。
  • 运行时覆盖:docker run -u 0(临时以 root 进去排错)、docker exec -u root -it xxx sh。

2.4.13 HEALTHCHECK —— 健康检查

HEALTHCHECK [选项] CMD <命令>
HEALTHCHECK NONE                 # 禁用从基础镜像继承的健康检查

完整参数:

参数 默认值 说明
--interval=<时长> 30s 两次检查的间隔
--timeout=<时长> 30s 单次检查的超时。超时视为失败
--start-period=<时长> 0s 容器启动后的宽限期。这段时间内检查失败不计入失败次数,用于慢启动应用(如 Java 应用需 60s 起)
--start-interval=<时长> 5s 宽限期内的检查间隔(Docker 25+)
--retries=<次数> 3 连续失败多少次才判定为 unhealthy

示例:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD curl -f http://localhost/ || exit 1

# 不依赖 curl 的写法
HEALTHCHECK --interval=10s CMD wget -q --spider http://127.0.0.1:8080/health || exit 1

# 数据库
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
    CMD mysqladmin ping -h 127.0.0.1 -u root -p"$MYSQL_ROOT_PASSWORD" || exit 1
HEALTHCHECK --interval=10s CMD redis-cli ping | grep -q PONG || exit 1
HEALTHCHECK --interval=10s CMD pg_isready -U postgres || exit 1

# Java 应用,启动慢
HEALTHCHECK --interval=15s --timeout=5s --start-period=90s --retries=4 \
    CMD curl -fsS http://localhost:8080/actuator/health || exit 1

检查命令的退出码语义(必须记住):

退出码 含义
0 success,健康
1 unhealthy,不健康
2 reserved,保留值,不要使用

容器健康状态在 docker ps 的 STATUS 列显示:

Up 2 minutes (health: starting)     <- 处于 start-period 或还未攒够判定次数
Up 5 minutes (healthy)
Up 8 minutes (unhealthy)

查看健康检查的历史输出(排错关键):

docker inspect --format '{{json .State.Health}}' myapp | jq
docker inspect --format '{{.State.Health.Status}}' myapp
docker ps --filter health=unhealthy
docker events --filter event=health_status

运行时指定(镜像没写 HEALTHCHECK 时):

docker run -d --name web \
  --health-cmd='curl -f http://localhost/ || exit 1' \
  --health-interval=30s \
  --health-timeout=3s \
  --health-start-period=10s \
  --health-retries=3 \
  nginx:1.25
docker run -d --no-healthcheck nginx      # 禁用健康检查

重要澄清:单机 Docker 下,容器变成 unhealthy 并不会自动重启它。 --restart 只对容器"退出"生效,不看健康状态。要做到不健康自动重启,需要 Swarm/K8s(liveness probe),或用 willfarrell/autoheal 这类辅助容器。

2.4.14 ONBUILD —— 延迟到子镜像构建时执行

ONBUILD <其他指令>

ONBUILD 注册一条触发器指令:它在当前镜像构建时不执行,而是当别人用 FROM 本镜像 构建下游镜像时,在其 FROM 之后立即执行。

用于制作"框架基础镜像":

# 基础镜像 Dockerfile(node-base:1.0)
FROM node:18-alpine
WORKDIR /app
ONBUILD COPY package.json package-lock.json ./
ONBUILD RUN npm ci --production
ONBUILD COPY . .
CMD ["node", "server.js"]
# 业务方的 Dockerfile 只需一行
FROM node-base:1.0

限制与注意:

  • 不能嵌套:ONBUILD ONBUILD 非法。
  • 不能用于 FROM、MAINTAINER。
  • 触发器只对直接子镜像生效,不会再往下传递(孙子镜像不会触发)。
  • 行为"隐式",使用者不看 Dockerfile 完全不知道发生了什么,排错困难。现代实践更倾向于用多阶段构建替代 ONBUILD。

查看镜像里注册的触发器:

docker inspect --format '{{json .Config.OnBuild}}' node-base:1.0

2.4.15 STOPSIGNAL —— 指定停止信号

STOPSIGNAL <signal>
STOPSIGNAL SIGTERM        # 默认值
STOPSIGNAL SIGQUIT        # nginx 官方镜像用这个做优雅退出
STOPSIGNAL SIGINT
STOPSIGNAL 9              # 也可以用数字

docker stop 时会先发送这个信号给 PID 1,等待宽限期后才发 SIGKILL。不同应用的优雅退出信号不同:

应用 优雅退出信号
nginx SIGQUIT(等待现有连接处理完)
apache SIGWINCH 或 SIGTERM
PostgreSQL SIGINT(fast shutdown)
大多数应用 SIGTERM

运行时覆盖:

docker run --stop-signal=SIGQUIT nginx
docker stop --time=30 nginx        # 同时延长宽限期
docker kill --signal=SIGHUP nginx  # 手动发任意信号(如让 nginx 重载配置)

2.4.16 SHELL —— 更换默认 shell

SHELL ["executable", "parameters"]

改变 shell 形式的 RUN/CMD/ENTRYPOINT 所使用的默认 shell。Linux 默认是 ["/bin/sh", "-c"],Windows 默认是 ["cmd", "/S", "/C"]。

# 需要用到 bash 特性(数组、[[ ]]、source、管道错误传递)时
SHELL ["/bin/bash", "-c"]
RUN source /etc/profile && echo $MY_VAR

# 让整个构建过程严格失败(强烈推荐的生产写法)
SHELL ["/bin/bash", "-o", "pipefail", "-euc"]
RUN curl -fsSL http://example.com/x.sh | bash     # 管道中任一环失败都会让构建失败

为什么需要 pipefail:默认 /bin/sh -c "a | b" 只看 b 的退出码。如果 curl 失败而 bash 成功,这一层会被认为构建成功,产出一个坏镜像。加上 -o pipefail 才能捕获。

SHELL 可在 Dockerfile 中多次出现,每次影响其后的指令。

其他指令可通过 man dockerfile 查看。

2.5 完整实战:编写 httpd 的 Dockerfile

2.5.1 编写 Dockerfile

cd /root
vim dockerfile

dockerfile 内容(源讲义完整保留):

FROM docker.io/ubuntu
ENV APACHE_RUN_USER www-data
ENV APACHE_RUN_GROUP www-data
ENV APACHE_LOG_DIR /var/log/apache2
ENV APACHE_PID_FILE /var/run/apache2.pid
ENV APACHE_RUN_DIR /var/run/apache2
ENV APACHE_LOCK_DIR /var/lock/apache2
ENV APACHE_SERVERADMIN admin@localhost
ENV APACHE_SERVERNAME localhost
ENV APACHE_SERVERALIAS docker.localhost
ENV APACHE_DOCUMENTROOT /var/www
ENV APACHE_RUN_DIR /var/run/apache2
RUN apt-get update && apt-get install apache2 -y
RUN echo "container main page" > /var/www/html/index.html
RUN mkdir /var/run/apache2
EXPOSE 80
CMD ["/usr/sbin/apache2","-D","FOREGROUND"]

2.5.2 构建镜像

docker build -f dockerfile -t my_httpd:0.1 .

注意命令最后有一个点 .,表示 Dockerfile 所在路径(构建上下文)。构建会分步骤执行,如果某步出错,修改 Dockerfile 后重新执行该命令,已经成功的步骤会走缓存。

查看构建出的镜像:

docker images | grep my_httpd
my_httpd                                0.1                 88cbfb7815ed        9 minutes ago       491.6 MB

2.5.3 运行容器并映射端口

docker run -i -p 8000:80 -t my_httpd:0.1
184e6440355b7052fcf3636046bf3207b1413cb13edbb6feee1e8e4cfd6a296d

-p 8000:80 表示把宿主机的 8000 端口映射到容器的 80 端口。

查看端口监听:

lsof -i:8000
COMMAND     PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
docker-pr 15165 root    4u  IPv6 185972      0t0  TCP *:irdmi (LISTEN)

注意 COMMAND 是 docker-pr(docker-proxy),这说明端口是由 Docker 的用户态代理进程持有的,不是容器直接监听宿主机端口。

查看运行中的容器:

docker ps -a | grep my_httpd
00622a3967f4        my_httpd:0.1        "/usr/sbin/httpd -D F"   3 minutes ago       Up 3 minutes        0.0.0.0:8000->80/tcp   berserk_mclean

验证访问:

curl 127.0.0.1:8000
container main page

最后使用浏览器访问 http://宿主机IP:8000 验证。

2.5.4 上面这份 Dockerfile 的优化版

对照前面讲的规则,把源讲义的 Dockerfile 改写成生产可用的形式:

FROM ubuntu:22.04

LABEL maintainer="ops@example.com" \
      description="Apache httpd demo"

# 一条 ENV 设置全部变量,只占一层
ENV APACHE_RUN_USER=www-data \
    APACHE_RUN_GROUP=www-data \
    APACHE_LOG_DIR=/var/log/apache2 \
    APACHE_PID_FILE=/var/run/apache2/apache2.pid \
    APACHE_RUN_DIR=/var/run/apache2 \
    APACHE_LOCK_DIR=/var/lock/apache2 \
    APACHE_SERVERADMIN=admin@localhost \
    APACHE_SERVERNAME=localhost \
    APACHE_SERVERALIAS=docker.localhost \
    APACHE_DOCUMENTROOT=/var/www \
    DEBIAN_FRONTEND=noninteractive \
    TZ=Asia/Shanghai

# 合并 RUN,加 --no-install-recommends,同层清理 apt 缓存
RUN apt-get update \
    && apt-get install -y --no-install-recommends apache2 ca-certificates \
    && mkdir -p /var/run/apache2 /var/lock/apache2 \
    && echo "container main page" > /var/www/html/index.html \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

EXPOSE 80

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD apache2ctl status >/dev/null 2>&1 || exit 1

STOPSIGNAL SIGWINCH

CMD ["/usr/sbin/apache2", "-D", "FOREGROUND"]

改动点对照:

原写法 改进 理由
FROM docker.io/ubuntu FROM ubuntu:22.04 锁定版本,避免 latest 漂移
11 条 ENV 1 条 ENV 减少 10 层元数据层
3 条 RUN 1 条 RUN 减少层数,并让清理生效
无 apt-get clean 同层清理 apt 缓存 镜像从 491MB 降到约 230MB
无 --no-install-recommends 加上 少装几十个无用的推荐包
无健康检查 加 HEALTHCHECK 可观测
无 STOPSIGNAL 加上 apache 优雅退出

2.6 镜像优化实践

2.6.1 选择合适的基础镜像

基础镜像 典型体积 包管理器 特点与适用
ubuntu:22.04 ~77 MB apt 工具齐全,调试方便,体积较大。适合复杂依赖
debian:12 ~117 MB apt 稳定,兼容性最好
debian:12-slim ~74 MB apt 去掉文档与多余 locale,日常首选
centos:7 ~204 MB yum 已 EOL,逐步被 rockylinux/almalinux 替代
rockylinux:9 ~180 MB dnf CentOS 的企业级替代
alpine:3.18 ~7 MB apk 极小。但用 musl libc 而非 glibc,可能导致 Java/Python 某些二进制轮子、DNS 解析、时区行为异常
xxx:slim(如 python:3.11-slim) 中等 apt 官方语言镜像的瘦身版,兼容 glibc,推荐的平衡点
gcr.io/distroless/static ~2 MB 无 只有运行时依赖,没有 shell、没有包管理器、没有 ls。安全性最高,攻击面最小;但完全无法 exec 进去排错
scratch 0 B 无 完全空白,只适合静态编译的 Go/Rust/C

Alpine 的常见坑:

FROM alpine:3.18
# 1. 没有 bash,只有 sh。docker exec -it xxx bash 会报 executable file not found
RUN apk add --no-cache bash
# 2. musl libc 的 DNS 解析器不支持 search domain 的某些行为,且不读 /etc/nsswitch.conf
# 3. 跑 glibc 编译的二进制会报 "no such file or directory"(实际是缺 ld-linux)
RUN apk add --no-cache libc6-compat     # 提供 glibc 兼容层
# 4. Python 的 wheel 包大多只发 manylinux(glibc) 版,alpine 上需要现场编译,构建反而更慢更大
# 5. 默认没有时区数据
RUN apk add --no-cache tzdata && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

选型建议: Go/Rust 用 scratch 或 distroless;Java 用 eclipse-temurin:17-jre-alpine 或 -jre;Python 用 python:3.11-slim(不要 alpine);Node 用 node:18-alpine 可以;需要频繁进容器排错的用 slim 系。

2.6.2 多阶段构建

多阶段构建的核心思想:编译需要的一大堆工具链,不应该留在最终镜像里。 用多个 FROM,只把产物 COPY --from= 过来。

Go 应用示例(效果最明显):

# ===== 阶段一:构建 =====
FROM golang:1.21 AS builder
WORKDIR /build
# 先只拷依赖文件,利用缓存
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 静态编译,-ldflags 去掉调试信息进一步瘦身
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-s -w -extldflags "-static"' -o /build/app ./cmd/server

# ===== 阶段二:运行 =====
FROM scratch
# 静态编译的程序也需要 CA 证书才能发 HTTPS 请求
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
COPY --from=builder /build/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]

效果对比:

方案 镜像体积
单阶段,直接用 golang:1.21 做运行镜像 约 900 MB
多阶段 + alpine:3.18 约 20 MB
多阶段 + gcr.io/distroless/static 约 12 MB
多阶段 + scratch 约 10 MB(减少 98%)

Java 应用示例:

FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn -B dependency:go-offline           # 依赖单独一层,代码改动不重下依赖
COPY src ./src
RUN mvn -B clean package -DskipTests

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /build/target/*.jar /app/app.jar
USER app
EXPOSE 8080
HEALTHCHECK --interval=20s --start-period=60s CMD wget -q --spider http://127.0.0.1:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]

前端项目示例(Node 构建 + Nginx 运行):

FROM node:18-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.25-alpine
COPY --from=builder /build/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

多阶段构建的其他技巧:

# 阶段可以互相引用,也可以从外部镜像复制
COPY --from=nginx:1.25 /etc/nginx/nginx.conf /etc/nginx/nginx.conf
docker build --target builder -t app:builder .   # 只构建到某阶段,用于调试

2.6.3 合并层与清理缓存

要清理的东西 清理命令
apt 缓存 apt-get clean && rm -rf /var/lib/apt/lists/*
yum/dnf 缓存 yum clean all && rm -rf /var/cache/yum
apk 缓存 用 apk add --no-cache,无需清理
pip 缓存 pip install --no-cache-dir -r requirements.txt
npm 缓存 npm ci --production && npm cache clean --force
编译中间产物 同层 rm -rf 源码目录与 .o 文件
文档与 man rm -rf /usr/share/doc/* /usr/share/man/*,或 yum 加 --setopt=tsflags=nodocs
临时文件 rm -rf /tmp/* /var/tmp/*

核心原则再强调一次:清理必须与产生它的操作在同一条 RUN 内。跨层删除对镜像体积毫无帮助。

2.6.4 层顺序与缓存利用

标准顺序模板:

FROM <锁定版本的基础镜像>          # 极少变
LABEL / ENV                        # 极少变
RUN 安装系统级依赖                  # 很少变
COPY 依赖清单文件(package.json / requirements.txt / pom.xml / go.mod)
RUN 安装语言级依赖                  # 只在依赖清单变化时才重跑
COPY 应用源代码                     # 变得最频繁,放最后
RUN 构建/编译(若需要)
EXPOSE / USER / HEALTHCHECK / ENTRYPOINT / CMD   # 元数据,0 成本

2.6.5 不要在镜像里放密钥

镜像层是不可变且可被任何拿到镜像的人完整解开的。以下做法都是错的:

# 错误 1:COPY 私钥进去,即使后面删了也还在层里
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@github.com:org/repo.git && rm -f /root/.ssh/id_rsa

# 错误 2:写在 ENV 里,docker inspect 一览无余
ENV DB_PASSWORD=Passw0rd123

# 错误 3:写在 ARG 里,docker history 一览无余
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc && npm ci

验证泄露(自查手段):

docker history --no-trunc myapp:v1 | grep -iE "passw|token|secret|key"
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' myapp:v1
# 把镜像解开逐层翻文件
docker save myapp:v1 -o /tmp/img.tar && mkdir -p /tmp/img && tar -xf /tmp/img.tar -C /tmp/img
grep -rIl "BEGIN RSA PRIVATE KEY" /tmp/img 2>/dev/null

正确做法:

# 方案 A:BuildKit secret,挂载只存在于该 RUN 期间,不进任何层
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
DOCKER_BUILDKIT=1 docker build --secret id=npmrc,src=$HOME/.npmrc -t app:v1 .
# 方案 B:SSH agent 转发,克隆私有仓库时用
DOCKER_BUILDKIT=1 docker build --ssh default -t app:v1 .
RUN --mount=type=ssh git clone git@github.com:org/private.git

运行时的密钥应通过环境变量、--env-file、配置中心、Docker Secret(Swarm)或 K8s Secret 注入,并把 .env 写进 .dockerignore。

2.6.6 镜像瘦身前后体积对比汇总

场景 优化前 优化后 手段
源讲义 httpd 镜像 491.6 MB 约 230 MB 合并 RUN + 清理 apt 缓存 + --no-install-recommends
同上,换 alpine 基础镜像 491.6 MB 约 60 MB 换基础镜像
Go Web 服务 900 MB 10 MB 多阶段 + scratch
Spring Boot 应用 700 MB(含 maven) 180 MB 多阶段 + jre-alpine
Vue 前端 1.2 GB(含 node_modules) 25 MB 多阶段 + nginx-alpine
Python 服务 1.1 GB(python 完整版) 150 MB python:3.11-slim + --no-cache-dir

检查工具:

docker images myapp                   # 看总体积
docker history myapp:v1               # 找出最大的层
# dive:交互式逐层浏览镜像,直接告出"浪费的空间"
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive:latest myapp:v1

2.7 从容器提交镜像:docker commit

2.7.1 基本用法

docker commit [选项] <容器ID或名> [仓库[:标签]]
选项 说明
-m 提交的描述信息(commit message)
-a 指定镜像作者
-c 在提交时应用 Dockerfile 指令,如 -c 'CMD ["/bin/bash"]'
-p / --pause 提交时暂停容器(默认 true,保证文件系统一致性)

2.7.2 完整示例(源讲义示例一)

先用镜像创建一个容器:

docker run -it ubuntu /bin/bash
root@f8064b303291:/#

在容器内部执行 apt-get update 并安装 vim 编辑器:

apt-get update
apt-get install -y vim

退出容器后,找到刚才创建的容器 ID:

docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS                        PORTS                NAMES
f8064b303291        ubuntu              "/bin/bash"              11 minutes ago      Exited (0) 7 seconds ago                           suspicious_rosalind

通过 docker commit 提交容器副本,构建成新的镜像:

docker commit -m="update vim" -a "tx" f8064b303291 tx/ubuntu:v1
sha256:89efb3eb25af5384a2ffc4fe67462a750c70fc20b5ecdf8ecebe7da5a7987309

参数说明:

参数 说明
-m 提交的描述信息
-a 指定镜像作者
f8064b303291 容器 ID
tx/ubuntu:v1 指定要创建的目标镜像名

查看刚才创建的镜像:

docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
tx/ubuntu           v1                  89efb3eb25af        2 minutes ago       187MB
nginx               1.16                18ff9f3390d6        2 days ago          109MB

使用新创建的镜像启动容器:

docker run -it tx/ubuntu:v1 /bin/bash
root@ceb5864f31e7:/# vim
root@ceb5864f31e7:/#

可以看到所创建的容器里已经安装了 vim 编辑器。

2.7.3 完整示例(源讲义示例二:让容器里 touch 的文件永久保存)

问题: 如何让容器里 touch 的文件永久保存?

答案: 把修改后的容器 commit 成一个新的镜像。

终端一:启动容器并创建文件(保持不退出):

docker run -i -t docker.io/ubuntu /bin/bash
bash-4.2# ls /
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
bash-4.2# touch /11111111111111
bash-4.2# ls /
11111111111111  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

此时不要退出。换到终端二:

docker ps -l
CONTAINER ID        IMAGE                                    COMMAND             CREATED             STATUS              PORTS               NAMES
429adceb8b2b        docker.io/ubuntu                         "/bin/bash"         25 seconds ago      Up 23 seconds                           stoic_morse

找到终端一中正在运行的容器 ID(429adceb8b2b),提交为新镜像 my_touch1111111:

docker commit 429adceb8b2b my_touch1111111
sha256:dd19bbad15e5ef186a60a4498c055e761cc38a83c9df6d278f870465961be9b4

再次列出本地镜像即可看到:

docker images
REPOSITORY                               TAG                 IMAGE ID            CREATED             SIZE
my_touch1111111                          latest              dd19bbad15e5        6 minutes ago       288.3 MB
docker.io/hello-world                    latest              1815c82652c0        10 weeks ago        1.84 kB
docker.io/ubuntu                         latest              ae904afa83bb        4 months ago        288.3 MB

用新镜像启动容器验证,文件确实在:

docker run -i -t my_touch1111111 /bin/bash
bash-4.2# ls /
11111111111111  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
bash-4.2# quit

2.7.4 为什么生产不推荐 docker commit

问题 说明
不可重复、不可审计 镜像里到底装了什么、改了什么,只有当时操作的人知道。半年后没人能复现
无版本控制 Dockerfile 可以进 Git 走 code review,commit 出来的镜像只是一个黑盒
层臃肿 每次 commit 增加一层,包含大量无用的中间文件(apt 缓存、日志、临时文件)
容易夹带垃圾与敏感数据 shell 历史、/tmp 残留、.bash_history、临时凭据都会被打进镜像
无法自动化 不能接入 CI/CD 流水线
易引入不一致 手工操作难免遗漏,多个环境的镜像会逐渐"漂移"

docker commit 的合理用途:

  1. 应急抢救:线上容器出了问题即将被销毁,先 commit 一份做现场快照供事后分析。
  2. 逆向摸索:手工在容器里试出一套安装步骤,再把这些步骤回写成 Dockerfile。
  3. 临时调试镜像。
# 应急保留现场
docker commit -m "incident-20260822 snapshot" bad_container debug/snapshot:20260822
docker save debug/snapshot:20260822 | gzip > /backup/snapshot.tar.gz
# 看看别人 commit 的镜像里到底改了什么
docker diff bad_container

结论:临时可以 commit,交付必须 Dockerfile。


三、容器管理

3.1 容器生命周期

                      docker create
   镜像 ─────────────────────────────────> Created(已创建,未启动)
     │                                          │ docker start
     │ docker run(= create + start)           v
     └────────────────────────────────────> Running(运行中)
                                    ┌──────────┼──────────┐
                     docker pause   │          │          │  docker stop / kill
                                    v          │          v
                                 Paused        │       Exited(已退出)
                     docker unpause │          │          │ docker start / restart
                                    └──────────┘          │
                                                          v
                                              docker rm ──> Removed(彻底删除,可写层销毁)

各状态在 docker ps -a 的 STATUS 列:

STATUS 显示 含义
Created 已创建但从未启动
Up 5 minutes 运行中
Up 5 minutes (healthy) 运行中且健康检查通过
Up 5 minutes (Paused) 已暂停
Exited (0) 3 minutes ago 已退出,退出码 0 表示正常结束
Exited (1) 应用自身报错退出
Exited (125) Docker daemon 本身出错(如参数不合法)
Exited (126) 命令找不到执行权限(不可执行)
Exited (127) 命令不存在(executable file not found)
Exited (137) 被 SIGKILL 杀死 = 128+9。常见于 OOMKilled 或 stop 超时被强杀
Exited (143) 被 SIGTERM 正常终止 = 128+15
Restarting (1) 5 seconds ago 按重启策略正在重启,通常意味着应用反复崩溃
Dead 容器处于无法清理的异常状态(多见于存储驱动故障)

3.2 docker run 全部常用选项

docker run [选项] 镜像 [命令] [参数...]

docker run = docker create + docker start + (可选的 docker attach)。

3.2.1 基础运行模式

前台执行单条命令:

docker run ubuntu /bin/echo "hello world"
hello world
docker run docker.io/ubuntu echo haha
haha

参数解析:

组成 说明
docker Docker 的二进制执行文件
run 与 docker 组合,表示运行一个容器
ubuntu 指定要运行的镜像。Docker 先在本地查找,若不存在则从 Docker Hub 下载公共镜像
/bin/echo "Hello world" 在启动的容器里执行的命令

上述命令的完整含义为:Docker 以 ubuntu 镜像创建一个新容器,然后在容器里执行 /bin/echo "hello world" 并输出结果。命令执行完毕,主进程结束,容器随即退出。

-it 交互式运行:

通过 -i、-t 两个参数,让容器具备"对话"能力,即进入容器内的终端:

docker run -it ubuntu /bin/bash
root@57d67fe1e4fa:/#
docker run -i -t docker.io/ubuntu /bin/bash

参数解析:

参数 说明
-t / --tty 在新容器内指定一个伪终端或终端
-i / --interactive 允许对容器内的标准输入(STDIN)进行交互,保持标准输入打开

可通过运行 exit 命令或使用 Ctrl+D 退出容器。退出后容器会停止(因为 bash 是主进程)。若想退出终端但保持容器运行,按 Ctrl+P Ctrl+Q。

指定版本运行容器示例:

docker run -it ubuntu:15.10 /bin/bash
Unable to find image 'ubuntu:15.10' locally
15.10: Pulling from library/ubuntu
7dcf5a444392: Pull complete
759aa75f3cee: Pull complete
3fa871dc8a2b: Pull complete
224c42ae46e7: Pull complete
Digest: sha256:02521a2d079595241c6793b2044f02eecf294034f31d6e235ac4b2b54ffc41f3
Status: Downloaded newer image for ubuntu:15.10
root@6b051d0332c9:/#
docker run -it ubuntu:14.04 /bin/bash
Unable to find image 'ubuntu:14.04' locally
14.04: Pulling from library/ubuntu
d6fdcbe24ed5: Pull complete
1115217119d4: Pull complete
8180a23d2c44: Pull complete
ceab3b3c2a5e: Pull complete
Digest: sha256:b04dc5313878a8b74808d104a510e50505c79c9c11725bfdaf286f7cedb9295c
Status: Downloaded newer image for ubuntu:14.04
root@23f84215bf42:/#

踩坑(源讲义):在交互式容器里 touch 创建一个文件后退出,再次 docker run -it 会发现文件没了。原因是退出再登录其实是又产生了一个新的容器,而不是回到之前的容器。解法见 3.4 与 2.7.3。

-d 后台(detached)模式:

docker run -d ubuntu /bin/sh -c "while true; do echo hello world; sleep 1; done"
6b56f1af009c6316246c77f15a37bb33a032a83430c6bcc8dd00ac4b42e8c219
docker run -d docker.io/ubuntu /bin/sh -c "while true; do echo haha; sleep 3; done"

-d 表示后台运行,命令会返回一个容器 ID:

21e27256af4ad3185e1632fae30b9e202f4cf2d1e6e7a01c68a82dd70e40487a

查看运行中的容器:

docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES
6b56f1af009c        ubuntu              "/bin/sh -c 'while t…"   41 seconds ago      Up 41 seconds                           loving_franklin

-d 的关键前提:容器的主进程必须在前台常驻。 docker run -d ubuntu 会立刻退出,因为 ubuntu 镜像的默认 CMD 是 bash,没有 -it 时 bash 读到 EOF 就结束了。

-d 与 -it 可以组合(-dit),表示后台运行但保留终端,之后可以 attach 或 exec 进去:

docker run -dit --name mybox ubuntu:22.04
docker exec -it mybox bash

3.2.2 命名与自动清理

选项 说明
--name <名字> 指定运行容器的名字。不指定时 Docker 随机生成 形容词_科学家名 形式(如 loving_franklin、objective_tu)。名字全局唯一,重名会报 Conflict. The container name is already in use
--rm 容器退出后自动删除容器及其匿名卷。适合一次性任务、调试、CI 作业。不能与 -d 的长期服务或 --restart 同时使用
docker run -d --name nginx nginx:1.16
docker run --rm -it alpine sh                      # 退出即销毁,不留垃圾
docker run --rm -v $PWD:/data alpine tar -czf /data/bak.tar.gz /data/src   # 一次性工具容器

用有意义的名字是运维刚需:docker logs nginx 比 docker logs c48d842f376a 好用得多,而且脚本里可以固定引用。

3.2.3 端口映射:-p 与 -P

-p / --publish 的四种写法:

写法 示例 含义
宿主端口:容器端口 -p 8080:80 监听宿主机所有网卡的 8080,转发到容器 80。最常用
宿主IP:宿主端口:容器端口 -p 127.0.0.1:8080:80 只监听指定 IP。绑到 127.0.0.1 表示只有本机能访问,外网访问不到,是重要的安全手段
容器端口(只写一个) -p 80 宿主机随机分配一个高位端口(32768~60999)映射到容器 80
宿主IP::容器端口 -p 127.0.0.1::80 绑定指定 IP,宿主端口随机

再加上协议与范围:

docker run -d -p 8080:80 -p 8443:443 nginx                  # 多个端口,-p 可重复
docker run -d -p 53:53/udp -p 53:53/tcp dns-server          # 指定协议,默认 tcp
docker run -d -p 8000-8010:8000-8010 myapp                  # 端口范围
docker run -d -p 127.0.0.1:3306:3306 mysql:8                # 数据库只对本机开放
docker run -d -p 192.168.1.10:80:80 nginx                   # 绑定到特定网卡 IP

-P / --publish-all(大写):把 Dockerfile 里所有 EXPOSE 声明的端口随机映射到宿主机高位端口。

docker run -d -P --name web nginx:1.16
docker port web
80/tcp -> 0.0.0.0:32768

源讲义示例:

docker run -d -p 80:80 --name nginx --restart=always nginx:1.16
c48d842f376aaa4d6385aecb65684cf3c1e292b51118e52a254dfaf841ebcadf

参数解析:

参数 说明
run 运行容器
--name 指定运行容器的名字
--restart=always 加入该参数后,容器退出时将自动重新启动
-d 让容器在后台运行
-p 暴露端口,格式为 <宿主机端口>:<容器内部端口>,例如 -p 80:8080 表示将容器内部的 8080 端口映射到宿主机的 80 端口

查看刚才运行的容器:

docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                NAMES
c48d842f376a        nginx:1.16          "nginx -g 'daemon of…"   20 seconds ago      Up 18 seconds       0.0.0.0:80->80/tcp   nginx

httpd 示例(映射到 8000):

docker run -i -p 8000:80 -t my_httpd:0.1

注意 -p 的顺序:宿主机在前,容器在后。写反了(如 -p 80:8080 本意是想把容器 80 映射到宿主 8080)是最高频的低级错误。

3.2.4 挂载:-v 与 --mount

docker run -d -v /data/html:/usr/share/nginx/html nginx        # bind mount
docker run -d -v mydata:/var/lib/mysql mysql:8                 # 具名卷
docker run -d -v /var/lib/mysql mysql:8                        # 匿名卷
docker run -d -v /data/conf:/etc/nginx/conf.d:ro nginx         # 只读
docker run -d --mount type=volume,source=mydata,target=/var/lib/mysql mysql:8

详细内容见第四章数据管理。

3.2.5 环境变量:-e 与 --env-file

docker run -d -e MYSQL_ROOT_PASSWORD=root123 -e MYSQL_DATABASE=appdb mysql:8
docker run -d -e TZ=Asia/Shanghai -e LANG=C.UTF-8 nginx
# 只写变量名,从宿主机当前环境继承值
export API_KEY=abc123
docker run --rm -e API_KEY alpine env | grep API_KEY
# 从文件批量导入
docker run -d --env-file ./app.env myapp:v1
docker run -d --env-file common.env --env-file prod.env myapp:v1   # 可指定多个,后面覆盖前面

--env-file 文件格式(注意:这不是 shell 脚本):

# app.env
MYSQL_HOST=10.0.0.10
MYSQL_PORT=3306
MYSQL_USER=appuser
MYSQL_PASSWORD=Passw0rd
APP_ENV=production
JAVA_OPTS=-Xms512m -Xmx1024m

--env-file 的规则与坑:

  • 一行一个 KEY=VALUE,# 开头为注释,空行忽略;
  • 不要给值加引号,引号会被当作值的一部分(A="b" 的值是 "b" 含引号);
  • 不支持变量引用(B=$A 里的 $A 不会展开);
  • 只写 KEY 不写 =VALUE 时,从宿主机环境继承;
  • 值里可以有空格,不需要转义;
  • -e 的优先级高于 --env-file。

查看容器实际生效的环境变量:

docker exec myapp env
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' myapp

3.2.6 网络相关选项

选项 说明
--network <名> 指定网络:bridge(默认)、host、none、container:<名>、或自定义网络名
--network-alias <别名> 在自定义网络中为容器再加一个 DNS 别名
--ip <IP> 指定固定 IP(只能用于自定义网络)
--hostname / -h 设置容器内的主机名(UTS namespace)。默认是容器 ID 前 12 位
--add-host <主机名>:<IP> 往容器的 /etc/hosts 里加一条记录。可重复
--dns <IP> 指定容器使用的 DNS 服务器,覆盖默认继承宿主机的设置
--dns-search <域> DNS 搜索域
--dns-option 传给 resolv.conf 的选项,如 ndots:2
--mac-address 指定 MAC 地址
--link <容器>:<别名> 已废弃(legacy),见下
docker run -d --network mynet --name web --network-alias frontend nginx
docker run -d --network mynet --ip 172.20.0.100 --name db mysql:8
docker run -it --hostname myhost --add-host db.local:10.0.0.5 --add-host cache.local:10.0.0.6 alpine sh
docker run -d --dns 223.5.5.5 --dns 114.114.114.114 --dns-search example.com nginx
# 让容器直接访问宿主机(Docker 20.10+ Linux 需配合 --add-host)
docker run --add-host=host.docker.internal:host-gateway -it alpine sh

--link 已废弃:

# 旧写法,不要再用
docker run -d --name db mysql:8
docker run -d --name web --link db:mysqldb nginx

废弃原因:

  1. 只是往容器的 /etc/hosts 写静态记录,目标容器重启换 IP 后不会更新,链接失效;
  2. 单向、非对称,web 能找到 db,db 找不到 web;
  3. 会把被链接容器的所有环境变量(包括数据库密码)泄露到发起方容器里;
  4. 只能在默认 bridge 网络中用,不支持跨主机。

替代方案:创建自定义网络,用容器名互访(详见第四章 4.2):

docker network create appnet
docker run -d --name db --network appnet mysql:8
docker run -d --name web --network appnet nginx      # 容器内直接 ping db 即可

3.2.7 重启策略:--restart

策略 行为 适用场景
no(默认) 永不自动重启 一次性任务、调试
on-failure[:最大次数] 仅在退出码非 0 时重启。可限制最大尝试次数,如 on-failure:5,超过后放弃 批处理任务、希望崩溃自愈但不希望无限重启的场景
always 无论退出码如何都重启。且 docker daemon 重启(宿主机重启)后也会自动拉起容器,即使它是被手动 stop 的(下次 daemon 启动时仍会启动) 需要开机自启的常驻服务
unless-stopped 与 always 相同,但如果容器是被人为 docker stop 停下的,daemon 重启后不会把它拉起来 生产推荐。尊重运维的手动停止决定,避免"我明明停了它怎么又起来了"
docker run -d --restart=always --name nginx -p 80:80 nginx:1.16
docker run -d --restart=unless-stopped --name app myapp:v1
docker run -d --restart=on-failure:5 --name job myjob:v1
docker run -d --restart=no --name test alpine sleep 100
# 对已存在的容器修改策略,不用重建
docker update --restart=unless-stopped nginx
docker inspect --format '{{.HostConfig.RestartPolicy}}' nginx

always 与 unless-stopped 的差异实验(重点):

docker run -d --restart=always --name a1 alpine sleep 3600
docker run -d --restart=unless-stopped --name u1 alpine sleep 3600
docker stop a1 u1
systemctl restart docker
docker ps
# 结果:a1 被自动拉起(Up),u1 保持 Exited

重启的退避机制:Docker 使用指数退避(100ms、200ms、400ms…最长 1 分钟),避免疯狂重启打满 CPU。查看重启次数:

docker inspect --format '{{.RestartCount}}' myapp

--restart 与 --rm 互斥,同时使用会报错。--restart 只关心容器是否退出,不关心健康检查状态。

3.2.8 工作目录、用户与入口覆盖

选项 说明
-w / --workdir <路径> 覆盖镜像的 WORKDIR
-u / --user <用户或UID[:组或GID]> 覆盖镜像的 USER
--entrypoint <程序> 覆盖镜像的 ENTRYPOINT
docker run --rm -w /tmp alpine pwd                  # 输出 /tmp
docker run --rm -u 1000:1000 alpine id
docker run --rm -u root alpine id                   # 临时提权排错
docker run --rm -u $(id -u):$(id -g) -v $PWD:/work -w /work node:18 npm install   # 让产物属主是当前用户,解决挂载权限问题

# 覆盖 ENTRYPOINT,最重要的排错手段之一
docker run --rm -it --entrypoint sh myapp:v1        # 镜像启动就崩时,绕过入口进去看
docker run --rm -it --entrypoint /bin/bash nginx:1.25
docker run --rm --entrypoint cat myapp:v1 /app/config.yaml   # 只想看一个文件

3.2.9 特权与能力:--privileged / --cap-add / --cap-drop

docker run -it --privileged ubuntu bash

--privileged 的实际含义(风险必须点明):

  • 授予容器几乎全部 Linux capabilities;
  • 解除 devices cgroup 限制,容器可以访问宿主机所有设备(/dev 下全部可见,可以直接 mount /dev/sda1);
  • 关闭 seccomp 与 AppArmor 默认限制;
  • 可以加载/卸载内核模块、修改宿主机内核参数。

结论:--privileged 容器 = 宿主机 root。 一次逃逸演示:

# 在特权容器内即可挂载宿主机磁盘并读写任意文件
docker run -it --rm --privileged alpine sh
# fdisk -l ; mkdir /host ; mount /dev/sda1 /host ; chroot /host sh   -> 拿到宿主机 root

正确做法是只加最小必要的 capability:

# 只需要改系统时间
docker run -d --cap-add=SYS_TIME myapp
# 只需要操作网络(如 tcpdump、ping、修改路由)
docker run -it --cap-add=NET_ADMIN --cap-add=NET_RAW nicolaka/netshoot
# 需要挂载
docker run -d --cap-add=SYS_ADMIN myapp
# 最安全的写法:先丢掉全部,再按需添加
docker run -d --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx
# 查看容器实际的 capability 配置
docker inspect --format '{{.HostConfig.CapAdd}} {{.HostConfig.CapDrop}}' myapp

常用 capability 对照:

Capability 允许的操作
NET_ADMIN 配置网卡、路由、iptables
NET_RAW 使用原始套接字(ping、tcpdump)
NET_BIND_SERVICE 非 root 用户绑定 1024 以下端口
SYS_TIME 修改系统时钟
SYS_ADMIN 挂载文件系统等(权限极大,接近 privileged,慎用)
SYS_PTRACE 调试其他进程(gdb、strace)
CHOWN / SETUID / SETGID 改属主、切换用户身份
DAC_OVERRIDE 绕过文件权限检查
KILL 向任意进程发信号

Docker 默认已经丢掉了 SYS_ADMIN、SYS_MODULE、NET_ADMIN 等危险能力,保留了一组"相对安全"的 14 个。所以大多数容器不需要任何 --cap-add。

3.2.10 资源限制:内存与 CPU

内存:

选项 说明
-m / --memory 内存硬上限。超出即被内核 OOM Killer 杀掉(ExitCode 137)。单位 b/k/m/g,最小 6m
--memory-swap 内存 + swap 的总上限。设为与 -m 相同的值表示禁用 swap;设为 -1 表示 swap 不限制
--memory-reservation 软限制。内存紧张时内核会尽量把该容器压回这个值以下,但不强制
--oom-kill-disable 禁止 OOM Killer 杀该容器(危险,可能导致整机挂起,必须与 -m 一起用)
--kernel-memory 内核内存限制
--oom-score-adj 调整 OOM 优先级(-1000~1000,越小越不容易被杀)

CPU:

选项 说明
--cpus=<数量> 最直观。--cpus=1.5 表示最多用 1.5 个核的算力。等价于 --cpu-period=100000 --cpu-quota=150000
-c / --cpu-shares 相对权重,默认 1024。只在 CPU 竞争时生效:一个 1024、一个 512 时,前者获得 2/3 时间片;CPU 空闲时任何容器都能跑满。不是硬限制
--cpu-period CFS 调度周期,默认 100000(100ms)
--cpu-quota 每个周期内可用的 CPU 时间(微秒)
--cpuset-cpus 绑定到指定核心:0-3、0,2,4。用于 NUMA 优化与降低上下文切换
--cpuset-mems 绑定 NUMA 内存节点

块设备 I/O:

选项 说明
--blkio-weight I/O 相对权重(10~1000,默认 500)
--device-read-bps / --device-write-bps 读写带宽上限:--device-write-bps /dev/sda:10mb
--device-read-iops / --device-write-iops 读写 IOPS 上限

其他:

选项 说明
--pids-limit 容器内最大进程数,防 fork 炸弹
--ulimit 设置 ulimit:--ulimit nofile=65536:65536、--ulimit nproc=1024
--shm-size /dev/shm 大小,默认 64MB。Chrome、PostgreSQL、某些 Java 应用需要调大

示例:

# 典型的生产资源限制
docker run -d --name app \
  -m 2g --memory-swap 2g \
  --cpus=2 \
  --pids-limit 512 \
  --ulimit nofile=65536:65536 \
  --restart=unless-stopped \
  myapp:v1

# CPU 权重(软限制),三个容器竞争时按 4:2:1 分配
docker run -d --cpu-shares=1024 --name high  busybox sleep 3600
docker run -d --cpu-shares=512  --name mid   busybox sleep 3600
docker run -d --cpu-shares=256  --name low   busybox sleep 3600

# 绑核
docker run -d --cpuset-cpus="0,1" --name pinned myapp
# 限制磁盘写入带宽
docker run -it --device-write-bps /dev/sda:10mb alpine sh
# 内存不足实验:观察被 OOM Kill
docker run -it -m 100m --memory-swap 100m alpine sh -c 'dd if=/dev/zero of=/dev/null bs=200M'

验证限制是否生效:

docker stats --no-stream
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}' app
# 在容器内查看(cgroup v1)
docker exec app cat /sys/fs/cgroup/memory/memory.limit_in_bytes
# cgroup v2
docker exec app cat /sys/fs/cgroup/memory.max

生产铁律:所有容器都必须设置 -m 和 --cpus。 不设限的容器可以把宿主机内存吃光,触发宿主机级 OOM,把同机的其他容器甚至 dockerd/sshd 一起杀掉。

3.2.11 健康检查与日志选项

docker run -d --name web \
  --health-cmd='curl -f http://localhost/ || exit 1' \
  --health-interval=30s --health-timeout=3s \
  --health-start-period=10s --health-retries=3 \
  nginx:1.25

docker run -d --log-driver=json-file --log-opt max-size=50m --log-opt max-file=3 nginx
docker run -d --log-driver=none nginx                        # 完全不收日志
docker run -d --log-driver=syslog --log-opt syslog-address=udp://10.0.0.5:514 nginx

3.2.12 其他常用选项汇总

选项 说明
--init 在容器内注入一个 tini 作为 PID 1,负责回收僵尸进程与转发信号(见 3.10)
--read-only 根文件系统只读,需要写的目录用 tmpfs 或卷挂出来
--tmpfs /tmp 挂载内存文件系统
--stop-signal 覆盖 STOPSIGNAL
--stop-timeout 覆盖停止宽限期
--sysctl 设置容器内核参数:--sysctl net.core.somaxconn=1024
--security-opt 安全选项:seccomp=unconfined、apparmor=unconfined、no-new-privileges
--pid=host 共享宿主机 PID namespace(危险,容器内能看到并操作宿主机所有进程)
--ipc=host 共享宿主机 IPC namespace
--net=host 共享宿主机网络栈
--userns=host 不使用用户命名空间
--device 映射设备:--device /dev/sdb:/dev/sdb
--group-add 追加附加组
--label 给容器打标签,便于 --filter label= 批量管理
--detach-keys 自定义 detach 组合键(默认 ctrl-p,ctrl-q)
-a / --attach 只附加指定的流:-a stdout -a stderr
--sig-proxy=false 不把信号代理给容器(attach 时防止 Ctrl+C 杀死容器)
docker run -d --init --read-only --tmpfs /tmp --tmpfs /var/run \
  --security-opt no-new-privileges \
  --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
  -m 512m --cpus=1 \
  --name hardened nginx:1.25-alpine

3.3 docker ps 查看容器

查看所有已创建的容器(不加 -a 则只查看正在运行的容器):

docker ps -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                      PORTS               NAMES
a75434ec0fb4        hello-world         "/hello"            11 minutes ago      Exited (0) 11 minutes ago                       objective_tu

旧版 docker 输出:

CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                     PORTS               NAMES
689668841cc1        hello-world         "/hello"            7 minutes ago       Exited (0) 7 minutes ago                       nostalgic_lumiere
  • docker ps 默认只显示运行中的容器;
  • -a 显示所有状态(包括已退出)的容器;
  • 使用 man docker-ps 查看参数说明。

查看最近创建的容器:

docker ps -l
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS                      PORTS               NAMES
a75434ec0fb4        hello-world         "/hello"            14 minutes ago      Exited (0) 14 minutes ago                       objective_tu

多容器时的完整输出示例:

CONTAINER ID        IMAGE                                    COMMAND                  CREATED             STATUS                      PORTS               NAMES
6d99ac6468f6        docker.io/ubuntu                         "/bin/sh -c 'while tr"   2 minutes ago       Up 2 minutes                                    distracted_bose
088dc45a5319        docker.io/ubuntu                         "/bin/bash"              5 minutes ago       Exited (0) 3 minutes ago                        happy_poitras
2df38e119f6a        docker.io/ubuntu                         "/bin/bash"              7 minutes ago       Exited (0) 5 minutes ago                        grave_khorana
329e9e12d6ea        docker.io/ubuntu                         "echo haha"              10 minutes ago      Exited (0) 10 minutes ago                       jolly_heyrovsky
689668841cc1        hello-world                              "/hello"                 31 minutes ago      Exited (0) 31 minutes ago                       nostalgic_lumiere

字段说明:

字段 说明
CONTAINER ID 容器 ID(前 12 位)
IMAGE 使用的镜像
COMMAND 容器启动时执行的命令(默认被截断)
CREATED 创建时间
STATUS 状态(见 3.1 状态表)
PORTS 端口映射
NAMES 容器名

常用选项:

选项 说明
-a / --all 显示所有状态的容器
-q / --quiet 只显示容器 ID,用于批量操作
-l / --latest 只显示最近创建的一个容器(无论状态)
-n <N> 显示最近创建的 N 个容器
-s / --size 显示容器的可写层大小与虚拟大小,排查哪个容器在往可写层写数据
--no-trunc 不截断,显示完整 ID 与完整 COMMAND
-f / --filter 过滤
--format 自定义输出

--filter 支持的条件:

docker ps -f "status=running"          # created/restarting/running/removing/paused/exited/dead
docker ps -a -f "status=exited"
docker ps -f "name=nginx"              # 名字包含 nginx(正则匹配)
docker ps -a -f "ancestor=nginx:1.16"  # 基于某镜像创建的容器
docker ps -f "label=env=prod"
docker ps -f "network=mynet"
docker ps -f "publish=80"              # 映射了 80 端口的
docker ps -f "expose=8080"
docker ps -f "health=unhealthy"        # 健康检查失败的
docker ps -a -f "exited=137"           # 按退出码过滤,快速找 OOM 的容器
docker ps -a -f "before=nginx" -f "since=web"
docker ps -f "volume=mydata"           # 挂载了某个卷的容器

--format 实用写法(运维日常最有用的技巧):

# 精简四列,去掉刷屏的 COMMAND 和 CREATED
docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}"
# 只要名字,一行一个,用于脚本
docker ps --format "{{.Names}}"
# 名字 + 镜像 + 状态
docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}"
# JSON 输出配合 jq
docker ps --format "{{json .}}" | jq -r '.Names'
# 设为默认格式,写进 ~/.docker/config.json
{
  "psFormat": "table {{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.Status}}\\t{{.Ports}}",
  "imagesFormat": "table {{.Repository}}\\t{{.Tag}}\\t{{.Size}}"
}

可用模板字段:.ID、.Image、.Command、.CreatedAt、.RunningFor、.Ports、.Status、.Size、.Names、.Labels、.Mounts、.Networks、.State。

3.4 start / stop / restart / kill

docker stop e70b2b6f9b38    # 停止
docker start e70b2b6f9b38   # 启动,继续运行

停止容器(同样可接 <container ID> 或容器 <NAMES>):

docker stop 6b56f1af009c
6b56f1af009c

四者的区别(重点):

命令 行为 数据与状态
docker start 启动一个已停止/已创建的容器,沿用创建时的全部参数(端口、卷、环境变量都不能改) 可写层保留,上次的修改仍在
docker stop 先发 STOPSIGNAL(默认 SIGTERM)给 PID 1,等待 10 秒宽限期,若进程仍在则发 SIGKILL 强杀 优雅停止,应用有机会保存状态、关闭连接
docker restart 等价于 stop + start,同样有宽限期 同上
docker kill 立即发送 SIGKILL(或 -s 指定的信号),没有宽限期,进程无法做任何清理 可能造成数据不一致,只在 stop 卡住时使用

SIGTERM → SIGKILL 的 10 秒宽限期:

docker stop 的完整时序:
t=0s     dockerd 向容器 PID 1 发送 SIGTERM(或 STOPSIGNAL 指定的信号)
         应用应在此时:停止接收新请求 -> 处理完存量请求 -> flush 缓冲 -> 关闭连接 -> 退出
t=0~10s  等待进程自行退出。一旦退出,立刻返回,不必等满 10 秒
t=10s    进程仍未退出 -> 发送 SIGKILL,内核强制杀死,进程无任何机会清理
         容器 STATUS 变为 Exited (137)   <- 137 = 128 + 9

用 -t / --time 调整宽限期:

docker stop -t 30 mysql           # 数据库需要更长时间落盘,给 30 秒
docker stop -t 0 myapp            # 等价于直接 kill,不给宽限
docker stop --time=60 kafka
docker restart -t 30 mysql
# 修改默认宽限期(对容器永久生效)
docker run -d --stop-timeout 30 --name mysql mysql:8

docker kill 的用法:

docker kill myapp                        # 默认 SIGKILL
docker kill -s SIGTERM myapp             # 指定信号
docker kill --signal=SIGHUP nginx        # 让 nginx 重载配置,不重启容器
docker kill --signal=SIGUSR1 nginx       # 让 nginx 重新打开日志文件(配合 logrotate)
docker kill --signal=SIGQUIT nginx       # 优雅退出

批量操作:

docker stop $(docker ps -q)               # 停止所有运行中的容器
docker stop $(docker ps -a -q)            # 停止所有容器(`-q` 参数仅显示容器 ID)
docker start $(docker ps -a -q -f status=exited)   # 启动所有已停止的容器
docker restart $(docker ps -q -f name=web)

重要提醒:docker start 不能改参数。 想改端口映射、加卷、改环境变量,都必须 docker rm 后用新参数 docker run。这也是"容器是不可变基础设施"的体现 —— 生产上应该把 run 参数写进脚本或 docker-compose.yml,而不是靠记忆。

3.5 pause / unpause

docker pause <容器>
docker unpause <容器>

pause 使用 cgroup 的 freezer 子系统,把容器内所有进程冻结在原地:

  • 进程状态变为 D(不可中断睡眠),不占用 CPU;
  • 内存仍然被占用,与 stop 完全不同;
  • 网络连接保持(但因为进程不响应,客户端会超时);
  • 容器 STATUS 显示 Up 5 minutes (Paused);
  • 被 pause 的容器不能 exec 进入,也不能 stop(需先 unpause)。

用途:

# 1. 备份数据卷前冻结容器,保证文件系统一致性
docker pause mysql
tar -czf /backup/mysql-$(date +%F).tar.gz /var/lib/docker/volumes/mysqldata/_data
docker unpause mysql

# 2. 临时释放 CPU 给紧急任务
docker pause batch-job
# ... 处理紧急事务 ...
docker unpause batch-job

# 3. 批量
docker pause $(docker ps -q)
docker unpause $(docker ps -q -f status=paused)

注意:docker pause 不能替代 stop。内存没释放,而且长时间 pause 会让上游服务的连接池积压超时连接。数据库备份更推荐用 mysqldump 这类逻辑备份,或用文件系统快照。

3.6 docker rm 删除容器

删除容器(必须是已停止的容器才能被删除):

docker rm 6b56f1af009c
6b56f1af009c

删除单个容器:

docker stop e28478d4b604
docker rm e28478d4b604

常用选项:

选项 说明
-f / --force 强制删除。内部先 SIGKILL 再删,运行中的容器也能删
-v / --volumes 同时删除与该容器关联的匿名卷(具名卷不会被删)
-l / --link 删除指定的 link 而非容器
docker rm -f myapp
docker rm -fv myapp                # 连匿名卷一起删,避免卷堆积

批量删除的写法:

# 停止所有容器(-q 参数仅显示容器 ID)
docker stop $(docker ps -a -q)
# 删除所有容器
docker rm $(docker ps -a -q)
# 一步强制删除所有容器
docker rm -f $(docker ps -aq)
# 只删除已退出的容器(更安全,推荐)
docker rm $(docker ps -aq -f status=exited)
docker container prune -f
# 按镜像删
docker rm -f $(docker ps -aq -f ancestor=nginx:1.16)
# 按名字前缀删
docker rm -f $(docker ps -aq -f "name=test-")
# 删除所有 Created 状态(创建失败残留)的容器
docker rm $(docker ps -aq -f status=created)
# 空列表保护:容器为空时 $() 展开为空会让 rm 报错,加个判断
[ -n "$(docker ps -aq)" ] && docker rm -f $(docker ps -aq)

docker rm 会永久销毁容器的可写层,容器内所有未挂载到卷的数据全部丢失,且不可恢复。执行前务必确认数据已经在卷里或已备份。

3.7 进入容器:exec 与 attach

3.7.1 docker exec(推荐方式)

使用 docker exec 可以在正在运行的容器中执行命令,或通过 -it 参数进入容器:

docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                NAMES
c48d842f376a        nginx:1.16          "nginx -g 'daemon of…"   12 minutes ago      Up 2 minutes        0.0.0.0:80->80/tcp   nginx

在容器内执行单条命令:

docker exec c48d842f376a ls /usr/share/nginx
html

以交互方式进入容器:

docker exec -it c48d842f376a /bin/bash
root@c48d842f376a:/#
root@c48d842f376a:/# ls
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

root@c48d842f376a:/# exit
exit

docker exec 的原理是在容器的 namespace 里新起一个进程。所以:

  • exit 只结束这个新进程,容器主进程不受影响,容器继续运行;
  • 这是进入容器的正确方式。

常用选项:

选项 说明
-i 保持标准输入打开
-t 分配伪终端
-d 在后台执行命令,不等待结果
-e 为这次执行设置环境变量
-u 以指定用户执行,-u root 用于给降权容器临时提权排错
-w 指定工作目录
--privileged 以特权方式执行

实用示例:

docker exec -it nginx bash
docker exec -it nginx sh                      # alpine 等没有 bash 的镜像
docker exec -u root -it myapp sh              # 以 root 进入降权容器安装排错工具
docker exec -it -w /var/log nginx sh
docker exec -e LANG=C.UTF-8 -it mysql bash
docker exec nginx nginx -t                    # 检查配置语法
docker exec nginx nginx -s reload             # 重载配置,不重启容器
docker exec mysql mysql -uroot -proot123 -e "show databases;"
docker exec -d nginx touch /tmp/flag          # 后台执行
docker exec nginx cat /etc/nginx/nginx.conf   # 看配置
docker exec nginx ps aux                      # 看进程
docker exec nginx env                         # 看环境变量
docker exec nginx netstat -tunlp || docker exec nginx ss -tunlp
docker exec nginx df -h                       # 看容器内磁盘
docker exec nginx date                        # 看时区,排查时间不一致

3.7.2 docker attach(危险,慎用)

docker attach 用于连接到一个运行中的容器;停止状态的容器需要先 start。详见 man docker-attach。

问题(源讲义问题一): 交互式进入镜像 touch 的文件,退出再连就看不到了(因为退出再连会产生新容器),如何让之前 touch 的文件还能看到?

答案: 继续连接先前的那个容器,而不是新建。

先启动一个容器并创建文件:

docker run -i -t docker.io/ubuntu /bin/bash
bash-4.2# touch /1111111
bash-4.2# ls /
1111111  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
bash-4.2# quit

查看最近一次创建的容器,获取其 ID:

docker ps -l
CONTAINER ID        IMAGE                                    COMMAND             CREATED             STATUS                      PORTS               NAMES
fe2ff7a78f55        docker.io/ubuntu                         "/bin/bash"         49 seconds ago      Exited (0) 19 seconds ago                       thirsty_meninsky

直接 attach 一个已停止的容器会报错:

docker attach fe2ff7a78f55
You cannot attach to a stopped container, start it first

需要先 start 再 attach:

docker start fe2ff7a78f55
docker attach fe2ff7a78f55

连上去后可以看到之前 touch 的 /1111111 文件仍然存在:

bash-4.2# ls /
1111111  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

docker attach 的危险性:

attach 是把你的终端接到容器主进程(PID 1)的标准输入输出上,不是新起进程。因此:

危险点 说明
exit 或 Ctrl+C 会杀死容器 因为你操作的就是 PID 1。attach 到 nginx 容器按 Ctrl+C,整个 nginx 就停了
多人同时 attach 会互相干扰 所有 attach 的终端共享同一个 stdin/stdout,一个人的输入所有人都看得到,输出也会混在一起
主进程不是 shell 时毫无用处 attach 到 nginx 容器,屏幕上只会滚动 nginx 的日志,无法执行任何命令
无法指定用户与工作目录 没有 -u、-w 选项

安全退出方式(不杀容器):按 Ctrl+P 然后 Ctrl+Q(需要容器启动时带了 -it)。

docker attach --sig-proxy=false myapp    # 防止 Ctrl+C 被转发给容器主进程
docker attach --detach-keys="ctrl-x" myapp

结论:日常运维一律用 docker exec -it。attach 只用于两个场景: ① 查看前台容器的实时输出(其实 docker logs -f 更好);② 回到一个用 -it 启动、主进程就是 shell 的容器(如源讲义的例子)。

3.7.3 nsenter(不依赖容器内工具的终极手段)

当容器是 scratch / distroless 基础镜像(没有 shell),或容器内 shell 已损坏,docker exec 完全用不了。此时用宿主机的 nsenter 直接进入容器的 namespace:

# 1. 拿到容器主进程在宿主机上的 PID
PID=$(docker inspect --format '{{.State.Pid}}' myapp)
echo $PID

# 2. 进入该进程的全部 namespace
nsenter -t $PID -m -u -i -n -p /bin/sh
# 只借用网络 namespace,用【宿主机】的工具排查容器网络(最实用)
nsenter -t $PID -n ip addr
nsenter -t $PID -n ss -tunlp
nsenter -t $PID -n netstat -rn
nsenter -t $PID -n tcpdump -i eth0 -nn port 80
nsenter -t $PID -n curl -v http://127.0.0.1:8080/health

nsenter 参数:

参数 进入的 namespace
-m / --mount 挂载(文件系统视图)
-u / --uts 主机名
-i / --ipc IPC
-n / --net 网络
-p / --pid 进程
-U / --user 用户
-a / --all 全部
-t / --target 目标进程 PID

另一种做法:用一个"工具容器"共享目标容器的 namespace(不需要在宿主机装工具):

# 借用网络 namespace 排查,netshoot 里带了 tcpdump/dig/curl/ss/iperf 等全套工具
docker run -it --rm --network container:myapp nicolaka/netshoot
# 借用 PID namespace 看进程
docker run -it --rm --pid container:myapp alpine ps aux
# 直接从宿主机访问容器的文件系统(无需进入)
docker cp myapp:/etc/app/config.yaml ./
ls /proc/$PID/root/etc/           # 通过 procfs 直接看容器根目录

3.7.4 三种进入方式对比表

对比项 docker exec docker attach nsenter
原理 在容器 namespace 内新建进程 接到已有 PID 1 的 stdio 宿主机进程切换到容器 namespace
退出是否影响容器 不影响 Ctrl+C/exit 会杀死容器 不影响
是否需要容器内有 shell 需要 需要 PID 1 本身是 shell 不需要(用宿主机的 shell/工具)
能否指定用户 / 目录 能(-u、-w) 不能 能(--setuid、--wd)
多人并发 各自独立,互不干扰 共享同一个 stdio,互相干扰 各自独立
能否用于 distroless/scratch 不能 不能 能
是否需要 docker 命令 需要 需要 不需要(只要能拿到 PID)
是否需要宿主机 root 不需要(docker 组即可) 不需要 需要 root
推荐度 日常首选 尽量避免 疑难排错备用

3.8 docker logs 查看日志

查看容器运行的日志信息(后接 <container ID> 或容器 <NAMES>):

docker logs 6b56f1af009c
hello world
hello world
hello world
docker logs <container_id 或 name>
docker logs e28478d4b604

使用 man docker-logs 查看参数说明。

常用选项:

选项 说明
-f / --follow 持续跟踪输出(类似 tail -f),Ctrl+C 退出(不会影响容器)
--tail <N> 只显示最后 N 行。默认 all。日志量大时必加,否则会刷屏几十万行
--since <时间> 只显示某时间之后的日志。支持时间戳、RFC3339、相对时间 10m/1h/3d
--until <时间> 只显示某时间之前的日志
-t / --timestamps 每行前面加上时间戳。排查时序问题必用(很多应用自己不打时间戳)
--details 显示额外的日志标签(--log-opt labels= 指定的)
-n --tail 的简写

实用组合:

docker logs -f --tail 100 nginx                   # 最常用:看最后100行并持续跟踪
docker logs -t --tail 50 myapp
docker logs --since 10m myapp                     # 最近 10 分钟
docker logs --since 1h --until 30m myapp          # 1小时前到30分钟前这段
docker logs --since "2026-08-22T10:00:00" myapp
docker logs --since "2026-08-22" --until "2026-08-23" myapp > /tmp/day.log
# 过滤(注意:日志走的是 stderr,要合并才能 grep 到全部)
docker logs myapp 2>&1 | grep -i error
docker logs myapp 2>&1 | grep -iE "exception|timeout|refused" | tail -50
# 只看 stderr
docker logs myapp 2>/dev/null           # 只保留 stdout
docker logs myapp 1>/dev/null           # 只保留 stderr
# 统计错误数量
docker logs --since 1h myapp 2>&1 | grep -c ERROR
# 导出日志
docker logs myapp > app.log 2> app.err
# 批量查看多个容器
for c in $(docker ps --format "{{.Names}}"); do echo "===== $c ====="; docker logs --tail 20 $c 2>&1; done

重要限制:

  1. docker logs 只能读取容器 PID 1 的 stdout 与 stderr。应用把日志写进容器内的文件(如 /var/log/app.log)时,docker logs 什么都看不到。这也是"容器内应用应把日志打到标准输出"这条 12-factor 原则的原因。
  2. docker logs 只在 json-file 与 local 日志驱动下可用。改成 syslog、fluentd、gelf、awslogs 后会报 configured logging driver does not support reading。
  3. 日志文件在宿主机上的实际位置:
docker inspect --format '{{.LogPath}}' myapp
# /var/lib/docker/containers/<完整容器ID>/<完整容器ID>-json.log
cat $(docker inspect --format '{{.LogPath}}' myapp) | jq -r '.log'

让容器内文件日志也能被 docker logs 看到的技巧(nginx 官方镜像的做法):

RUN ln -sf /dev/stdout /var/log/nginx/access.log \
    && ln -sf /dev/stderr /var/log/nginx/error.log

3.9 docker inspect 与 --format 提取字段

docker inspect 会返回一个 JSON 文件,记录着 Docker 容器的配置和状态信息:

docker inspect c48d842f376a
[
    {
        "Id": "c48d842f376aaa4d6385aecb65684cf3c1e292b51118e52a254dfaf841ebcadf",
        "Created": "2019-05-10T07:28:41.824064096Z",
        "Path": "nginx",
        "Args": [
            "-g",
            "daemon off;"
        ],
        "State": {
            "Status": "running",
            "Running": true,
            "Paused": false,
            "Restarting": false,
            "OOMKilled": false,
            "Dead": false,
            "Pid": 69496,
            "ExitCode": 0,
            "Error": "",
            "StartedAt": "2019-05-10T07:28:43.230473885Z",
            "FinishedAt": "0001-01-01T00:00:00Z"
        }
    }
]

State 各字段的排错价值:

字段 用途
Status running / exited / paused / restarting / created / dead
Running / Paused / Restarting 布尔快捷判断
OOMKilled 为 true 说明容器是被内存 OOM 杀掉的,配合 ExitCode: 137 确认
Dead 容器处于异常不可清理状态
Pid 主进程在宿主机上的真实 PID,nsenter 要用它。容器停止时为 0
ExitCode 退出码,见 3.1 状态表
Error daemon 记录的启动错误信息
StartedAt / FinishedAt 启动与结束时间,用于计算运行时长与崩溃间隔
Health 健康检查状态与最近几次检查的输出

--format 提取字段的实用技巧(运维高频):

# 提取容器 IP —— 最常用的一条
docker inspect --format '{{.NetworkSettings.IPAddress}}' nginx
# 自定义网络下 IP 在各网络的子结构里,要遍历
docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' nginx
docker inspect --format '{{.NetworkSettings.Networks.mynet.IPAddress}}' nginx
# 带网络名一起输出
docker inspect --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}} {{end}}' nginx

# 状态与退出码
docker inspect --format '{{.State.Status}}' nginx
docker inspect --format '{{.State.ExitCode}}' nginx
docker inspect --format '{{.State.OOMKilled}}' nginx
docker inspect --format '{{.State.Pid}}' nginx
docker inspect --format '{{.RestartCount}}' nginx

# 挂载信息(排查挂载没生效时必看)
docker inspect --format '{{range .Mounts}}{{.Type}} {{.Source}} -> {{.Destination}} (RW={{.RW}}){{println}}{{end}}' nginx
# 端口映射
docker inspect --format '{{json .NetworkSettings.Ports}}' nginx
docker inspect --format '{{range $p,$c := .NetworkSettings.Ports}}{{$p}} -> {{(index $c 0).HostPort}}{{println}}{{end}}' nginx
# 环境变量
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' nginx
# 启动命令与入口
docker inspect --format '{{.Path}} {{.Args}}' nginx
docker inspect --format '{{json .Config.Entrypoint}} {{json .Config.Cmd}}' nginx
# 资源限制
docker inspect --format 'Mem={{.HostConfig.Memory}} NanoCPUs={{.HostConfig.NanoCpus}}' nginx
# 重启策略与日志路径
docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' nginx
docker inspect --format '{{.LogPath}}' nginx
# 健康检查
docker inspect --format '{{json .State.Health}}' nginx | jq

# 批量:一次列出所有容器的 名字+IP
docker inspect -f '{{.Name}} {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -q)
# 配合 jq 更灵活
docker inspect nginx | jq -r '.[0].NetworkSettings.Networks | to_entries[] | "\(.key): \(.value.IPAddress)"'

--format 支持的 Go 模板函数:json(转 JSON)、println、index、range、if、len、printf、upper、lower、split、join。

3.10 docker top 与 docker stats

3.10.1 docker top 查看容器内进程

docker top c48d842f376a
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
root                69496               69442               0                   15:28               ?                   00:00:00            nginx: master process nginx -g daemon off;
101                 69894               69496               0                   15:28               ?                   00:00:00            nginx: worker process

要点:

  • 显示的 PID 是宿主机上的真实 PID(69496),不是容器内看到的 PID 1。这体现了 PID namespace 的映射关系。
  • UID 是宿主机 UID。上例中 worker 进程的 UID 是 101,对应容器内的 nginx 用户。
  • 不需要容器内有 ps 命令(它读的是宿主机的 /proc),所以对 alpine、distroless 容器同样可用。
  • 支持传入 ps 的参数:
docker top nginx aux
docker top nginx -eo pid,ppid,user,%cpu,%mem,cmd
docker top nginx -o pid,cmd

对比 docker exec nginx ps aux —— 后者显示的是容器内视角的 PID(master 为 1),且要求容器内有 ps。

3.10.2 docker stats 实时资源监控

docker stats
CONTAINER ID   NAME      CPU %     MEM USAGE / LIMIT     MEM %     NET I/O           BLOCK I/O         PIDS
c48d842f376a   nginx     0.00%     3.164MiB / 1.795GiB   0.17%     1.45kB / 0B       0B / 0B           2
6d99ac6468f6   app       12.35%    412.5MiB / 512MiB     80.57%    2.31MB / 1.05MB   4.1MB / 8.19kB    24

字段含义:

字段 含义与关注点
CPU % CPU 使用率。可以超过 100%,200% 表示用满了 2 个核
MEM USAGE / LIMIT 已用内存 / 限额。LIMIT 显示宿主机总内存时说明没设 -m
MEM % 内存使用率。持续接近 100% 就快被 OOM Kill 了,是最需要报警的指标
NET I/O 容器网卡累计收 / 发字节数
BLOCK I/O 块设备累计读 / 写字节数(只统计 bind mount 与卷,不含容器可写层的部分场景)
PIDS 容器内进程/线程数。异常增长说明有进程泄漏或僵尸进程堆积

常用选项:

选项 说明
--no-stream 只输出一次快照就退出,脚本与监控采集必用(默认是每秒刷新的交互界面,会阻塞脚本)
-a / --all 包括已停止的容器(数值全为 0)
--format 自定义输出
--no-trunc 不截断
docker stats --no-stream
docker stats --no-stream nginx app db                     # 只看指定容器
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
docker stats --no-stream --format "{{.Name}},{{.CPUPerc}},{{.MemPerc}}" > /tmp/stats.csv
# 找出 CPU 最高的 5 个容器
docker stats --no-stream --format "{{.CPUPerc}}\t{{.Name}}" | sort -rh | head -5
# 找出内存占用率最高的
docker stats --no-stream --format "{{.MemPerc}}\t{{.Name}}" | sort -rh | head -5
# 只看 stats 的 JSON
docker stats --no-stream --format "{{json .}}"

docker stats 的 MEM USAGE 包含了 page cache,所以数值常常比应用真实的 RSS 大。要看更准确的内存构成:docker exec app cat /sys/fs/cgroup/memory/memory.stat(cgroup v1)。

3.11 docker cp 双向拷贝

使用 docker cp 可以将宿主机文件复制到容器内部:

touch /cp.txt
docker cp /cp.txt c48d842f376a:/
docker exec c48d842f376a ls /
bin boot cp.txt dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

可以看到已成功将宿主机的 /cp.txt 复制到容器内部的 / 目录下。

双向语法:

# 宿主机 -> 容器
docker cp <宿主机路径> <容器>:<容器内路径>
# 容器 -> 宿主机
docker cp <容器>:<容器内路径> <宿主机路径>

实用示例:

# 拷进去
docker cp ./nginx.conf nginx:/etc/nginx/nginx.conf
docker cp ./html/ nginx:/usr/share/nginx/          # 拷目录
docker cp ./app.jar myapp:/app/

# 拷出来(排错取现场最常用)
docker cp nginx:/etc/nginx/nginx.conf ./nginx.conf.bak
docker cp myapp:/app/logs/ ./logs/                 # 把容器内日志目录整个取出
docker cp mysql:/var/log/mysql/error.log ./
docker cp myapp:/tmp/heapdump.hprof ./             # 取 Java 堆转储分析

# 用 - 表示标准输入/输出,可以做流式传输
docker cp myapp:/app/data - | tar -tv               # 列出容器内目录内容
tar -cf - ./conf | docker cp - myapp:/app/          # 从标准输入拷入

选项:

选项 说明
-a / --archive 保留 UID/GID 属主信息
-L / --follow-link 跟随源路径中的符号链接

要点与限制:

  • 容器停止状态下也能 cp(这是取崩溃现场的关键能力);
  • 路径规则同 Linux cp:目标以 / 结尾表示放进该目录,否则表示重命名;
  • docker cp 不会触发 nginx/应用重载配置,改完配置还要 docker exec nginx nginx -s reload;
  • 不要把 docker cp 当作持久化手段。容器一删,拷进去的东西就没了。配置文件应该用 bind mount 挂进去。
  • docker cp 拷进去的文件属主是 root,降权容器的应用可能读不了,需要 -a 或事后 chown。

3.12 docker diff 查看文件系统变更

docker diff <容器>
docker diff nginx
C /run
A /run/nginx.pid
C /etc
C /etc/nginx
A /etc/nginx/conf.d/test.conf
D /etc/nginx/conf.d/default.conf
C /var/cache/nginx
A /var/cache/nginx/client_temp

前缀含义:

前缀 含义
A Added,新增的文件或目录
C Changed,被修改的文件或目录(目录内有变化也算 C)
D Deleted,被删除的文件

用途:

# 1. commit 之前确认到底改了什么
docker diff mycontainer
# 2. 排查是谁在往可写层写数据导致磁盘涨
docker diff myapp | grep '^A' | head -50
docker ps -s                        # 配合看可写层大小
# 3. 排除挂载点的干扰(挂载的卷不会出现在 diff 里)
docker diff myapp | grep -v '^C /proc'

注意:docker diff 只对比容器可写层与镜像层的差异,挂载的数据卷与 bind mount 里的变化不会显示。

3.13 docker port 查看端口映射

docker port c48d842f376a
80/tcp -> 0.0.0.0:80
docker port <容器>                    # 列出所有映射
docker port <容器> 80                 # 只查容器 80 端口映射到哪
docker port <容器> 80/tcp
docker port web
80/tcp -> 0.0.0.0:32768

对于运行中的容器,可以使用 docker port 加上容器端口和容器 ID 查看其映射到宿主机的端口。这在使用 -P 随机映射时特别有用。

脚本中提取宿主端口:

HOST_PORT=$(docker port web 80 | cut -d: -f2)
curl http://127.0.0.1:$HOST_PORT/
# 或者
docker inspect --format '{{(index (index .NetworkSettings.Ports "80/tcp") 0).HostPort}}' web

3.14 docker rename 重命名

docker rename <旧名> <新名>
docker rename nostalgic_lumiere hello-test
docker rename web web-old            # 蓝绿切换时腾出名字

随时可用,容器运行中也能改,不影响容器状态。用途:把 Docker 随机生成的名字改成有意义的名字;或在替换容器时先把老容器改名,让新容器接管原名。

3.15 docker update 动态调整资源限制

docker update [选项] <容器...>

可动态修改的项:

选项 说明
-m / --memory 内存上限
--memory-swap 内存+swap 上限
--memory-reservation 内存软限制
--kernel-memory 内核内存
--cpus CPU 数量
-c / --cpu-shares CPU 权重
--cpu-period / --cpu-quota CFS 参数
--cpuset-cpus / --cpuset-mems 绑核
--blkio-weight I/O 权重
--pids-limit 进程数上限
--restart 重启策略
# 容器内存不够了,在线扩到 4G,无需重启
docker update -m 4g --memory-swap 4g myapp
docker update --cpus=4 myapp
docker update --cpu-shares=2048 myapp
docker update --cpuset-cpus="0-3" myapp
docker update --restart=unless-stopped myapp
# 批量给所有容器加限制
docker update -m 1g --cpus=1 $(docker ps -q)

限制:

  • 不能改端口映射、卷挂载、环境变量、网络 —— 这些只能删掉容器重建。
  • 调小内存时如果容器当前用量已超过新值,会立刻触发 OOM Kill。
  • 部分参数(如 --memory-swap)在某些内核上要求容器处于停止状态。

3.16 docker wait 与 docker events

docker wait:阻塞直到容器停止,并输出退出码。 用于脚本中等待一次性任务完成:

docker wait <容器>
docker run -d --name job myjob:v1
EXIT_CODE=$(docker wait job)
echo "任务退出码:$EXIT_CODE"
if [ "$EXIT_CODE" -ne 0 ]; then
    docker logs job
    exit 1
fi
docker rm job
# 可以同时等多个容器,按顺序输出退出码
docker wait job1 job2 job3

docker events:实时订阅 Docker daemon 的事件流。 这是审计与自动化的入口。

docker events
docker events --since '2026-08-22T00:00:00'    # 查看历史事件
docker events --since 1h --until 10m
docker events --filter 'container=nginx'
docker events --filter 'event=die'             # 只看容器异常退出
docker events --filter 'event=start' --filter 'event=stop'
docker events --filter 'type=container'        # container/image/volume/network/daemon/plugin/service
docker events --filter 'type=volume'
docker events --filter 'image=nginx:1.16'
docker events --filter 'label=env=prod'
docker events --filter 'event=health_status'   # 监控健康状态变化
docker events --format '{{.Time}} {{.Type}} {{.Action}} {{.Actor.Attributes.name}}'
docker events --format '{{json .}}'

常见事件类型:create、start、restart、stop、kill、die、destroy、pause、unpause、oom、health_status、exec_create、exec_start、attach、commit、rename、update。

自动化示例 —— 容器异常退出时告警:

docker events --filter 'event=die' --format '{{.Actor.Attributes.name}} {{.Actor.Attributes.exitCode}}' | \
while read name code; do
    [ "$code" != "0" ] && echo "告警:容器 $name 异常退出,退出码 $code" | mail -s "Docker Alert" ops@example.com
done

监听 OOM 事件(配合 3.9 的 OOMKilled 判断):

docker events --filter 'event=oom' --format '{{.Time}} OOM: {{.Actor.Attributes.name}}'

3.17 容器内的 PID 1 问题、信号处理与僵尸进程

容器内 PID 1 的进程承担了传统 Linux 上 init(systemd)的角色,但普通应用程序完全没有为此设计,由此产生三类问题。

3.17.1 问题一:PID 1 的信号语义特殊

内核对 PID 1 有特殊规则:对于没有安装处理函数的信号,PID 1 会直接忽略它们(普通进程的默认行为是被杀死)。

后果:如果应用没有显式处理 SIGTERM,docker stop 发出的 SIGTERM 会被完全忽略,容器必须等满 10 秒宽限期后被 SIGKILL 强杀。表现是:

  • 每次 docker stop 都要卡 10 秒;
  • 应用没有机会做优雅退出(连接没关、缓冲没落盘、注册中心没注销);
  • 退出码永远是 137。

3.17.2 问题二:shell 形式的 CMD 让 sh 成为 PID 1

CMD java -jar /app/app.jar

实际运行的是 /bin/sh -c "java -jar /app/app.jar",进程树是:

PID 1: /bin/sh -c java -jar /app/app.jar
PID 7:   \_ java -jar /app/app.jar

docker stop 的 SIGTERM 发给 PID 1(sh)。sh 不会把信号转发给子进程,所以 java 进程完全不知道要退出,10 秒后被连带 SIGKILL。

验证:

docker exec myapp ps -ef       # 看 PID 1 是不是你的应用
docker inspect --format '{{.Path}} {{.Args}}' myapp

解法:用 exec 形式

CMD ["java", "-jar", "/app/app.jar"]

若必须用 shell(需要变量展开),在命令前加 exec:

CMD exec java -jar /app/app.jar -Dport=$PORT

入口脚本里也必须用 exec:

#!/bin/sh
set -e
# 前置初始化...
exec "$@"        # 关键:用 exec 替换掉 shell 自身,让应用成为 PID 1

3.17.3 问题三:僵尸进程(zombie process)无人回收

进程退出后会保留一个 task 结构等待父进程 wait() 读取其退出状态,此期间状态为 Z(zombie)。如果父进程先死了,孤儿进程会被 PID 1 收养,由 PID 1 负责回收。

传统 init 会循环调用 wait() 回收所有被收养的子进程。但你的应用程序作为 PID 1 时通常不做这件事,于是僵尸进程越积越多:

docker exec myapp ps -ef | grep defunct
docker exec myapp ps aux | awk '$8 ~ /Z/ {print}'
docker stats --no-stream          # PIDS 列不断增长

最终耗尽 PID 上限(--pids-limit 或系统 kernel.pid_max),无法再 fork,应用报 Resource temporarily unavailable。

典型触发场景: 应用内部频繁 fork 外部命令(如 Java 调 Runtime.exec、Python subprocess、shell 脚本循环起子进程),并且中间进程会退出。

3.17.4 解法:--init 选项

docker run -d --init --name myapp myapp:v1

--init 会在容器内注入一个极小的 init 程序 tini(/sbin/docker-init)作为 PID 1,它做两件事:

  1. 回收僵尸进程:循环 waitpid() 清理所有被收养的孤儿进程;
  2. 转发信号:把收到的 SIGTERM 等信号转发给它的子进程(即你的应用)。

进程树变为:

PID 1: /sbin/docker-init -- java -jar /app/app.jar
PID 7:   \_ java -jar /app/app.jar

验证:

docker exec myapp ps -ef | head -3
docker inspect --format '{{.HostConfig.Init}}' myapp

也可以把 tini 直接打进镜像(不依赖运行时加 --init):

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends tini \
    && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["java", "-jar", "/app/app.jar"]

Alpine:

RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]

或让 daemon 默认对所有容器启用:

{
  "init": true
}

该不该用 --init 的判断:

情况 建议
应用是单进程、不 fork 子进程(如 nginx、redis 官方镜像) 不需要,它们自己处理了信号
应用会 fork 外部命令(Java/Python/Node 调外部程序) 建议加 --init
入口是 shell 脚本且没用 exec 加 --init,但更应该修 Dockerfile
容器内跑多个进程(supervisord/s6) 用专业的进程管理器,它们本身就有 init 能力
不确定 加上,代价几乎为零(tini 只有几百 KB)

3.17.5 应用侧的优雅退出示例

// Java:注册 shutdown hook
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    server.stop();          // 停止接收新请求
    pool.shutdown();        // 等待存量任务
    db.close();
}));
# Python
import signal, sys
def graceful(signum, frame):
    server.shutdown(); sys.exit(0)
signal.signal(signal.SIGTERM, graceful)
// Go
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGTERM, syscall.SIGINT)
<-c
srv.Shutdown(context.Background())

3.18 容器日志驱动与磁盘写满的防治

3.18.1 日志驱动一览

驱动 说明 docker logs 是否可用
json-file 默认。日志以 JSON 行写入宿主机文件 可用
local Docker 18.09+ 的优化格式,带压缩,性能更好,默认限制 100m×5 可用
none 完全丢弃日志 不可用
journald 写入 systemd journal,可用 journalctl 查询 可用
syslog 发送到 syslog 服务器(本地或远程) 不可用
fluentd 发送到 Fluentd/Fluent Bit 不可用
gelf Graylog Extended Log Format,发给 Graylog/Logstash 不可用
awslogs / gcplogs 云厂商日志服务 不可用
splunk Splunk HEC 不可用
etwlogs Windows 专用 不可用

3.18.2 json-file 的 max-size 与 max-file(磁盘写满的核心防线)

默认配置下 json-file 日志是无上限增长的。 一个打日志很凶的应用可以在几天内写出几十 GB 的单个日志文件,把 /var/lib/docker 所在分区撑满,导致:

  • dockerd 无法写入,所有容器异常;
  • 宿主机 / 满,sshd 都可能登不上去;
  • 删除日志文件不会立刻释放空间(文件仍被 dockerd 进程持有),必须重启容器或用 truncate。

全局配置(推荐,一劳永逸) /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "3",
    "compress": "true"
  }
}
systemctl daemon-reload
systemctl restart docker

参数说明:

参数 说明
max-size 单个日志文件的大小上限,支持 k/m/g。达到后轮转
max-file 保留的日志文件个数(含当前文件)。设为 3 表示最多 x.log、x.log.1、x.log.2
compress 轮转后的历史文件是否 gzip 压缩
labels / env 把指定的标签/环境变量附加到每条日志中,便于日志系统区分来源
mode blocking(默认,写日志阻塞应用)或 non-blocking(异步,配合 max-buffer-size,日志系统慢时不拖累应用)

单个容器的最大日志占用 = max-size × max-file。上例为 300MB。规划时要乘以容器数量。

单容器配置:

docker run -d --log-driver=json-file \
  --log-opt max-size=50m --log-opt max-file=3 \
  --log-opt compress=true \
  --name web nginx
# 非阻塞模式,日志高峰不影响应用性能
docker run -d --log-opt mode=non-blocking --log-opt max-buffer-size=4m --name app myapp
# 完全不要日志(如已由应用自己写文件外送)
docker run -d --log-driver=none --name app myapp

关键提醒:daemon.json 的日志配置只对【之后新建】的容器生效,已有容器不会变。改完必须重建容器:

# 检查现有容器的日志配置
docker inspect --format '{{.Name}} {{.HostConfig.LogConfig}}' $(docker ps -aq)

3.18.3 日志占用的排查与紧急清理

# 1. 看 Docker 总占用
du -sh /var/lib/docker
docker system df

# 2. 找出日志最大的容器(按日志文件大小排序)
du -sh /var/lib/docker/containers/* | sort -rh | head -10
find /var/lib/docker/containers/ -name "*-json.log" -exec ls -lh {} \; | awk '{print $5, $9}' | sort -rh | head -10

# 3. 把容器 ID 翻译成容器名
for f in $(find /var/lib/docker/containers/ -name "*-json.log" -size +500M); do
    id=$(basename $(dirname $f))
    echo "$(du -h $f | cut -f1)  $(docker ps -a --filter id=$id --format '{{.Names}}')"
done

# 4. 紧急清空日志(正确做法:用 truncate,不要 rm)
truncate -s 0 /var/lib/docker/containers/*/*-json.log
# 或针对单个容器
truncate -s 0 $(docker inspect --format='{{.LogPath}}' myapp)
# 一键清空所有
docker ps -aq | xargs -I {} sh -c 'truncate -s 0 $(docker inspect --format="{{.LogPath}}" {})'

为什么用 truncate 而不是 rm: rm 删除的是目录项,dockerd 仍持有该文件的 inode,磁盘空间不会释放(df 显示还是满的,但 du 变小了),而且之后 dockerd 继续往一个不存在的文件写,docker logs 彻底失效。truncate -s 0 保留 inode,立即释放空间且日志功能正常。

也可以用 logrotate 兜底(适用于无法重建容器的老环境):

# /etc/logrotate.d/docker-container
/var/lib/docker/containers/*/*.log {
    rotate 3
    daily
    compress
    missingok
    delaycompress
    copytruncate
    notifempty
    size 100M
}

详见 08-02-日志管理.md

生产上更完整的方案是把日志外送到集中式日志平台(ELK/EFK/Loki),容器本地只保留少量最近日志:

docker run -d --log-driver=fluentd \
  --log-opt fluentd-address=10.0.0.20:24224 \
  --log-opt tag="docker.{{.Name}}" \
  --log-opt fluentd-async-connect=true \
  --name app myapp:v1

3.19 实战:在容器中运行 httpd 应用

前面的容器都没有跑实际应用,生产环境中容器就是用来跑应用的。下面在 Ubuntu 容器中安装并运行 Apache。

3.19.1 交互式启动 Ubuntu 容器

docker run -i -t docker.io/ubuntu /bin/bash

3.19.2 在容器内安装 Apache

更新软件源:

apt-get update

安装 vim:

apt-get install vim-gtk -y

安装 ifconfig:

apt install net-tools -y

安装 curl:

apt install curl -y

安装 Apache:

apt-get install apache2 -y

启动 Apache 服务:

/etc/init.d/apache2 restart

创建主页:

echo "web" > /var/www/html/index.html

验证:

curl 127.0.0.1
web

退出容器:

exit

3.19.3 重新启动已退出的容器

退出后容器处于停止状态:

docker ps -l
CONTAINER ID        IMAGE                                    COMMAND             CREATED             STATUS                      PORTS               NAMES
2339171d8a4f        docker.io/ubuntu                         "/bin/bash"         21 minutes ago      Exited (0) 23 seconds ago                       adoring_shockley

再次启动:

docker start 2339171d8a4f
docker ps -l
CONTAINER ID        IMAGE                                    COMMAND             CREATED             STATUS              PORTS               NAMES
2339171d8a4f        docker.io/ubuntu                         "/bin/bash"         21 minutes ago      Up 1 seconds                            adoring_shockley

这个手工过程只适合教学理解。生产上应该用 2.5 节的 Dockerfile 方式构建 httpd 镜像 —— 可重复、可版本化、可自动化。

3.20 常用命令速查(源讲义表格)

操作 命令
搜索镜像 docker search <关键字>
拉取镜像 docker pull <镜像名>
列出本地镜像 docker images
运行容器(交互) docker run -it <镜像> /bin/bash
运行容器(后台) docker run -d <镜像> <命令>
列出所有容器 docker ps -a
查看容器日志 docker logs <容器>
停止容器 docker stop <容器>
启动容器 docker start <容器>
连接运行中容器 docker attach <容器>
提交容器为镜像 docker commit <容器> <镜像名>
删除容器 docker rm <容器>
删除镜像 docker rmi <镜像名>

四、数据管理与网络

4.1 数据持久化

4.1.1 容器可写层为什么不能存数据

回顾 1.3.3:容器 = 只读镜像层 + 一个可写层。可写层有四个致命缺陷:

缺陷 说明
生命周期与容器绑定 docker rm 之后可写层被销毁,数据永久丢失且不可恢复。而容器在生产中是"随时可被替换"的 —— 升级镜像、调整参数、扩缩容都要重建容器
无法在容器间共享 每个容器有自己独立的可写层,两个容器看不到彼此的数据
性能差 联合文件系统的写时复制有写放大问题。修改一个 1GB 的数据库文件,第一次写要先整体复制该文件到可写层
难以备份与迁移 数据埋在 /var/lib/docker/overlay2/<hash>/diff 深处,路径随机,无法直接用常规备份工具处理

结论:容器是无状态的执行单元,所有需要保留的数据必须放到容器外面。

Docker 提供三种挂载方式:

              宿主机文件系统
   ┌────────────────────────────────────────────┐
   │  /var/lib/docker/volumes/  ← Volume        │  Docker 管理
   │  /data/myapp/              ← Bind Mount    │  用户指定任意路径
   │  (内存,不落盘)           ← tmpfs         │  内存文件系统
   └────────────────────────────────────────────┘
                      ↕ 挂载进容器
   ┌────────────────────────────────────────────┐
   │              容器文件系统                    │
   └────────────────────────────────────────────┘

4.1.2 Volume 数据卷(推荐)

数据卷是由 Docker 管理的、存放在宿主机特定目录下的一块独立存储。

卷的管理命令:

docker volume create <卷名>              # 创建
docker volume ls                         # 列出
docker volume inspect <卷名>             # 查看详情
docker volume rm <卷名>                  # 删除
docker volume prune                      # 清理所有未被使用的卷

docker volume create:

docker volume create mydata
docker volume create --name mysql-data
# 指定驱动与选项
docker volume create --driver local \
  --opt type=none --opt device=/data/mysql --opt o=bind mysql-bind
# 创建一个 tmpfs 类型的卷
docker volume create --driver local --opt type=tmpfs --opt device=tmpfs --opt o=size=100m,uid=1000 cache
# NFS 卷(跨主机共享数据的常用做法)
docker volume create --driver local \
  --opt type=nfs \
  --opt o=addr=192.168.1.200,rw,nfsvers=4 \
  --opt device=:/export/data nfsdata
# 打标签,便于批量管理
docker volume create --label env=prod --label app=mysql mysql-prod

docker volume ls:

docker volume ls
DRIVER    VOLUME NAME
local     mydata
local     mysql-data
local     3f8a9c2e1b7d5a4f6e8c9b0a1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e

那个长 hash 就是匿名卷。

docker volume ls -q                                  # 只列名字
docker volume ls -f dangling=true                    # 只列未被任何容器使用的卷(清理前必查)
docker volume ls -f name=mysql                       # 按名字过滤
docker volume ls -f driver=local
docker volume ls -f label=env=prod
docker volume ls --format "table {{.Name}}\t{{.Driver}}\t{{.Mountpoint}}"

docker volume inspect:

docker volume inspect mydata
[
    {
        "CreatedAt": "2026-08-22T10:15:32+08:00",
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/var/lib/docker/volumes/mydata/_data",
        "Name": "mydata",
        "Options": {},
        "Scope": "local"
    }
]
# 直接拿到宿主机上的实际路径 —— 备份与排错的关键
docker volume inspect --format '{{.Mountpoint}}' mydata
ls -l $(docker volume inspect --format '{{.Mountpoint}}' mydata)

docker volume rm 与 prune:

docker volume rm mydata
docker volume rm $(docker volume ls -q -f dangling=true)    # 删除所有悬空卷
docker volume prune                                          # 交互确认
docker volume prune -f
docker volume prune -f --filter "label!=keep"
docker volume prune -a                                       # Docker 23+:连具名卷也删(极危险)

docker volume rm 无法删除正在被容器使用的卷(即使容器已停止),会报 volume is in use。需要先删掉引用它的容器:

docker ps -a --filter volume=mydata
docker rm $(docker ps -aq --filter volume=mydata)
docker volume rm mydata

docker volume prune 是最容易造成生产事故的命令之一。 只要数据库容器当时是被删掉的(比如你正在做升级),它的数据卷就成了"未被使用",一 prune 就没了。执行前必须先 docker volume ls -f dangling=true 看清单。

4.1.3 匿名卷与具名卷

类型 创建方式 名字 特点
具名卷(named volume) -v mydata:/path 或 docker volume create mydata 用户指定 可预测、可复用、可被多容器共享、便于备份。生产必用
匿名卷(anonymous volume) -v /path(只写容器内路径)或 Dockerfile 的 VOLUME 64 位随机 hash 容器删除时若加 -v/--rm 会一并删除;否则永久残留在磁盘上,越积越多
# 具名卷:容器删了重建,数据还在
docker run -d --name mysql1 -v mysqldata:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=root mysql:8
docker rm -f mysql1
docker run -d --name mysql2 -v mysqldata:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=root mysql:8   # 数据完整保留

# 匿名卷:名字是随机 hash,几乎无法管理
docker run -d --name t1 -v /var/lib/mysql mysql:8
docker volume ls        # 看到一串 hash

# 清理堆积的匿名卷
docker volume ls -q -f dangling=true | wc -l
docker volume prune -f
# 用 --rm 或 rm -v 从源头避免堆积
docker run --rm -v /tmp/cache alpine sh -c 'echo x > /tmp/cache/f'
docker rm -v mycontainer

匿名卷堆积是"/var/lib/docker 越来越大"最隐蔽的原因之一。反复 docker run 带 VOLUME 指令的镜像(mysql、postgres、redis 官方镜像都有 VOLUME)却不用 --rm,每次都会新建一个几百 MB 的匿名卷。

4.1.4 -v 与 --mount 两种语法对照

# -v / --volume 语法:冒号分隔的位置参数
-v [源]:[容器内路径][:选项]

# --mount 语法:逗号分隔的 key=value
--mount type=<类型>,source=<源>,target=<容器内路径>[,其他选项]

完整对照表:

场景 -v 写法 --mount 写法
具名卷 -v mydata:/data --mount type=volume,source=mydata,target=/data
匿名卷 -v /data --mount type=volume,target=/data
Bind Mount -v /host/path:/data --mount type=bind,source=/host/path,target=/data
只读 -v mydata:/data:ro --mount type=volume,source=mydata,target=/data,readonly
tmpfs 不支持 --mount type=tmpfs,target=/tmp,tmpfs-size=100m
指定卷驱动 不支持 --mount type=volume,source=nfsdata,target=/data,volume-driver=local
禁止自动填充卷 不支持 --mount type=volume,source=v1,target=/data,volume-nocopy
SELinux 标签 -v /host:/data:z 不直接支持(需 --security-opt)
挂载传播 -v /host:/data:rshared --mount type=bind,source=/host,target=/data,bind-propagation=rshared
一致性(macOS) -v /host:/data:cached --mount type=bind,source=/host,target=/data,consistency=cached

两者的核心区别(重点):

对比项 -v --mount
语法 简洁,位置参数,易记 冗长,显式 key=value,自解释
源路径不存在时 自动创建目录(bind mount 场景)。这是一个危险的隐式行为 —— 路径打错时不会报错,而是悄悄建了个空目录,容器里看到的是空的,让人误以为"挂载没生效" 直接报错退出:bind source path does not exist。行为更安全
源是卷名还是路径 靠"是否以 / 开头"来猜。-v data:/x 是卷,-v ./data:/x 是 bind 靠 type= 显式指定,无歧义
支持 tmpfs 不支持 支持
支持卷驱动选项 不支持 支持
Swarm service 不支持 只支持 --mount
官方推荐 兼容保留 推荐
# -v 的隐式创建陷阱(把 html 打成了 htmls)
docker run -d -v /data/htmls:/usr/share/nginx/html nginx
# 结果:自动创建了空目录 /data/htmls,网站变成 403,且没有任何报错

# --mount 会立刻报错,让你马上发现拼错了
docker run -d --mount type=bind,source=/data/htmls,target=/usr/share/nginx/html nginx
# docker: Error response from daemon: invalid mount config: bind source path does not exist: /data/htmls

实践建议:交互命令行、教学演示用 -v(快);脚本、docker-compose、生产部署用 --mount(安全、明确)。

4.1.5 数据卷的实际存储位置

/var/lib/docker/volumes/<卷名>/_data/
docker volume create mydata
docker run --rm -v mydata:/data alpine sh -c 'echo "hello volume" > /data/test.txt'
# 从宿主机直接读到刚写的内容
cat /var/lib/docker/volumes/mydata/_data/test.txt
hello volume
ls -l /var/lib/docker/volumes/
du -sh /var/lib/docker/volumes/* | sort -rh | head -10      # 找出最占空间的卷

注意:

  • 该目录属主是 root,权限 700,普通用户看不了;
  • 如果改了 daemon.json 的 data-root,路径要相应变化:docker info | grep "Docker Root Dir";
  • 虽然可以直接从宿主机改这个目录,但不推荐。 正确做法是用一个临时容器挂载该卷来操作,这样能保证权限与 SELinux 上下文正确:
docker run --rm -it -v mydata:/data alpine sh

4.1.6 数据卷容器 --volumes-from

数据卷容器(Data Volume Container)是一个专门用来"持有卷"的容器,其他容器通过 --volumes-from 继承它的所有挂载点。

# 1. 创建数据卷容器(不需要运行,create 即可;用极小的镜像)
docker create -v /dbdata -v /dbconf --name dbstore busybox
# 或者使用具名卷,更清晰
docker create -v dbdata:/dbdata -v dbconf:/dbconf --name dbstore busybox

# 2. 其他容器继承它的挂载
docker run -d --volumes-from dbstore --name db1 mysql:8 -e MYSQL_ROOT_PASSWORD=root
docker run -d --volumes-from dbstore --name db2 postgres:15
docker run -it --volumes-from dbstore --name tool alpine sh

# 3. 可以继承多个数据卷容器
docker run -d --volumes-from dbstore --volumes-from logstore --name app myapp

# 4. 也可以从任意普通容器继承(不限于数据卷容器)
docker run -it --volumes-from db1 alpine sh

# 5. 指定读写权限
docker run -d --volumes-from dbstore:ro --name reader myapp

使用场景:

场景 说明
多容器共享同一批数据 多个应用实例读写同一份上传文件目录
集中管理挂载点定义 挂载点写一次,N 个容器复用,避免每个 docker run 都写一长串 -v
备份 / 恢复 / 迁移 起一个临时容器 --volumes-from 数据容器,在里面 tar 打包(见下节)
日志集中收集 应用容器与日志采集容器共享日志目录

注意:

  • 数据卷容器不需要处于运行状态,docker create 即可,甚至可以是 Exited 状态;
  • 删除数据卷容器不会删除卷本身(除非用 docker rm -v);
  • --volumes-from 继承的是"挂载点定义",无法只挑其中一个;
  • 这是较早期的模式,现代实践更倾向直接用具名卷 —— 多个容器都写 -v shareddata:/data 即可,语义更明确,也不需要维护一个多余的容器。docker-compose 里更是直接用 volumes: 段声明。

4.1.7 备份与恢复数据卷的完整命令

核心思路:起一个临时容器,同时挂载"要备份的卷"和"宿主机备份目录",在容器里用 tar 打包。

备份:

# 方式一:通过数据卷容器
docker run --rm \
  --volumes-from dbstore \
  -v /backup:/backup \
  alpine tar -czf /backup/dbdata-$(date +%Y%m%d-%H%M%S).tar.gz -C /dbdata .

# 方式二:直接挂载具名卷(更常用)
docker run --rm \
  -v mysqldata:/data:ro \
  -v /backup:/backup \
  alpine tar -czf /backup/mysqldata-$(date +%F).tar.gz -C /data .

# 方式三:不落地,直接通过标准输出流式备份到宿主机
docker run --rm -v mysqldata:/data:ro alpine tar -czf - -C /data . > /backup/mysqldata-$(date +%F).tar.gz

# 数据库的正确姿势:先冻结或用逻辑备份保证一致性
docker pause mysql
docker run --rm -v mysqldata:/data:ro -v /backup:/backup alpine tar -czf /backup/mysql-$(date +%F).tar.gz -C /data .
docker unpause mysql
# 更推荐用逻辑备份
docker exec mysql sh -c 'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --all-databases' | gzip > /backup/all-$(date +%F).sql.gz

恢复:

# 1. 创建(或清空)目标卷
docker volume create mysqldata-restore

# 2. 解压进去
docker run --rm \
  -v mysqldata-restore:/data \
  -v /backup:/backup \
  alpine sh -c "cd /data && tar -xzf /backup/mysqldata-2026-08-22.tar.gz"

# 3. 用恢复出的卷启动容器
docker run -d --name mysql-new -v mysqldata-restore:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=root mysql:8

# 恢复到原卷(先清空,注意 * 不含隐藏文件,所以用 find 或 rm -rf /data/..? 组合)
docker run --rm -v mysqldata:/data alpine sh -c 'rm -rf /data/* /data/.[!.]* 2>/dev/null; true'
docker run --rm -v mysqldata:/data -v /backup:/backup alpine sh -c "cd /data && tar -xzf /backup/mysqldata-2026-08-22.tar.gz"

卷之间迁移(换机器 / 换卷名):

# 同机复制卷
docker volume create newvol
docker run --rm -v oldvol:/from:ro -v newvol:/to alpine sh -c 'cd /from && cp -a . /to/'

# 跨主机迁移:流式经 ssh 传输,不落中间文件
docker run --rm -v mydata:/data:ro alpine tar -czf - -C /data . | \
  ssh root@newhost 'docker volume create mydata && docker run --rm -i -v mydata:/data alpine tar -xzf - -C /data'

自动备份脚本示例:

#!/bin/bash
# /usr/local/bin/docker-volume-backup.sh
set -euo pipefail
BACKUP_DIR=/backup/volumes
KEEP_DAYS=7
mkdir -p "$BACKUP_DIR"
for vol in $(docker volume ls -q -f label=backup=true); do
    ts=$(date +%Y%m%d-%H%M%S)
    echo "备份卷 $vol ..."
    docker run --rm -v "${vol}":/data:ro -v "${BACKUP_DIR}":/backup alpine \
        tar -czf "/backup/${vol}-${ts}.tar.gz" -C /data . || echo "警告:$vol 备份失败"
done
# 清理过期备份
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +${KEEP_DAYS} -delete
echo "备份完成:$(ls -lh $BACKUP_DIR | tail -5)"
# 给需要备份的卷打标签
docker volume create --label backup=true mysqldata
# 加进 crontab
echo "0 2 * * * /usr/local/bin/docker-volume-backup.sh >> /var/log/docker-backup.log 2>&1" | crontab -

4.1.8 Bind Mount 绑定挂载

把宿主机上的任意目录或文件直接挂进容器。

docker run -d -v /宿主机绝对路径:/容器内路径[:选项] 镜像
docker run -d --mount type=bind,source=/宿主机路径,target=/容器内路径 镜像

常用示例:

# 挂网站根目录,改宿主机文件立刻在容器内生效,开发调试神器
docker run -d -p 80:80 -v /data/html:/usr/share/nginx/html nginx:1.25
# 挂配置文件(单个文件也可以挂)
docker run -d -v /data/nginx.conf:/etc/nginx/nginx.conf:ro nginx:1.25
# 挂日志目录出来,便于宿主机上的日志采集 agent 读取
docker run -d -v /var/log/myapp:/app/logs myapp:v1
# 挂时区文件解决容器时间不对
docker run -d -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro myapp
# 开发环境:挂源码,实现热重载
docker run -it -v $PWD:/app -w /app node:18 npm run dev
# 挂只读的证书目录
docker run -d -v /etc/ssl/private:/certs:ro nginx

规则与坑:

① 宿主机路径必须是绝对路径

# 错误:会被当成【卷名】而不是路径!Docker 会创建一个名叫 "data" 的具名卷
docker run -v data:/app/data nginx
docker run -v ./data:/app/data nginx     # 旧版本报错;新版本可用但不推荐

# 正确
docker run -v /data/app:/app/data nginx
docker run -v $PWD/data:/app/data nginx
docker run -v $(pwd)/data:/app/data nginx

② 挂载会完全覆盖容器内该路径的原有内容

这是 Bind Mount 与 Volume 最重要的行为差异:

# 容器内 /usr/share/nginx/html 原本有 index.html
# 挂一个空目录上去,容器内该目录就变成空的,访问网站 403 Forbidden
mkdir -p /data/empty
docker run -d -p 80:80 -v /data/empty:/usr/share/nginx/html nginx
curl localhost      # 403

# 对比:Volume 会把镜像里的原有内容【复制】到新建的空卷里
docker volume create htmlvol
docker run -d -p 8080:80 -v htmlvol:/usr/share/nginx/html nginx
curl localhost:8080     # 正常显示 nginx 欢迎页!
ls /var/lib/docker/volumes/htmlvol/_data     # index.html 50x.html 都在
行为 Bind Mount Volume(空卷首次挂载)
容器内原有文件 被完全遮蔽,看不到 被复制到卷中,可见

这个差异是无数"挂载后应用起不来"故障的根源。挂配置目录前,一定要先把镜像里的原始文件拷出来:

docker run --rm nginx cat /etc/nginx/nginx.conf > /data/nginx.conf
docker create --name tmp nginx && docker cp tmp:/etc/nginx/conf.d /data/ && docker rm tmp

③ 只读挂载 :ro

docker run -d -v /data/conf:/etc/nginx/conf.d:ro nginx
docker run -d --mount type=bind,source=/data/conf,target=/etc/nginx/conf.d,readonly nginx
# 验证
docker exec nginx touch /etc/nginx/conf.d/x    # touch: cannot touch: Read-only file system
docker inspect --format '{{range .Mounts}}{{.Destination}} RW={{.RW}}{{println}}{{end}}' nginx

配置文件、证书、静态资源一律建议 :ro,可以防止容器内的程序(或攻击者)篡改宿主机文件。

④ SELinux 的 :z 与 :Z 后缀

在启用了 SELinux 的 CentOS/RHEL 上,宿主机目录的 SELinux 上下文默认不允许容器进程访问,表现为容器内明明有读权限却 Permission denied:

docker run -d -v /data/html:/usr/share/nginx/html nginx
docker exec nginx ls /usr/share/nginx/html      # Permission denied
# 宿主机上能看到 AVC 拒绝记录
ausearch -m avc -ts recent | tail
tail -f /var/log/audit/audit.log | grep denied

解决办法:加 :z 或 :Z 后缀,Docker 会自动为该目录重打 SELinux 标签(container_file_t):

后缀 含义 适用
:z(小写) 打上共享标签(s0,无 MCS 类别),多个容器都可以访问该目录 多容器共享同一目录
:Z(大写) 打上私有标签(带唯一 MCS 类别,如 s0:c123,c456),只有该容器能访问 单容器独占,安全性更高
docker run -d -v /data/html:/usr/share/nginx/html:z nginx        # 共享
docker run -d -v /data/mysql:/var/lib/mysql:Z mysql:8            # 私有
docker run -d -v /data/conf:/etc/nginx/conf.d:ro,z nginx         # 与 ro 组合

:Z 的危险用法警告:绝对不要对 /home、/usr、/etc、/var 这类系统目录使用 :Z。 它会递归修改该目录下所有文件的 SELinux 标签,可能导致 sshd、httpd 等系统服务无法访问自己的文件,把系统搞坏。

手工设置标签(更可控,且是持久化的正确做法):

semanage fcontext -a -t container_file_t "/data/html(/.*)?"
restorecon -Rv /data/html
ls -lZ /data/html

或允许容器访问所有主机文件(放宽策略,不推荐生产使用):

setsebool -P container_manage_cgroup on
setsebool -P virt_sandbox_use_all_caps on
# 查看容器相关的 SELinux 布尔值
getsebool -a | grep container

详见 07-03-防火墙与SELinux.md

4.1.9 Volume 与 Bind Mount 对比表

对比项 Volume(数据卷) Bind Mount(绑定挂载)
宿主机位置 Docker 管理,固定在 /var/lib/docker/volumes/<名>/_data 用户任意指定的绝对路径
创建方式 docker volume create 或 -v 名:/路径 自动创建 -v /宿主路径:/容器路径,路径不存在时 -v 会自动建空目录,--mount 报错
容器内原有文件 首次挂载空卷时会被复制进卷,内容保留 被完全遮蔽,看不到
与宿主机目录结构解耦 是。可移植,不依赖宿主机路径布局 否。强依赖宿主机具体路径,换机器要保证路径一致
Docker 命令可管理 是(volume ls/inspect/rm/prune) 否,只能用 ls、du 等系统命令
备份便利性 高(有统一位置与标准做法) 高(路径已知,直接 tar)
支持卷驱动(NFS/云盘/加密) 支持,可对接分布式存储 不支持
Windows/macOS 上性能 好 差(要经过文件共享层,大量小文件时慢十倍以上)
SELinux 处理 Docker 自动处理好标签 需手动 :z / :Z
权限管理 卷目录属主由 Docker 初始化,通常匹配镜像内用户 沿用宿主机目录属主,易出现 UID 不匹配导致 Permission denied
能否挂载单个文件 不能(卷是目录) 能
宿主机上直接编辑 可以但不推荐(路径深、需 root) 方便,这是它的主要优势
适用场景 数据库数据、应用产生的持久数据、需要备份迁移的数据、生产环境首选 配置文件、证书、静态资源、开发时挂源码、日志导出、时区文件

选型口诀:数据用 Volume,配置用 Bind Mount。

4.1.10 tmpfs 挂载

把一块内存挂载到容器目录。数据只存在于内存中,容器停止即消失,永不落盘。

docker run -d --tmpfs /tmp nginx
docker run -d --tmpfs /run:rw,noexec,nosuid,size=64m nginx
docker run -d --mount type=tmpfs,target=/tmp,tmpfs-size=100m,tmpfs-mode=1777 nginx

选项:

选项 说明
tmpfs-size 大小上限(字节或带单位)。不设则默认为宿主机内存的 50%,有打满内存的风险,建议总是显式设置
tmpfs-mode 权限模式,默认 1777(如 /tmp)。多数场景应设为 1770 或 0755
noexec / nosuid / nodev 安全挂载选项,禁止执行/setuid/设备文件

用途:

场景 说明
存放敏感临时数据 密钥、token、会话数据,绝不落盘,容器销毁即彻底消失
配合 --read-only 使用 根文件系统只读时,用 tmpfs 提供必要的可写目录(/tmp、/run、/var/cache)
高频临时 I/O 编译缓存、图片处理临时文件,内存速度远快于磁盘
减少磁盘写入 SSD 寿命敏感场景

配合只读根文件系统的完整生产写法:

docker run -d --name secure-nginx \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /run:rw,noexec,nosuid,size=16m \
  --tmpfs /var/cache/nginx:rw,size=128m \
  -v /data/html:/usr/share/nginx/html:ro \
  -p 80:80 \
  nginx:1.25-alpine

限制:

  • 只能在 Linux 上使用;
  • 不能在多个容器之间共享;
  • -v 语法不支持 tmpfs,必须用 --tmpfs 或 --mount type=tmpfs;
  • 占用的是宿主机内存,会计入容器的内存限额(-m),设太大可能导致 OOM。
# 验证
docker exec nginx df -h /tmp
docker exec nginx mount | grep tmpfs

4.2 Docker 网络

4.2.1 四种基础网络驱动

docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
a1b2c3d4e5f6   bridge    bridge    local
b2c3d4e5f6a1   host      host      local
c3d4e5f6a1b2   none      null      local

Docker 安装后自带三个网络。加上 container 模式(不是独立网络,而是 run 时的一种指定方式),共四种基础模式:

模式 指定方式 原理 容器 IP 端口冲突 隔离性 性能
bridge --network bridge(默认) 容器接入 docker0 虚拟网桥,通过 veth pair 与宿主机连通,出网走 SNAT,入网走 DNAT 独立私有 IP(172.17.x.x) 不冲突,各容器独立端口空间 好 有 NAT 开销
host --network host 不创建 NET namespace,直接使用宿主机网络栈 就是宿主机 IP 会与宿主机及其他 host 容器冲突 无网络隔离 最好,零开销
none --network none 有独立 NET namespace,但只有 lo 环回接口,没有任何外部网卡 无(只有 127.0.0.1) 不涉及 最强,完全断网 不涉及
container --network container:<名> 共享另一个容器的 NET namespace,两者网卡、IP、端口空间完全相同 与目标容器相同 与被共享容器共享端口空间,会冲突 与目标容器同级 同 bridge

bridge 模式(默认):

docker run -d --name web1 nginx
docker run -d --network bridge --name web2 nginx
docker exec web1 ip addr        # eth0 上有 172.17.0.x
docker inspect --format '{{.NetworkSettings.IPAddress}}' web1

host 模式:

docker run -d --network host --name web nginx
# 不需要也不能用 -p,直接就监听宿主机 80 端口
ss -tunlp | grep :80
docker exec web hostname -i        # 显示宿主机 IP
docker exec web ip addr            # 看到的就是宿主机的全部网卡

用途与风险:

  • 适用:需要极致网络性能(高并发网关、DPDK 应用)、需要监听大量端口(被动模式 FTP、SIP、RTP 媒体流)、需要抓宿主机流量的网络监控工具。
  • 风险:完全没有网络隔离,容器可以监听宿主机任意端口、访问宿主机 127.0.0.1 上的服务(如未设密码的本地 redis)、修改宿主机网络。-p、-P、--dns、--add-host 在 host 模式下全部失效(会有警告)。

none 模式:

docker run -it --network none alpine sh
# / # ip addr        只有 lo
# / # ping 8.8.8.8   完全不通

用途:处理不可信数据的沙箱(离线的图片/文档转换)、纯计算任务、由自定义 CNI 插件接管网络的场景。

container 模式:

docker run -d --name web nginx
docker run -it --network container:web alpine sh
# 在 alpine 里 ip addr 看到的网卡与 web 完全一样
# 可以直接 curl 127.0.0.1 访问 web 的 nginx(因为共享 localhost)
docker run -it --rm --network container:web nicolaka/netshoot ss -tunlp

这就是 K8s Pod 内多容器共享网络的实现原理(Pod 内有一个 pause 容器持有网络 namespace,其他容器都 container:pause)。用途:sidecar 模式(日志采集、服务网格代理)、给没有网络工具的容器做网络排错。

4.2.2 overlay 与 macvlan 简述

驱动 说明 适用场景
overlay 跨主机的覆盖网络。用 VXLAN 隧道把多台宿主机上的容器接入同一个二层网络,容器可跨主机用容器名直接互访。需要 Swarm 模式或外部 KV 存储(etcd/consul) Swarm 集群、多主机容器互联
macvlan 给容器分配一个与宿主机同网段的独立 MAC 与 IP,容器在物理网络上表现为一台独立主机,不经过 NAT 需要容器直接暴露在物理网络(传统应用迁移、需要被局域网直接访问、监听广播/组播的应用)。要求交换机允许一个物理口有多个 MAC(部分云环境禁止)
ipvlan 类似 macvlan 但共享宿主机 MAC,只分配不同 IP macvlan 受 MAC 数量限制时的替代
# overlay(需先 docker swarm init)
docker network create -d overlay --attachable myoverlay
# macvlan
docker network create -d macvlan \
  --subnet=192.168.1.0/24 --gateway=192.168.1.1 \
  -o parent=eth0 mymacvlan
docker run -d --network mymacvlan --ip 192.168.1.150 --name web nginx

详见 13-03-Docker-Swarm集群.md

4.2.3 默认 bridge 网络的原理:docker0、veth pair、iptables

docker0 网桥:

Docker 启动时会在宿主机创建一个名为 docker0 的 Linux bridge(虚拟交换机),默认网段 172.17.0.0/16,网关 172.17.0.1:

ip addr show docker0
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP
    link/ether 02:42:5a:1c:8f:3e brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
brctl show                       # 需要安装 bridge-utils
ip link show master docker0      # 查看挂在 docker0 上的接口
bridge link show

veth pair(虚拟网线):

每启动一个 bridge 模式的容器,Docker 就创建一对 veth 虚拟网卡设备。它们像一根虚拟网线的两头:

       容器 NET namespace                      宿主机 NET namespace
   ┌────────────────────────┐            ┌──────────────────────────────┐
   │  eth0 (172.17.0.2/16)  │◄══veth═══► │  vethXXXXXX ── docker0 网桥   │
   │  默认网关 172.17.0.1     │            │      (172.17.0.1/16)         │
   └────────────────────────┘            │             │                │
                                         │        eth0 (物理网卡)         │
   ┌────────────────────────┐            │             │                │
   │  eth0 (172.17.0.3/16)  │◄══veth═══► │  vethYYYYYY ─┘                │
   └────────────────────────┘            └──────────────────────────────┘
                                                       │
                                                    外部网络

验证:

docker run -d --name web nginx
# 容器内的 eth0
docker exec web ip addr show eth0
docker exec web ip route          # default via 172.17.0.1 dev eth0
# 找出容器 eth0 对应宿主机上的 veth 编号
docker exec web cat /sys/class/net/eth0/iflink     # 假设输出 15
ip link | grep -A1 "^15:"                          # 宿主机上第 15 号接口就是配对的 veth
# 宿主机上看到大量 vethXXXX@if2 就是各容器的对端
ip link show type veth

容器如何访问外网(SNAT / MASQUERADE):

容器 172.17.0.2 → 8.8.8.8
1. 容器查路由:默认网关 172.17.0.1(docker0)
2. 包经 veth 到 docker0,进入宿主机网络栈
3. 宿主机 ip_forward=1,允许转发
4. nat 表 POSTROUTING 链的 MASQUERADE 规则把源 IP 从 172.17.0.2 改写为宿主机出口 IP
5. 包从宿主机 eth0 发出。对外部看来,流量来自宿主机
6. 回包到达宿主机,conntrack 反向还原目标 IP 为 172.17.0.2,送回容器
iptables -t nat -L POSTROUTING -n -v
Chain POSTROUTING (policy ACCEPT)
target      prot opt source          destination
MASQUERADE  all  --  172.17.0.0/16   0.0.0.0/0
MASQUERADE  tcp  --  172.17.0.2      172.17.0.2      tcp dpt:80

因此容器能否上网的三个前提:

# ① 内核转发必须打开
sysctl net.ipv4.ip_forward         # 必须是 1
echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.conf && sysctl -p
# ② docker0 网卡存在且 UP
ip addr show docker0
# ③ MASQUERADE 规则存在
iptables -t nat -S POSTROUTING | grep 172.17

外网如何访问容器(DNAT / docker-proxy):

docker run -d -p 8080:80 --name web nginx
iptables -t nat -L DOCKER -n
Chain DOCKER (2 references)
target  prot opt source      destination
RETURN  all  --  0.0.0.0/0   0.0.0.0/0
DNAT    tcp  --  0.0.0.0/0   0.0.0.0/0   tcp dpt:8080 to:172.17.0.2:80
外部 → 宿主机:8080
1. nat 表 PREROUTING 跳到 DOCKER 链
2. DNAT 规则把目标地址从 宿主机:8080 改写为 172.17.0.2:80
3. 转发到 docker0,经 veth 送进容器
4. 容器回包,conntrack 反向 SNAT 还原为 宿主机:8080

同时 Docker 还会起一个 docker-proxy 用户态进程占住宿主机端口(这就是 2.5.3 里 lsof -i:8000 看到 docker-pr 的原因)。它的作用是兜底:处理来自宿主机自身 localhost 的访问(这种流量不经过 PREROUTING,DNAT 规则命中不到)。

ps -ef | grep docker-proxy
lsof -i:8080
ss -tunlp | grep 8080
# 完整看 Docker 建立的所有规则
iptables -t nat -S | grep -i docker
iptables -S DOCKER
iptables -S DOCKER-USER
iptables -S DOCKER-ISOLATION-STAGE-1

4.2.4 自定义网络的必要性(关键区别)

默认 bridge 与自定义 bridge 的最重要区别:内置 DNS 服务发现。

能力 默认 bridge 网络 自定义 bridge 网络
用容器名互相访问 不支持! 只能用 IP,或用已废弃的 --link 支持。Docker 内置 DNS(127.0.0.11)自动解析容器名与网络别名
容器间隔离 所有默认 bridge 容器都在一个大网段,互相可达,无法隔离 不同自定义网络之间默认互不通,可做业务隔离
动态接入/断开 不支持热插拔 docker network connect/disconnect 可对运行中容器操作
自定义网段/网关 只能改 daemon 全局配置 每个网络独立指定 --subnet、--gateway、--ip-range
指定固定 IP 不支持 支持 --ip
网络别名 不支持 支持 --network-alias(一个容器可有多个 DNS 名,也可多容器共用一个别名做 DNS 轮询)

实验验证这个关键区别:

# ===== 默认 bridge:容器名不通 =====
docker run -d --name db1 nginx
docker run -d --name web1 nginx
docker exec web1 ping -c 2 db1
# ping: bad address 'db1'      <- 解析失败!
docker exec web1 ping -c 2 172.17.0.2       # 只能用 IP,但 IP 会变

# ===== 自定义网络:容器名直接通 =====
docker network create appnet
docker run -d --name db2 --network appnet nginx
docker run -d --name web2 --network appnet nginx
docker exec web2 ping -c 2 db2
# PING db2 (172.18.0.2): 56 data bytes
# 64 bytes from 172.18.0.2: seq=0 ttl=64 time=0.081 ms    <- 成功!
docker exec web2 cat /etc/resolv.conf
# nameserver 127.0.0.11        <- Docker 内置 DNS 服务器
docker exec web2 nslookup db2

这个特性为什么至关重要: 容器的 IP 在每次重建后都会变化。如果应用配置里写死了 172.17.0.2,数据库容器一重启,整个应用就断了。用容器名(服务名)访问,Docker DNS 会自动跟踪最新 IP,这就是服务发现。所有 docker-compose 项目能用服务名互访,就是因为 compose 会自动创建一个自定义网络。

结论:除了最简单的单容器测试,一律使用自定义网络。

4.2.5 docker network 命令详解

创建网络:

docker network create <网络名>
docker network create appnet
# 完整参数
docker network create \
  --driver bridge \
  --subnet 172.20.0.0/16 \
  --ip-range 172.20.10.0/24 \
  --gateway 172.20.0.1 \
  --opt com.docker.network.bridge.name=br-app \
  --opt com.docker.network.driver.mtu=1450 \
  --label env=prod \
  appnet
选项 说明
-d / --driver 驱动:bridge(默认)、overlay、macvlan、ipvlan、host、none、第三方插件
--subnet 网段,如 172.20.0.0/16。可指定多个
--ip-range 从 subnet 中划出一段用于自动分配,其余留给静态分配
--gateway 网关地址,默认取网段第一个可用地址
--aux-address 保留特定 IP 不被分配:--aux-address="host=172.20.0.5"
--internal 内部网络,完全禁止访问外网(不加 MASQUERADE),适合数据库层
--attachable overlay 网络允许独立容器(非 service)接入
--ipv6 启用 IPv6
--opt / -o 驱动特定选项,如指定网桥名、MTU、是否启用 ICC
--label 标签
# 创建一个不能上外网的内部网络给数据库用
docker network create --internal --subnet 172.30.0.0/16 dbnet
docker run -d --name db --network dbnet mysql:8 -e MYSQL_ROOT_PASSWORD=root
docker exec db ping -c 2 8.8.8.8       # 不通,符合预期

查看网络:

docker network ls
docker network ls -q
docker network ls --filter driver=bridge
docker network ls --filter type=custom          # 只看自定义网络
docker network ls --filter type=builtin         # 只看内置的三个
docker network ls --filter name=app
docker network ls --format "table {{.Name}}\t{{.Driver}}\t{{.Scope}}"
docker network ls --no-trunc
docker network inspect appnet
docker network inspect bridge
[
    {
        "Name": "appnet",
        "Id": "f2e8a1b3c4d5...",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Config": [
                {
                    "Subnet": "172.20.0.0/16",
                    "Gateway": "172.20.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Containers": {
            "a1b2c3d4e5f6": {
                "Name": "web",
                "EndpointID": "aaa...",
                "MacAddress": "02:42:ac:14:00:02",
                "IPv4Address": "172.20.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {}
    }
]
# 快速列出某网络里的所有容器与 IP —— 排查网络问题第一步
docker network inspect appnet --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'
docker network inspect appnet --format '{{json .IPAM.Config}}'
docker network inspect appnet | jq -r '.[0].Containers | to_entries[] | "\(.value.Name)\t\(.value.IPv4Address)"'

connect / disconnect(热插拔):

# 把运行中的容器加入另一个网络(容器会多一张网卡)
docker network connect appnet web
docker network connect --ip 172.20.0.100 appnet web        # 指定固定 IP
docker network connect --alias mysql-primary dbnet db      # 加 DNS 别名
# 从网络断开
docker network disconnect bridge web
docker network disconnect -f appnet web                    # 强制

一个容器可以同时接入多个网络,这是实现分层架构的标准手法:

docker network create frontnet
docker network create --internal backnet

docker run -d --name db --network backnet mysql:8 -e MYSQL_ROOT_PASSWORD=root
docker run -d --name api --network backnet myapi:v1
docker network connect frontnet api          # api 同时在前后端网络,充当桥梁
docker run -d --name nginx --network frontnet -p 80:80 nginx

# 结果:nginx 能访问 api,api 能访问 db,但 nginx 无法直接访问 db(网络隔离)
docker exec nginx ping -c 2 api      # 通
docker exec nginx ping -c 2 db       # 不通!
docker exec api ping -c 2 db         # 通

删除与清理:

docker network rm appnet
docker network rm net1 net2 net3
docker network prune            # 删除所有未被使用的网络
docker network prune -f
docker network prune -f --filter "until=24h"

网络里还有容器时无法删除,报 has active endpoints。需先 docker network disconnect 或删掉容器:

docker network inspect appnet --format '{{range .Containers}}{{.Name}} {{end}}'
docker rm -f $(docker ps -aq --filter network=appnet)
docker network rm appnet

内置的 bridge、host、none 三个网络不能删除。

4.2.6 端口映射的原理与 IP 绑定

端口映射是 Docker 中很重要的功能。每次运行容器时,容器 IP 不能指定,而是在桥接网卡地址范围内随机生成;宿主机 IP 固定,把容器端口映射到宿主机端口,就免去每次访问容器服务都要查容器 IP 的麻烦。

映射的完整链路(-p 8080:80):

① iptables nat 表 PREROUTING → DOCKER 链:
   DNAT tcp dpt:8080 → 172.17.0.2:80        (处理来自外部网络的流量)
② iptables filter 表 DOCKER 链:
   ACCEPT tcp → 172.17.0.2 dpt:80           (放行转发)
③ docker-proxy 进程监听宿主机 0.0.0.0:8080  (处理来自宿主机 localhost 的流量)
④ net.ipv4.ip_forward = 1                   (允许转发,缺了就完全不通)
docker run -d -p 8080:80 --name web nginx
# 查看这三处
iptables -t nat -S DOCKER | grep 8080
iptables -S DOCKER | grep 172.17
ss -tunlp | grep 8080

用 -p 的 IP 绑定限制监听地址(重要的安全手段):

# 默认:0.0.0.0,宿主机所有网卡都能访问,包括公网 IP!
docker run -d -p 3306:3306 mysql:8
ss -tunlp | grep 3306        # 0.0.0.0:3306   <- 数据库直接暴露在公网

这是极高频的安全事故:云主机上跑一个 -p 6379:6379 的 redis 或 -p 27017:27017 的 mongo,几小时内就会被扫描到并植入挖矿程序。

# 正确做法:只绑定 127.0.0.1,仅本机可访问
docker run -d -p 127.0.0.1:3306:3306 mysql:8
ss -tunlp | grep 3306        # 127.0.0.1:3306
# 只绑定内网网卡
docker run -d -p 192.168.1.10:3306:3306 mysql:8
# 只绑定特定 IPv6
docker run -d -p '[::1]:3306:3306' mysql:8

也可以改 daemon 默认绑定地址,让所有 -p 默认只绑本机:

{
  "ip": "127.0.0.1"
}

最佳实践:数据库、缓存、内部 API 一律不做端口映射,而是与应用容器放进同一个自定义网络,用容器名互访。只有真正需要对外的入口(nginx / 网关)才映射端口。

docker network create appnet
docker run -d --name mysql --network appnet -e MYSQL_ROOT_PASSWORD=root mysql:8      # 不映射端口
docker run -d --name redis --network appnet redis:7                                   # 不映射端口
docker run -d --name web --network appnet -p 80:80 nginx                              # 只有入口映射
# 应用连接字符串直接写:mysql:3306、redis:6379

4.2.7 容器间通信的最佳实践

优先级 做法 说明
1(推荐) 自定义 bridge 网络 + 容器名 内置 DNS 服务发现,IP 变化无感。单机首选
2 自定义网络 + --network-alias 一个容器多个 DNS 名;多容器共用一个别名可实现简单的 DNS 轮询负载均衡
3 分层网络(前端网 / 后端网 / internal 网) 用网络边界做安全隔离,最小暴露面
4 overlay 网络 跨主机通信(Swarm)
5 通过宿主机 IP + 映射端口 可行但绕远路(走 NAT,性能差),且耦合宿主机 IP
不要用 --link 已废弃,IP 不跟随、泄露环境变量、不支持跨主机
不要用 写死容器 IP 容器重建后 IP 变化,必然故障

DNS 轮询示例:

docker network create appnet
docker run -d --name api1 --network appnet --network-alias api myapi:v1
docker run -d --name api2 --network appnet --network-alias api myapi:v1
docker run -d --name api3 --network appnet --network-alias api myapi:v1
# 其他容器访问 api 时,Docker DNS 会随机返回三个 IP 之一
docker run --rm --network appnet alpine sh -c 'for i in 1 2 3 4 5 6; do nslookup api | tail -3; done'

排错工具容器(强烈推荐,网络问题必备):

docker run -it --rm --network appnet nicolaka/netshoot
# 里面自带:dig nslookup curl ping tcpdump ss netstat iperf3 mtr nmap ...
docker run -it --rm --network container:web nicolaka/netshoot tcpdump -i eth0 -nn

4.2.8 Docker 与宿主机防火墙的冲突(常见坑)

现象: 明明用 firewalld 只放通了 22 端口,为什么 -p 8080:80 的容器还是能被外网访问?

firewall-cmd --list-all
# 输出里 ports 只有 22/tcp,但 curl http://宿主机IP:8080 却能通!

根因: Docker 会自行往 iptables 里插入规则,而且插在链的最前面:

  1. Docker 在 nat 表的 PREROUTING 链里插入跳转到自己的 DOCKER 链,DNAT 在这里就完成了;
  2. DNAT 之后,包变成了"转发(FORWARD)流量"而不是"发往本机(INPUT)流量";
  3. firewalld 的 public zone 规则主要作用在 INPUT 链,转发流量根本不经过它;
  4. Docker 在 FORWARD 链最前面插了自己的 DOCKER-USER 与 DOCKER 链并放行。

所以 firewalld 的规则看起来"失效"了 —— 实际上它压根没被匹配到。

验证:

iptables -t nat -S PREROUTING
iptables -S FORWARD                 # 观察 DOCKER-USER 和 DOCKER 链的位置在最前
iptables -S DOCKER-USER
firewall-cmd --direct --get-all-rules

解决思路(四种):

方案一(推荐):把限制规则写进 DOCKER-USER 链

Docker 专门留了 DOCKER-USER 链给管理员,它在 DOCKER 链之前被处理,且 Docker 重启不会清空它。

# 只允许 10.0.0.0/8 访问容器,其他全拒绝
iptables -I DOCKER-USER -i eth0 ! -s 10.0.0.0/8 -j DROP
# 只允许特定 IP 访问容器的 3306
iptables -I DOCKER-USER -i eth0 -p tcp --dport 3306 ! -s 192.168.1.100 -j DROP
# 允许已建立的连接(放在最前)
iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT
# 保存规则(否则重启丢失)
service iptables save                          # CentOS 6
iptables-save > /etc/sysconfig/iptables        # CentOS 7
# 或使用 iptables-persistent(Debian 系)

方案二(最佳实践):不暴露端口,用 -p 127.0.0.1: 绑定本机

从源头上不产生对外的 DNAT 规则,防火墙问题自然消失。对外服务由宿主机上的 nginx 反代到 127.0.0.1:端口,这样 firewalld 的 INPUT 规则又重新生效了。

docker run -d -p 127.0.0.1:8080:80 --name web nginx
# 宿主机 nginx 反代,此时 80 端口由宿主机 nginx 持有,受 firewalld 管辖

方案三:让 Docker 不碰 iptables(不推荐)

{
  "iptables": false
}

代价极大:容器将无法访问外网(没有 MASQUERADE),端口映射也不生效,所有规则都要你手写。除非有专门的网络方案(如 Calico 接管),否则不要用。

方案四:用 firewalld 的 direct rule 或 rich rule 配合

firewall-cmd --permanent --direct --add-rule ipv4 filter DOCKER-USER 0 \
  -i eth0 ! -s 10.0.0.0/8 -j DROP
firewall-cmd --reload
# 把 docker0 加入 trusted zone(允许容器访问宿主机服务)
firewall-cmd --permanent --zone=trusted --add-interface=docker0
firewall-cmd --permanent --zone=trusted --add-masquerade
firewall-cmd --reload

另一个相关的坑:重启 firewalld 会清掉 Docker 的规则,导致容器突然全部断网:

systemctl restart firewalld
docker exec web ping 8.8.8.8       # 突然不通了
# 解决:重启 firewalld 后必须重启 docker 重建规则
systemctl restart docker

Docker 20.10+ 对 firewalld 支持有所改善(会自动创建 docker zone),但重启 firewalld 后重启 docker 仍是最可靠的做法。可以写成 systemd 依赖:在 /etc/systemd/system/docker.service.d/firewalld.conf 中加 After=firewalld.service。

详见 07-03-防火墙与SELinux.md

4.2.9 网络实验完整记录

实验一:端口映射与验证(源讲义完整保留)

docker run -i -p 8000:80 -t my_httpd:0.1
184e6440355b7052fcf3636046bf3207b1413cb13edbb6feee1e8e4cfd6a296d

-p 8000:80 表示把宿主机的 8000 端口映射到容器的 80 端口。

查看端口监听:

lsof -i:8000
COMMAND     PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
docker-pr 15165 root    4u  IPv6 185972      0t0  TCP *:irdmi (LISTEN)

查看运行中的容器:

docker ps -a | grep my_httpd
00622a3967f4        my_httpd:0.1        "/usr/sbin/httpd -D F"   3 minutes ago       Up 3 minutes        0.0.0.0:8000->80/tcp   berserk_mclean

验证访问:

curl 127.0.0.1:8000
container main page

最后使用浏览器访问 http://宿主机IP:8000 验证。

实验二:容器内部服务访问验证(源讲义完整保留)

docker run -i -t docker.io/ubuntu /bin/bash
apt-get update
apt-get install vim-gtk -y
apt install net-tools -y
apt install curl -y
apt-get install apache2 -y
/etc/init.d/apache2 restart
echo "web" > /var/www/html/index.html
curl 127.0.0.1
web
ifconfig            # 查看容器内网卡与 IP(172.17.0.x)
exit

实验三:查看容器端口映射

docker port c48d842f376a
80/tcp -> 0.0.0.0:80
docker port web
80/tcp -> 0.0.0.0:32768

实验四:自定义网络的容器名互访(补充实验)

# 1. 建网络
docker network create --subnet 172.25.0.0/16 labnet
docker network inspect labnet --format '{{json .IPAM.Config}}'

# 2. 起两个容器
docker run -d --name lab-web --network labnet -p 8081:80 nginx:1.25-alpine
docker run -dit --name lab-tool --network labnet alpine sh

# 3. 用容器名互访
docker exec lab-tool ping -c 3 lab-web
docker exec lab-tool nslookup lab-web
docker exec lab-tool sh -c 'apk add --no-cache curl >/dev/null && curl -s http://lab-web/ | head -5'
docker exec lab-tool cat /etc/resolv.conf      # nameserver 127.0.0.11

# 4. 查看网络内的成员与 IP
docker network inspect labnet --format '{{range .Containers}}{{.Name}} {{.IPv4Address}}{{println}}{{end}}'

# 5. 验证跨网络隔离
docker run -dit --name outsider alpine sh          # 在默认 bridge
docker exec outsider ping -c 2 lab-web             # 不通!网络隔离生效
docker network connect labnet outsider             # 加入 labnet
docker exec outsider ping -c 2 lab-web             # 现在通了
docker exec outsider ip addr | grep "inet "        # 有两张网卡两个 IP

# 6. 清理
docker rm -f lab-web lab-tool outsider
docker network rm labnet

实验五:验证宿主机网络原理(补充实验)

docker run -d --name nettest nginx:1.25-alpine
PID=$(docker inspect --format '{{.State.Pid}}' nettest)

# 容器内网卡
docker exec nettest ip addr
docker exec nettest ip route
# 找到对应的宿主机 veth
docker exec nettest cat /sys/class/net/eth0/iflink
ip link | grep -w "^$(docker exec nettest cat /sys/class/net/eth0/iflink):"
# 宿主机上的 docker0 与挂在上面的接口
ip addr show docker0
ip link show master docker0
# NAT 规则
iptables -t nat -S POSTROUTING | grep 172.17
# 用 nsenter 从宿主机进入容器网络 namespace
nsenter -t $PID -n ip addr
nsenter -t $PID -n ss -tunlp
# 抓包看容器流量
nsenter -t $PID -n tcpdump -i eth0 -nn -c 10 &
docker exec nettest wget -qO- http://baidu.com >/dev/null

docker rm -f nettest

五、私有仓库、编排衔接、安全与排错

5.1 上传镜像到公共仓库

可以把自己修改后保存的镜像上传到 Docker Hub(需要官网账号)。若使用阿里云容器或网易容器,它们有专门的上传操作界面。

在本地生成镜像时,镜像名必须包含 Docker Hub 的仓库地址(即用户名):

docker commit 6ec9511d90bf docker.io/durjaya/my_touch11

在 Docker Hub 上注册账号后,在终端登录:

docker login
# 按提示输入 docker 的账号密码

将本地镜像推送到公共仓库:

docker push docker.io/durjaya/my_touch11

补充说明:

# 已有镜像想推送,用 tag 改名即可(不需要重新 commit)
docker tag my_touch1111111 durjaya/my_touch11:v1
docker push durjaya/my_touch11:v1

# 登录其他仓库
docker login registry.cn-hangzhou.aliyuncs.com -u 用户名
docker login 192.168.1.100:5000
docker login -u user --password-stdin < ~/.docker-pass    # 避免密码进 shell 历史
docker logout
docker logout registry.cn-hangzhou.aliyuncs.com

# 凭据保存位置(base64 明文,注意保护)
cat ~/.docker/config.json

安全提示:~/.docker/config.json 里的 auth 字段是 base64 编码的明文 用户名密码,不是加密。生产环境应配置凭据助手(credsStore)或使用短期 token。

5.2 私有仓库搭建

5.2.1 用 registry 镜像快速搭建

步骤一:启动 registry 容器

# 1. 拉取官方 registry 镜像
docker pull registry:2

# 2. 创建数据目录并启动(务必挂载数据卷,否则容器一删镜像全丢)
mkdir -p /data/registry
docker run -d \
  --name registry \
  --restart=always \
  -p 5000:5000 \
  -v /data/registry:/var/lib/registry \
  registry:2

# 3. 验证
curl http://127.0.0.1:5000/v2/
# 返回 {} 表示正常
curl http://127.0.0.1:5000/v2/_catalog
# {"repositories":[]}
docker ps | grep registry

步骤二:配置 insecure-registries

registry 默认只提供 HTTP,而 Docker 默认只信任 HTTPS 仓库。不配置的话推送会报:

Error response from daemon: Get https://192.168.1.100:5000/v2/: http: server gave HTTP response to HTTPS client

在所有需要使用该仓库的机器(包括 registry 所在机器)上编辑 /etc/docker/daemon.json:

{
  "registry-mirrors": ["https://docker.m.daocloud.io"],
  "insecure-registries": [
    "192.168.1.100:5000",
    "registry.internal.com:5000",
    "10.0.0.0/24"
  ]
}
systemctl daemon-reload
systemctl restart docker
# 验证配置已生效
docker info | grep -A 3 "Insecure Registries"

CentOS 6(SysV init)的对应操作:

# 在 /etc/sysconfig/docker 的 OPTIONS 里加参数
vim /etc/sysconfig/docker
# OPTIONS='--insecure-registry 192.168.1.100:5000'
service docker restart

步骤三:推送镜像的完整流程

# 1. 打标签 —— 必须包含仓库地址,这是 push 目标的唯一来源
docker tag nginx:1.25 192.168.1.100:5000/nginx:1.25
docker tag myapp:v1 192.168.1.100:5000/library/myapp:v1
docker tag myapp:v1 192.168.1.100:5000/team-a/myapp:v1.0.3
docker images | grep 192.168.1.100

# 2. 推送
docker push 192.168.1.100:5000/nginx:1.25
The push refers to repository [192.168.1.100:5000/nginx]
2c8d61c46a2d: Pushed
d1e9b2e9e0e5: Pushed
1.25: digest: sha256:aa0afebbb3cf... size: 1570
# 3. 查看仓库内容(registry API)
curl http://192.168.1.100:5000/v2/_catalog
# {"repositories":["library/myapp","nginx","team-a/myapp"]}
curl http://192.168.1.100:5000/v2/nginx/tags/list
# {"name":"nginx","tags":["1.25"]}
curl http://192.168.1.100:5000/v2/_catalog?n=100
# 查看某个 tag 的 manifest 与 digest
curl -s -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
  http://192.168.1.100:5000/v2/nginx/manifests/1.25 | python -m json.tool

# 4. 在另一台机器上拉取
docker pull 192.168.1.100:5000/nginx:1.25
docker run -d -p 80:80 192.168.1.100:5000/nginx:1.25
# 拉下来后可以改回短名字方便使用
docker tag 192.168.1.100:5000/nginx:1.25 nginx:1.25

tag 命名规范建议:

<仓库地址>/<项目或团队>/<应用名>:<版本>

192.168.1.100:5000/infra/nginx:1.25.3
192.168.1.100:5000/team-pay/order-service:v1.4.2
192.168.1.100:5000/team-pay/order-service:v1.4.2-20260822
192.168.1.100:5000/team-pay/order-service:a1b2c3d          # git commit short sha
192.168.1.100:5000/team-pay/order-service:main-latest

规则:版本号必须唯一且不可复用(用语义化版本 + 构建号或 git sha);生产部署禁止用 latest;可以额外打一个 latest 或 stable 作为便捷别名,但部署清单里引用具体版本。

5.2.2 带认证的 registry(htpasswd 基础认证)

步骤一:生成密码文件

mkdir -p /data/registry/auth
# 用 registry 镜像自带的 htpasswd 工具生成(避免装 httpd-tools)
docker run --rm --entrypoint htpasswd registry:2.7 -Bbn admin 'Passw0rd123' > /data/registry/auth/htpasswd
# 追加更多用户(注意用 >> 不要覆盖)
docker run --rm --entrypoint htpasswd registry:2.7 -Bbn developer 'DevPass456' >> /data/registry/auth/htpasswd
cat /data/registry/auth/htpasswd
admin:$2y$05$Xa1b2C3d4E5f6G7h8I9j0K...
developer:$2y$05$Yb2c3D4e5F6g7H8i9J0k1L...

registry:2.8+ 的镜像移除了 htpasswd 二进制,所以上面固定用 registry:2.7。也可以用宿主机的:yum install -y httpd-tools && htpasswd -Bbn admin Passw0rd123 > htpasswd。 必须用 -B(bcrypt),registry 只支持 bcrypt,用默认的 MD5 会导致认证一直失败。

步骤二:生成自签 TLS 证书(认证要求必须走 HTTPS)

mkdir -p /data/registry/certs
openssl req -newkey rsa:4096 -nodes -sha256 \
  -keyout /data/registry/certs/domain.key \
  -x509 -days 3650 \
  -out /data/registry/certs/domain.crt \
  -subj "/C=CN/ST=Beijing/L=Beijing/O=MyOrg/CN=registry.internal.com" \
  -addext "subjectAltName=DNS:registry.internal.com,DNS:localhost,IP:192.168.1.100"

注意:新版 Docker 校验 SAN(Subject Alternative Name),没有 -addext subjectAltName 会报 x509: certificate relies on legacy Common Name field。

步骤三:启动带认证与 TLS 的 registry

docker run -d \
  --name registry-auth \
  --restart=always \
  -p 5000:5000 \
  -v /data/registry/data:/var/lib/registry \
  -v /data/registry/auth:/auth \
  -v /data/registry/certs:/certs \
  -e "REGISTRY_AUTH=htpasswd" \
  -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \
  -e "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd" \
  -e "REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt" \
  -e "REGISTRY_HTTP_TLS_KEY=/certs/domain.key" \
  -e "REGISTRY_STORAGE_DELETE_ENABLED=true" \
  registry:2

步骤四:客户端信任自签证书

# 每台客户端机器都要做
mkdir -p /etc/docker/certs.d/registry.internal.com:5000/
cp domain.crt /etc/docker/certs.d/registry.internal.com:5000/ca.crt
# 加 hosts 解析
echo "192.168.1.100 registry.internal.com" >> /etc/hosts
systemctl restart docker

步骤五:登录并使用

docker login registry.internal.com:5000 -u admin
# Password: Passw0rd123
# Login Succeeded

docker tag myapp:v1 registry.internal.com:5000/myapp:v1
docker push registry.internal.com:5000/myapp:v1
docker pull registry.internal.com:5000/myapp:v1

# 带认证时调用 API 要带凭据
curl -u admin:Passw0rd123 https://registry.internal.com:5000/v2/_catalog --cacert domain.crt
curl -k -u admin:Passw0rd123 https://registry.internal.com:5000/v2/_catalog

registry 完整配置文件方式(比一堆 -e 更清晰):

# /data/registry/config.yml
version: 0.1
log:
  level: info
  formatter: text
storage:
  filesystem:
    rootdirectory: /var/lib/registry
  delete:
    enabled: true
  cache:
    blobdescriptor: inmemory
http:
  addr: :5000
  headers:
    X-Content-Type-Options: [nosniff]
  tls:
    certificate: /certs/domain.crt
    key: /certs/domain.key
auth:
  htpasswd:
    realm: basic-realm
    path: /auth/htpasswd
health:
  storagedriver:
    enabled: true
    interval: 10s
    threshold: 3
docker run -d --name registry -p 5000:5000 --restart=always \
  -v /data/registry/config.yml:/etc/docker/registry/config.yml:ro \
  -v /data/registry/data:/var/lib/registry \
  -v /data/registry/auth:/auth \
  -v /data/registry/certs:/certs \
  registry:2

5.2.3 镜像清理与垃圾回收

registry 默认不允许删除镜像,必须先启用 delete:

-e "REGISTRY_STORAGE_DELETE_ENABLED=true"
# 或配置文件里 storage.delete.enabled: true

删除流程(两步,缺一不可):

# 步骤 1:通过 API 删除 manifest(此时只是打了删除标记,磁盘空间不释放)
# 1a. 先取得该 tag 的 digest
DIGEST=$(curl -sI -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
  http://192.168.1.100:5000/v2/myapp/manifests/v1 | grep -i Docker-Content-Digest | awk '{print $2}' | tr -d '\r')
echo $DIGEST
# 1b. 按 digest 删除
curl -X DELETE http://192.168.1.100:5000/v2/myapp/manifests/$DIGEST
# 返回 202 Accepted 表示成功

# 步骤 2:执行垃圾回收,真正释放磁盘空间
docker exec registry bin/registry garbage-collect /etc/docker/registry/config.yml
# 先预演,看会删什么但不真删
docker exec registry bin/registry garbage-collect --dry-run /etc/docker/registry/config.yml
# 同时清理没有被引用的 manifest(新版本支持)
docker exec registry bin/registry garbage-collect -m /etc/docker/registry/config.yml

重要:垃圾回收期间必须让 registry 只读或停止写入,否则正在推送的层可能被误删,造成镜像损坏。生产上的做法是先把 storage.maintenance.readonly.enabled 置为 true 并重启 registry,GC 完成后再恢复读写。

批量清理脚本(保留每个仓库最新 N 个 tag):

#!/bin/bash
REG=http://192.168.1.100:5000
KEEP=5
for repo in $(curl -s $REG/v2/_catalog | python -c "import sys,json;print('\n'.join(json.load(sys.stdin)['repositories']))"); do
    tags=$(curl -s $REG/v2/$repo/tags/list | python -c "import sys,json;t=json.load(sys.stdin)['tags'] or [];print('\n'.join(sorted(t)))")
    total=$(echo "$tags" | wc -l)
    if [ "$total" -gt "$KEEP" ]; then
        echo "$tags" | head -n $((total - KEEP)) | while read tag; do
            d=$(curl -sI -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
                "$REG/v2/$repo/manifests/$tag" | grep -i Docker-Content-Digest | awk '{print $2}' | tr -d '\r')
            echo "删除 $repo:$tag ($d)"
            curl -s -X DELETE "$REG/v2/$repo/manifests/$d"
        done
    fi
done
docker exec registry bin/registry garbage-collect /etc/docker/registry/config.yml

查看仓库磁盘占用:

du -sh /data/registry/data
du -sh /data/registry/data/docker/registry/v2/blobs
du -sh /data/registry/data/docker/registry/v2/repositories/*

5.2.4 Harbor 的定位与功能

原生 registry 只是一个"能存能取"的最小实现,没有 Web UI、没有用户/项目管理、没有镜像扫描、没有审计日志。生产环境应使用 Harbor(CNCF 毕业项目,VMware 开源)。

Harbor 相比 registry 的能力:

功能 registry Harbor
Web 管理界面 无 有,可视化浏览镜像、tag、层、构建历史
用户与权限 仅 htpasswd 全局账号 项目级 RBAC(项目管理员 / 开发者 / 访客 / 受限访客)
对接企业认证 无 LDAP / AD / OIDC / OAuth2 单点登录
镜像漏洞扫描 无 内置 Trivy(或 Clair),可设置"有高危漏洞禁止拉取"
镜像签名与验签 无 Notary / Cosign 内容信任,可强制只允许拉取已签名镜像
镜像复制 无 多实例远程复制(主从同步、多地域分发、从 Docker Hub 同步代理缓存)
镜像保留策略 需自己写脚本 可视化 tag 保留规则(保留最近 N 个 / N 天内 / 匹配某模式),自动 GC
配额管理 无 按项目限制存储配额
审计日志 无 完整操作日志(谁在何时推/拉/删了什么)
Webhook 无 推送镜像时触发 CI/CD
Helm Chart 仓库 无 支持(ChartMuseum / OCI)
代理缓存 无 可作为 Docker Hub 的 pull-through cache,解决国内拉取慢
不可变 tag 无 可设置 tag 不可覆盖,防止 v1.0 被偷偷替换

Harbor 安装要点:

# 1. 前置依赖(Harbor 基于 docker-compose 部署)
docker --version                # >= 20.10
docker compose version          # >= 2.x(或 docker-compose >= 1.29)
# 资源要求:最低 2C4G 40G 磁盘,生产建议 4C8G 100G+

# 2. 下载离线安装包(推荐离线包,在线包受网络影响大)
wget https://github.com/goharbor/harbor/releases/download/v2.10.0/harbor-offline-installer-v2.10.0.tgz
tar -xzf harbor-offline-installer-v2.10.0.tgz
cd harbor

# 3. 准备 TLS 证书(生产必须 HTTPS)
mkdir -p /data/cert
openssl req -newkey rsa:4096 -nodes -sha256 -keyout /data/cert/harbor.key \
  -x509 -days 3650 -out /data/cert/harbor.crt \
  -subj "/CN=harbor.example.com" -addext "subjectAltName=DNS:harbor.example.com"

# 4. 编辑配置
cp harbor.yml.tmpl harbor.yml
vim harbor.yml

harbor.yml 关键配置:

# hostname 必须是客户端能访问到的域名或 IP,不能写 localhost
hostname: harbor.example.com

http:
  port: 80
https:
  port: 443
  certificate: /data/cert/harbor.crt
  private_key: /data/cert/harbor.key

# 首次登录的 admin 密码,安装后请立即修改
harbor_admin_password: Harbor12345

database:
  password: root123
  max_idle_conns: 100
  max_open_conns: 900

# Harbor 所有数据(镜像、数据库、日志)的存放目录,务必放在大盘上
data_volume: /data/harbor

trivy:
  ignore_unfixed: false
  skip_update: false
  offline_scan: false

log:
  level: info
  local:
    rotate_count: 50
    rotate_size: 200M
    location: /var/log/harbor
# 5. 执行安装(--with-trivy 启用漏洞扫描)
./install.sh --with-trivy
# 也可以加 --with-notary 启用镜像签名

# 6. 验证
docker compose ps                    # 应有十来个容器全部 Up (healthy)
curl -k https://harbor.example.com/api/v2.0/health

# 7. 浏览器访问 https://harbor.example.com,用 admin / Harbor12345 登录

# 8. 客户端使用
mkdir -p /etc/docker/certs.d/harbor.example.com/
cp harbor.crt /etc/docker/certs.d/harbor.example.com/ca.crt
systemctl restart docker
docker login harbor.example.com -u admin
docker tag myapp:v1 harbor.example.com/library/myapp:v1
docker push harbor.example.com/library/myapp:v1

日常运维:

cd /path/to/harbor
docker compose ps
docker compose stop
docker compose start
docker compose down                    # 停止并删除容器(数据在 data_volume 里,不会丢)
docker compose up -d
# 改了 harbor.yml 之后必须重新生成配置
./prepare
docker compose down && docker compose up -d
# 备份
tar -czf /backup/harbor-$(date +%F).tar.gz /data/harbor

5.3 容器编排的必要性与方案对比

单机 docker run 在真实业务里很快就撑不住,典型痛点:

  1. 一条命令太长且不可复现:一个带 10 个 -e、3 个 -v、2 个 -p 的 docker run 没人记得住,换台机器就重新拼一遍,参数漂移。
  2. 多容器依赖关系无法表达:web 依赖 mysql 和 redis,谁先启动、启动失败要不要重试、如何等对方就绪,docker run 无法描述。
  3. 扩缩容全靠手工:要跑 5 个副本就手敲 5 次并起 5 个不同名字,负载均衡还得自己配 Nginx。
  4. 没有自愈能力:宿主机宕机,上面的容器就全没了,不会自动在别的机器上重建。--restart=always 只能救进程崩溃,救不了机器挂掉。
  5. 发布与回滚危险:滚动更新、金丝雀发布、一键回滚,纯 Docker 命令做不到不中断服务。
  6. 跨主机网络与服务发现缺失:默认 bridge 网络只在单机内有效,跨主机容器互访要自己搞 SNAT 或端口映射。
  7. 配置与密钥管理缺失:配置文件靠挂载、密码靠 -e 明文写在命令行里,docker inspect 谁都能看到。

方案对比:

维度 docker run(单机) Docker Compose Docker Swarm Kubernetes
定位 手工跑单个容器 单机多容器编排(声明式 YAML) 轻量级多机集群 工业级容器编排平台
规模 1 台 1 台(开发/测试/小型单机生产) 几台到几十台 几台到数千台
学习成本 极低 低 中 高
声明式配置 无 docker-compose.yml docker-stack.yml 大量 YAML 资源对象
服务发现 自定义网络内置 DNS 内置 DNS,按 service 名访问 内置 DNS + VIP 负载均衡 Service / CoreDNS / Ingress
跨主机网络 不支持 不支持 overlay 网络原生支持 CNI 插件(Calico/Flannel/Cilium)
扩缩容 手工起多个 --scale N(同机) docker service scale kubectl scale / HPA 自动扩缩
自愈 --restart 仅进程级 同左 节点故障自动迁移 节点/Pod 级自愈 + 驱逐 + 重调度
滚动更新与回滚 无 需手动 up -d 重建 --update-parallelism 内置 Deployment 滚动更新 + rollout undo
健康检查驱动流量 无 无 有 readinessProbe 控制是否接流量
配置与密钥 -e / 挂载 .env / 挂载 config / secret ConfigMap / Secret
存储编排 手工 volume 命名卷 卷驱动 PV / PVC / StorageClass 动态供给
生态 — 一般 官方已基本停止演进 事实标准,生态最丰富
适用场景 学习、临时调试 本地开发环境、CI、单机部署 中小规模、要求简单的集群 生产集群、微服务、多团队平台

选型建议:

  • 本地开发、CI 流水线、单机跑一套 LNMP/ELK → Docker Compose
  • 只有几台机器,团队不想背 K8s 的复杂度 → Docker Swarm(配置最简单,但要接受生态停滞)
  • 生产环境、需要弹性伸缩/多团队隔离/完善生态 → Kubernetes

详见 13-02-Docker-Compose.md 详见 13-03-Docker-Swarm集群.md 详见 13-04-Kubernetes集群.md

5.4 Docker 安全

容器不是安全边界。容器与宿主机共享同一个内核,一旦逃逸就直接拿到宿主机权限。安全加固要一条条落实。

5.4.1 --privileged 与 --cap-add 的风险

# 危险:特权容器几乎等于宿主机 root
docker run -d --privileged centos:7 sleep 3600

--privileged 会做四件事:授予全部 Linux capabilities、关闭 seccomp 与 AppArmor/SELinux 限制、允许访问宿主机所有设备(/dev 全暴露)、把 /sys 挂载为可写。

逃逸演示(说明危害,切勿在生产执行):

# 特权容器里可以直接挂载宿主机磁盘,读写宿主机任意文件
docker run -it --privileged centos:7 bash
fdisk -l                              # 能看到宿主机所有磁盘
mkdir /host && mount /dev/sda1 /host  # 挂载宿主机根分区
chroot /host                          # 直接切换到宿主机文件系统,改 /etc/shadow、加 SSH key
# 还能通过 cgroup notify_on_release 在宿主机上执行任意命令

危险的单个 capability(即使不用 --privileged,加错一个也能逃逸):

capability 危害
SYS_ADMIN 可 mount、可操作 cgroup,是最常见的逃逸入口
SYS_PTRACE 可注入宿主机进程(若同时共享 PID 命名空间)
SYS_MODULE 可加载内核模块,直接完全控制宿主机
DAC_READ_SEARCH 绕过文件读权限检查(配合 open_by_handle_at 可读宿主机文件)
NET_ADMIN 可改宿主机网络(若共享网络命名空间),可抓包、做中间人
SYS_BOOT 可重启宿主机

正确做法:最小权限

# 先全部丢弃,再按需加回
docker run -d \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --security-opt=no-new-privileges:true \
  nginx:1.25

# 确实需要某个能力时只加那一个,而不是开 --privileged
docker run -it --cap-add=NET_ADMIN nicolaka/netshoot   # 只为了调试网络
docker run -d --device=/dev/sdb:/dev/sdb myapp          # 只暴露需要的设备,而不是 --privileged

# 查看容器实际拥有的 capabilities
docker run --rm --cap-drop=ALL centos:7 capsh --print
docker inspect nginx --format '{{.HostConfig.CapAdd}} {{.HostConfig.CapDrop}} {{.HostConfig.Privileged}}'

5.4.2 docker 组等同 root 的原因与 rootless 模式

原因:dockerd 以 root 运行,通过 /var/run/docker.sock 提供 API,这个 socket 没有细粒度权限模型——能连上它就能让 root 守护进程替你干任何事。

ls -l /var/run/docker.sock
# srw-rw---- 1 root docker 0 Aug 22 10:00 /var/run/docker.sock
#                    ^^^^^^ docker 组成员可读写 = 可提权到 root

只要在 docker 组里,一条命令即可拿到宿主机 root:

# 挂载宿主机根目录并 chroot
docker run -it -v /:/host centos:7 chroot /host bash
# 直接以宿主机 root 身份运行
docker run -it --privileged --pid=host centos:7 nsenter -t 1 -m -u -n -i bash
# 给自己加 sudo 权限
docker run --rm -v /etc:/etc-host centos:7 sh -c 'echo "hacker ALL=(ALL) NOPASSWD:ALL" >> /etc-host/sudoers'

结论:把用户加入 docker 组,等价于给他免密 sudo 的 root 权限。 因此:

# 审计谁在 docker 组里
getent group docker
grep docker /etc/group

# 不要随便加人
usermod -aG docker devuser    # 只在完全信任该用户时才做

# 更安全的替代:用 sudo 白名单精细授权,并记录审计日志
cat >> /etc/sudoers.d/docker-limited <<'EOF'
devuser ALL=(root) NOPASSWD: /usr/bin/docker ps, /usr/bin/docker logs *, /usr/bin/docker restart myapp
EOF

rootless 模式(Docker 19.03+ 实验性,20.10+ 正式):让 dockerd 本身以普通用户身份运行,利用 user namespace 把容器内的 root 映射为宿主机的普通用户 UID,即使逃逸也只是个普通用户。

# 前置条件
yum install -y fuse-overlayfs slirp4netns shadow-utils iptables
# 需要内核支持非特权 user namespace
cat /proc/sys/user/max_user_namespaces      # 应 > 0
echo "user.max_user_namespaces=28633" >> /etc/sysctl.d/99-rootless.conf && sysctl --system
# 需要 subuid/subgid 映射
grep dockeruser /etc/subuid /etc/subgid
# dockeruser:100000:65536

# 以普通用户身份安装并启动
su - dockeruser
dockerd-rootless-setuptool.sh install
export PATH=/usr/bin:$PATH
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
systemctl --user start docker
systemctl --user enable docker
loginctl enable-linger dockeruser      # 让用户服务在未登录时也运行

docker info | grep -i rootless
docker run --rm hello-world

rootless 的限制:不能用 --net=host(默认)、不能绑定 1024 以下端口(除非设置 net.ipv4.ip_unprivileged_port_start)、--privileged 受限、部分存储驱动不可用(需 fuse-overlayfs)、cgroup v1 下资源限制不生效(需 cgroup v2 + systemd)。

用户命名空间重映射(非 rootless 但也很有效):

{
  "userns-remap": "default"
}
systemctl restart docker
# Docker 会自动创建 dockremap 用户,容器内 root(0) 映射为宿主机的高位 UID
grep dockremap /etc/subuid
ls -ln /var/lib/docker/100000.100000/

5.4.3 不要把 docker.sock 挂载进容器

# 极其危险:这等于把宿主机 root 权限交给容器里的进程
docker run -d -v /var/run/docker.sock:/var/run/docker.sock some-image

容器里只要装个 docker CLI 或用 curl 调 API,就能创建特权容器逃逸:

# 容器内部
curl -s --unix-socket /var/run/docker.sock http://localhost/v1.41/containers/json
curl -s --unix-socket /var/run/docker.sock -X POST -H "Content-Type: application/json" \
  -d '{"Image":"centos:7","Cmd":["chroot","/host","bash","-c","cat /etc/shadow"],"HostConfig":{"Binds":["/:/host"],"Privileged":true}}' \
  http://localhost/v1.41/containers/create

确实需要(Jenkins 构建镜像、监控采集容器指标)时的缓解手段:

# 1. 只读挂载(挡不住写 API,但能挡一部分工具)
-v /var/run/docker.sock:/var/run/docker.sock:ro

# 2. 用 socket 代理限制可调用的 API(推荐)
docker run -d --name docker-socket-proxy \
  -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -e CONTAINERS=1 -e POST=0 -e EXEC=0 -e IMAGES=1 \
  --network monitor-net \
  tecnativa/docker-socket-proxy
# 业务容器连 tcp://docker-socket-proxy:2375,而不是直接连 socket

# 3. 构建镜像改用不需要 daemon 的工具
#    kaniko / buildah / img,或 BuildKit 的 rootless 模式

5.4.4 使用非 root 用户运行容器进程(USER 指令)

镜像默认以 root 运行进程,一旦应用被 RCE,攻击者在容器内就是 root,配合内核漏洞更容易逃逸。

FROM openjdk:17-jre-slim

# 创建专用低权限用户(固定 UID/GID,便于宿主机挂载目录授权)
RUN groupadd -g 10001 app && \
    useradd -u 10001 -g app -s /sbin/nologin -M app

WORKDIR /app
COPY --chown=app:app target/app.jar /app/app.jar

# 关键:切换到非 root 用户
USER app:app
# 也可以直接写 UID,避免镜像里必须存在该用户名(K8s runAsUser 场景)
# USER 10001:10001

EXPOSE 8080
ENTRYPOINT ["java","-jar","/app/app.jar"]
# 运行时强制指定用户(不改镜像也能降权)
docker run -d -u 1000:1000 myapp:v1
docker run -d --user $(id -u):$(id -g) myapp:v1
# 验证
docker exec myapp id
# uid=10001(app) gid=10001(app) groups=10001(app)
docker inspect myapp --format '{{.Config.User}}'

# 排查全集群哪些容器还在用 root
docker ps -q | xargs -I{} sh -c 'echo -n "{} "; docker inspect {} --format "{{.Name}} user=[{{.Config.User}}]"'

注意点:非 root 用户无法绑定 1024 以下端口,所以镜像里让应用监听 8080 再用 -p 80:8080 映射;挂载的宿主机目录要 chown 成对应 UID,否则应用写不进去。

5.4.5 镜像来源可信与镜像扫描

# 1. 只用官方镜像或自建仓库里经过审核的镜像
#    Docker Hub 上 xxx/mysql 这类第三方镜像可能被植入挖矿程序或后门

# 2. 用 digest 锁定,防止 tag 被替换
docker pull nginx@sha256:aa0afebbb3cf1a4d7c0a2c04d6b0a2c8e3a9c9d0e1f2a3b4c5d6e7f8a9b0c1d2
# Dockerfile 里也可以锁
# FROM nginx@sha256:aa0afeb...

# 3. 启用内容信任(Docker Content Trust),只允许拉取已签名镜像
export DOCKER_CONTENT_TRUST=1
docker pull nginx:1.25          # 未签名的镜像会直接拒绝
echo 'export DOCKER_CONTENT_TRUST=1' >> /etc/profile.d/dct.sh

# 4. 检查镜像来源与构建历史,看有没有可疑指令
docker history --no-trunc myapp:v1
docker inspect myapp:v1 --format '{{json .Config}}' | python -m json.tool

镜像扫描工具:

# Trivy(最常用,支持 OS 包 + 语言依赖 + 配置错误 + 密钥泄露检测)
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro \
  -v $HOME/.cache:/root/.cache/ aquasec/trivy:latest image myapp:v1
# 只看高危和严重,且有可修复版本的
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock:ro aquasec/trivy \
  image --severity HIGH,CRITICAL --ignore-unfixed myapp:v1
# CI 里发现高危就让流水线失败
trivy image --exit-code 1 --severity CRITICAL myapp:v1
# 扫描 Dockerfile 配置问题
trivy config ./Dockerfile
# 扫描镜像里误提交的密钥
trivy image --scanners secret myapp:v1

# Docker 官方扫描(需登录)
docker scan myapp:v1
docker scout cves myapp:v1

# grype
grype myapp:v1

# Docker Bench Security:检查宿主机与 Docker 配置是否符合 CIS 基线
docker run --rm --net host --pid host --userns host --cap-add audit_control \
  -v /etc:/etc:ro -v /var/lib:/var/lib:ro -v /var/run/docker.sock:/var/run/docker.sock:ro \
  docker/docker-bench-security

5.4.6 只读根文件系统 --read-only

让容器根文件系统只读,攻击者即使进来也无法写入木马、无法修改二进制、无法留后门。

# 基础用法
docker run -d --read-only nginx:1.25
# 但很多程序需要写临时文件,用 tmpfs 精确开放可写点
docker run -d \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /run:rw,noexec,nosuid,size=16m \
  --tmpfs /var/cache/nginx:rw,size=32m \
  -v nginx-logs:/var/log/nginx \
  nginx:1.25

# 验证
docker exec -it nginx touch /test
# touch: cannot touch '/test': Read-only file system

docker inspect nginx --format '{{.HostConfig.ReadonlyRootfs}}'

配合的其他加固挂载选项:

docker run -d \
  --read-only \
  --security-opt=no-new-privileges:true \
  --tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
  -v /data/config:/app/config:ro \
  --cap-drop=ALL \
  myapp:v1
选项 作用
--read-only 根文件系统只读
--tmpfs /tmp:rw,noexec,nosuid 内存临时目录,noexec 禁止在此执行程序(挡下载木马后运行)
--security-opt=no-new-privileges:true 禁止通过 setuid 程序提权,强烈建议默认开启
-v xxx:ro 配置文件只读挂载,防篡改
--pids-limit 防 fork 炸弹

5.4.7 资源限制防止单容器打满宿主机

不限制资源时,一个容器的内存泄漏或死循环就能拖垮整台机器上的所有服务(噪声邻居问题),甚至触发宿主机 OOM 杀掉别的进程。

docker run -d --name myapp \
  -m 512m --memory-swap 512m \
  --memory-reservation 256m \
  --oom-kill-disable=false \
  --cpus="1.5" \
  --cpu-shares=512 \
  --cpuset-cpus="0,1" \
  --pids-limit=200 \
  --ulimit nofile=4096:8192 \
  --ulimit nproc=1024 \
  --device-read-bps /dev/sda:20mb \
  --device-write-bps /dev/sda:20mb \
  --blkio-weight=300 \
  --shm-size=128m \
  myapp:v1
参数 说明
-m 512m 内存硬上限,超出后容器内进程被 OOM Killer 杀掉(ExitCode 137)
--memory-swap 512m 内存 + swap 总量。设成与 -m 相同即完全禁用 swap(推荐,避免性能抖动);设 -1 为不限 swap
--memory-reservation 256m 软限制,宿主机内存紧张时才回收到此值
--cpus="1.5" 最多用 1.5 个核(等价 --cpu-period=100000 --cpu-quota=150000)
--cpu-shares=512 相对权重(默认 1024),仅在 CPU 竞争时生效,不是硬限制
--cpuset-cpus="0,1" 绑定到指定 CPU 核,减少上下文切换,适合延迟敏感服务
--pids-limit=200 最大进程数,防 fork 炸弹(:(){ :|:& };:)
--ulimit nofile=4096:8192 文件描述符软/硬限制,防 too many open files 打满宿主机
--device-write-bps 磁盘写带宽限制,防日志/备份把磁盘 IO 打满
--shm-size /dev/shm 大小,默认仅 64MB,Chrome/PostgreSQL 等常需调大
# 实时观察是否触碰上限
docker stats --no-stream
# 检查是否被 OOM 杀掉
docker inspect myapp --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
# true 137
dmesg -T | grep -i -E "oom|killed process"
# 运行中调整(无需重建容器)
docker update -m 1g --memory-swap 1g --cpus 2 myapp

生产原则:每个容器都必须设内存上限和 pids-limit,CPU 上限按业务确定;容器内的 JVM 要用 -XX:MaxRAMPercentage=75 或 -XX:+UseContainerSupport(JDK 10+ 默认开启)让堆感知 cgroup 限额,否则 JVM 按宿主机内存算堆大小,必被 OOM。

5.4.8 seccomp 与 AppArmor 简述

seccomp(Secure Computing Mode):内核级系统调用过滤器。Docker 默认启用一份 profile,屏蔽了约 44 个危险 syscall(如 mount、reboot、kexec_load、init_module、ptrace(旧版)、bpf、clock_settime 等),显著缩小内核攻击面。

# 查看是否启用
docker info | grep -i seccomp
# Security Options: seccomp Profile: builtin

# 容器内确认
docker run --rm centos:7 grep Seccomp /proc/1/status
# Seccomp: 2      (2 = filter 模式已生效;0 = 未启用)

# 使用自定义 profile(只放行白名单 syscall,最严格)
docker run -d --security-opt seccomp=/etc/docker/seccomp/myapp.json myapp:v1

# 禁用(调试用,生产禁止)
docker run -it --security-opt seccomp=unconfined centos:7 bash

最小 seccomp profile 示例:

{
  "defaultAction": "SCMP_ACT_ERRNO",
  "architectures": ["SCMP_ARCH_X86_64"],
  "syscalls": [
    {
      "names": ["read","write","open","openat","close","stat","fstat","mmap",
                "mprotect","munmap","brk","rt_sigaction","rt_sigprocmask",
                "ioctl","access","execve","exit","exit_group","socket",
                "connect","accept4","bind","listen","sendto","recvfrom",
                "epoll_create1","epoll_ctl","epoll_wait","clone","futex"],
      "action": "SCMP_ACT_ALLOW"
    }
  ]
}

AppArmor(Ubuntu/Debian 默认) / SELinux(RHEL/CentOS 默认):基于路径或标签的强制访问控制(MAC),限制容器进程能访问哪些文件、能力和网络。

# AppArmor
docker info | grep -i apparmor
aa-status | grep docker
docker run -d --security-opt apparmor=docker-default nginx:1.25
docker run -d --security-opt apparmor=my-nginx-profile nginx:1.25
docker run -it --security-opt apparmor=unconfined centos:7 bash    # 关闭(不推荐)

# SELinux(CentOS/RHEL)
getenforce
# 启用 SELinux 标签隔离,容器进程被打上 svirt_lxc_net_t 标签
docker run -d --security-opt label=level:s0:c100,c200 myapp:v1
docker run -it --security-opt label=disable centos:7 bash          # 关闭(不推荐)
ps -eZ | grep container_t
# 挂载宿主机目录时需要 :z / :Z 重打标签
docker run -d -v /data/web:/usr/share/nginx/html:ro,Z nginx:1.25

详见 07-03-防火墙与SELinux.md

很多教程为了省事直接 setenforce 0 关掉 SELinux(本文档源材料的环境准备里也这么做),这是牺牲安全换便利。生产环境应保留 SELinux 并通过 :z/:Z 和自定义策略解决访问问题。

5.4.9 及时更新与 CVE 关注

容器共享宿主机内核,内核漏洞就是逃逸漏洞,而 Docker 自身组件(runc、containerd)的漏洞同样致命。

历史上的重大 CVE:

CVE 组件 危害
CVE-2019-5736 runc 容器内可覆写宿主机 runc 二进制,执行 docker exec 时即逃逸获得 root
CVE-2020-15257 containerd 共享网络命名空间(--net=host)的容器可访问 containerd-shim API 逃逸
CVE-2021-30465 runc 挂载竞态(symlink-exchange),可挂载宿主机任意目录
CVE-2022-0492 内核 cgroup v1 release_agent 逃逸,容器内以 root 可在宿主机执行命令
CVE-2024-21626 runc 文件描述符泄漏导致工作目录逃逸("Leaky Vessels")
CVE-2019-14271 docker cp 加载容器内恶意 libnss,宿主机上执行代码
脏牛 CVE-2016-5195 / DirtyPipe CVE-2022-0847 内核 容器内提权并逃逸
# 检查当前版本
docker version
rpm -qa | grep -E "docker|containerd|runc"
runc --version
uname -r

# 升级(先排空业务或做好重启预期)
yum makecache fast
yum list docker-ce --showduplicates | sort -r
yum update -y docker-ce docker-ce-cli containerd.io
systemctl restart docker
# 注意:重启 dockerd 默认会重启所有容器;配置 live-restore 可避免
{
  "live-restore": true
}

live-restore: true 让 dockerd 重启/升级时不停止正在运行的容器(不支持 Swarm 模式)。生产环境建议开启。

日常运维动作:订阅 Docker 官方安全公告与发行版内核安全通告;定期跑 Trivy 扫描镜像与 docker-bench-security 扫描主机;建立基础镜像统一升级机制(基础镜像更新后触发所有业务镜像重建),不要让镜像里的 openssl/glibc 停留在几年前的版本。

5.4.10 不要把密钥写进镜像层

最常见的严重错误:以为删掉文件就安全了。镜像是分层的,任何一层里出现过的内容都永久保存在镜像里,后续层删除只是加了个 whiteout 标记。

# 错误示范一:删除也没用,上一层里还在
RUN wget --user=admin --password=Secret123 https://internal/app.tar.gz
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@internal:app.git && rm -f /root/.ssh/id_rsa

# 错误示范二:ENV / ARG 都会留在镜像元数据里,docker inspect 直接可见
ENV DB_PASSWORD=Secret123
ARG NPM_TOKEN=npm_xxxxxxxxxxxx

# 错误示范三:把配置文件(含明文密码)COPY 进镜像
COPY application-prod.yml /app/config/

如何证明泄露:

# 查看构建历史,命令行里的密码一览无余
docker history --no-trunc myapp:v1
# 查看环境变量
docker inspect myapp:v1 --format '{{json .Config.Env}}'
docker inspect myapp:v1 --format '{{json .Config.Labels}}'
# 导出镜像逐层翻找被"删除"的文件
docker save myapp:v1 -o app.tar && mkdir x && tar -xf app.tar -C x
find x -name "layer.tar" -exec tar -tf {} \; | grep -i -E "id_rsa|\.env|password"
# 用 dive 逐层浏览
dive myapp:v1
# 用 trivy 扫描密钥
trivy image --scanners secret myapp:v1

正确做法:

# 方案 1:运行时通过环境变量注入(最常用)
docker run -d -e DB_PASSWORD='Secret123' myapp:v1
# 但 -e 会出现在 ps 输出和 docker inspect 里,更好的是用 --env-file
cat > /etc/myapp/app.env <<'EOF'
DB_HOST=10.0.0.5
DB_PASSWORD=Secret123
EOF
chmod 600 /etc/myapp/app.env
chown root:root /etc/myapp/app.env
docker run -d --env-file /etc/myapp/app.env myapp:v1

# 方案 2:只读挂载宿主机上的密钥文件,权限收紧
docker run -d -v /etc/myapp/secrets:/run/secrets:ro myapp:v1

# 方案 3:BuildKit 构建期 secret —— 挂载但不落入任何镜像层(构建时拉私有依赖的标准解法)
export DOCKER_BUILDKIT=1
docker build --secret id=npmtoken,src=$HOME/.npmtoken -t myapp:v1 .
docker build --ssh default -t myapp:v1 .        # 用宿主机 ssh-agent clone 私有仓库
# syntax=docker/dockerfile:1.4
FROM node:20-alpine AS build
WORKDIR /src
COPY package*.json ./
# secret 只在这条 RUN 期间可见,不会写入镜像层
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN=$(cat /run/secrets/npmtoken) \
    npm config set //registry.npmjs.org/:_authToken=$NPM_TOKEN && \
    npm ci --omit=dev && \
    npm config delete //registry.npmjs.org/:_authToken
COPY . .
RUN npm run build

FROM nginx:1.25-alpine
COPY --from=build /src/dist /usr/share/nginx/html
# 方案 4:Docker Swarm / Compose secret(内容以 tmpfs 挂到 /run/secrets)
echo "Secret123" | docker secret create db_password -
docker service create --name myapp --secret db_password myapp:v1
# 容器内读取
cat /run/secrets/db_password

# 方案 5:外部密钥管理系统(生产推荐)
#   HashiCorp Vault / 云厂商 KMS / K8s Secret + 外部 Secrets Operator
#   容器启动时用短期 token 动态拉取密钥,密钥可轮转、可审计、不落盘

安全检查清单:

# 上线前自查
docker history --no-trunc IMAGE | grep -i -E "password|token|secret|key|-p "
docker inspect IMAGE --format '{{json .Config.Env}}' | grep -i -E "pass|token|secret"
git log -p --all | grep -i -E "password|BEGIN RSA PRIVATE KEY"    # 源码里也别有
cat .dockerignore | grep -E "\.env|id_rsa|\.git|\*\.pem"          # 构建上下文里要排除

如果密钥已经进了镜像:只能作废该密钥(轮转),删镜像没用——镜像可能已经被别人 pull 走了。

5.5 生产实践与性能优化

5.5.1 存储驱动的选择

# 查看当前使用的存储驱动
docker info | grep -A 5 "Storage Driver"
 Storage Driver: overlay2
  Backing Filesystem: xfs
  Supports d_type: true
  Native Overlay Diff: true
  userxattr: false
存储驱动 适用内核/系统 评价
overlay2 内核 4.0+(CentOS 7.4+ 的 3.10 也支持) 首选,性能好、稳定、社区默认
overlay 内核 3.18+ overlay2 的旧版,inode 消耗大,已过时
devicemapper (direct-lvm) CentOS 6 / 早期 7 需手工配置 LVM 薄池,配置复杂;loop-lvm 模式性能极差且不可用于生产
aufs Ubuntu 旧内核 Docker 早期默认,未进主线内核,已淘汰
btrfs / zfs 对应文件系统 支持快照,但运维复杂,社区使用少
vfs 任意 无写时复制,每层完整复制,只用于测试
# 明确指定 overlay2 并检查 xfs 是否支持 d_type(必须为 true)
xfs_info /var/lib/docker | grep ftype
# ftype=1 才行;ftype=0 需要重新格式化:mkfs.xfs -n ftype=1 /dev/sdb1
{
  "storage-driver": "overlay2",
  "storage-opts": [
    "overlay2.override_kernel_check=true",
    "overlay2.size=20G"
  ]
}

overlay2.size 限制单个容器可写层大小(仅 xfs + pquota 支持),可防止某个容器把磁盘写满。挂载时需 defaults,pquota。 切换存储驱动会导致原有镜像和容器全部不可见(数据仍在旧目录里但 Docker 不再识别),必须先 docker save 备份镜像。

CentOS 6 使用 devicemapper 的配置要点(仅存档参考,生产不建议再用 CentOS 6):

# 准备一块裸盘做 direct-lvm,切勿使用默认的 loop-lvm
pvcreate /dev/sdb
vgcreate docker /dev/sdb
lvcreate --wipesignatures y -n thinpool docker -l 95%VG
lvcreate --wipesignatures y -n thinpoolmeta docker -l 1%VG
lvconvert -y --zero n -c 512K --thinpool docker/thinpool --poolmetadata docker/thinpoolmeta
# /etc/sysconfig/docker
# OPTIONS='--storage-driver=devicemapper --storage-opt dm.thinpooldev=/dev/mapper/docker-thinpool --storage-opt dm.use_deferred_removal=true'
service docker restart

5.5.2 日志与镜像的磁盘治理

问题:/var/lib/docker 撑爆磁盘是生产上最高频的 Docker 故障,来源有三:容器日志无限增长、悬空镜像堆积、停止的容器与匿名卷残留。

# 1. 查看整体占用
docker system df
docker system df -v          # 详细到每个镜像/容器/卷
du -sh /var/lib/docker/*
du -sh /var/lib/docker/containers/* | sort -rh | head -10
du -sh /var/lib/docker/overlay2 /var/lib/docker/volumes /var/lib/docker/image

# 2. 找出最大的日志文件
find /var/lib/docker/containers -name "*-json.log" -exec ls -lh {} \; | sort -k5 -rh | head
# 定位日志属于哪个容器
docker ps -a --format '{{.ID}} {{.Names}}' | grep $(basename $(dirname /var/lib/docker/containers/abc123.../abc123-json.log) | cut -c1-12)

日志治理(全局默认,最重要的一条配置):

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "5",
    "compress": "true"
  }
}
systemctl daemon-reload && systemctl restart docker
# 注意:全局配置只对新创建的容器生效,已有容器需重建
docker inspect myapp --format '{{json .HostConfig.LogConfig}}'
# 单容器覆盖
docker run -d --log-opt max-size=50m --log-opt max-file=3 nginx:1.25

# 紧急清理已经很大的日志:用 truncate,绝不要 rm
truncate -s 0 /var/lib/docker/containers/*/*-json.log
# 或
for f in $(find /var/lib/docker/containers -name "*-json.log"); do cat /dev/null > $f; done

为什么不能 rm 日志文件:dockerd 仍持有该文件的 fd,删除后磁盘空间不会释放(df 显示还满,du 却查不到),必须重启 docker 才回收。truncate -s 0 直接清空且立即释放。

镜像与容器清理:

# 安全清理:悬空镜像 + 停止的容器 + 未使用网络 + 构建缓存
docker system prune
docker system prune -f
# 激进清理:连"未被任何容器使用的镜像"一起删(谨慎,会删掉备用镜像)
docker system prune -a -f
# 连未使用的卷一起删(极度危险,可能删掉数据库数据)
docker system prune -a --volumes -f

# 分项清理
docker container prune -f
docker image prune -f
docker image prune -a -f --filter "until=168h"      # 删 7 天前未使用的镜像
docker volume prune -f
docker network prune -f
docker builder prune -f
docker builder prune --keep-storage 10GB -f

定时清理任务:

cat > /usr/local/bin/docker-cleanup.sh <<'EOF'
#!/bin/bash
LOG=/var/log/docker-cleanup.log
echo "===== $(date '+%F %T') 开始清理 =====" >> $LOG
docker system df >> $LOG

# 1. 清空过大的容器日志(超过 500M 的)
find /var/lib/docker/containers -name "*-json.log" -size +500M | while read f; do
    echo "截断日志 $f ($(du -h $f | cut -f1))" >> $LOG
    truncate -s 0 "$f"
done

# 2. 删除退出超过 24 小时的容器
docker container prune -f --filter "until=24h" >> $LOG 2>&1

# 3. 删除悬空镜像和 7 天前未使用的镜像
docker image prune -f >> $LOG 2>&1
docker image prune -a -f --filter "until=168h" >> $LOG 2>&1

# 4. 清理构建缓存,保留 10G
docker builder prune -f --keep-storage 10GB >> $LOG 2>&1

# 5. 清理未使用网络
docker network prune -f >> $LOG 2>&1

# 注意:卷不自动清理,避免误删数据;如需清理请人工确认
echo "剩余空间:" >> $LOG
df -h /var/lib/docker >> $LOG
docker system df >> $LOG
echo "===== $(date '+%F %T') 清理结束 =====" >> $LOG
EOF

chmod +x /usr/local/bin/docker-cleanup.sh
# 每天凌晨 3 点执行
echo "0 3 * * * /usr/local/bin/docker-cleanup.sh" >> /var/spool/cron/root
crontab -l

磁盘水位告警:

cat > /usr/local/bin/docker-disk-check.sh <<'EOF'
#!/bin/bash
USE=$(df /var/lib/docker | awk 'NR==2{print $5}' | tr -d '%')
if [ "$USE" -gt 80 ]; then
    echo "警告:Docker 目录使用率 ${USE}%" | mail -s "Docker 磁盘告警 $(hostname)" ops@example.com
    docker system df
fi
EOF
chmod +x /usr/local/bin/docker-disk-check.sh
echo "*/10 * * * * /usr/local/bin/docker-disk-check.sh" >> /var/spool/cron/root

详见 08-02-日志管理.md

5.5.3 迁移 Docker 数据目录的完整步骤

/var/lib/docker 默认在根分区,很容易把根分区写满。生产上应该把它迁到独立的大容量数据盘。

方法一:修改 daemon.json 的 data-root(推荐)

# ========== 步骤 1:查看现状,确认新盘已挂载 ==========
docker info | grep "Docker Root Dir"
# Docker Root Dir: /var/lib/docker
du -sh /var/lib/docker
df -h
lsblk

# 若新盘还未准备,先分区格式化挂载(示例用 /dev/sdb)
mkfs.xfs -n ftype=1 /dev/sdb1          # overlay2 要求 xfs 的 ftype=1
mkdir -p /data
mount /dev/sdb1 /data
# 写入 fstab 保证开机自动挂载
blkid /dev/sdb1
echo 'UUID=xxxx-xxxx /data xfs defaults,pquota 0 0' >> /etc/fstab
mount -a && df -h /data

# ========== 步骤 2:停止 Docker(业务需停机窗口)==========
docker ps -a --format '{{.Names}} {{.Status}}' > /root/containers-before.txt
docker images > /root/images-before.txt
systemctl stop docker
systemctl stop docker.socket        # 必须一起停,否则会被 socket 激活重新拉起
systemctl stop containerd
ps -ef | grep -E "dockerd|containerd" | grep -v grep     # 确认已无进程

# ========== 步骤 3:迁移数据(保留权限、属主、SELinux 上下文、硬链接)==========
mkdir -p /data/docker
rsync -avxHAX --progress /var/lib/docker/ /data/docker/
# 参数说明:-a 归档 -v 详细 -x 不跨文件系统 -H 保留硬链接(overlay2 大量硬链接,必须加)
#          -A 保留 ACL -X 保留扩展属性(SELinux 上下文)
# 也可以用 cp -a /var/lib/docker/* /data/docker/ 但 rsync 更可靠且可断点续传

# 校验大小一致
du -sh /var/lib/docker /data/docker

# ========== 步骤 4:修改配置 ==========
vim /etc/docker/daemon.json
{
  "data-root": "/data/docker",
  "storage-driver": "overlay2",
  "registry-mirrors": ["https://docker.m.daocloud.io"],
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "100m",
    "max-file": "5"
  },
  "live-restore": true
}
# 语法检查(有语法错误会导致 dockerd 起不来)
python -m json.tool /etc/docker/daemon.json

# ========== 步骤 5:启动并验证 ==========
systemctl daemon-reload
systemctl start docker
systemctl status docker

docker info | grep "Docker Root Dir"
# Docker Root Dir: /data/docker
docker images                       # 与 images-before.txt 对比
docker ps -a                        # 与 containers-before.txt 对比
docker run --rm hello-world         # 功能验证
docker volume ls                    # 卷是否都在

# ========== 步骤 6:确认无误后再删除旧数据(建议观察几天)==========
mv /var/lib/docker /var/lib/docker.bak
# 观察 3~7 天业务正常后
rm -rf /var/lib/docker.bak

方法二:修改 systemd 启动参数(旧写法,不推荐与 daemon.json 混用)

mkdir -p /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/data-root.conf <<'EOF'
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd --data-root=/data/docker -H fd:// --containerd=/run/containerd/containerd.sock
EOF
systemctl daemon-reload
systemctl restart docker
docker info | grep "Docker Root Dir"

注意:--data-root(或旧参数 -g / --graph)不能同时在 daemon.json 和 systemd 的 ExecStart 里指定,否则 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 而拒绝启动。

方法三:软链接(应急手段,不推荐长期使用)

systemctl stop docker docker.socket
mv /var/lib/docker /data/docker
ln -s /data/docker /var/lib/docker
# SELinux 环境下需要修复上下文
restorecon -Rv /data/docker
systemctl start docker

软链接方式的问题:某些工具按真实路径检查、SELinux 标签易出错、备份脚本可能重复计算,能用方法一就不要用这个。

CentOS 6 的对应做法:

service docker stop
rsync -avxHAX /var/lib/docker/ /data/docker/
vim /etc/sysconfig/docker
# other_args="-g /data/docker"    # 老版本用 -g
service docker start
docker info | grep "Root Dir"

5.5.4 宿主机内核参数调优

容器密度高的宿主机,默认内核参数往往不够用。以下参数必须调整。

cat > /etc/sysctl.d/99-docker.conf <<'EOF'
# ========== 必须项 ==========
# 开启 IP 转发,否则容器无法访问外网(Docker 会自动开,但重启或被其他配置覆盖后会失效)
net.ipv4.ip_forward = 1
# 允许 iptables 处理桥接流量,容器间通信与端口映射依赖此项
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1

# ========== 文件句柄(容器多时极易打满)==========
# 系统级最大文件句柄数
fs.file-max = 2097152
# inotify 监听数量,大量容器 + 日志采集必调,否则报 "too many open files"
fs.inotify.max_user_instances = 8192
fs.inotify.max_user_watches = 1048576
# AIO 上下文数量,数据库容器需要
fs.aio-max-nr = 1048576

# ========== 进程与命名空间 ==========
kernel.pid_max = 4194304
kernel.threads-max = 1000000
user.max_user_namespaces = 28633

# ========== 网络连接(高并发容器必调)==========
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 262144
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65000
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_syncookies = 1

# ========== conntrack 表(端口映射走 NAT,容器多时必调)==========
# 表满会报 "nf_conntrack: table full, dropping packet",表现为随机丢包
net.netfilter.nf_conntrack_max = 1048576
net.netfilter.nf_conntrack_tcp_timeout_established = 3600

# ========== 内存 ==========
# 容器化环境建议关闭或降低 swap 倾向
vm.swappiness = 10
vm.max_map_count = 262144
vm.overcommit_memory = 1
vm.panic_on_oom = 0
EOF

# 加载并验证
sysctl --system
sysctl -p /etc/sysctl.d/99-docker.conf

# 逐项验证关键参数
sysctl net.ipv4.ip_forward
cat /proc/sys/net/ipv4/ip_forward           # 必须是 1
sysctl fs.file-max
cat /proc/sys/fs/file-max
sysctl net.bridge.bridge-nf-call-iptables
sysctl net.netfilter.nf_conntrack_max

net.bridge.* 和 net.netfilter.* 参数需要先加载对应内核模块,否则 sysctl 报 cannot stat /proc/sys/...: No such file or directory:

modprobe br_netfilter
modprobe nf_conntrack
modprobe overlay
# 开机自动加载
cat > /etc/modules-load.d/docker.conf <<'EOF'
br_netfilter
nf_conntrack
overlay
EOF
lsmod | grep -E "br_netfilter|nf_conntrack|overlay"

用户级 ulimit(进程可打开的文件数):

cat >> /etc/security/limits.conf <<'EOF'
*  soft  nofile  1048576
*  hard  nofile  1048576
*  soft  nproc   unlimited
*  hard  nproc   unlimited
*  soft  memlock unlimited
*  hard  memlock unlimited
EOF

# dockerd 自身的 limit 由 systemd 控制,limits.conf 对它无效!
mkdir -p /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/limits.conf <<'EOF'
[Service]
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
EOF
systemctl daemon-reload
systemctl restart docker

# 验证 dockerd 实际生效的限制
cat /proc/$(pgrep -o dockerd)/limits
# 验证容器内的限制
docker run --rm centos:7 sh -c 'ulimit -n; ulimit -u'

daemon.json 里也可以设置容器的默认 ulimit:

{
  "default-ulimits": {
    "nofile": { "Name": "nofile", "Hard": 65535, "Soft": 65535 },
    "nproc":  { "Name": "nproc",  "Hard": 32768, "Soft": 32768 }
  },
  "default-shm-size": "128M",
  "max-concurrent-downloads": 10,
  "max-concurrent-uploads": 5,
  "shutdown-timeout": 30
}

关闭 swap 与透明大页(数据库类容器建议):

swapoff -a
sed -i '/swap/s/^/#/' /etc/fstab
free -h
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# 开机生效
echo 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' >> /etc/rc.local
chmod +x /etc/rc.d/rc.local

5.5.5 容器监控

内置命令(临时排查够用):

# 实时资源占用
docker stats
docker stats --no-stream
docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}\t{{.PIDs}}"
# 按内存排序找出内存大户
docker stats --no-stream --format "{{.MemPerc}}\t{{.Name}}" | sort -rn | head
# 容器内进程
docker top myapp
docker top myapp -eo pid,ppid,user,%cpu,%mem,rss,cmd
# 事件流(容器启停、OOM、健康状态变化都在这里)
docker events --filter 'event=die' --filter 'event=oom' --filter 'event=health_status'
docker events --since '1h' --filter 'container=myapp'

直接读 cgroup(脚本采集、无 docker 命令时可用):

CID=$(docker inspect -f '{{.Id}}' myapp)

# cgroup v1
cat /sys/fs/cgroup/memory/docker/$CID/memory.usage_in_bytes
cat /sys/fs/cgroup/memory/docker/$CID/memory.limit_in_bytes
cat /sys/fs/cgroup/memory/docker/$CID/memory.failcnt          # 触碰上限次数
cat /sys/fs/cgroup/memory/docker/$CID/memory.max_usage_in_bytes
cat /sys/fs/cgroup/cpuacct/docker/$CID/cpuacct.usage
cat /sys/fs/cgroup/cpu/docker/$CID/cpu.stat                   # nr_throttled 说明被限流
cat /sys/fs/cgroup/blkio/docker/$CID/blkio.throttle.io_service_bytes

# cgroup v2(CentOS 8+/内核 5.x)
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.current
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.stat

生产监控体系:Prometheus + cAdvisor + node_exporter + Grafana

# 1. 让 dockerd 暴露自身 metrics
{
  "metrics-addr": "0.0.0.0:9323",
  "experimental": true
}
systemctl restart docker
curl -s http://127.0.0.1:9323/metrics | head -30

# 2. cAdvisor 采集每个容器的 CPU/内存/网络/磁盘指标
docker run -d \
  --name cadvisor \
  --restart=always \
  -p 8080:8080 \
  --volume=/:/rootfs:ro \
  --volume=/var/run:/var/run:ro \
  --volume=/sys:/sys:ro \
  --volume=/var/lib/docker/:/var/lib/docker:ro \
  --volume=/dev/disk/:/dev/disk:ro \
  --privileged \
  --device=/dev/kmsg \
  gcr.io/cadvisor/cadvisor:v0.47.2
# 浏览器访问 http://host:8080 可看图;指标接口在 /metrics
curl -s http://127.0.0.1:8080/metrics | grep container_memory_usage_bytes | head

# 3. node_exporter 采集宿主机指标
docker run -d --name node-exporter --restart=always \
  --net=host --pid=host \
  -v /:/host:ro,rslave \
  prom/node-exporter:latest --path.rootfs=/host

# 4. Prometheus
mkdir -p /data/prometheus
cat > /data/prometheus/prometheus.yml <<'EOF'
global:
  scrape_interval: 15s
scrape_configs:
  - job_name: 'docker'
    static_configs:
      - targets: ['192.168.1.100:9323']
  - job_name: 'cadvisor'
    static_configs:
      - targets: ['192.168.1.100:8080']
  - job_name: 'node'
    static_configs:
      - targets: ['192.168.1.100:9100']
EOF
docker run -d --name prometheus --restart=always -p 9090:9090 \
  -v /data/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro \
  -v prom-data:/prometheus \
  prom/prometheus:latest

# 5. Grafana(导入 Dashboard ID 893 / 14282 即可看容器大盘)
docker run -d --name grafana --restart=always -p 3000:3000 \
  -v grafana-data:/var/lib/grafana \
  -e GF_SECURITY_ADMIN_PASSWORD='Grafana123' \
  grafana/grafana:latest

关键告警指标:

指标 告警阈值建议 含义
container_memory_usage_bytes / container_spec_memory_limit_bytes > 85% 即将 OOM
rate(container_cpu_cfs_throttled_periods_total[5m]) > 0 持续 CPU 被限流,--cpus 设太小
container_last_seen 变化 / 重启次数 5 分钟内 > 3 次 容器反复重启(CrashLoop)
container_fs_usage_bytes > 80% 容器可写层或卷将满
engine_daemon_container_states_containers{state="running"} 突降 批量容器退出
宿主机 /var/lib/docker 使用率 > 80% 磁盘治理告警
nf_conntrack 使用率 > 80% 连接跟踪表将满,会随机丢包

5.5.6 健康检查与自动重启

只看容器"在运行"是不够的:进程活着但已经死锁、连不上数据库、返回 500,docker ps 依然显示 Up。必须靠健康检查判断"是否真的可用"。

镜像内置健康检查:

FROM nginx:1.25-alpine
COPY dist/ /usr/share/nginx/html/
RUN apk add --no-cache curl
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
    CMD curl -fsS http://localhost/healthz || exit 1

运行时指定(不改镜像):

docker run -d --name myapp \
  --health-cmd='curl -fsS http://localhost:8080/actuator/health || exit 1' \
  --health-interval=30s \
  --health-timeout=5s \
  --health-start-period=60s \
  --health-retries=3 \
  --restart=always \
  myapp:v1

# 禁用镜像里定义的健康检查
docker run -d --no-healthcheck myapp:v1

查看健康状态:

docker ps
# STATUS 列显示 Up 2 minutes (healthy) / (unhealthy) / (health: starting)
docker ps --filter health=unhealthy
docker ps --filter health=healthy -q

docker inspect myapp --format '{{.State.Health.Status}}'
docker inspect myapp --format '{{json .State.Health}}' | python -m json.tool
# 查看最近的检查输出(排查为什么 unhealthy 的关键)
docker inspect myapp --format '{{range .State.Health.Log}}{{.Start}} exit={{.ExitCode}} {{.Output}}{{end}}'
docker inspect myapp --format '{{.State.Health.FailingStreak}}'

# 监听健康状态变化事件
docker events --filter event=health_status

不同应用的健康检查写法:

# HTTP 服务
--health-cmd='curl -fsS http://localhost:8080/health || exit 1'
--health-cmd='wget -qO- http://localhost/ >/dev/null 2>&1 || exit 1'      # alpine 无 curl 时
# MySQL
--health-cmd='mysqladmin ping -h 127.0.0.1 -uroot -p$MYSQL_ROOT_PASSWORD || exit 1'
--health-cmd='mysql -uroot -p$MYSQL_ROOT_PASSWORD -e "SELECT 1" || exit 1'
# Redis
--health-cmd='redis-cli ping | grep -q PONG || exit 1'
# PostgreSQL
--health-cmd='pg_isready -U postgres || exit 1'
# 纯 TCP 端口
--health-cmd='nc -z localhost 9000 || exit 1'
# 进程存在性(最弱的检查,不得已才用)
--health-cmd='pgrep -x java >/dev/null || exit 1'

关键坑:--restart=always 不会因为 unhealthy 而重启容器! Docker 只在容器进程退出时才按重启策略处理,健康检查失败只改状态、不重启(Swarm/K8s 才会自动替换)。单机上要自愈需要额外手段:

# 方案 1:autoheal 容器(监听 health_status 事件自动重启 unhealthy 容器)
docker run -d --name autoheal --restart=always \
  -e AUTOHEAL_CONTAINER_LABEL=all \
  -e AUTOHEAL_INTERVAL=10 \
  -e AUTOHEAL_START_PERIOD=60 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  willfarrell/autoheal
# 注意:这需要挂载 docker.sock,属于高权限,请评估风险(见 5.4.3)

# 方案 2:自己写 cron 脚本(不必挂载 socket 到容器)
cat > /usr/local/bin/docker-autoheal.sh <<'EOF'
#!/bin/bash
for c in $(docker ps -q --filter health=unhealthy); do
    name=$(docker inspect -f '{{.Name}}' $c | tr -d '/')
    echo "$(date '+%F %T') 重启 unhealthy 容器 $name" >> /var/log/docker-autoheal.log
    docker inspect $c --format '{{range .State.Health.Log}}{{.Output}}{{end}}' >> /var/log/docker-autoheal.log
    docker restart $c
done
EOF
chmod +x /usr/local/bin/docker-autoheal.sh
echo "*/2 * * * * /usr/local/bin/docker-autoheal.sh" >> /var/spool/cron/root

# 方案 3:让应用自己在检测到不可恢复错误时主动退出,交给 --restart 处理(最干净)

重启策略回顾与生产选择:

docker run -d --restart=no myapp                 # 默认,不重启
docker run -d --restart=on-failure:5 myapp       # 非 0 退出时最多重启 5 次(适合批处理任务)
docker run -d --restart=always myapp             # 总是重启,docker 服务启动时也会拉起
docker run -d --restart=unless-stopped myapp     # 总是重启,但人工 stop 后 docker 重启不会拉起它

# 查看策略与重启次数
docker inspect myapp --format '{{.HostConfig.RestartPolicy.Name}} 已重启 {{.RestartCount}} 次'
# 运行中修改策略
docker update --restart=unless-stopped myapp
策略 dockerd 重启后 人工 stop 后 dockerd 重启 适用场景
no 不拉起 不拉起 一次性调试容器
on-failure:N 拉起(若上次非正常退出) 不拉起 批处理、定时任务
always 拉起 仍会拉起(容易造成"我明明停了它"的困惑) 需要开机自启的常驻服务
unless-stopped 拉起 不拉起(尊重人工意图) 生产常驻服务推荐

Docker 的重启有指数退避:100ms、200ms、400ms……最长 1 分钟,防止疯狂重启拖垮机器。docker events 里能看到每次 die 和 start。

结合日志判断反复重启的原因:

docker inspect myapp --format '{{.RestartCount}}'
docker logs --tail 100 --timestamps myapp
docker inspect myapp --format 'Exit={{.State.ExitCode}} OOM={{.State.OOMKilled}} Error={{.State.Error}}'
docker events --since 30m --filter container=myapp

5.6 排错清单

问题 1:Cannot connect to the Docker daemon

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?

排查步骤:

# 1. 服务是否在运行
systemctl status docker                  # CentOS 7
service docker status                    # CentOS 6
ps -ef | grep dockerd

# 2. 没起来就看日志,找具体原因
journalctl -u docker -n 100 --no-pager
journalctl -u docker --since "10 min ago"
tail -50 /var/log/messages | grep -i docker      # CentOS 6

# 3. 最常见原因:daemon.json 语法错误(多一个逗号就起不来)
python -m json.tool /etc/docker/daemon.json
# 报 Expecting property name: line 5 column 3 就是语法错误
cat /etc/docker/daemon.json

# 4. 权限问题:当前用户不在 docker 组
ls -l /var/run/docker.sock
id
sudo docker ps                           # 加 sudo 能通就是权限问题
usermod -aG docker $USER && newgrp docker

# 5. socket 文件丢失或 socket 单元没启
systemctl start docker.socket
systemctl start docker

# 6. DOCKER_HOST 环境变量被误设(指向了不存在的远程 daemon)
echo $DOCKER_HOST
unset DOCKER_HOST

# 7. 磁盘满导致 dockerd 启动失败
df -h /var/lib/docker
df -i /var/lib/docker                    # inode 也可能耗尽

# 8. 存储驱动不兼容(如切换过 storage-driver)
journalctl -u docker | grep -i "storage-driver\|error"

# 9. 前台手动启动,直接看报错(最有效的终极手段)
systemctl stop docker
dockerd --debug
# 看到具体报错后按提示修复,Ctrl+C 退出再用 systemctl 启动

问题 2:镜像拉取超时与加速器配置

Error response from daemon: Get "https://registry-1.docker.io/v2/": net/http: request canceled while waiting for connection
Error response from daemon: received unexpected HTTP status: 503 Service Unavailable
docker: error pulling image configuration: download failed after attempts=6: dial tcp: i/o timeout

解决:配置镜像加速器

mkdir -p /etc/docker
cat > /etc/docker/daemon.json <<'EOF'
{
  "registry-mirrors": [
    "https://docker.m.daocloud.io",
    "https://dockerproxy.com",
    "https://docker.nju.edu.cn",
    "https://mirror.ccs.tencentyun.com",
    "https://<你的ID>.mirror.aliyuncs.com"
  ],
  "max-concurrent-downloads": 10,
  "dns": ["223.5.5.5", "119.29.29.29"]
}
EOF
systemctl daemon-reload
systemctl restart docker
# 验证配置已生效
docker info | grep -A 6 "Registry Mirrors"
docker pull nginx:1.25
# 加速器也不通时的其他手段
# 1. 检查基础网络与 DNS
ping -c 2 223.5.5.5
curl -I https://registry-1.docker.io/v2/
nslookup registry-1.docker.io
cat /etc/resolv.conf

# 2. 配置代理(企业内网常见)
mkdir -p /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/proxy.conf <<'EOF'
[Service]
Environment="HTTP_PROXY=http://10.0.0.8:3128"
Environment="HTTPS_PROXY=http://10.0.0.8:3128"
Environment="NO_PROXY=localhost,127.0.0.1,192.168.0.0/16,*.internal.com"
EOF
systemctl daemon-reload && systemctl restart docker
docker info | grep -i proxy

# 3. 从能联网的机器导出再导入(离线环境标准做法)
#    机器 A
docker pull nginx:1.25 && docker save nginx:1.25 | gzip > nginx-1.25.tar.gz
#    传到机器 B
docker load < nginx-1.25.tar.gz

# 4. 用第三方代理前缀
docker pull dockerproxy.com/library/nginx:1.25
docker tag dockerproxy.com/library/nginx:1.25 nginx:1.25

# 5. 时间不同步也会导致 TLS 握手失败
date
ntpdate -u ntp.aliyun.com          # CentOS 6/7
chronyc sources -v                 # CentOS 7+
timedatectl

问题 3:docker0 网桥丢失或 ip_forward 关闭导致容器不能上网

现象:容器内 ping 223.5.5.5 不通,但宿主机正常。

# 1. 检查 docker0 网桥是否存在且 UP
ip addr show docker0
ifconfig docker0
brctl show                      # 需要 yum install bridge-utils
# 若不存在:
systemctl restart docker        # dockerd 启动时会自动创建
# 仍不存在则手工建
ip link add name docker0 type bridge
ip addr add 172.17.0.1/16 dev docker0
ip link set docker0 up

# 2. 检查 IP 转发(最高频原因)
sysctl net.ipv4.ip_forward
cat /proc/sys/net/ipv4/ip_forward
# 为 0 则临时开启
sysctl -w net.ipv4.ip_forward=1
echo 1 > /proc/sys/net/ipv4/ip_forward
# 永久开启
echo "net.ipv4.ip_forward = 1" >> /etc/sysctl.d/99-docker.conf
sysctl --system
systemctl restart docker

# 3. 检查 SNAT(MASQUERADE)规则是否存在
iptables -t nat -L POSTROUTING -n -v
# 应有:MASQUERADE all -- 172.17.0.0/16 !docker0
# 缺失就重启 docker 让它重建
systemctl restart docker
# 手工补
iptables -t nat -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE

# 4. 检查 FORWARD 链默认策略是否被别人改成 DROP
iptables -L FORWARD -n | head -3
# Chain FORWARD (policy DROP)  ← 这就是问题
iptables -P FORWARD ACCEPT
# 或确认 DOCKER-USER / DOCKER-ISOLATION 链存在
iptables -L DOCKER-USER -n
iptables -L DOCKER -n

# 5. 容器内验证
docker run --rm centos:7 ip a
docker run --rm centos:7 ip route            # 默认路由应指向 172.17.0.1
docker run --rm centos:7 ping -c 2 172.17.0.1        # 通网关
docker run --rm centos:7 ping -c 2 223.5.5.5         # 通外网 IP
docker run --rm centos:7 ping -c 2 baidu.com         # 通域名(不通则是 DNS 问题,见问题 10)

# 6. bip 与内网网段冲突(172.17 段与公司内网重叠会造成路由混乱)
{
  "bip": "172.31.0.1/16",
  "default-address-pools": [
    { "base": "172.31.0.0/16", "size": 24 }
  ]
}
systemctl restart docker
ip addr show docker0

问题 4:端口映射不生效 / 端口被占用

docker: Error response from daemon: driver failed programming external connectivity on endpoint web:
Bind for 0.0.0.0:80 failed: port is already allocated

Error starting userland proxy: listen tcp4 0.0.0.0:8000: bind: address already in use
# 1. 查谁占用了端口
netstat -tunlp | grep :80
ss -tunlp | grep :80
lsof -i:8000
# 输出里 docker-pr 就是 docker-proxy 进程,说明已被别的容器占用
# COMMAND    PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
# docker-pr 4021  root    4u  IPv6  40129      0t0  TCP *:irdmi (LISTEN)

# 2. 找出是哪个容器占的
docker ps -a --format 'table {{.Names}}\t{{.Ports}}\t{{.Status}}'
docker ps --filter publish=80
# 停掉旧容器或换端口
docker stop old-web
docker run -d -p 8081:80 nginx:1.25

# 3. 端口没被占但外部访问不了 —— 检查映射是否真的建立
docker port myweb
docker ps    # PORTS 列必须有 0.0.0.0:8080->80/tcp;只显示 80/tcp 说明没做映射
docker inspect myweb --format '{{json .NetworkSettings.Ports}}'

# 4. 检查容器内服务是否真的在监听,且监听的是 0.0.0.0 而不是 127.0.0.1
docker exec myweb ss -tunlp
docker exec myweb netstat -tunlp
# 只监听 127.0.0.1 时,宿主机的端口映射进不去 —— 必须改应用配置监听 0.0.0.0
docker exec myweb curl -I http://127.0.0.1:80        # 容器内自己访问

# 5. 检查 DNAT 规则
iptables -t nat -L DOCKER -n
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
iptables -t nat -L -n -v | grep 8080

# 6. 宿主机防火墙拦截
firewall-cmd --state
firewall-cmd --list-all
firewall-cmd --zone=public --add-port=8080/tcp --permanent && firewall-cmd --reload
iptables -L INPUT -n --line-numbers          # CentOS 6
service iptables status                      # CentOS 6
# CentOS 6 放行
iptables -I INPUT -p tcp --dport 8080 -j ACCEPT
service iptables save

# 7. 映射语法自查(宿主机端口在前,容器端口在后 —— 写反是常见错误)
-p 8080:80              # 正确:宿主机 8080 → 容器 80
-p 80:8080              # 如果服务在容器 80 上,这就写反了
-p 127.0.0.1:8080:80    # 只绑本机(更安全)
-p 8080:80/udp          # UDP 端口
-P                      # 随机映射 EXPOSE 的所有端口

# 8. 云服务器还要检查安全组 / 网络 ACL 是否放行

问题 5:容器启动后立即退出(前台进程结束)

现象:docker run -d centos:7 之后 docker ps 里什么都没有,docker ps -a 显示 Exited (0) 2 seconds ago。

根本原因:容器的生命周期等于其 1 号进程的生命周期。 1 号进程一结束,容器立刻退出。centos:7 默认 CMD 是 /bin/bash,没有 -it 时 bash 读不到输入立即结束,容器就退出了。这不是故障,是设计如此。

# 复现
docker run -d centos:7
docker ps -a
# CONTAINER ID  IMAGE     COMMAND      STATUS                     NAMES
# 3f2a1b0c9d8e  centos:7  "/bin/bash"  Exited (0) 3 seconds ago   nice_kepler

诊断流程:

# 1. 看退出码(最重要的第一手信息)
docker ps -a
docker inspect myapp --format 'ExitCode={{.State.ExitCode}} Error={{.State.Error}} OOM={{.State.OOMKilled}} FinishedAt={{.State.FinishedAt}}'

# 2. 看日志(应用自己的报错都在这里)
docker logs myapp
docker logs --tail 200 --timestamps myapp
docker logs myapp 2>&1 | tail -50
# 容器已被 --rm 删掉就看不到日志了,调试时不要加 --rm

# 3. 看它到底执行了什么命令
docker inspect myapp --format 'Entrypoint={{json .Config.Entrypoint}} Cmd={{json .Config.Cmd}}'
docker inspect myapp --format '{{.Path}} {{.Args}}'
docker history --no-trunc myapp:v1 | grep -i -E "CMD|ENTRYPOINT"

# 4. 覆盖 entrypoint 进容器手工排查(王牌手段)
docker run -it --rm --entrypoint /bin/sh myapp:v1
docker run -it --rm --entrypoint /bin/bash myapp:v1
# 进去后手动执行原本的启动命令,看真实报错
/app/start.sh
java -jar /app/app.jar
# 检查文件是否存在、有无执行权限、配置能不能读
ls -l /app/
cat /app/config/application.yml
env | sort

# 5. 保留失败容器的文件系统再进去看
docker create --name debug myapp:v1
docker start debug; docker logs debug
docker cp debug:/app/logs ./logs        # 停止的容器也能 cp
docker diff debug

# 6. 用 sleep 顶住让容器活着,再 exec 进去排查
docker run -d --name debug --entrypoint sleep myapp:v1 3600
docker exec -it debug sh

退出码含义速查:

退出码 含义 处理方向
0 正常结束(主进程跑完就退出) 加 -it、或改成常驻前台进程
1 应用自身报错退出 看 docker logs 里应用的异常栈
125 docker run 自身参数错误(容器根本没创建) 检查命令行选项拼写
126 命令找到了但没有执行权限 chmod +x,检查脚本权限与 shebang
127 命令不存在(找不到可执行文件) 路径写错 / 镜像里没这个程序(见问题 6)
137 128+9,被 SIGKILL 杀死 多数是 OOM,看 .State.OOMKilled(见问题 12)
139 128+11,段错误 SIGSEGV 程序崩溃,架构不匹配也会(arm 镜像跑在 x86)
143 128+15,收到 SIGTERM 正常停止 docker stop 的正常结果

让容器保持前台运行的正确写法:

# 错误:后台化自己会导致容器立即退出
docker run -d nginx:1.25 nginx                      # nginx 默认 daemon on,会退出
docker run -d myimage service httpd start           # 脚本执行完就结束
docker run -d myimage /etc/init.d/apache2 start      # 同上

# 正确:前台运行
docker run -d nginx:1.25 nginx -g "daemon off;"
docker run -d httpd:2.4 httpd-foreground
docker run -d myimage /usr/sbin/apache2ctl -D FOREGROUND
docker run -d mysql:8.0 mysqld
# 交互式容器必须给 -it
docker run -it centos:7 /bin/bash
docker run -it ubuntu:15.10 /bin/bash
# 调试时用死循环顶住
docker run -d centos:7 /bin/sh -c "while true; do echo haha; sleep 3; done"
docker run -d centos:7 tail -f /dev/null
docker run -d centos:7 sleep infinity

这也解释了源文档里的现象:docker run -it ubuntu:15.10 /bin/bash 里 touch /test 建的文件,exit 后容器停止;再执行一次 docker run 是新建了一个容器,当然没有那个文件。要回到原容器必须 docker start + docker attach(见本章练习"问题一")。

问题 6:exec 报 executable file not found / alpine 没有 bash

OCI runtime exec failed: exec failed: unable to start container process:
exec: "bash": executable file not found in $PATH: unknown

OCI runtime exec failed: exec: "/bin/bash": stat /bin/bash: no such file or directory

原因:Alpine 基础镜像用 busybox + musl libc,只提供 /bin/sh(ash),没有 bash;distroless、scratch 镜像里连 shell 都没有。

# 解决 1:改用 sh
docker exec -it myapp sh
docker exec -it myapp /bin/sh
docker exec -it myapp /bin/ash            # alpine 的 shell 就是 ash

# 解决 2:先探测镜像里有哪些 shell
docker exec myapp ls /bin/ | grep -E "^(sh|ash|bash|dash)$"
docker exec myapp cat /etc/os-release
docker run --rm myapp:v1 sh -c 'echo $0; ls /bin/sh /bin/bash 2>&1'

# 解决 3:容器内临时装 bash(仅调试用,重启后失效)
docker exec -it myapp apk add --no-cache bash      # alpine
docker exec -it myapp apt-get update && docker exec -it myapp apt-get install -y bash   # debian
docker exec -it myapp yum install -y bash          # centos

# 解决 4:构建镜像时就装上(会增大体积,生产按需权衡)
FROM alpine:3.19
RUN apk add --no-cache bash curl bind-tools tzdata
# 解决 5:distroless / scratch 镜像完全没有 shell,用共享命名空间的调试容器
docker run -it --rm \
  --pid=container:myapp \
  --net=container:myapp \
  --cap-add=SYS_PTRACE \
  nicolaka/netshoot sh
# 进去后能看到目标容器的进程和网络
ps -ef
ss -tunlp
# 通过 /proc 访问目标容器的文件系统
ls /proc/1/root/app/

# 解决 6:用 nsenter 从宿主机直接进入(不依赖容器内有任何工具)
PID=$(docker inspect -f '{{.State.Pid}}' myapp)
nsenter -t $PID -m -u -i -n -p sh
nsenter -t $PID -n ss -tunlp             # 只进网络命名空间看端口
ls -l /proc/$PID/root/                   # 从宿主机直接看容器文件系统
cat /proc/$PID/root/app/config.yml

其他相关报错:

standard_init_linux.go:228: exec user process caused: exec format error

原因是镜像架构与宿主机不匹配(如 arm64 镜像跑在 x86_64),或脚本 shebang 有 Windows 换行符(\r\n)。

docker inspect myapp:v1 --format '{{.Architecture}} {{.Os}}'
uname -m
# 拉取正确架构
docker pull --platform linux/amd64 myapp:v1
# 修复 CRLF
dos2unix entrypoint.sh
sed -i 's/\r$//' entrypoint.sh
file entrypoint.sh
head -1 entrypoint.sh | od -c | head -2
exec /entrypoint.sh: permission denied         # 退出码 126
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# 或在 COPY 时直接给权限(BuildKit 支持)
COPY --chmod=755 entrypoint.sh /entrypoint.sh

问题 7:容器时区与宿主机不一致

现象:容器内 date 比宿主机慢 8 小时(UTC vs CST),日志时间全错,业务时间计算出问题。

# 确认现象
date
docker exec myapp date
docker exec myapp cat /etc/timezone 2>/dev/null
docker exec myapp ls -l /etc/localtime

方案一:运行时挂载宿主机时区文件(最快,无需改镜像)

docker run -d --name myapp \
  -v /etc/localtime:/etc/localtime:ro \
  -v /etc/timezone:/etc/timezone:ro \
  myapp:v1
# CentOS 没有 /etc/timezone,只挂 localtime 即可
docker run -d -v /etc/localtime:/etc/localtime:ro myapp:v1
# 验证
docker exec myapp date

方案二:设置 TZ 环境变量(推荐,最干净)

docker run -d -e TZ=Asia/Shanghai myapp:v1
# 全局默认:daemon.json 不支持,可在启动脚本或 compose 里统一加
# alpine 需要先装 tzdata,否则 TZ 变量无效
FROM alpine:3.19
RUN apk add --no-cache tzdata && \
    cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
    echo "Asia/Shanghai" > /etc/timezone && \
    apk del tzdata
ENV TZ=Asia/Shanghai
# debian/ubuntu
FROM ubuntu:22.04
ENV TZ=Asia/Shanghai DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y tzdata && \
    ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
    echo $TZ > /etc/timezone && \
    rm -rf /var/lib/apt/lists/*
# centos
FROM centos:7
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

方案三:构建镜像时直接写死时区文件

FROM openjdk:17-jre-slim
COPY --from=alpine:3.19 /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo "Asia/Shanghai" > /etc/timezone
ENV TZ=Asia/Shanghai
# Java 应用还可以在 JVM 层指定
ENTRYPOINT ["java","-Duser.timezone=Asia/Shanghai","-jar","/app.jar"]

三种方案对比:

方案 优点 缺点
挂载 /etc/localtime 立即生效,不用改镜像,与宿主机永久一致 依赖宿主机配置,镜像不自包含,换宿主机可能变
-e TZ=Asia/Shanghai 干净、显式、可在编排文件里统一设置 alpine 等精简镜像必须先装 tzdata 才生效
镜像内固化时区 镜像自包含,到哪都对 镜像与地域绑定,多地域部署需多套镜像或配合 TZ 覆盖

生产推荐:镜像内装好 tzdata + ENV TZ=Asia/Shanghai,同时允许运行时用 -e TZ 覆盖。数据库容器还要单独设置服务端时区(如 MySQL 的 --default-time-zone='+8:00')。

# 宿主机时区本身就错的话先改宿主机
timedatectl set-timezone Asia/Shanghai      # CentOS 7+
ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime   # CentOS 6
timedatectl status
hwclock -w

问题 8:/var/lib/docker 占满磁盘

no space left on device
write /var/lib/docker/tmp/...: no space left on device
Error response from daemon: mkdir /var/lib/docker/overlay2/...: no space left on device
# 1. 先确认是空间满还是 inode 满
df -h /var/lib/docker
df -i /var/lib/docker            # IUse% 100% 说明是 inode 耗尽(小文件太多)

# 2. 定位大头
docker system df -v
du -sh /var/lib/docker/* | sort -rh
# 常见排序:overlay2(镜像+容器层) > containers(日志) > volumes(数据)

# 3. 分项深挖
# 3a. 日志
find /var/lib/docker/containers -name "*-json.log" -exec du -h {} + | sort -rh | head -10
# 3b. 容器可写层
docker ps -as --format 'table {{.Names}}\t{{.Size}}' | sort -k2 -rh | head
# 3c. 卷
du -sh /var/lib/docker/volumes/* | sort -rh | head -10
docker volume ls -qf dangling=true | wc -l
# 3d. 镜像
docker images --format 'table {{.Size}}\t{{.Repository}}:{{.Tag}}' | sort -rh | head
docker images -f dangling=true | wc -l
# 3e. 构建缓存
docker builder du
docker builder du --verbose

# 4. 清理(按危险程度从低到高)
truncate -s 0 /var/lib/docker/containers/*/*-json.log      # 清日志,最安全
docker container prune -f                                  # 删已退出容器
docker image prune -f                                      # 删悬空镜像
docker builder prune -f                                    # 删构建缓存
docker image prune -a -f --filter "until=168h"             # 删 7 天未用镜像
docker volume ls -qf dangling=true | xargs -r docker volume rm    # 删匿名孤儿卷(确认无数据!)
docker system prune -a -f                                  # 一把梭(会删所有未使用镜像)

# 5. 被删除但 fd 仍被占用的文件(df 满而 du 查不到)
lsof +L1 | grep -i docker
lsof -nP | grep deleted | head
# 只能重启持有 fd 的进程
systemctl restart docker

# 6. 长期治理
#    a. 配置日志轮转(问题 2 的 daemon.json)
#    b. 部署定时清理脚本(5.5.2)
#    c. 把 /var/lib/docker 迁到独立大盘(5.5.3)
#    d. 磁盘水位告警
#    e. 限制单容器可写层大小
docker run -d --storage-opt size=10G myapp:v1     # 需 overlay2 + xfs pquota

问题 9:容器 DNS 解析失败

ping: baidu.com: Name or service not known
curl: (6) Could not resolve host: registry-1.docker.io
Temporary failure in name resolution
# 1. 区分是 DNS 问题还是网络问题
docker exec myapp ping -c 2 223.5.5.5        # IP 能通 → 网络 OK,是 DNS 问题
docker exec myapp ping -c 2 baidu.com        # 域名不通

# 2. 看容器内的 resolv.conf
docker exec myapp cat /etc/resolv.conf
# 默认网络:内容复制自宿主机 /etc/resolv.conf(去掉 127.0.0.1 类地址)
# 自定义网络:nameserver 127.0.0.11(Docker 内嵌 DNS)

# 3. 检查宿主机 DNS 是否正常
cat /etc/resolv.conf
nslookup baidu.com
dig baidu.com +short
# 宿主机只写了 127.0.0.1(如本机 dnsmasq/systemd-resolved),
# Docker 会丢弃它并回退到 8.8.8.8,国内往往不通 → 这是最高频原因

# 4. 指定 DNS(单容器)
docker run -d --dns 223.5.5.5 --dns 119.29.29.29 myapp:v1
docker run -d --dns 223.5.5.5 --dns-search internal.com --dns-opt timeout:2 myapp:v1
{
  "dns": ["223.5.5.5", "119.29.29.29", "8.8.8.8"],
  "dns-search": ["internal.com"],
  "dns-opts": ["timeout:2", "attempts:2", "ndots:1"]
}
systemctl restart docker
docker run --rm centos:7 cat /etc/resolv.conf

# 5. 容器名解析不了 —— 默认 bridge 网络不提供 DNS 服务发现
docker exec web ping -c 2 db
# ping: db: Name or service not known
# 解决:创建自定义网络(内置 DNS 才生效)
docker network create appnet
docker network connect appnet web
docker network connect appnet db
docker exec web ping -c 2 db           # 通了
# 或直接在自定义网络上启动
docker run -d --name db --network appnet mysql:8.0
docker run -d --name web --network appnet nginx:1.25

# 6. 内嵌 DNS 本身不通
docker exec web nslookup db 127.0.0.11
docker exec web cat /etc/resolv.conf     # 应为 nameserver 127.0.0.11
iptables -t nat -L -n | grep 127.0.0.11

# 7. 精简镜像缺少解析工具/库
docker exec myapp apk add --no-cache bind-tools     # alpine 装 nslookup/dig
# musl libc 不支持 /etc/resolv.conf 的部分选项(如 search 多域、options rotate),
# 表现为 alpine 能 ping 通但程序解析失败 —— 换 debian-slim 基础镜像可绕过

# 8. 用专业网络调试容器
docker run -it --rm --net container:myapp nicolaka/netshoot bash
nslookup baidu.com; dig db; ss -tunlp; tcpdump -i any port 53 -nn

问题 10:挂载后文件权限不对与 UID 映射

Permission denied
nginx: [emerg] open() "/var/log/nginx/error.log" failed (13: Permission denied)
mysqld: Can't create/write to file '/var/lib/mysql/is_writable' (Errcode: 13)

根本原因:容器和宿主机共用同一套 UID/GID 数字,但用户名不共享。容器内的 app 用户 UID 是 10001,宿主机上 10001 可能不存在或属于别人;内核只认数字。

# 1. 查清双方的 UID
docker exec myapp id
# uid=10001(app) gid=10001(app)
ls -ln /data/myapp
# drwxr-xr-x 2 0 0 4096 Aug 22 10:00 /data/myapp    ← 属主是 UID 0(root)

# 2. 解决方案 A:把宿主机目录改成容器内进程的 UID(最常用)
chown -R 10001:10001 /data/myapp
chmod -R 755 /data/myapp
ls -ln /data/myapp

# 3. 解决方案 B:让容器以宿主机目录属主的身份运行
docker run -d -u $(stat -c '%u:%g' /data/myapp) -v /data/myapp:/app/data myapp:v1
docker run -d -u $(id -u):$(id -g) -v $PWD:/workspace myapp:v1

# 4. 解决方案 C:用命名卷代替 bind mount
#    命名卷首次挂载时会自动把镜像里该目录的内容和权限复制过来,权限天然正确
docker volume create myapp-data
docker run -d -v myapp-data:/app/data myapp:v1
ls -ln /var/lib/docker/volumes/myapp-data/_data

# 5. 解决方案 D:入口脚本里启动前修权限(镜像需以 root 启动再降权)
#!/bin/sh
# entrypoint.sh
chown -R app:app /app/data /app/logs
exec su-exec app "$@"          # alpine 用 su-exec
# exec gosu app "$@"           # debian 用 gosu
FROM alpine:3.19
RUN apk add --no-cache su-exec && \
    addgroup -g 10001 app && adduser -u 10001 -G app -D app
COPY --chmod=755 entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["/app/myapp"]
# 6. SELinux 导致的 Permission denied(CentOS/RHEL 特有)
#    现象:权限看起来完全正确,但依然拒绝访问
getenforce
ls -lZ /data/myapp
# 宿主机日志里能看到 avc: denied
ausearch -m avc -ts recent | tail
tail -20 /var/log/audit/audit.log | grep denied
# 解决:挂载时加 :z(多容器共享)或 :Z(独占)自动重打标签
docker run -d -v /data/myapp:/app/data:z myapp:v1
docker run -d -v /data/myapp:/app/data:Z myapp:v1
# 或手工打标签
chcon -Rt svirt_sandbox_file_t /data/myapp
semanage fcontext -a -t container_file_t "/data/myapp(/.*)?" && restorecon -Rv /data/myapp

详见 07-03-防火墙与SELinux.md

# 7. userns-remap 环境下的 UID 换算
grep dockremap /etc/subuid
# dockremap:100000:65536
# 容器内 UID 10001 → 宿主机 UID 110001
chown -R 110001:110001 /data/myapp

# 8. 常见官方镜像的 UID 速查(挂载前先查清)
docker run --rm nginx:1.25 id nginx           # uid=101
docker run --rm mysql:8.0 id mysql            # uid=999
docker run --rm postgres:16 id postgres       # uid=999
docker run --rm redis:7 id redis              # uid=999
docker run --rm elasticsearch:8.11 id elasticsearch   # uid=1000
docker run --rm grafana/grafana id grafana    # uid=472
# 例:ES 数据目录必须 chown 1000
chown -R 1000:1000 /data/es

另一类典型问题:挂载覆盖了镜像里的内容

# bind mount 会用宿主机目录"盖住"容器内原有目录
# 若宿主机目录是空的,容器内该目录就变成空的 → 程序找不到文件
mkdir -p /data/empty
docker run -d -v /data/empty:/usr/share/nginx/html nginx:1.25
curl 127.0.0.1        # 403 Forbidden,因为 index.html 被盖没了

# 正确做法:先把镜像里的内容拷出来
docker create --name tmp nginx:1.25
docker cp tmp:/usr/share/nginx/html/. /data/web/
docker rm tmp
docker run -d -v /data/web:/usr/share/nginx/html:ro nginx:1.25

# 或用命名卷(会自动填充镜像内容)
docker run -d -v web-content:/usr/share/nginx/html nginx:1.25
ls /var/lib/docker/volumes/web-content/_data      # index.html 在里面

问题 11:容器被 OOM 杀死的判定

现象:容器莫名重启或消失,docker logs 里应用日志戛然而止,没有异常栈。

# 1. 最直接的判定:看 State
docker inspect myapp --format '{{.State.OOMKilled}}'
# true → 确认是被 OOM 杀掉的
docker inspect myapp --format 'OOM={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} Error={{.State.Error}} Restarts={{.RestartCount}}'
# OOM=true ExitCode=137
docker inspect myapp --format '{{json .State}}' | python -m json.tool

退出码 137 = 128 + 9(SIGKILL)。但 137 不等于一定是 OOM——手工 docker kill 也是 137,必须结合 OOMKilled 字段和内核日志一起判断。

# 2. 看宿主机内核日志(最权威的证据)
dmesg -T | grep -i -E "oom|killed process"
# [Fri Aug 22 10:23:45 2026] Memory cgroup out of memory: Killed process 12345 (java)
#   total-vm:4194304kB, anon-rss:524288kB, file-rss:0kB
# [Fri Aug 22 10:23:45 2026] oom_reaper: reaped process 12345 (java)
journalctl -k | grep -i oom
grep -i oom /var/log/messages
# 注意区分两种 OOM:
#   "Memory cgroup out of memory"  → 容器超出自己的 -m 限制(cgroup OOM)
#   "Out of memory: Kill process"  → 宿主机整体内存耗尽(全局 OOM,会波及其他容器)

# 3. 看 cgroup 计数器
CID=$(docker inspect -f '{{.Id}}' myapp)
cat /sys/fs/cgroup/memory/docker/$CID/memory.failcnt        # 触碰上限次数
cat /sys/fs/cgroup/memory/docker/$CID/memory.max_usage_in_bytes
cat /sys/fs/cgroup/memory/docker/$CID/memory.limit_in_bytes
cat /sys/fs/cgroup/memory/docker/$CID/memory.oom_control
# oom_kill 计数 > 0 即发生过

# 4. 看事件流
docker events --since 24h --filter event=oom
docker events --since 24h --filter container=myapp

# 5. 复现验证
docker run -it --rm -m 100m --memory-swap 100m centos:7 \
  sh -c 'cat /dev/zero | head -c 500m | tail'
echo $?      # 137

处理思路:

# a. 确认限额是否设得过小
docker inspect myapp --format 'Mem={{.HostConfig.Memory}} MemSwap={{.HostConfig.MemorySwap}}'
docker stats --no-stream myapp
docker update -m 1g --memory-swap 1g myapp        # 调大

# b. JVM 容器:必须让堆感知 cgroup 限额,否则 JVM 按宿主机内存算堆,必被 OOM
docker run -d -m 2g -e JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+UseContainerSupport" myapp
docker exec myapp java -XX:+PrintFlagsFinal -version | grep -i maxheapsize
# JDK 8u191 以下不支持 UseContainerSupport,必须手工设 -Xmx
docker run -d -m 2g -e JAVA_OPTS="-Xmx1500m -Xms1500m" myapp

# c. 排查真实内存泄漏,而不是一味调大
docker exec myapp top -b -n 1
docker exec myapp cat /proc/1/status | grep -i vm
docker exec myapp jmap -histo:live 1 | head -20      # Java
docker exec myapp jcmd 1 GC.heap_info

# d. 关闭 swap 抖动、设置软限制平滑过渡
docker run -d -m 1g --memory-swap 1g --memory-reservation 768m myapp

# e. 保护关键容器不被全局 OOM 优先杀掉(谨慎使用)
docker run -d --oom-score-adj=-500 critical-app
# --oom-kill-disable 需配合 -m 使用,且容器会被挂起而非杀死,可能拖死宿主机,不建议

问题 12:too many open files

socket: too many open files
accept4: too many open files
java.io.IOException: Too many open files
dockerd: failed to start container: open /proc/xxx: too many open files
# 1. 三个层面都要查:宿主机全局、dockerd 进程、容器内进程

# 1a. 系统全局
sysctl fs.file-max
cat /proc/sys/fs/file-max
cat /proc/sys/fs/file-nr          # 已分配 未使用 上限
lsof | wc -l

# 1b. dockerd 自身(受 systemd 控制,limits.conf 对它无效)
cat /proc/$(pgrep -o dockerd)/limits | grep -i "open files"
ls /proc/$(pgrep -o dockerd)/fd | wc -l

# 1c. 容器内进程
docker exec myapp sh -c 'ulimit -n'
PID=$(docker inspect -f '{{.State.Pid}}' myapp)
cat /proc/$PID/limits | grep "open files"
ls /proc/$PID/fd | wc -l
# 找出容器内哪个进程占最多 fd
docker exec myapp sh -c 'for p in /proc/[0-9]*; do echo "$(ls $p/fd 2>/dev/null|wc -l) $(cat $p/comm)"; done | sort -rn | head'

# 2. 提高系统级上限
cat >> /etc/sysctl.d/99-docker.conf <<'EOF'
fs.file-max = 2097152
fs.nr_open = 2097152
fs.inotify.max_user_instances = 8192
fs.inotify.max_user_watches = 1048576
EOF
sysctl --system

# 3. 提高 dockerd 上限
mkdir -p /etc/systemd/system/docker.service.d
cat > /etc/systemd/system/docker.service.d/limits.conf <<'EOF'
[Service]
LimitNOFILE=1048576
LimitNPROC=infinity
TasksMax=infinity
EOF
systemctl daemon-reload && systemctl restart docker
cat /proc/$(pgrep -o dockerd)/limits | grep -i "open files"

# 4. 提高单容器上限
docker run -d --ulimit nofile=65535:65535 myapp:v1
docker run -d --ulimit nofile=65535:65535 --ulimit nproc=32768 myapp:v1
docker inspect myapp --format '{{json .HostConfig.Ulimits}}'
{
  "default-ulimits": {
    "nofile": { "Name": "nofile", "Hard": 65535, "Soft": 65535 }
  }
}
# 5. CentOS 6 的写法
cat >> /etc/security/limits.conf <<'EOF'
* soft nofile 65535
* hard nofile 65535
EOF
# CentOS 6 的 dockerd 由 sysvinit 启动,在启动脚本里加 ulimit
vim /etc/sysconfig/docker
# ulimit -n 65535
service docker restart

# 6. 关键:先排查是不是 fd 泄漏,而不是无脑调大
#    连接没关闭、文件没 close、大量 CLOSE_WAIT 都会耗尽 fd
ss -s
ss -tan state close-wait | wc -l
docker exec myapp ls -l /proc/1/fd | awk '{print $NF}' | sort | uniq -c | sort -rn | head
# 大量指向同一个 socket 或同一个文件 → 应用层泄漏,改代码

# 7. inotify 耗尽的特殊表现(日志采集、热加载场景)
cat /proc/sys/fs/inotify/max_user_instances
cat /proc/sys/fs/inotify/max_user_watches
find /proc/*/fd -lname anon_inode:inotify 2>/dev/null | wc -l

问题 13:Docker 与 firewalld 规则冲突

现象:明明用 firewall-cmd 关掉了 8080 端口,-p 8080:80 的容器却依然能被外网访问;或者反过来,firewall-cmd --reload 之后所有容器突然无法访问外网。

原因:Docker 不通过 firewalld 管理规则,它直接往 iptables 的 nat 表和 filter 表插入自己的链(DOCKER、DOCKER-USER、DOCKER-ISOLATION-STAGE-1/2)。端口映射的 DNAT 规则在 PREROUTING 阶段就完成了目标地址转换,之后走 FORWARD 链而不是 INPUT 链,而 firewalld 的 public zone 规则主要作用在 INPUT 链上——所以 firewalld 的端口封禁对容器映射端口无效。

# 复现与观察
firewall-cmd --list-ports                 # 8080 不在里面
docker run -d -p 8080:80 nginx:1.25
curl http://<宿主机IP>:8080               # 外部依然能访问!

# 看 Docker 插入的规则
iptables -t nat -L DOCKER -n --line-numbers
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
iptables -L FORWARD -n --line-numbers
# DOCKER-USER  all  --  0.0.0.0/0  0.0.0.0/0        ← 优先级最高,留给用户
# DOCKER-ISOLATION-STAGE-1 ...
# DOCKER  all  --  0.0.0.0/0  0.0.0.0/0
iptables -L DOCKER-USER -n
iptables -S | grep -i docker
firewall-cmd --get-active-zones
firewall-cmd --zone=docker --list-all

解决方案一:在 DOCKER-USER 链上写规则(官方推荐)

DOCKER-USER 链在所有 Docker 规则之前执行,且 Docker 永远不会清空它,是唯一正确的自定义入口。

# 只允许 10.0.0.0/8 访问容器,其他源全部拒绝
iptables -I DOCKER-USER -i eth0 ! -s 10.0.0.0/8 -j DROP
# 精确封某个映射端口(注意匹配的是容器 IP 和容器端口,不是宿主机端口)
iptables -I DOCKER-USER -i eth0 -p tcp --dport 80 -d 172.17.0.2 -j DROP
# 用 conntrack 匹配 DNAT 前的原始目标端口,更直观
iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 -j DROP
# 只放行特定 IP 访问 8080
iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 ! -s 1.2.3.4 -j DROP
# 允许已建立连接(放在最前面,避免把回包也拦了)
iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT

# 查看与持久化
iptables -L DOCKER-USER -n -v --line-numbers
iptables -D DOCKER-USER 1                 # 删除第 1 条
service iptables save                     # CentOS 6
yum install -y iptables-services && systemctl enable iptables && service iptables save   # CentOS 7
# 或用 firewalld 的 direct 规则持久化(重启和 reload 都保留)
firewall-cmd --permanent --direct --add-rule ipv4 filter DOCKER-USER 0 -i eth0 ! -s 10.0.0.0/8 -j DROP
firewall-cmd --reload
firewall-cmd --direct --get-all-rules

解决方案二:根本上不暴露端口——只绑定回环或内网 IP

# 最省事也最安全:需要外部访问的才映射到 0.0.0.0
docker run -d -p 127.0.0.1:3306:3306 mysql:8.0          # 只有本机能连
docker run -d -p 192.168.1.100:8080:80 nginx:1.25       # 只绑内网网卡
# 容器之间通信根本不需要端口映射,放到同一个自定义网络即可
docker network create appnet
docker run -d --name db --network appnet mysql:8.0       # 完全不 -p
docker run -d --name web --network appnet -p 80:80 nginx:1.25
# web 内部用 db:3306 访问,数据库端口对外零暴露
{
  "ip": "127.0.0.1"
}

在 daemon.json 里设 "ip": "127.0.0.1",则所有未显式指定 IP 的 -p 都只绑回环,是很有效的全局安全默认值。

解决方案三:让 Docker 不再自动改 iptables(需自行接管全部规则,慎用)

{
  "iptables": false,
  "ip-forward": false
}
systemctl restart docker
# 代价:容器将无法访问外网、端口映射失效,必须自己写完整的 NAT 与 FORWARD 规则
iptables -t nat -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
iptables -A FORWARD -i docker0 -o eth0 -j ACCEPT
iptables -A FORWARD -i eth0 -o docker0 -m state --state RELATED,ESTABLISHED -j ACCEPT
sysctl -w net.ipv4.ip_forward=1
# 不建议在生产使用,除非有专职网络管理且规则纳入配置管理

解决方案四:把容器网卡纳入 firewalld 的 zone 管理(新版 firewalld)

firewall-cmd --get-zones | tr ' ' '\n' | grep docker
firewall-cmd --zone=docker --list-all
# 把 docker0 加入 trusted 或 docker zone
firewall-cmd --permanent --zone=trusted --add-interface=docker0
firewall-cmd --permanent --zone=docker --add-masquerade
firewall-cmd --reload
# 检查 reload 后 Docker 规则是否被清掉(老版 firewalld 的经典问题)
iptables -t nat -L DOCKER -n | head
# 若被清掉,重启 docker 重建规则
systemctl restart docker

firewall-cmd --reload 后容器网络中断的处理:

# 原因:firewalld reload 会重建整个 iptables,冲掉 Docker 的链
# 现象:容器无法访问外网,端口映射失效
# 解决:reload 之后重启 docker(新版 firewalld + Docker 20.10+ 已能自愈)
firewall-cmd --reload && systemctl restart docker
# 或写成 firewalld 的 post-reload 钩子
cat > /etc/systemd/system/docker-restart-after-firewalld.service <<'EOF'
[Unit]
Description=Restart docker after firewalld reload
After=firewalld.service
Requires=firewalld.service
[Service]
Type=oneshot
ExecStart=/bin/systemctl restart docker
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable docker-restart-after-firewalld

排查顺序总结:

检查项 命令
Docker 的 nat 规则是否存在 iptables -t nat -L DOCKER -n
FORWARD 链策略与 Docker 子链 iptables -L FORWARD -n \| head -5
自定义拦截规则位置是否正确 iptables -L DOCKER-USER -n -v
firewalld 的 zone 与端口 firewall-cmd --list-all-zones
IP 转发 sysctl net.ipv4.ip_forward
桥接流量是否走 iptables sysctl net.bridge.bridge-nf-call-iptables
实际监听 ss -tunlp \| grep docker-proxy
是否真的从外部不可达 在另一台机器上 telnet <IP> <PORT> / nc -zv <IP> <PORT>

详见 07-03-防火墙与SELinux.md

核心结论:不要用 firewalld/iptables 的 INPUT 链去管容器端口——要么写在 DOCKER-USER 链,要么一开始就用 -p 127.0.0.1:xxx 不暴露。


练习

问题一:如何进入一个已经存在(已停止)的容器并验证数据仍在?

背景:在容器里创建了文件,退出后再 docker run 却找不到,因为那是一个新容器。

完整操作过程:

# 1. 启动一个交互式容器并在里面建立文件
docker run -i -t docker.io/ubuntu /bin/bash
root@429adceb8b2b:/# touch /1111111
root@429adceb8b2b:/# ls /
1111111  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
root@429adceb8b2b:/# exit
exit
# 2. 查看最后一次创建的容器
docker ps -l
CONTAINER ID    IMAGE               COMMAND        CREATED              STATUS                     PORTS    NAMES
429adceb8b2b    docker.io/ubuntu    "/bin/bash"    About a minute ago   Exited (0) 5 seconds ago            elated_bohr
# 3. 直接 attach 已停止的容器会失败
docker attach 429adceb8b2b
You cannot attach to a stopped container, start it first
# 4. 必须先 start 再 attach
docker start 429adceb8b2b
429adceb8b2b
docker attach 429adceb8b2b
root@429adceb8b2b:/# ls /
1111111  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
# 文件 1111111 依然存在,说明容器的可写层数据一直保留在容器里

答案要点:

  1. docker run 每次都新建容器,不会复用之前的容器,所以之前建的文件"消失"了。
  2. 要回到原容器,先 docker ps -a(或 docker ps -l)找到容器 ID,docker start <ID> 启动,再 docker attach <ID> 进入。
  3. 停止的容器不能直接 attach,会报 You cannot attach to a stopped container, start it first。
  4. 容器的可写层数据在容器被 docker rm 之前一直存在,stop 不会丢数据。
  5. 更推荐的方式是 docker exec(新开一个进程,退出不会停止容器):
docker start 429adceb8b2b
docker exec -it 429adceb8b2b /bin/bash
ls /1111111
exit                      # 容器仍在运行
docker ps
进入方式 是否需要容器已运行 退出后容器状态 说明
docker attach 需要(必须先 start) 可能被停止(Ctrl+C 会杀 PID 1) 接管 PID 1 的标准输入输出,多人 attach 会互相看到
docker exec -it 需要 仍在运行 新起一个进程,最安全,日常首选
nsenter 需要 仍在运行 从宿主机进入,容器内无 shell 也能用

docker attach 想安全退出用 Ctrl+P 然后 Ctrl+Q(detach 快捷键),不要按 Ctrl+C。

问题二:如何把容器里的修改保存下来,做成新镜像?

背景:容器删除后可写层的数据就没了,需要把修改固化成镜像。

完整操作过程(需要两个终端):

# 【终端 1】启动容器并做修改,注意不要退出
docker run -i -t docker.io/ubuntu /bin/bash
root@429adceb8b2b:/# touch /11111111111111
root@429adceb8b2b:/# ls /
11111111111111  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
# 保持这个终端不动,容器处于运行状态
# 【终端 2】查看运行中的容器并提交为新镜像
docker ps
CONTAINER ID    IMAGE               COMMAND        CREATED          STATUS          PORTS    NAMES
429adceb8b2b    docker.io/ubuntu    "/bin/bash"    2 minutes ago    Up 2 minutes             elated_bohr
docker commit 429adceb8b2b my_touch1111111
sha256:6ec9511d90bf1e3a3a3f6b09d4b0d5f9a3c2b1e0d9c8b7a6f5e4d3c2b1a0f9e8
# 验证新镜像已生成
docker images
REPOSITORY          TAG       IMAGE ID        CREATED           SIZE
my_touch1111111     latest    6ec9511d90bf    5 seconds ago     120.9 MB
docker.io/ubuntu    latest    47b19964fb50    3 weeks ago       120.9 MB
# 用新镜像启动容器,验证文件在镜像里
docker run -it my_touch1111111 /bin/bash
root@8f3d2c1b0a9e:/# ls /
11111111111111  bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var
# 文件在,说明修改已经固化进镜像

带说明信息的规范写法:

docker commit -m="update vim" -a "tx" f8064b303291 tx/ubuntu:v1
参数 说明
-m 提交的描述信息(类似 git commit -m)
-a 指定镜像作者
f8064b303291 容器 ID
tx/ubuntu:v1 要创建的目标镜像名与 tag
# 提交时暂停容器保证一致性(推荐,避免写入中途被打快照)
docker commit -p 429adceb8b2b my_touch:v1
# 同时修改镜像配置
docker commit -c 'CMD ["nginx","-g","daemon off;"]' -c 'EXPOSE 80' mycontainer mynginx:v1
docker commit -c 'ENV APP_ENV=prod' -c 'WORKDIR /app' -c 'USER app' mycontainer myapp:v1
# 查看 commit 出来的镜像历史(会发现看不到任何构建步骤)
docker history my_touch1111111

答案要点:

  1. 用 docker commit <容器ID> <新镜像名> 把容器当前的可写层固化成新的镜像层。
  2. 容器不需要停止也能 commit,但建议加 -p 暂停容器以保证文件一致性。
  3. -m 写说明、-a 写作者、-c 可以顺便改镜像配置(CMD/ENV/EXPOSE 等)。
  4. commit 出来的镜像不适合生产使用,原因见下表。
commit 的问题 说明
不可复现 别人无法知道你在容器里敲了什么命令,无法重建同样的镜像
无法版本管理 没有 Dockerfile 就没法进 git,无法 code review、无法追溯变更
体积臃肿 容器里的临时文件、缓存、日志、shell history 全被打包进去
层数失控 每次 commit 加一层,反复 commit 会逼近 127 层上限
容易夹带敏感信息 ~/.bash_history、临时密钥、测试数据被固化进镜像
无法自动化 CI/CD 流水线无法自动执行"手工进容器改东西"这个步骤

正确做法:把在容器里试验成功的命令整理成 Dockerfile,用 docker build 构建。 docker commit 只适合两种场景:紧急抢修后临时保存现场、以及探索阶段快速试验(试完仍要落成 Dockerfile)。

问题三:把自己的镜像上传到公共仓库

# 1. 提交容器为镜像时,镜像名必须带上仓库地址(Docker Hub 就是用户名)
docker commit 6ec9511d90bf docker.io/durjaya/my_touch11

# 2. 登录 Docker Hub(需先在官网注册账号)
docker login
# Username: durjaya
# Password:
# Login Succeeded

# 3. 推送
docker push docker.io/durjaya/my_touch11

答案要点:

  1. 镜像名必须是 <仓库地址>/<用户名>/<镜像名>:<tag> 格式,否则 docker push 找不到目标。
  2. 已有镜像不必重新 commit,用 docker tag 改名即可:docker tag my_touch1111111 durjaya/my_touch11:v1。
  3. docker login 的凭据以 base64 明文存在 ~/.docker/config.json,公共机器上用完要 docker logout。
  4. 阿里云、网易等国内容器服务有专门的上传界面,也支持 docker login registry.cn-hangzhou.aliyuncs.com。

问题四:删除全部容器与镜像

# 停止所有容器
docker stop $(docker ps -a -q)
# 删除所有容器
docker rm $(docker ps -a -q)
# 删除指定镜像
docker rmi my_touch1111111
# 删除所有镜像
docker rmi $(docker images -q)

答案要点与注意事项:

# 1. 有正在运行的容器时 rmi 会失败
docker rmi ubuntu
# Error response from daemon: conflict: unable to delete 47b19964fb50 (must be forced)
#   - image is being used by stopped container 429adceb8b2b
# 必须先删容器,或用 -f 强制(-f 只是解除 tag,层可能仍在)
docker ps -a --filter ancestor=ubuntu           # 找出用了该镜像的容器

# 2. 没有容器时上面的命令会因为参数为空而报错,用 xargs -r 更稳
docker ps -aq | xargs -r docker rm -f
docker images -q | xargs -r docker rmi -f

# 3. 更安全的批量操作
docker rm -f $(docker ps -aq -f status=exited)  # 只删已退出的
docker container prune -f                       # 等效且更安全
docker image prune -a -f                        # 删所有未被使用的镜像

# 4. 生产环境务必先确认清单再执行
docker ps -a --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}'

问题五:在容器里手工部署 httpd 并访问

# 1. 启动容器(-p 做端口映射,方便宿主机访问)
docker run -i -p 8000:80 -t docker.io/ubuntu /bin/bash

# 2. 容器内安装并启动 apache2
apt-get update
apt-get install -y vim-gtk net-tools curl apache2
/etc/init.d/apache2 restart
echo "web" > /var/www/html/index.html
curl 127.0.0.1
# web
netstat -tunlp | grep 80

# 3. 退出后容器停止,需要重新 start
exit
docker ps -l
docker start 2339171d8a4f
# 注意:start 之后 apache2 并没有自动启动,因为容器的 1 号进程是 bash
docker exec -it 2339171d8a4f /etc/init.d/apache2 start
docker exec -it 2339171d8a4f curl 127.0.0.1

答案要点:

  1. 手工在容器里装软件只适合试验和摸清安装步骤,正式交付必须写成 Dockerfile。
  2. 容器重启后服务不会自动起来,因为 1 号进程是 bash 而不是 apache。生产镜像必须让服务以前台方式作为 1 号进程运行(apache2ctl -D FOREGROUND)。
  3. 端口映射必须在 docker run 时指定,运行中的容器不能追加 -p(只能提交为镜像后重新 run,或用 docker commit + 新 run)。

问题六:把上面的 httpd 部署写成 Dockerfile 并构建运行

# dockerfile
FROM docker.io/ubuntu
MAINTAINER tx
RUN apt-get update
RUN apt-get install -y vim-gtk net-tools curl apache2
RUN echo "web page in container" > /var/www/html/index.html
EXPOSE 80
CMD ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]
# 构建
docker build -f dockerfile -t my_httpd:0.1 .
docker images | grep my_httpd
# my_httpd   0.1   0e7f1b2c3d4e   10 seconds ago   491.6 MB

# 运行
docker run -i -p 8000:80 -t my_httpd:0.1
# 或后台运行
docker run -d -p 8000:80 --name httpd01 my_httpd:0.1

# 验证
lsof -i:8000
# COMMAND    PID  USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
# docker-pr 4021  root    4u  IPv6  40129      0t0  TCP *:irdmi (LISTEN)
docker ps -a | grep my_httpd
curl 127.0.0.1:8000
# web page in container

优化后的版本(对照练习,体会优化效果):

FROM ubuntu:22.04
LABEL maintainer="ops@example.com" \
      description="apache2 demo" \
      version="1.0"

ENV DEBIAN_FRONTEND=noninteractive TZ=Asia/Shanghai

# 合并为一层,装完立刻清缓存,不装调试用的 vim-gtk
RUN apt-get update && \
    apt-get install -y --no-install-recommends apache2 curl ca-certificates tzdata && \
    ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
    rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*

RUN echo "web page in container" > /var/www/html/index.html

# 用非 root 用户运行(apache 的 www-data 已存在)
RUN mkdir -p /var/run/apache2 /var/lock/apache2 && \
    chown -R www-data:www-data /var/run/apache2 /var/lock/apache2 /var/log/apache2 /var/www/html

EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD curl -fsS http://localhost/ || exit 1

STOPSIGNAL SIGWINCH
CMD ["apache2ctl", "-D", "FOREGROUND"]
docker build -t my_httpd:0.2 .
docker images | grep my_httpd
# 对比两个版本的体积差异
docker run -d -p 8001:80 --name httpd02 --restart=unless-stopped my_httpd:0.2
docker ps                       # 观察 STATUS 里的 (healthy)
curl 127.0.0.1:8001

答案要点:

  1. docker build -f <Dockerfile路径> -t <镜像名>:<tag> <构建上下文>,最后那个 . 是构建上下文,不能省。
  2. 原版三条 RUN 产生三层且 apt 缓存留在镜像里,合并成一条并 rm -rf /var/lib/apt/lists/* 可显著减小体积。
  3. EXPOSE 80 只是声明,不会真的开放端口,必须靠 docker run -p 才能从宿主机访问。
  4. CMD 必须让服务前台运行,用 /etc/init.d/apache2 start 会导致容器立即退出。
  5. lsof -i:8000 看到的 docker-pr 就是 docker-proxy 进程,它是端口映射的用户态代理。

问题七:数据持久化实验

要求:用 Volume 和 Bind Mount 两种方式部署 nginx,删除容器后验证数据是否还在。

# ========== 方式一:命名卷 ==========
docker volume create web-data
docker volume inspect web-data
docker run -d --name web1 -p 8081:80 -v web-data:/usr/share/nginx/html nginx:1.25
# 命名卷会自动填充镜像里原有的内容
ls /var/lib/docker/volumes/web-data/_data
docker exec web1 sh -c 'echo "volume data" > /usr/share/nginx/html/index.html'
curl 127.0.0.1:8081
# volume data

# 删除容器
docker rm -f web1
# 卷仍然存在,数据没丢
docker volume ls
cat /var/lib/docker/volumes/web-data/_data/index.html
# 新容器挂上同一个卷,数据回来了
docker run -d --name web2 -p 8081:80 -v web-data:/usr/share/nginx/html nginx:1.25
curl 127.0.0.1:8081
# volume data

# ========== 方式二:Bind Mount ==========
mkdir -p /data/web
echo "bind mount data" > /data/web/index.html
docker run -d --name web3 -p 8082:80 -v /data/web:/usr/share/nginx/html:ro nginx:1.25
curl 127.0.0.1:8082
# bind mount data
# 宿主机改文件,容器内立即可见
echo "changed" > /data/web/index.html
curl 127.0.0.1:8082
# changed
# 只读挂载,容器内不能写
docker exec web3 sh -c 'echo x > /usr/share/nginx/html/a.html'
# sh: can't create /usr/share/nginx/html/a.html: Read-only file system

docker rm -f web3
ls /data/web        # 文件还在

# ========== 验证可写层不能持久化 ==========
docker run -d --name web4 nginx:1.25
docker exec web4 sh -c 'echo "in writable layer" > /tmp/x'
docker exec web4 cat /tmp/x
docker rm -f web4
docker run -d --name web5 nginx:1.25
docker exec web5 cat /tmp/x
# cat: /tmp/x: No such file or directory    ← 数据随容器删除而消失

# ========== 备份与恢复 ==========
# 备份命名卷
docker run --rm -v web-data:/data:ro -v /backup:/backup alpine \
  tar -czf /backup/web-data-$(date +%F).tar.gz -C /data .
ls -lh /backup
# 恢复到新卷
docker volume create web-data-restore
docker run --rm -v web-data-restore:/data -v /backup:/backup alpine \
  sh -c 'cd /data && tar -xzf /backup/web-data-*.tar.gz'
docker run --rm -v web-data-restore:/data alpine ls -l /data

# 清理
docker rm -f web2 web5 2>/dev/null
docker volume rm web-data web-data-restore

答案要点:

  1. 容器可写层不能存数据——容器一删就没了,而且性能差(写时复制)、无法被其他容器共享。 2 . 命名卷由 Docker 管理,位于 /var/lib/docker/volumes/<name>/_data,首次挂载会自动继承镜像里该目录的内容。
  2. Bind Mount 直接映射宿主机路径,必须用绝对路径,且会完全覆盖容器内原有目录(宿主机目录为空则容器内也为空)。
  3. :ro 只读挂载适合配置文件和静态资源,防止容器篡改。
  4. 备份卷的标准做法是启动一个临时容器同时挂载卷和备份目录,用 tar 打包。

问题八:自定义网络与服务发现实验

要求:验证默认 bridge 网络不能用容器名互访,自定义网络可以。

# ========== 默认 bridge 网络 ==========
docker run -d --name c1 alpine sleep 3600
docker run -d --name c2 alpine sleep 3600
docker inspect c1 --format '{{.NetworkSettings.IPAddress}}'    # 172.17.0.2
docker inspect c2 --format '{{.NetworkSettings.IPAddress}}'    # 172.17.0.3

# IP 能通
docker exec c2 ping -c 2 172.17.0.2
# 容器名不能通 —— 默认 bridge 没有内置 DNS 服务发现
docker exec c2 ping -c 2 c1
# ping: bad address 'c1'
docker exec c2 cat /etc/resolv.conf     # 是宿主机的 DNS,不是 127.0.0.11

# ========== 自定义网络 ==========
docker network create --driver bridge \
  --subnet 172.20.0.0/16 --gateway 172.20.0.1 appnet
docker network ls
docker network inspect appnet

docker run -d --name d1 --network appnet alpine sleep 3600
docker run -d --name d2 --network appnet alpine sleep 3600

# 容器名直接可通!
docker exec d2 ping -c 2 d1
docker exec d2 cat /etc/resolv.conf
# nameserver 127.0.0.11              ← Docker 内嵌 DNS
docker exec d2 nslookup d1
docker exec d2 nslookup d1 127.0.0.11

# 网络别名与 DNS 轮询
docker run -d --name d3 --network appnet --network-alias backend alpine sleep 3600
docker run -d --name d4 --network appnet --network-alias backend alpine sleep 3600
docker exec d2 nslookup backend        # 返回 d3 和 d4 两个 IP

# 动态连接与断开
docker network connect appnet c1
docker exec c1 ip a                    # c1 有两块网卡了
docker exec d2 ping -c 2 c1            # 现在能通了
docker network disconnect appnet c1

# 网络隔离验证:不同自定义网络之间默认不通
docker network create othernet
docker run -d --name e1 --network othernet alpine sleep 3600
docker exec d2 ping -c 2 -W 2 $(docker inspect e1 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
# 不通(DOCKER-ISOLATION 链拦截)

# 清理
docker rm -f c1 c2 d1 d2 d3 d4 e1
docker network rm appnet othernet

答案要点:

  1. 默认 bridge 网络不提供 DNS 服务发现,只能用 IP 互访,而容器 IP 每次重启都可能变——这是生产上必须用自定义网络的核心原因。
  2. 自定义 bridge 网络内置 DNS(127.0.0.11),容器名即主机名,可直接互访。
  3. --network-alias 可以给容器起服务名,多个容器用同一别名时 DNS 会轮询返回,实现简易负载均衡。
  4. 不同自定义网络之间默认隔离,靠 DOCKER-ISOLATION-STAGE-1/2 链实现,需要互通用 docker network connect。
  5. 容器间通信不需要端口映射,同网络内直接用容器名 + 容器端口即可,数据库等敏感服务不要 -p 暴露。

问题九:搭建私有仓库并完成推送拉取

# 1. 启动 registry
mkdir -p /data/registry
docker run -d --name registry --restart=always -p 5000:5000 \
  -v /data/registry:/var/lib/registry registry:2
curl http://127.0.0.1:5000/v2/_catalog

# 2. 配置 insecure-registries(本机与所有客户端都要配)
cat > /etc/docker/daemon.json <<'EOF'
{
  "registry-mirrors": ["https://docker.m.daocloud.io"],
  "insecure-registries": ["192.168.1.100:5000"]
}
EOF
systemctl daemon-reload && systemctl restart docker
docker info | grep -A 2 "Insecure Registries"

# 3. 打标签并推送
docker tag my_httpd:0.2 192.168.1.100:5000/demo/my_httpd:0.2
docker push 192.168.1.100:5000/demo/my_httpd:0.2

# 4. 验证仓库内容
curl http://192.168.1.100:5000/v2/_catalog
curl http://192.168.1.100:5000/v2/demo/my_httpd/tags/list

# 5. 删除本地镜像后重新拉取
docker rmi 192.168.1.100:5000/demo/my_httpd:0.2
docker pull 192.168.1.100:5000/demo/my_httpd:0.2
docker run -d -p 8003:80 192.168.1.100:5000/demo/my_httpd:0.2
curl 127.0.0.1:8003

答案要点:

  1. registry 必须挂载数据卷,否则容器删除后所有镜像丢失。
  2. 不配 insecure-registries 会报 server gave HTTP response to HTTPS client,因为 Docker 默认只信任 HTTPS 仓库。
  3. docker push 的目标完全由镜像名决定,所以必须先 docker tag 加上仓库地址。
  4. 修改 daemon.json 后必须 systemctl daemon-reload && systemctl restart docker 才生效,并用 docker info 确认。

问题十:综合排错演练

对下面每种现象,写出排查命令与结论:

# 现象 1:docker run -d centos:7 之后 docker ps 里没有容器
docker ps -a
docker inspect <ID> --format '{{.State.ExitCode}} {{.Config.Cmd}}'
# 结论:CMD 是 /bin/bash,无 -it 时立即结束,退出码 0。属正常行为,需给 -it 或换常驻前台进程。

# 现象 2:容器内 ping IP 通、ping 域名不通
docker exec app cat /etc/resolv.conf
sysctl net.ipv4.ip_forward
# 结论:DNS 问题。在 daemon.json 配 "dns": ["223.5.5.5"] 或 docker run --dns。

# 现象 3:-p 8080:80 但外部访问不了
docker port app; docker exec app ss -tunlp; iptables -t nat -L DOCKER -n; firewall-cmd --list-ports
# 结论:依次排查 映射是否建立 → 容器内是否监听 0.0.0.0 → DNAT 规则 → 宿主机防火墙/云安全组。

# 现象 4:容器反复重启
docker inspect app --format '{{.RestartCount}} {{.State.OOMKilled}} {{.State.ExitCode}}'
docker logs --tail 100 app; dmesg -T | grep -i oom
# 结论:ExitCode 137 + OOMKilled true → 内存不足,调大 -m 或修内存泄漏;ExitCode 1 → 看应用日志。

# 现象 5:docker exec -it app bash 报 executable file not found
docker exec -it app sh
docker exec app cat /etc/os-release
# 结论:alpine 镜像只有 /bin/sh,没有 bash。

# 现象 6:磁盘满,df 显示 100% 但 du 找不到大文件
lsof +L1 | grep docker; find /var/lib/docker/containers -name "*-json.log" -size +100M
# 结论:日志文件被 rm 但 fd 未释放;应用 truncate -s 0 清空并配置 max-size 轮转。

# 现象 7:firewall-cmd 封了端口但容器仍可访问
iptables -L DOCKER-USER -n; iptables -t nat -L DOCKER -n
# 结论:Docker 走 FORWARD 链而非 INPUT,规则必须写在 DOCKER-USER 链,或改用 -p 127.0.0.1:xxx。

# 现象 8:容器内 date 比宿主机慢 8 小时
docker exec app date; docker exec app ls -l /etc/localtime
# 结论:时区未设。加 -e TZ=Asia/Shanghai(alpine 需装 tzdata)或挂载 /etc/localtime:ro。

# 现象 9:挂载目录后应用报 Permission denied
docker exec app id; ls -ln /data/app; getenforce; ausearch -m avc -ts recent | tail
# 结论:UID 不匹配 → chown 成容器内 UID;或 SELinux 拦截 → 挂载加 :z / :Z。

# 现象 10:docker 命令报 Cannot connect to the Docker daemon
systemctl status docker; journalctl -u docker -n 50; python -m json.tool /etc/docker/daemon.json
# 结论:多为 daemon.json 语法错误或服务未启动;权限问题则是当前用户不在 docker 组。

本章小结

核心概念回顾

  1. 容器不是轻量虚拟机:容器与宿主机共享内核,靠 Namespace 做隔离、Cgroups 做限制、UnionFS 做分层文件系统。因此启动是秒级、性能损耗接近零,但隔离性弱于虚拟机,不是安全边界。
  2. 镜像是只读的分层模板,容器是镜像加一个可写层。可写层随容器删除而消失,所以任何需要保留的数据必须放在卷里。
  3. 容器的生命周期等于其 1 号进程的生命周期。这一条能解释绝大多数"容器启动就退出"的问题。
  4. docker run 每次都是新建容器,不是恢复之前的容器;要回到旧容器用 docker start + docker exec。
  5. EXPOSE 只是声明,-p 才真正映射;映射靠 iptables 的 DNAT + docker-proxy 实现。
  6. 默认 bridge 网络没有 DNS 服务发现,生产必须用自定义网络才能用容器名互访。
  7. 生产镜像必须用 Dockerfile 构建,docker commit 只用于应急和探索。

常用命令速查

命令 作用
docker search <关键字> 在仓库中搜索镜像
docker pull <镜像>[:tag] 拉取镜像
docker push <仓库/镜像:tag> 推送镜像
docker images / docker image ls 列出本地镜像(-a 含中间层,-q 只显示 ID)
docker rmi <镜像> 删除镜像(-f 强制)
docker tag <源> <目标> 给镜像起别名(push 前必做)
docker history <镜像> 查看镜像分层与构建命令
docker save / docker load 镜像的导出/导入(保留分层和 tag)
docker export / docker import 容器文件系统的导出/导入(丢失分层与历史)
docker build -t <名>:<tag> . 从 Dockerfile 构建镜像(. 是构建上下文)
docker commit <容器> <镜像> 把容器固化为镜像(-m 说明 -a 作者 -p 暂停)
docker run 创建并启动容器(选项见下)
docker create 只创建不启动
docker ps 列出运行中的容器(-a 全部,-q 只 ID,-l 最后一个,-s 含体积)
docker start/stop/restart <容器> 启动/停止/重启(stop 先 SIGTERM,10 秒后 SIGKILL)
docker kill <容器> 立即 SIGKILL(-s 指定信号)
docker pause/unpause <容器> 暂停/恢复(SIGSTOP,进程冻结但不退出)
docker rm <容器> 删除容器(-f 强制,-v 同时删匿名卷)
docker exec -it <容器> sh 在运行中的容器里新起进程(日常首选)
docker attach <容器> 接管 1 号进程的 IO(Ctrl+P Ctrl+Q 安全退出)
docker logs <容器> 查看日志(-f 跟踪 --tail N --since -t 时间戳)
docker inspect <对象> 查看完整元数据(配合 --format 精确取值)
docker top <容器> 查看容器内进程
docker stats 实时资源占用(--no-stream 只取一次)
docker cp 容器与宿主机之间双向复制文件
docker diff <容器> 查看可写层的文件变更(A 新增 C 修改 D 删除)
docker port <容器> 查看端口映射关系
docker rename / docker update 改名 / 在线调整资源限制与重启策略
docker wait / docker events 阻塞等待退出码 / 实时事件流
docker volume create/ls/inspect/rm/prune 卷管理
docker network create/ls/inspect/connect/rm/prune 网络管理
docker system df / docker system prune 查看磁盘占用 / 清理
docker login / docker logout 登录/登出仓库
docker info / docker version 查看引擎配置 / 客户端与服务端版本

docker run 关键选项速查

选项 作用
-d 后台运行(detach)
-i / -t / -it 保持标准输入打开 / 分配伪终端 / 交互式进入容器
--name 指定容器名(不指定则随机生成)
--rm 退出后自动删除容器(调试用,会丢日志)
-p 8080:80 端口映射(宿主机:容器),可加 IP 前缀限制绑定范围
-P 随机映射所有 EXPOSE 的端口
-v / --mount 挂载卷或宿主机目录(:ro 只读,:z/:Z SELinux)
-e / --env-file 设置环境变量 / 从文件读取(避免密码进 ps 输出)
--network 指定网络(bridge/host/none/container:xxx/自定义)
--restart 重启策略(no / on-failure:N / always / unless-stopped 推荐)
-w / -u 指定工作目录 / 运行用户(降权关键)
--entrypoint 覆盖镜像 ENTRYPOINT(排错王牌)
-m / --cpus / --pids-limit 内存上限 / CPU 上限 / 进程数上限(生产必设)
--privileged / --cap-add / --cap-drop 特权模式(危险)/ 增删能力(推荐 --cap-drop=ALL 后按需加)
--read-only + --tmpfs 只读根文件系统 + 内存临时目录
--health-cmd 等 运行时健康检查
--log-opt max-size/max-file 日志轮转(防磁盘写满)
--dns / --add-host / --hostname 指定 DNS / 追加 hosts 记录 / 设置主机名
--init 注入 tini 作为 1 号进程(解决僵尸进程与信号转发)

Dockerfile 指令速查

指令 作用与要点
FROM 指定基础镜像,必须是第一条(ARG 除外);scratch 为空基础镜像;AS <名> 用于多阶段构建
LABEL 添加元数据(替代已废弃的 MAINTAINER),可写维护者、版本、描述
MAINTAINER 声明作者(已废弃,改用 LABEL maintainer=)
RUN 构建时执行命令,每条产生一层;&& 合并并在同层清理缓存;shell 形式 vs exec 形式
CMD 容器默认启动命令,可被 docker run 后面的参数覆盖;多条只有最后一条生效
ENTRYPOINT 容器入口点,不会被覆盖(除非 --entrypoint);与 CMD 组合时 CMD 作为其默认参数
COPY 复制文件到镜像,只能来自构建上下文;--from= 跨阶段复制;--chown / --chmod 设权限
ADD 类似 COPY,额外支持自动解压 tar 和下载 URL;行为不透明,优先用 COPY
WORKDIR 设置后续指令与容器启动时的工作目录,会自动创建;相当于持久的 cd
ENV 设置环境变量,构建期和运行期都生效,会写进镜像元数据(不要放密钥)
ARG 构建期变量,运行期不存在;docker build --build-arg 传入;也会留在 history 里
EXPOSE 仅声明容器监听的端口,不做任何映射;供 -P 和文档使用
VOLUME 声明匿名卷挂载点,防止数据写进可写层;声明后该目录在后续 RUN 中的修改会丢失
USER 切换后续指令与容器进程的运行用户,安全加固关键;建议写 UID 数字
HEALTHCHECK 定义健康检查(--interval --timeout --start-period --retries);退出码 0 健康 / 1 不健康
ONBUILD 延迟触发指令,只在本镜像被当作基础镜像时执行;用于制作模板镜像
STOPSIGNAL 指定 docker stop 发送的信号(如 nginx 用 SIGQUIT,apache 用 SIGWINCH)
SHELL 更改 shell 形式指令使用的 shell(默认 /bin/sh -c)

CMD 与 ENTRYPOINT 组合速查

ENTRYPOINT CMD 实际执行 说明
无 ["nginx","-g","daemon off;"] nginx -g "daemon off;" CMD 全部可被 run 参数覆盖
["nginx"] 无 nginx 固定入口,无默认参数
["nginx"] ["-g","daemon off;"] nginx -g "daemon off;" 最佳实践:入口固定,参数可覆盖
["nginx"] ["-g","daemon off;"] + docker run img -v nginx -v run 的参数替换了 CMD,ENTRYPOINT 保留

两条铁律:能用 exec 形式(JSON 数组)就用 exec 形式(避免多套一层 sh -c 导致 PID 1 是 shell、信号收不到);入口脚本最后一行必须是 exec "$@"(让真正的业务进程成为 PID 1)。

镜像优化要点

  1. 选小基础镜像:alpine(约 7MB)> -slim(约 80MB)> 完整发行版(约 200MB+);对安全要求高用 distroless。
  2. 多阶段构建:编译环境与运行环境分离,Go 应用可从 1GB 降到 15MB,Java 从 700MB 降到 200MB。
  3. 合并 RUN 并在同一层清理缓存(rm -rf /var/lib/apt/lists/*、yum clean all、--no-cache)——分层清理无效,上一层里还在。
  4. 写 .dockerignore 排除 .git、node_modules、日志、密钥,既减小上下文又防泄露。
  5. 层顺序按变更频率排:依赖声明文件(package.json/pom.xml)先 COPY 并安装,源码最后 COPY,最大化利用构建缓存。
  6. 绝不把密钥写进任何一层——用 --env-file、只读挂载、BuildKit --secret 或外部 KMS;已泄露的密钥只能作废轮转。
  7. 用 USER 降权、HEALTHCHECK 加检查、LABEL 加元数据、明确 tag 不用 latest。

排错口诀

"看状态、看日志、看配置、看网络、看资源" —— 五步定位法:

步骤 命令 找什么
1. 看状态 docker ps -a / docker inspect --format '{{.State.ExitCode}} {{.State.OOMKilled}}' 退出码、是否 OOM、重启次数、健康状态
2. 看日志 docker logs --tail 200 -t <容器> / journalctl -u docker -n 100 应用异常栈、dockerd 自身报错
3. 看配置 docker inspect --format '{{json .Config}}' / python -m json.tool /etc/docker/daemon.json 启动命令、环境变量、挂载、daemon.json 语法
4. 看网络 docker exec app ss -tunlp / iptables -t nat -L DOCKER -n / cat /etc/resolv.conf 是否监听 0.0.0.0、DNAT 规则、DNS、ip_forward
5. 看资源 docker stats / df -h /var/lib/docker / dmesg -T \| grep -i oom 内存/CPU 是否触顶、磁盘与 inode、OOM 记录

退出码速记:0 正常结束(多半是前台进程跑完了)、1 应用报错、125 run 参数错、126 没执行权限、127 命令不存在、137 被 SIGKILL(先查 OOM)、139 段错误(查架构是否匹配)、143 被 SIGTERM 正常停止。

三个"最高频"故障与一句话解法:

  1. 容器起了就退出 → 1 号进程结束了。让服务前台运行(nginx -g "daemon off;"、apache2ctl -D FOREGROUND),交互式加 -it。
  2. 磁盘被 /var/lib/docker 写满 → 配 log-opts 的 max-size/max-file,定时 docker system prune,日志用 truncate -s 0 而不是 rm,长期方案是把 data-root 迁到独立大盘。
  3. 端口/防火墙不生效 → Docker 走 FORWARD 链不走 INPUT 链,规则写 DOCKER-USER,或者干脆 -p 127.0.0.1:xxx:xxx 不对外暴露。

生产上线检查清单:

[ ] 镜像用 Dockerfile 构建,已进版本库,tag 唯一且非 latest
[ ] 镜像已扫描(trivy),无高危 CVE,无密钥泄露
[ ] 进程以非 root 用户运行(USER 指令)
[ ] 已设 -m 内存上限、--pids-limit、--ulimit nofile
[ ] 已配 --restart=unless-stopped 与 HEALTHCHECK
[ ] 已配 --log-opt max-size/max-file 防日志写满磁盘
[ ] 数据在命名卷或 bind mount 里,已有备份方案并验证过恢复
[ ] 敏感端口未对 0.0.0.0 暴露;容器间通信走自定义网络
[ ] 未使用 --privileged,未挂载 docker.sock
[ ] 时区已正确设置(TZ=Asia/Shanghai)
[ ] 宿主机内核参数已调优(ip_forward、fs.file-max、nf_conntrack_max)
[ ] /var/lib/docker 在独立分区,已配磁盘水位告警与定时清理
[ ] docker 组成员已审计(等同 root 权限)
[ ] 已接入监控(cAdvisor + Prometheus + Grafana)与告警

单机 Docker 只是起点。真正的生产环境需要编排系统来解决多容器依赖、跨主机网络、弹性伸缩、滚动发布与自愈: 详见 13-02-Docker-Compose.md 详见 13-03-Docker-Swarm集群.md 详见 13-04-Kubernetes集群.md

最后修改:2026 年 09 月 20 日
如果觉得我的文章对你有用,请随意赞赏