运维常见题-Docker容器

目录
本文所引用的核心资料与数据,均由 DeepSeek V4 Pro 辅助生成。为确保内容的可靠性,笔者已对大部分关键论点进行了人工复核与校验。但鉴于大模型的固有局限,本文仍可能存在认知偏差或未尽准确之处。若您在阅读中发现存疑或矛盾之处,欢迎反馈讨论,笔者将及时核实与修正。

🤔 简述 Docker 核心实现原理?

  • Docker 不是虚拟机,它是直接跑在宿主机内核上的"进程级"隔离技术。它的核心实现原理,本质上是把 Linux 内核早已具备的三大底层能力——Namespace(命名空间,负责"隔离")、Cgroup(控制组,负责"限制")、UnionFS(联合文件系统,负责镜像"分层复用")——组合封装起来,再通过 Client/Server 架构与 OCI 运行时规范统一调度管理。

    • Namespace(命名空间):实现资源隔离

      • 作用:让每个容器拥有独立的进程、网络、挂载点、主机名等视图,容器之间、容器与宿主机之间互不干扰,仿佛一台独立机器
      • Linux 内核提供多种命名空间,Docker 主要用到以下七种:
        • PID:进程号隔离,容器内进程从 PID 1 开始,看不到宿主机和其他容器的进程
        • NET:网络隔离,每个容器拥有独立的网卡、IP、端口、路由表与 iptables 规则
        • MNT:挂载点隔离,容器拥有独立的文件系统视图(配合镜像的 rootfs
        • UTS:主机名与域名隔离,容器内可独立设置 hostname
        • IPC:进程间通信隔离(信号量、消息队列、共享内存)
        • USER:用户与用户组隔离,容器内的 root 可映射为宿主机上的普通用户(Docker 默认未启用,需 --userns-remap 显式开启)
        • Cgroup:让容器看到被限制后的资源视图(如 /proc 中的 CPU、内存信息)
    • Cgroup(控制组):实现资源限制与统计

      • 作用:命名空间只解决"隔离",不解决"争抢"。Cgroup 负责限制容器可用的 CPU、内存、磁盘 I/O、网络带宽等资源,避免单个容器耗尽宿主机资源
      • 演进:早期是 cgroup v1(各子系统独立挂载),主流发行版配合 systemd 默认使用 cgroup v2(统一层级结构,资源控制更一致);Docker 20.10 起支持 cgroup v2,并默认使用 systemd cgroup 驱动
    • UnionFS(联合文件系统):实现镜像分层与复用

      • 作用:把多个只读的镜像层叠加成一个文件系统视图,再在顶层加一个可写层;写操作通过"写时复制(Copy-on-Write)“落到可写层,读操作则从上往下逐层查找
      • 意义:镜像层可复用与共享,同一基础镜像的多个容器共享底层只读层,拉取、存储、分发更高效
      • 存储驱动:Docker 18.09 起默认使用 overlay2(更早还有 aufsdevicemapper 等);Docker 29.0 起镜像存储后端默认转向 containerd snapshotters(底层仍是 overlayfs
    • Client/Server 架构与 OCI 运行时

      • Docker 采用 C/S 架构:用户通过 docker 命令行(Client)与 dockerdDaemon)通信,dockerd 负责镜像、容器、网络、存储的统一管理
      • 运行时调用链:dockerdcontainerd(容器运行时管理器)→ containerd-shim(进程托管)→ runcOCI 运行时,真正调用内核的 Namespace/Cgroup 创建容器)
      • OCIOpen Container Initiative):定义了容器镜像(image-spec)与运行时(runtime-spec)的开放标准,使 runccrun 等底层运行时实现,以及 containerdPodmanDocker 等上层引擎/管理器能围绕这套标准互通
  • 协助记忆

    • Namespace 管"隔”:给每个容器一间独立房间,房号(进程)、网线、门牌(主机名)各归各
    • Cgroup 管"限":给每间房单独装水电表,用多少有上限
    • UnionFS 管"分层复用":公共基础层大家共享,写东西只改自己那一层
  • 进阶思考

    • 容器本质上是宿主机的一个进程,为什么普通进程做不到这种隔离?

      • 因为普通进程默认运行在宿主机全局的命名空间与资源账本里。Docker 通过 clone() 系统调用传入不同的命名空间标志(如 CLONE_NEWPIDCLONE_NEWNET),并把进程注册到对应的 Cgroup 中,才让这个进程拥有了"独立世界"和"资源配额"。所以容器不是新技术,而是内核已有能力被封装后的产物。
    • containerdcontainerd-shimrunc 三者的分工是什么?

      • containerd 是上层容器生命周期管理器,负责镜像拉取/存储、容器元数据、生命周期调度;runc 是最底层的 OCI 运行时,只根据 OCI 配置调用内核创建并启动一个容器进程,创建完就退出;containerd-shim 负责持续托管容器进程——维持其 stdin/stdout、回报退出状态,并让容器生命周期与 daemon 解耦,dockerd/containerd 重启升级时容器不受影响。
    • 既然容器共享宿主机内核,那宿主机内核升级重启时容器会怎样?

      • 容器依赖宿主机内核,无法拥有独立内核(这也是它比虚拟机轻量、但隔离性弱于虚拟机的原因)。宿主机内核升级需要重启时,所有容器都会随宿主机一起重启。因此需要"跨内核版本迁移"或"强多租户隔离"的场景,仍应回到虚拟机方案。
  • 扩展信息

    • 运行时的演进Docker 早期基于 LXCLinux Containers)实现,Docker 0.9(2014 年)起用自研的 libcontainer 替换 LXC,之后又把 libcontainer 剥离为独立的 runc 并捐赠给 OCI(2015 年),成为容器运行时的事实标准。这也是为什么 containerdPodmanKata Containers 等不同生态都能围绕 OCI 标准互通。

🤔 容器与虚拟机有什么区别?

  • 一句话:虚拟机是在硬件层面虚拟出整台"计算机"(含独立内核与完整操作系统),而容器只是宿主机内核上一个被隔离的"进程"(共享宿主机内核)。这是二者一切差异的根源——虚拟机隔离更强、更重、启动慢;容器更轻、更快、但隔离性弱于虚拟机。

    • 架构差异(根本区别)

      • 虚拟机:通过 Hypervisor(如 KVMESXi)在物理硬件之上虚拟出完整的虚拟硬件(vCPU、内存、磁盘、网卡),每个虚拟机都要安装独立的 Guest OS(客户操作系统)与内核
      • 容器:直接运行在宿主机内核之上,靠 Namespace 做隔离、Cgroup 做限制,容器内没有独立内核,本质是宿主机上一个"被隔离的进程"
    • 关键维度对比

      • 启动速度:容器秒级(本质是进程启动);虚拟机要引导完整内核与系统服务,传统全量 OS 镜像通常需数十秒~分钟级
      • 资源开销:虚拟机每台都要跑一整套操作系统,内存/CPU 开销大;容器共享内核,只额外占用应用自身资源,开销小、密度高
      • 隔离性/安全性:虚拟机是硬件级隔离(不同 VM、VM 与宿主机之间隔离性显著更强、可信计算基更小);容器是内核级隔离,共享内核,隔离性弱于虚拟机,内核漏洞可能波及所有容器
      • 性能:容器接近宿主机原生性能;虚拟机因 Hypervisor 与完整 OS 存在虚拟化损耗
      • 内核与系统:容器共享宿主机内核——可运行不同 Linux 发行版(用户态不同),但 Linux 容器不能运行 Windows 内核;虚拟机可运行与虚拟硬件架构兼容的独立系统(如 Linux 宿主机上跑 Windows)
    • 关系与选型

      • 二者不是"替代"而是"互补":要强隔离、多系统共存、跨内核版本用虚拟机;要轻量、快速、高密度、快速迭代用容器。生产中常见"裸机/虚拟机 + 容器"的组合
  • 协助记忆

    • 虚拟机 = 每家每户在小区里再各盖一栋独立小楼(地基、水电、墙体各自独立)
    • 容器 = 同一栋楼里用隔断隔出的房间(共享整栋楼的地基与水电总表,靠隔断与门牌区分)
  • 进阶思考

    • 为什么容器比虚拟机启动快、密度高?
      • 因为容器不需要引导内核和整套操作系统,本质就是一次进程启动(clone 创建命名空间 + 挂载文件系统即可运行),而虚拟机要从 BIOS/UEFI 引导、加载内核、初始化系统服务,链路长得多;密度高则因为容器共享内核与底层镜像层,单个容器额外的内存/磁盘开销极小。
    • 既然容器共享内核隔离弱,那"容器不安全"的说法对吗?
      • 不准确。容器安全是"纵深防御"问题:现代运行时配合 seccomp(限制系统调用)、Capabilities(裁剪特权)、只读根文件系统、rootlessuser namespace 等手段已大幅增强隔离。说"容器不安全"是拿它和虚拟机的硬件级隔离比,但配合这些机制与正确配置,容器足以满足大多数生产场景。
  • 扩展信息

    • 轻量级虚拟机(融合趋势):为兼顾"虚拟机的强隔离"与"容器的轻量快速",出现了 Kata Containers、AWS Firecracker 等"安全容器/微虚拟机",用极简内核 + 硬件虚拟化跑容器,隔离性接近虚拟机、速度接近容器,使二者边界逐渐模糊(其中 Kata Containers 定位为可直接被 containerd 调用的容器运行时,Firecracker 定位为 serverless 场景的轻量微虚拟机)。

🤔 Docker 存储引擎有什么作用?

  • Docker 存储引擎(storage driver,旧称 graphdriver)的核心作用是:在宿主机磁盘上实现镜像的分层存储与容器的可写层管理——用"写时复制(Copy-on-Write)“的方式,把只读镜像层高效地叠加、复用,并为每个容器提供一个独立的可写层。它只管镜像与容器层的存储,不负责数据卷(volume)。

    • 实现镜像分层与复用

      • 镜像由多层只读层组成,存储引擎负责把这些层组织、挂载成一个统一的文件系统视图
      • 同一基础镜像的多个容器共享相同的只读层,只在各自可写层记录差异,极大节省磁盘空间与拉取时间
    • 管理容器的可写层

      • 容器启动时,存储引擎在镜像层之上创建一层可写层(container layer
      • 写操作通过"写时复制(Copy-on-Write)“落到可写层:读操作从上层往下逐层查找;新建文件直接写入可写层,修改已有文件则先把文件复制(copy-up)到可写层再修改,原始只读层保持不变
      • 容器删除后,可写层随之销毁,镜像只读层不受影响
    • 与数据卷的分工(易混淆点)

      • 存储引擎管的是"镜像层 + 容器层"这类分层文件系统的存储方式
      • 数据持久化、跨容器共享、宿主机目录挂载等,则由 volume / bind mount(挂载机制,严格说 bind mount 不属数据卷)负责,二者职责不同
    • 为什么需要关注它

      • 存储引擎的选择与实现直接影响容器读写性能、磁盘占用与 I/O 效率(不同驱动在 CoW 粒度、inode 消耗、并发写性能上差异明显)
  • 协助记忆

    • 存储引擎像"图书馆的书架管理系统”:只读的镜像层是"公共馆藏”,可写层是"每位读者的借阅笔记本"——读者改内容只改自己的笔记本,公共书本身不动。
  • 进阶思考

    • 为什么同一个镜像跑 100 个容器,磁盘不会直接膨胀 100 倍?
      • 因为 100 个容器共享同一套只读镜像层,各自只有一个很小的可写层(初始为空),只有在容器实际写入数据时可写层才会增长。这就是存储引擎"分层复用 + 写时复制"带来的收益。

🤔 Docker 支持哪些存储引擎?

  • Docker 支持的存储引擎(storage driver)主要有 overlay2(长期默认)、aufsdevicemappervfsbtrfszfs 等,Docker 29.0 起全新安装默认使用基于 containerd snapshotters 的镜像后端(升级场景默认沿用 overlay2,需手动迁移)。不同引擎在底层文件系统、写时复制实现、适用场景上各有差异,选型取决于宿主机文件系统与发行版。

    • overlay2(长期默认,主流)

      • 基于 Linux 主线内核的 overlayfs 文件系统,Docker 18.09 起长期作为默认;Docker 29.0 起全新安装默认转向 containerd snapshotters 后,overlay2 被标记为 legacy(仍受支持,升级场景需手动迁移才切换)
      • 要求内核 >= 4.0(RHEL/CentOS 可用 >= 3.10.0-514)且文件系统支持 d_type
      • 优点:性能好、内存占用低、inode 消耗合理,是绝大多数环境的推荐选择
    • aufs(历史)

      • 曾是 Debian/Ubuntu 早年的默认,基于非主线内核的 AUFS 补丁,维护成本高,已被废弃,v24.0 移除
    • devicemapper(历史)

      • 曾是 RHEL/CentOS 早年的默认,基于 Device Mapper(块设备级),v18.09 废弃、v23.0 默认禁用、v25.0 移除
    • vfs(兜底/测试)

      • 不实现写时复制,每次写都完整复制一层,性能最差,仅用于测试或不支持其他驱动的环境
    • btrfs / zfs(特殊场景)

      • btrfs / ZFS 文件系统深度集成,利用文件系统原生的快照、CoW、压缩等能力,适合已使用这些文件系统的存储场景
    • containerd snapshotters(新)

      • Docker 29.0 起全新安装默认的镜像存储后端(底层仍是 overlayfs),支持多平台镜像与 attestations
  • 协助记忆

    • 把存储引擎理解为"不同品牌的书架":overlay2 是主流标准书架,aufs/devicemapper 是已经下架的老书架,vfs 是没有复用的简易书架,btrfs/zfs 是带特殊功能的定制书架。
  • 进阶思考

    • overlay2 为什么能取代 aufsdevicemapper
      • 因为它基于内核主线的 overlayfs,无需额外内核补丁(aufs 要打补丁),性能和 inode 消耗优于 devicemapper,且实现简单可靠,因此自 Docker 18.09 起成为默认并被长期推荐。

🤔 Docker 有哪些核心组件及其作用?

  • Docker 采用 Client/Server 架构,核心组件可拆成一条清晰的调用链:docker(客户端)→ dockerd(守护进程)→ containerd(容器运行时管理器)→ containerd-shim(进程托管)→ runc(OCI 运行时),再加上负责镜像构建的 BuildKit 与负责镜像存储分发的 Registry(镜像仓库)。

    • docker(客户端 CLI)

      • 用户与 Docker 交互的命令行入口,把 docker rundocker build 等指令通过 REST API 发送给 dockerd
      • 客户端可以连本地也可以连远程 daemon(如设置 DOCKER_HOST 指向远程)
    • dockerd(守护进程 / Daemon)

      • Docker 的核心服务进程,负责镜像、容器、网络、存储、构建、插件等几乎所有上层管理,并对外暴露 REST API
      • 收到指令后,把容器生命周期管理等底层工作委托给 containerd
    • containerd(容器运行时管理器)

      • 独立于 dockerd 的守护进程,通过 gRPCdockerd 通信,负责容器元数据与生命周期调度(Docker 1.11 起从 daemon 中剥离);镜像拉取/存储在传统架构中仍由 dockerd 管理,Docker 29.0 启用 containerd image store 后才由 containerd 接管
      • 它本身不直接创建容器,而是把"创建容器"这件事下发给 runc
    • containerd-shim(进程托管)

      • 每个容器一个 shim 进程,作为容器进程的父进程,负责维持容器的 stdin/stdout、把退出状态回报给上层
      • 关键作用:让容器生命周期与 daemon 解耦,dockerd/containerd 重启或升级时容器不受影响
    • runc(OCI 运行时)

      • 最底层的 OCI 运行时实现,只根据 OCI 配置调用内核(Namespace/Cgroup)创建并启动一个容器进程,创建完即退出
      • libcontainer 演化而来并捐赠给 OCI
    • BuildKit(构建引擎)

      • 新一代镜像构建引擎,支持并行构建、更精细的缓存、多阶段构建等,Docker 23.0 起成为默认 builder(docker build 底层调用)
    • Registry(镜像仓库)

      • 负责镜像的存储与分发,如官方 Docker Hub、自建 Harbor/registrydocker pull/push 的对象就是它
  • 协助记忆

    • 把这条链想象成"点外卖":docker 是你(下单),dockerd 是外卖平台(接单、调度),containerd 是餐厅后厨(备餐),containerd-shim 是骑手(盯着配送、反馈进度),runc 是最后开火炒菜的那一下(真正把菜做出来)。
  • 进阶思考

    • 为什么要把 containerd、runc 从 dockerd 里拆出来?
      • 为了解耦与标准化。拆出后,containerd/runc 成为独立、可复用的底层运行时,Kubernetes 等编排系统也能直接对接 containerd(通过 CRI),而不必依赖 dockerd;同时各组件可独立升级、独立演进。

🤔 Docker 网络模式有哪些?

  • Docker 的网络可以从两个层面理解:一是容器挂载的"网络模式"(--network 指定,主要有 bridgehostnonecontainer 四种),二是创建网络时用的"网络驱动"(bridgehostoverlaymacvlanipvlannone 等)。日常面试里说的"网络模式"通常指前者。

    • 容器网络模式(--network

      • bridge(默认):容器接到 docker0 网桥上,获得独立的私有 IP,通过 NAT 访问外网,靠端口映射(-p)对外提供服务
      • host:容器直接共享宿主机的网络命名空间,不做网络隔离,性能最好但端口易冲突
      • none:容器只有 loopback 回环网卡,不配置任何网络,适合需要完全隔离、后续自行配网的场景
      • container:<name>:与指定容器共享同一网络命名空间(如与 sidecar 容器共享网络)
    • 网络驱动(network driver

      • bridge:单机默认,基于 Linux 网桥 + NAT
      • overlay:用于 Swarm 的跨主机容器通信,通过 VXLAN 隧道打通不同主机;Kubernetes 的跨主机网络由 CNI 插件(如 flannel/calico)实现,同样常用 VXLAN 技术
      • macvlan:给容器分配独立 MACIP,直接暴露到物理网络;ipvlan:所有容器共享父网卡的同一 MAC,只分配独立 IP,适合需要直连、高性能的场景
      • host / none:与上面两种模式对应
    • 默认网络

      • docker run 不指定时默认使用 bridge 模式,容器被接入名为 bridge 的默认网络(docker0
      • 注意:默认 bridge 网络不提供内置 DNS 名称解析(容器间通信需用 IP--link);用 docker network create 创建的自定义 bridge 网络支持自动 DNS 解析,且隔离性更好
  • 协助记忆

    • bridge = 小区内网(NAT 出门);host = 直接住进房东家里共用地址;none = 自己搭帐篷没网络;container = 跟室友共用一根网线;overlay = 跨城市的虚拟专线。
  • 进阶思考

    • bridge 模式下,宿主机是怎么访问到容器端口的?
      • 通过 iptablesDNAT 规则:-p 8080:80 会在 DOCKER 链里把发往宿主机 8080 端口的流量 DNAT 到容器 IP:80。可以用 iptables -t nat -L -n 看到 docker 自动写入的规则。
  • 扩展信息

    • 容器之间怎么通信?
      • 同一自定义 bridge 网络内:直接用容器名访问(内置 DNS 自动解析),如 curl http://web
      • 默认 bridge 网络:没有内置 DNS,只能通过对方容器 IP 访问(--link 已过时,不推荐)
      • 不同网络之间:默认隔离,需把容器连到同一网络,或通过宿主机端口映射中转
    • 容器怎么访问宿主机(物理机)?
      • 通过 docker0 网关 IP(如 172.17.0.1)访问宿主机,但该 IP 随网络配置可能变化
      • Docker Desktop(macOS/Windows)提供 host.docker.internal 域名;Linux 下需手动 --add-host=host.docker.internal:host-gateway
      • 也可直接用宿主机在局域网中的真实 IP
    • 容器怎么访问外网?
      • 数据经 docker0 网桥走到宿主机,再由 iptablesSNAT/MASQUERADE 把源地址改写成宿主机地址后出去,回包再由连接跟踪(conntrack)还原
    • 宿主机/外部怎么访问容器?
      • 靠端口映射 -p 8080:80,宿主机 8080 端口的流量经 DNAT 转发到容器 IP:80

🤔 为什么容器内执行 free 命令看到的是宿主机内存总量?

  • 因为 free 读取的是 /proc/meminfo,而 /proc 里的内存信息默认反映的是宿主机内核的全局内存状态,并不随容器的 cgroup 内存限制而"缩小"。容器共享宿主机内核,容器内挂载的是同一内核的 procfs,而 meminfo 是内核全局统计、不受命名空间隔离,所以容器里看到的 MemTotal 就是宿主机物理内存总量。

    • free 的数据来源

      • free 命令本质上是解析 /proc/meminfo 这个伪文件
      • /proc/meminfo 里的 MemTotalMemAvailable 等字段由内核统计宿主机物理内存而来
    • 为什么容器看不到"被限制后"的内存

      • 容器的内存限制(-m / --memory)是通过 cgroup 实现的,cgroup 限制的是"容器进程最多能用多少内存",它不会改写 /proc/meminfo 的数值
      • 容器内挂载的是同一个内核的 procfsmeminfo 是内核全局统计、不受命名空间隔离,因此容器读到的还是全局数据
    • 那怎么看容器真实的限制与用量

      • cgroup v1:容器子 cgroup 下 /sys/fs/cgroup/memory/docker/<容器ID>/memory.limit_in_bytes(限制)、memory.usage_in_bytes(用量)
      • cgroup v2:对应容器子 cgroup 下的 memory.max(限制)、memory.current(用量)
      • 注意:上面列的是根 cgroup 之外的容器子 cgroup 路径,需定位到容器对应子目录(或在宿主机读取);更简单的是直接用 docker inspect / docker stats 查看
    • 这个现象带来的实际坑

      • 某些应用启动时会读取 /proc/meminfoMemTotal 来自动设置堆大小,容器里拿到的是宿主机内存,可能设出远大于 -m 限制的堆,导致触发 cgroup 限制被 OOM Kill(现代 JVM 8u191+/10+ 默认 UseContainerSupport=true 会优先读 cgroup 而非 meminfo,此坑主要针对旧版本 JVM 或其他应用)
  • 协助记忆

    • free 看到的是一整栋楼的"总水电表"(宿主机物理内存),而你的房间电表(cgroup 限制)装在了 cgroup/sys/fs/cgroup 里,得去那里看,/proc/meminfo 不会因为你的房间限电就变小。
  • 进阶思考

    • 有没有办法让容器里的 free 显示"被限制后"的内存?
      • 没有简单通用的办法让 /proc/meminfo 虚拟化(lxcfs 这类 FUSE 方案可以做到,但需额外组件)。业界普遍做法是:应用层不信任 /proc/meminfo,而是读取 cgroup 限制(如 JVM 的 UseContainerSupportMaxRAMPercentage),或配合编排系统(Kubernetesdownward API)注入资源限制。

🤔 容器网络是怎么实现的?

  • 容器网络的实现,本质上是 Linux 内核网络能力的一次组合:用 Network Namespace 给容器一个独立网络栈,用 veth pair(虚拟网线)把容器接到宿主机的 docker0 网桥(Linux Bridge)上,再用 iptablesNAT 规则完成出网与端口映射。

    • Network Namespace(独立网络栈)

      • 每个容器拥有独立的网卡、IP、路由表、iptables 规则(通常为空)、端口空间,互不影响
      • 这也是"两个容器能同时监听 80 端口"的原因
    • veth pair(虚拟网线)

      • 一对虚拟以太网设备,一端放进容器的 Network Namespace(表现为 eth0),另一端留在宿主机、插到 docker0 网桥上
      • 数据从容器发出,经 veth 对传递到宿主机侧,反之亦然
    • docker0 网桥(Linux Bridge)

      • 一个二层虚拟交换机,把同一宿主机上所有容器的 veth 对连在一起,实现同网桥容器间的二层互通
      • 同时作为容器的默认网关(如 172.17.0.1
    • iptables NAT(出网与端口映射)

      • 出网:容器访问外网时,SNAT/MASQUERADE 把源地址改写成宿主机地址再出去
      • 入网:-p 8080:80 通过 DNAT 把宿主机 8080 端口的流量转发到容器 IP:80,另有一个用户态代理 docker-proxy 处理 loopback 访问与 hairpin NAT(容器访问自身发布端口)
    • 跨主机

      • 单机靠 docker0 即可;跨主机则需要 overlay 网络(VXLAN 隧道)或 macvlan/ipvlan 等驱动
  • 协助记忆

    • 容器网络 = “给每户(容器)装独立门牌和网线(Namespace + veth),楼道里放一台交换机(docker0),出门统一走小区大门 NAT(iptables)"。
  • 进阶思考

    • 为什么两个容器都能同时监听 80 端口,而不冲突?
      • 因为它们分处不同的 Network Namespace,各自的端口空间是独立的。只有当它们共享同一个网络命名空间(如 host 模式或 container:<name> 模式)时,端口才会冲突。

🤔 Docker 如何实现数据持久化?

  • 容器自身的可写层是临时且随容器删除而消失的,因此 Docker 通过独立的"挂载"机制实现数据持久化,主要有三种:volume(命名卷,推荐)、bind mount(宿主机目录挂载)、tmpfs(内存临时挂载)。持久化的关键是把数据写到"可写层之外"的存储上。

    • 为什么容器数据默认会丢

      • 容器在镜像层之上有一个可写层(container layer),容器删除后这个可写层随之销毁,里面的数据就没了
      • 所以需要持久化的数据(数据库、日志、配置)必须挂载到外部存储
    • 三种持久化方式

      • volume(推荐):由 Docker 管理的卷,Linux 下默认存放在 /var/lib/docker/volumes/(Docker Desktop 存于其管理的 VM 内),独立于容器生命周期;支持命名、跨容器共享、docker volume 子命令管理,还能对接第三方卷驱动(如 NFS、云盘);未显式命名的匿名卷也落在这里,但难以复用,建议显式命名
      • bind mount:把宿主机任意路径(目录或文件)直接挂载进容器,位置由用户指定,常用于开发调试或挂载配置文件
      • tmpfs:挂载到内存中,不落磁盘,容器停止即消失,适合临时文件、敏感数据
    • volumebind mount 的区别

      • 位置:volume 由 Docker 管理(/var/lib/docker/volumes/),bind mount 位置由用户指定
      • 管理:volume 有专门命令管理、可命名、可被驱动管理;bind mount 直接依赖宿主机文件系统
      • 推荐:生产环境数据持久化优先用 volume(可移植、隔离性更好)
    • 其他方式

      • docker commit 把容器固化成镜像只能捕获可写层,不包含 volume/bind mount 里的数据,且不利于分层、不适合动态数据,不是推荐的持久化手段
      • 数据卷容器(--volumes-from)可让多个容器共享同一套卷,属较早的共享模式,现代更推荐命名卷或 Compose 直接声明
  • 协助记忆

    • 容器像"一次性便当盒”,吃完(删除)就扔;volume 是"餐厅中央冰箱",bind mount 是"直接塞进你家冰箱",tmpfs 是"桌上现吃现用的碗"。
  • 进阶思考

    • docker commit 能保留数据,为什么不用它做持久化?
      • 因为 commit 会把运行时数据打进镜像层,镜像体积膨胀、分层混乱,且不利于版本管理与分发。持久化的本质诉求是"数据与容器生命周期解耦",而 volume/bind mount 正好做到这一点。
  • 扩展信息

    • 生产环境数据持久化实战避坑指南

      • 权限问题(80% 的挂载异常都栽在这)
        • bind mount 宿主机目录时,容器内进程的用户(如 nginx:nginx UID 101)可能无权限读写,需确保宿主机目录的 uid/gid 匹配,或运行时用 --user 指定用户,或 chown 调整目录权限
        • Volume 由 Docker 自动创建时默认 root 归属,若容器非 root 运行需注意,常用方案:写 Dockerfile 时 RUN useradd 并固定 UID,或容器启动脚本里 chown 卷目录
      • bind mount 文件覆盖陷阱
        • 若宿主机挂载的是一个空目录,容器内目标目录的内容会被"遮挡",容器看不到镜像内原有文件(如挂空目录到 /etc/nginx/conf.d 会让默认配置丢失)
        • 解决:先 docker cp 容器默认配置到宿主机目录作为初始模板,或改用 volume(Docker 会自动初始化卷内容)
      • 备份与迁移
        • Volume 备份:docker run --rm -v vol_name:/data -v $(pwd):/backup alpine tar czf /backup/vol_backup.tar.gz -C /data .
        • Volume 恢复:docker run --rm -v vol_name:/data -v $(pwd):/backup alpine tar xzf /backup/vol_backup.tar.gz -C /data
        • 多机环境不要依赖本地 volume,应使用卷驱动(如 NFS、云厂商 CSI)实现跨主机共享
      • 容器内数据库(MySQL/PostgreSQL)持久化
        • 绝对禁止使用 bind mount 挂载 macOS/Windows 的目录到数据库容器(性能差且文件系统不兼容),必须用 volume
        • 数据目录挂载后,数据库容器的 initdb 初始化只在数据目录为空时执行,恢复时注意目录权限和 owner
      • 清理与维护
        • 删除容器时加 -v 会同时删除匿名卷,但命名卷需手动 docker volume rm,否则会残留
        • 定期清理不再使用的卷:docker volume prune -f
        • Docker Compose 中声明 external: true 的卷,docker compose down -v 不会删除它,防止误删共享数据
      • Dockerfile 里的 VOLUME 指令
        • VOLUME /data 会在容器运行时自动创建匿名卷,但无法在 Dockerfile 中指定宿主机路径或命名,适合镜像作者声明"这里需要挂载"
        • :如果在 VOLUME 之后在 Dockerfile 中对该目录做 COPY/ADD,这些内容不会进卷(构建阶段没有卷),部署时该目录会被卷覆盖,可能为空
        • 建议:声明 VOLUME 的镜像,在运行时显式用 -v 或 Compose 覆盖为自己的命名卷
    • Docker Compose 持久化配置参考

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      
      services:
        db:
          image: postgres:15
          volumes:
            - pg_data:/var/lib/postgresql/data      # 命名卷
            - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro  # bind mount 配置文件
          environment:
            POSTGRES_PASSWORD: secret
      
        app:
          image: myapp:latest
          volumes:
            - app_config:/etc/myapp/config          # 命名卷
            - ./logs:/var/log/myapp                 # bind mount 开发用
      
      volumes:
        pg_data:     # 显式声明命名卷,Docker 会自动管理
        app_config:  # 可额外配置 driver 或 external

🤔 容器内 root 用户和宿主机 root 用户有什么权限差异?

  • 默认情况下,容器内的 rootuid 0)和宿主机 root 是同一个 uid 0,但它们拥有的"能力"并不相同:容器内 root 默认被裁剪了绝大多数 Linux Capabilities,且受内核命名空间隔离,无法像宿主机 root 那样直接操作全部硬件、加载内核模块或访问其他容器。不过它仍能以 root 身份读写挂载进来的宿主机目录,这是最大的安全隐患。

    • uid 层面:本质是同一个 root

      • 容器进程以 uid 0 运行,和宿主机 root 是同一个数字 0(未启用 user namespace 时)
      • 所以容器内 root 创建的文件,在宿主机上看到 owner 也是 root
    • 能力层面:Capabilities 被大幅裁剪

      • Linux 把 root 的超级权限拆成了几十个细粒度 Capabilities,Docker 默认只给容器保留 14 个(如 CHOWNDAC_OVERRIDENET_BIND_SERVICESETUIDSETGIDNET_RAWKILL 等)
      • SYS_ADMIN(挂载等广泛管理操作)、SYS_MODULE(加载内核模块)、SYS_TIME(改系统时间)等危险能力默认都没有
      • 因此容器内 root 不能挂载文件系统、不能加载内核模块、不能改宿主机系统时间
    • 隔离层面:受命名空间限制

      • 容器内 root 只能看到自己命名空间里的进程与文件系统;注意默认 bridge 网络下容器间网络仍互通(除非 --icc=false),它只是"看不到"对方的进程和文件系统
    • 真正的风险点

      • 容器内 root 对"挂载进来的宿主机目录"拥有 root 权限(如 -v /data:/data),可以读写、删除、chmod/chown 该目录下任何文件——这是常见的安全事故来源
      • 缓解手段:user namespace--userns-remap 把容器内 root 映射为宿主机普通用户)、--cap-drop 进一步裁剪能力、--read-only 只读根文件系统(注意它只约束根文件系统,bind mount 目录需单独 :ro)、rootless Docker
  • 协助记忆

    • 容器内 root 是"小区里的楼长"——在自己那栋楼(命名空间)里看起来权力很大,但电梯卡(Capabilities)被物业扣掉了一大半,出了楼就管不了别人;真正的风险是他拿到了"你家的钥匙"(挂载的宿主机目录)。
  • 进阶思考

    • 容器内 root 能不能逃逸出来变成宿主机 root?
      • 正常配置下不能直接"变",因为缺关键能力且受命名空间隔离。但历史上出现过多次"容器逃逸漏洞"(如利用 SYS_ADMIN 或内核漏洞),一旦逃逸成功,容器内 root 就等价于宿主机 root。因此生产环境应遵循:不用 root 跑容器、裁剪能力、启用 user namespace/seccomp、及时打内核补丁。

🤔 如何实现跨主机的容器通信?

  • 跨主机容器通信,本质上要解决"不同宿主机上的容器如何互通"的问题。常见方案有:overlay 网络(Swarm 内置,基于 VXLAN)、macvlan/ipvlan 直连物理网络、宿主机端口映射,以及第三方网络方案(flannel/calico/weave 等,多用于 Kubernetes 的 CNI)。

    • overlay 网络(Docker 原生跨主机)

      • Docker 内置的跨主机方案,通过 VXLAN 隧道在底层网络上叠加一个虚拟二层网络,让不同宿主机上的容器看似在同一网段
      • 主要用于 Swarm 集群,需先创建 overlay 网络并把容器 attach 上去;standalone(非 Swarm)模式下还需外部 KV 存储(Consul/etcd/ZooKeeper)做节点发现
    • macvlan / ipvlan

      • 给容器分配与物理网络同网段的独立 MAC/IP(macvlan)或独立 IP(ipvlan,L2 模式共享父网卡 MAC,另有 L3 路由模式),容器直接暴露到物理网络,无需 NAT
      • 适合需要直连、高性能、或对端要求"容器有真实 IP"的场景
    • 宿主机端口映射(最简单)

      • 把容器端口通过 -p 映射到宿主机端口,其他主机通过"宿主机 IP:端口"访问
      • 本质是单机通信的延伸,不要求容器网络打通,最简单但端口规划与管理成本高
    • 第三方网络方案(CNI)

      • Kubernetes 等编排系统通过 CNI 插件实现跨主机 Pod 网络,如 flannel(VXLAN/host-gw)、calico(BGP 路由,亦支持 VXLAN/IPIP)、cilium(eBPF);weave 已停止原厂维护,仅作历史方案提及
      • 这些方案通常自带 IP 分配、路由、网络策略等能力
    • 选型建议

      • 少量容器、无编排:端口映射即可
      • Swarm 集群:用 overlay
      • Kubernetes:用 CNI(flannel/calico 等)
      • 需要直连物理网段:macvlan/ipvlan
  • 协助记忆

    • overlay = 跨城打隧道(VXLAN);macvlan = 给容器发"真实身份证"直接进物理网;端口映射 = 不用打通网络,走宿主机大门转发;CNI = 交给专业网络公司(K8s 插件)。
  • 进阶思考

    • overlayVXLAN 隧道是怎么把不同主机容器"连"起来的?
      • 容器发出的以太网帧被封装进 UDP 报文(VXLAN 头 + 外层 IP),通过底层网络传到对端宿主机,再由对端解封装还原成原始帧交给目标容器,从而在物理网络上虚拟出一个跨主机的二层网络。

🤔 容器挂载宿主机上目录后,提示权限不足如何解决?

  • 挂载宿主机目录后容器内提示"权限不足"(Permission denied),根因通常是两类:一是容器内进程的 uid/gid 与宿主机目录的属主不匹配,二是启用了 SELinux(RHEL/CentOS 系)导致标签限制。按根因对症处理即可。

    • 原因一:uid/gid 不匹配

      • 容器内进程默认以 root(uid 0)运行,但很多镜像会以非 root 用户运行——如 mysql 整体以 mysql(uid 999) 运行,nginx 则是 master 仍为 root、仅 worker 切到 nginx(uid 101)——而宿主机目录属主是别的用户,导致读写被拒
      • 反过来,若容器以非 root 运行,宿主机目录属主是 root 且权限不足,同样会报错
    • 原因二:SELinux 限制

      • RHEL/CentOS 上启用 SELinux 时,容器进程带有 container_t 标签,访问宿主机目录会因标签不匹配被拒绝
    • 解决方案

      • 调整目录权限/属主:chmod -R 777 <dir>chown -R <容器内uid>:<gid> <dir>(快速但权限放得较宽)
      • 指定容器运行用户:docker run --user <uid>:<gid>,让容器内进程以匹配的 uid 运行
      • SELinux 加标签:挂载时加 :Z(私有,仅该容器可用)或 :z(共享,多容器可用),如 -v /data:/data:Z
      • 改用命名卷:当镜像挂载点已预置正确属主的内容时,命名卷首次挂载会自动继承其属主,可减少手动处理权限
  • 协助记忆

    • 报权限不足就像"你拿着别人的门卡(uid 不匹配)去开门",要么把门锁换成你能开的(chmod/chown),要么给你换成对的门卡(–user),要么找物业改门禁规则(SELinux 加 :Z)。
  • 进阶思考

    • -v /data:/data:Z 里的 :Z:z 有什么区别?
      • :Z:z 都会重新打 SELinux 标签,区别在于::Z 打私有标签,只有当前容器能访问;:z 打共享标签,多个容器可共同访问。注意用 :Z 挂载共享目录可能导致其他容器(及受 SELinux 限制的宿主服务)无法访问,需谨慎选择。
  • 扩展信息

    • --user vs entrypoint 改 UID:两种权限解决方案的深度对比
      • 很多人在遇到挂载权限问题时,会纠结"到底该用 --user 还是写个 entrypoint 去改用户 UID",这两者不是互斥的,而是适用场景完全不同

        对比维度docker run --user 1010:1010entrypoint 中 usermod -u 1010 www-data
        本质指定进程运行时的有效 UID/GID,不修改 /etc/passwd修改 /etc/passwd 中已有用户的 UID/GID
        容器内 whoami可能报错 I have no name!(如果 1010 不在 passwd 中)正常显示 www-data
        对子进程的影响主进程以 1010 运行,但子进程若主动 su 或切换用户,可能切回镜像默认 UID所有以 www-data 身份启动的进程,UID 都是 1010
        对文件归属的影响新建文件的 UID 是 1010,但容器内 ls -l 显示为数字(无名称映射)新建文件的 UID 是 1010,ls -l 显示为 www-data
        多进程场景(PHP-FPM/Nginx)❌ FPM worker 切到 www-data 时会失败(因为 UID 还是 33)✅ FPM worker 切到 www-data 时实际是 1010,正常工作
        对镜像层的影响无,纯运行时参数无,仅运行时修改 /etc/passwd(容器重启后重新执行)
        灵活性高,每次 docker run 可直接指定不同 UID中,需通过环境变量 NEW_UID 控制
        依赖只需宿主机知道目标 UID/GID需要容器内有 usermod/groupmod 命令(Alpine 需 shadow 包)
      • 典型使用场景

        场景推荐方案理由
        CLI 一次性任务(如 docker run --rm php php script.php--user简单直接,不需要维护 entrypoint
        开发调试(频繁切换项目,每个项目 UID 不同)--user每次启动时用 $(id -u) 动态传入,无需改镜像
        PHP-FPM 长期运行的生产容器entrypoint 改 UIDFPM 多进程架构需要 www-data 这个用户名及对应的 UID 一致
        Nginx + PHP-FPM 双容器共享文件entrypoint 改 UID两个容器都需要以同一个 www-data(相同 UID)运行,才能同时读写共享目录
        镜像内硬编码了用户名的检测逻辑(如 Laravel 某些命令 whoamientrypoint 改 UID代码期望 www-data 这个名字,用 --user 会导致名字查找失败
        Kubernetes 环境(Pod 内多容器共享卷)entrypoint 改 UID(配合环境变量注入)K8s 不支持 --user 在 Pod 级别的统一设置,更推荐镜像层面统一 UID
      • 为什么官方镜像(如 PHP、Nginx)默认用 www-data 且 UID 是 33?

        • 33 是 Debian/Ubuntu 体系下 www-data 的约定 UID,它不是一个安全随机数,而是发行版惯例。这样设计是为了让不同镜像间共享数据时,默认 UID 一致(比如 Nginx 和 PHP-FPM 都用 33),开箱即用。但生产环境往往有自己的 UID 规范(如公司统一用 1010),就需要 entrypoint 或构建时改掉它。
      • 一个容易踩的坑:--user + entrypoint 改 UID 同时使用

        • 如果你的 entrypoint 里已经 usermod -u 1010 www-data,同时又用了 docker run --user 1010:1010,会出现什么?
          • 进程直接以 1010 运行,但 /etc/passwdwww-data 也是 1010 → 一切正常,只是冗余。
          • 真正的坑:如果 entrypoint 改 UID 到 1010,但 --user 指定了 33,进程以 33 运行,而 /etc/passwd 里 33 现在变成 old_www-data(或不存在)→ 权限混乱,难以排查。
        • 最佳实践:二选一。如果用 entrypoint 改 UID,就不加 --user;如果用 --user,entrypoint 里就跳过用户修改逻辑(可加 SKIP_USER_MODIFY=true 开关)。
      • 生产环境推荐策略

         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        13
        
        # Docker Compose 中推荐写法:entrypoint 改 UID + 环境变量
        services:
          php:
            image: php-fpm:8.2
            environment:
              NEW_UID: 1010   # 与宿主机用户对齐
              NEW_GID: 1010
            volumes:
              - ./project:/var/www/html
            # 不加 user: 字段,让 entrypoint 全权处理
        
          # 开发环境快速切换写法:docker run --user
          # docker run --rm --user $(id -u):$(id -g) -v $(pwd):/app php:cli php script.php
      • 一句话总结

        --user 适合"临时换身份干完活就走"的一次性任务;entrypoint 改 UID 适合"长期居住、且名字(用户名)不能丢"的生产应用,特别是 PHP-FPM/Nginx 这种多进程+共享文件的场景。
        如果你在用 PHP-FPM,你的 entrypoint 脚本不是多余的,它是解决权限问题最稳的方案。

🤔 为什么建议容器运行单个应用?

  • 这是容器化的核心最佳实践,源于"单一职责"原则:一个容器只跑一个进程(或一个紧密相关的进程组),让容器的生命周期、扩缩容、故障隔离、镜像复用都与"单个应用"对齐,从而最大化容器轻量、可编排、可替换的优势。

    • 生命周期清晰

      • 容器内 PID 1 进程退出,容器即退出。跑单个应用时,容器的启停/重启/退出状态与应用的运行状态一一对应,docker stop 的信号能正确传递给应用(前提是应用作为 PID 1 且安装了 SIGTERM 处理器,否则 docker stop 会等满 10s 宽限期后 SIGKILL 强杀)
      • 跑多个进程时,需要额外的进程管理器(如 supervisord)协调,谁挂了、怎么重启、信号怎么转发都变得复杂
    • 按应用粒度扩缩容

      • 一个应用一个容器,扩容就是"多起几个容器",缩容就是"少几个",与编排系统(SwarmKubernetes)的副本模型天然匹配
      • 若一个容器塞多个应用,无法对其中某个应用单独扩缩容
    • 故障隔离与定位

      • 一个容器只影响一个应用,故障域小,日志、监控、资源占用都清晰归属于单个应用
      • 某个应用崩溃不会拖垮同容器内的其他应用
    • 镜像复用与发布

      • 单应用镜像职责单一、体积可控、易于复用和分层;更新某个应用只需重新构建对应镜像,不影响其他
      • 符合"不可变基础设施":应用更新 = 换新镜像 + 重建容器,而不是进容器改东西
    • 并非绝对

      • 确有紧密耦合的辅助进程(如日志采集)需要与主进程共享命名空间,应以独立 sidecar 容器实现(在 Kubernetes 中是同 Pod 的另一个容器),而不是在单个容器内塞多个进程
  • 协助记忆

    • 容器像"集装箱",一个箱子装一种货,装卸、调度、清点都简单;塞成"杂货箱"后,找货、理赔、单独发货全都乱套。
  • 进阶思考

    • 一个容器真的只能有一个进程吗?技术上怎么限制?
      • 技术上容器并不强制单进程,可以跑多个。推荐单进程是工程实践而非硬性约束;如果必须跑多进程,应确保 PID 1 能转发信号、回收僵尸进程(如用 tini--init),否则会出现信号无法优雅传递、僵尸进程堆积等问题。

🤔 容器的健康检查机制有什么作用?

  • 健康检查(HEALTHCHECK)的作用是:让 Docker(及编排系统)区分"容器进程还在跑"和"应用服务真正可用"这两种状态——进程活着不等于服务就绪,健康检查通过主动探测应用的存活/就绪情况,为自动重启、流量摘除、滚动更新提供依据。

    • 为什么需要它

      • 容器处于 running 只说明进程没退出,但应用可能已经"假死"(如死锁、端口不通、依赖服务挂了)
      • 健康检查能主动发现这类"进程活着但服务不可用"的情况
    • 三种状态

      • starting:初始状态,检查尚未完成(含 --start-period 启动宽限期,此期间的失败不计入重试)
      • healthy:检查通过,服务可用
      • unhealthy:连续检查失败,服务不可用
    • 如何定义

      • Dockerfile 中:HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD curl -f http://localhost/ || exit 1
      • docker run --health-cmd 指定;检查命令返回 0 视为健康、1 视为不健康(2 为保留值,勿使用)
    • 在编排系统中的作用

      • Swarmunhealthy 的容器会依服务的 restart policy 重调度
      • Kubernetes:对应 liveness(存活,失败则重启)与 readiness(就绪,失败则摘除流量)探针,配合滚动更新实现"先就绪再接入流量"
    • 注意点

      • 健康检查命令本身要轻量、幂等,避免给应用带来额外压力
      • 没有健康检查时,容器一启动就可能被接入流量,即使应用还没就绪
      • 单机 docker run 的容器 unhealthy 后不会自动重启,仅反映在 docker ps 状态中(自动重启是 Swarm/编排系统的能力)
  • 协助记忆

    • 进程状态是"人还坐在工位上"(running),健康检查是"确认他真在干活"(healthy)——有人坐着发呆甚至睡着了,就得靠健康检查发现并换人。
  • 进阶思考

    • livenessreadiness 有什么区别?
      • liveness 判断"该不该重启":失败说明应用卡死无望,应重启进程;readiness 判断"该不该接流量":失败说明应用还没准备好(如依赖未就绪、预热中),应暂时摘除流量但不重启。二者用途不同,生产环境通常都要配。

🤔 容器内的时间与宿主机不一致,该如何解决?

  • 容器共享宿主机内核,所以时钟(时间本身)是一致的;“不一致"几乎都是时区(timezone)配置差异——镜像默认是 UTC,而宿主机可能是 Asia/Shanghai,导致显示出来的时间差 8 小时。解决思路就是给容器配置正确的时区。

    • 为什么"时间不一致"其实是时区问题

      • 容器与宿主机共用同一个内核时钟,date +%s(时间戳)一定相同
      • 但镜像里默认时区是 UTC,用 date 显示时就会比北京时间少 8 小时
    • 解决方案一:挂载宿主机时区文件(最常用)

      • docker run -v /etc/localtime:/etc/localtime:ro .../etc/localtime 是必须的;/etc/timezone 仅 Debian/Ubuntu 系才有,可省略)
      • 直接把宿主机的时区配置"借"给容器,改动最小、即时生效
    • 解决方案二:设置环境变量 TZ

      • docker run -e TZ=Asia/Shanghai ...
      • 使用 Asia/Shanghai 这类具名时区时需镜像内安装 tzdata,很多精简镜像(如 alpine)默认不带
    • 解决方案三:在镜像里固化

      • Dockerfile 中 RUN apk add tzdata && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime(debian 系用 apt-get install -y tzdata
    • 注意:时间同步(NTP)

      • 不要在容器内做 NTP 同步,容器共享内核时钟,时间同步应在宿主机完成;容器内通常也无权修改系统时钟(缺 SYS_TIME 能力)
  • 协助记忆

    • 时钟(内核)是"北京时间标准"大家共用一套,但每个容器手里拿的"手表”(时区文件)默认调成了 UTC,所以要做的不是"校准时间",而是"把表盘时区拨对"。
  • 进阶思考

    • 为什么 alpine 镜像里 -e TZ=Asia/Shanghai 经常不生效?
      • 因为 TZ 环境变量要生效,需要系统里有 /usr/share/zoneinfo 下的时区数据(tzdata 包)。alpine 默认精简不带 tzdata,所以要先 apk add tzdata,否则 TZ 设了也没用。

🤔 Dockerfile 中 CMD 和 ENTRYPOINT 有什么区别?

  • 一句话:ENTRYPOINT 定义容器启动时"固定执行的主程序",CMD 提供"默认参数/默认命令"。它们最关键的差异是——docker run 后面跟的命令会覆盖 CMD,但不会覆盖 ENTRYPOINT(除非显式用 --entrypoint)。

    • CMD(默认命令,可被覆盖)

      • 指定容器启动时的默认命令或参数,docker run <image> <命令> 会直接覆盖它
      • 三种写法:CMD ["executable","param1"](exec 形式,推荐)、CMD command param1(shell 形式)、CMD ["param1"](作为 ENTRYPOINT 的默认参数)
    • ENTRYPOINT(固定入口,不易覆盖)

      • 指定容器的主程序,docker run 后面的参数会作为参数追加给它,而不是替换它
      • 要覆盖需显式 docker run --entrypoint <cmd>
    • 组合用法(最常见)

      • ENTRYPOINT ["nginx"] + CMD ["-g", "daemon off;"]:主程序固定,默认参数可被 docker run 追加参数覆盖
      • 适合"容器即某个可执行程序"的场景,如把镜像当作 CLI 工具
    • shell 形式与 exec 形式的差异

      • shell 形式(CMD echo hello)会经过 /bin/sh -c 包装,PID 1 是 shell;shell 虽能收到信号但不会转发给子进程,导致主程序收不到
      • exec 形式(CMD ["echo","hello"])不经过 shell,进程直接作为 PID 1,能正确接收信号
  • 协助记忆

    • ENTRYPOINT 是"定了菜(主程序)",CMD 是"默认的调料和配菜(默认参数)"——你可以临时换调料(覆盖 CMD),但菜本身(ENTRYPOINT)一般不改。
  • 进阶思考

    • ENTRYPOINT 用 shell 形式会有什么坑?
      • shell 形式会让 /bin/sh -c 成为 PID 1,主程序变成它的子进程,导致 docker stop 发出的 SIGTERM 到达不了主程序、无法优雅退出。所以 ENTRYPOINT 应尽量用 exec 形式。

🤔 Dockerfile 中 COPY 和 ADD 区别是什么?

  • 两者都能把文件复制进镜像,区别在于 ADD 多了两个"高级"能力——自动解压本地 tar 压缩包、以及从 URL 下载文件;而 COPY 只做纯粹的本地文件复制。官方推荐优先用 COPY,只有在确实需要自动解压时才用 ADD

    • COPY(纯粹复制)

      • 只支持把构建上下文(或前一阶段)里的本地文件/目录复制到镜像指定路径
      • 语义单一、行为可预测,是推荐的首选
    • ADD(复制 + 两个额外能力)

      • 自动解压:ADD app.tar.gz /app/ 会把压缩包自动解压到目标目录(支持 gzipbzip2xz,含未压缩的 .tar
      • 远程下载:ADD http://example.com/file /app/ 可下载远程文件到镜像
      • 注意:自动解压是"隐式"行为,可能带来意外;URL 下载的缓存依赖服务器的 ETag/Last-Modified,且无法校验内容完整性
    • 选型建议

      • 默认用 COPY
      • 需要"本地压缩包自动解压"时才用 ADD
      • 需要下载远程文件时,推荐 RUN curl/wget 而不是 ADD(便于清理、缓存和校验)
  • 协助记忆

    • COPY 是"普通快递员",只负责把包送到;ADD 是"快递 + 自动拆箱 + 代购",能力多但可能拆错箱、也可能买到假货(URL 不可校验)。
  • 进阶思考

    • 为什么官方不推荐用 ADD 下载远程文件?
      • 因为 ADD 的 URL 下载无法校验文件完整性(没有固定校验和),且下载的文件没有"清理"环节。用 RUN curl/wget && ... 可以结合缓存、校验 sha256 并即时清理,更可控。

🤔 Dockerfile 中的多阶段构建是什么意思?

  • 多阶段构建(multi-stage build)就是在同一个 Dockerfile 里写多个 FROM,把"编译/构建环境"和"运行时环境"拆开:在前一阶段用完整工具链编译,只把产物复制到后一个精简阶段,最终镜像只包含运行时需要的文件,从而大幅减小体积、减少攻击面。

    • 为什么需要它

      • 传统做法要么把整个编译工具链都留在镜像里(体积巨大),要么用两个 Dockerfile + shell 脚本拼装(维护麻烦)
      • 多阶段构建把"构建"与"运行"分离,用一份 Dockerfile 搞定
    • 工作原理

      • 每个 FROM 开启一个新阶段,前面的阶段是"构建阶段",最后的阶段是"运行阶段"
      • COPY --from=<阶段名或编号> <源> <目标> 把前面阶段的产物复制到当前阶段;也可用 docker build --target=<阶段名> 只构建到某个中间阶段
      • 最终镜像只保留最后一个 FROM 的内容(中间阶段仅作为构建缓存保留、不进入最终镜像,但构建时仍需拉取其基础镜像)
    • 典型示例(Go 程序)

      • 阶段一:FROM golang:1.22 AS builder + RUN go build ... 编译出二进制
      • 阶段二:FROM alpine + COPY --from=builder /app/server /server,只带二进制不带 Go 工具链
      • 结果镜像可能从 1GB+ 瘦身到几十 MB
    • 带来的好处

      • 体积小(不含编译器、依赖包、源码)
      • 更安全(少了很多不必要的组件,攻击面小)
      • 可维护(单个 Dockerfile 完整表达构建流程)
  • 协助记忆

    • 多阶段构建像"在装修车间(构建阶段)把家具做好,最后只把成品搬进新家(运行阶段)"——新家里不留下工具、木料和废料。
  • 进阶思考

    • COPY --from 除了从前一阶段复制,还能从别处复制吗?
      • 可以。COPY --from=0 可用编号引用任意阶段,COPY --from=<镜像名> 甚至能从其他镜像(包括远程镜像)复制文件,例如 COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

🤔 编写 Dockerfile 有哪些最佳实践?

  • 编写 Dockerfile 的核心原则是:镜像更小、构建更快、更安全。具体做法围绕"选好基础镜像、利用构建缓存、减少层数与体积、以非 root 运行、版本固定"这几条展开。

    • 选对基础镜像

      • 优先官方镜像,尽量选体积小、攻击面小的基础镜像(如 alpinedistrolessslim 变体;注意 alpine 用 musl libc,部分依赖 glibc 的二进制/原生扩展可能不兼容)
      • 用具体版本标签(如 node:20-alpine),不要用 latest(不可复现)
    • 利用构建缓存与分层

      • 把"变化少的"放前面、“变化多的"放后面:先 COPY package.json 装依赖,再 COPY . . 复制源码,这样改代码不会让依赖层失效
      • .dockerignore 排除不需要的文件(.gitnode_modules、日志等),减小上下文、避免误拷贝
    • 减少层数与体积

      • 用多阶段构建分离"编译"与"运行”,最终只带运行时产物
      • 合并可合并的 RUN(如 apt-get update && apt-get install -y xxx && rm -rf /var/lib/apt/lists/*),并在同层清理缓存
    • 以非 root 运行

      • USER 指定非 root 用户运行应用,降低容器逃逸后的危害
      • COPY --chown 设置文件属主(仅适用于 Linux 镜像)
    • 其他

      • CMD/ENTRYPOINT 尽量用 exec 形式(能正确接收信号)
      • HEALTHCHECK 暴露健康状态
      • 不要在镜像里放密钥/凭据(用 BuildKit 的 RUN --mount=type=secret + docker build --secret 传入,或运行时注入)
  • 协助记忆

    • 好的 Dockerfile 像"搬家打包":只带必需品(小镜像)、常用物放门口(缓存分层)、贵重品上锁(非 root)、易碎品单独装(多阶段构建)。
  • 进阶思考

    • “减少层数"和"利用缓存"有时冲突,怎么权衡?
      • 合并 RUN 能减层,但会把"易变步骤"和"稳定步骤"绑在一起,导致缓存频繁失效。权衡原则是:稳定且可共享的步骤适当拆分以利缓存,临时性、清理性的操作合并进同一 RUN。核心是让"变化最少的内容"尽量靠前。

🤔 一台 Docker 服务器磁盘使用率超 80%,如何清理优化?

  • Docker 占用磁盘的大头通常是无用镜像、停止的容器、悬空卷和构建缓存。清理思路是:先用 docker system df 摸清各占多少,再有针对性地 prune,最后通过日志限制等手段做长期控制。

    • 先摸清占用

      • docker system df:看镜像、容器、卷、构建缓存各自占用的空间
      • df -h 确认 /var/lib/docker 所在分区使用率
    • 分类清理

      • 清理悬空/无用镜像:docker image prune(悬空镜像 dangling)、docker image prune -a(所有未被容器使用的镜像,谨慎)
      • 清理停止的容器:docker container prune
      • 清理无用卷:docker volume prune(自 Docker 23.0 起默认只删匿名的未使用卷,命名卷需 -a
      • 清理构建缓存:docker builder prune
      • 一键清理:docker system prune -a --volumes(清掉未使用镜像、已停止容器、未使用网络、匿名卷及构建缓存,最彻底但需确认无在用数据)
    • 定位并治理日志膨胀

      • 容器日志默认写进 /var/lib/docker/containers/<id>/<id>-json.log,日志没做轮转会无限增长
      • du -sh /var/lib/docker/containers/*/* 找大日志文件
      • 治理:docker run --log-opt max-size=10m --log-opt max-file=3(仅对 json-file/local 日志驱动生效),或在 /etc/docker/daemon.json 全局配置 log-driver + log-opts
    • 长期控制

      • 配置日志轮转上限(daemon.json)
      • 定期跑 prune(可用 cron 或监控告警联动)
      • 规范镜像生命周期(及时删旧版本镜像)
      • 磁盘监控告警(提前发现,而非等到 80% 才处理)
  • 协助记忆

    • 先"称重”(docker system df)→ 再"清四类垃圾"(镜像/容器/卷/构建缓存)→ 最后"治源头"(日志轮转 + 定期清理)。
  • 进阶思考

    • docker system prune 会误删正在使用的数据吗?
      • 默认不会。prune 只删除"未被任何容器使用"的对象(悬空镜像、已停止容器、未被引用的卷等)。但 -a 会删除未被任何容器(含已停止容器)引用的镜像,--volumes 会删未被引用的匿名卷——这些都可能包含你"暂时不用但还想保留"的数据,所以生产环境执行前务必确认。

🤔 Docker 镜像和容器有什么区别?

  • 一句话:镜像是"只读的静态模板",容器是"镜像的运行实例"。镜像定义了应用要跑什么(文件系统、依赖、配置),容器则是在镜像之上加了一层可写层、并以隔离进程形式运行起来的"活的"东西。

    • 镜像(Image)

      • 只读、不可变的静态模板,由多层文件系统叠加而成
      • 包含运行应用所需的文件系统(代码、运行时、依赖库、配置),并在元数据(image config)中携带环境变量、启动命令等
      • 通过 docker build 构建、docker pull 拉取,一旦生成就不会被修改(更新就是生成新镜像)
    • 容器(Container)

      • 镜像的运行时实例:在镜像只读层之上叠加一层可写层(container layer),并以进程形式运行
      • 拥有独立的命名空间(进程、网络、文件系统等)与资源限制,是一个被隔离的运行环境
      • 容器可以被启动、停止、删除,容器内的写入只落在自己的可写层,不影响镜像
    • 核心关系

      • 类比:镜像 ≈ “类 / 程序安装盘 / 光盘”,容器 ≈ “实例 / 运行中的进程 / 播放出来的画面”
      • 一个镜像可以同时启动多个容器,每个容器共享同一套只读镜像层,各自有独立的可写层
      • 容器删除后,可写层数据丢失,镜像仍在
  • 协助记忆

    • 镜像 = 菜谱(只读模板),容器 = 照菜谱做出来的一盘菜(运行实例);菜吃完了(容器删了)菜谱还在(镜像还在),同一张菜谱能反复做出很多盘菜。
  • 进阶思考

    • 为什么说镜像是"不可变"的,但我在容器里明明能改文件?
      • 因为改的是容器的可写层,而不是镜像本身。写操作通过"写时复制"落到可写层,镜像的只读层始终不变;容器删除后,这些修改也随之消失。这就是"镜像不可变、容器可变"的本质。

🤔 什么是 Docker 镜像分层?为什么要分层?

  • Docker 镜像是"分层"存储的:每一条会改变文件系统的 Dockerfile 指令(如 RUNCOPYADD)都会生成一个新的只读层,层层叠加组成镜像。分层的核心价值是"复用 + 缓存"——共享基础层、加速构建和拉取、节省磁盘。

    • 分层是怎么产生的

      • 每条产生文件系统变更的指令对应一层:FROM 是基础层,RUN apt-get installCOPY . . 等各自生成一层
      • 所有层都是只读的,通过 UnionFS(如 overlay2)叠加成一个统一的文件系统视图
    • 为什么要分层(好处)

      • 复用共享:多个镜像可共享同一个基础层(如都基于 ubuntu:20.04,底层只存一份)
      • 加速构建:某层没变就命中缓存,不用重新执行;只重建变化的层及之后的部分
      • 加速分发:拉取镜像时,已存在的层可以跳过,只拉缺失的层
      • 节省磁盘:同一层在宿主机上只存一份
    • 与容器可写层的关系

      • 容器启动时,在镜像层之上再叠加一层可写层;写操作通过"写时复制(copy-up)“落到可写层,读操作从上往下逐层查找
      • 删除文件并不是真的从只读层删除,而是在可写层用"白化(whiteout)“文件(overlayfs 中实为字符设备文件)遮挡
    • 分层带来的注意点

      • 层不宜过多(overlay2 驱动对层数有 128 层的硬性上限,且层多也影响性能),也不宜把易变内容放太靠前(缓存失效)
      • 层是不可变的,想"改"某一层只能重新构建
  • 协助记忆

    • 镜像分层像"千层蛋糕”:每一层是固定的一层(只读),要加新料就在最上面叠一层;同一块蛋糕底(基础层)可以给很多个蛋糕复用,换口味(改镜像)只需重做上面的几层。
  • 进阶思考

    • 删除一个大文件后,镜像体积为什么没有变小?
      • 因为该文件还存在于下层的只读层里,上层的"删除"只是加了一个 whiteout 遮挡标记,实际数据仍占用空间。要真正减小体积,需要重建镜像(在安装该文件的同一层里一并删掉),或用多阶段构建只保留最终产物。

🤔 Dockerfile 中 ENV 和 ARG 有什么区别?

  • 一句话:ENV 定义的是"环境变量”,构建期和运行期都生效,并会永久写进镜像;ARG 定义的是"构建参数",只在 docker build 构建期间生效,不会写进镜像,容器运行时看不到。

    • ENV(环境变量)

      • 在 Dockerfile 中声明,构建时可用于后续指令(RUNCMD 等),运行时也会作为容器环境变量存在
      • 会固化到镜像里(docker inspect 可见),可用 docker run -e 在运行时覆盖
      • 适合放"运行时需要的配置",如 ENV NODE_ENV=production
    • ARG(构建参数)

      • 只存在于构建阶段,供 docker build 传入:docker build --build-arg VERSION=1.0 .
      • 不会写进最终镜像,容器运行时无法访问;作用域限定在声明它的构建阶段内,跨阶段(多阶段构建)需重新声明,首个 FROM 之前的 ARG 只能用于选择基础镜像
      • 未传值且无默认值时展开为空字符串(构建不报错)
      • 适合放"只在构建时用的值",如版本号、代理地址、下载源
    • 常见组合用法

      • ARG VERSION=1.0 + ENV APP_VERSION=$VERSION:用 ARG 在构建时传参,再通过 ENV 固化到镜像
      • 这样既能灵活构建不同版本,又能在运行时读取到该值
    • 注意点

      • ARG 可作默认值,但不要用 ARG 传递敏感信息(如密码)——因为 ARG 虽不进最终镜像,但会残留在镜像的构建历史(image history)元数据里,仍可能泄露
  • 协助记忆

    • ENV 是"身份证"(写进档案、终身有效),ARG 是"施工证"(只在工地(构建期)用,出了工地就作废)。
  • 进阶思考

    • 为什么说"不要用 ARG 传密钥"?ARG 不是不写进镜像吗?
      • ARG 的值会出现在构建过程中,且会被记录在镜像的构建历史(image history)元数据里,docker history 可能看到。真正安全传递密钥应使用 BuildKit 的 --secretRUN --mount=type=secret),它在构建后不会残留。

🤔 EXPOSE 和 -p 有什么区别?

  • 一句话:EXPOSE 是 Dockerfile 里的"声明"——告诉使用者这个容器打算监听哪些端口,本身不做任何映射;-p--publish)是 docker run 时的"实际操作"——把宿主机端口真正映射到容器端口。

    • EXPOSE(声明,元数据)

      • 写在 Dockerfile 中,如 EXPOSE 80,只作为镜像的元数据文档
      • 不发布端口到宿主机,不改变网络行为
      • 作用:① 供人阅读、了解镜像监听端口;② 配合 -P(大写)批量映射所有 EXPOSE 声明的端口到宿主机随机端口;③ 仅作文档/元数据,同一网络内容器能否互访由网络连通性决定,与 EXPOSE 无关
    • -p / --publish(实际映射)

      • docker run -p 8080:80:把宿主机 8080 映射到容器 80,完整语法 [ip:][hostPort:]containerPort[/protocol]
      • 可选指定 IP:-p 127.0.0.1:8080:80(只绑定本地回环);可省略宿主机端口(-p 80 由 Docker 随机分配);可指定协议(-p 8080:80/udp
      • 真正在 iptables 里写入 DNAT 规则,实现外部访问
    • 两者的关系

      • 二者独立:有 EXPOSE 没有 -p,外部仍访问不到;有 -p 没有 EXPOSE,照样能映射
      • EXPOSE 是"说明书的端口清单",-p 是"实际开门"
  • 协助记忆

    • EXPOSE 是"海报上写的经营范围"(声明有哪些服务),-p 是"实际开张营业的窗口"(真正对外接待)。
  • 进阶思考

    • -P(大写)和 -p(小写)有什么区别?
      • -P 会把镜像中所有 EXPOSE 声明的端口,自动映射到宿主机上的随机高端口(如 32768+);-p 则是精确指定宿主机端口到容器端口的映射。-P 省事但端口不可控,-p 精确但需手动逐个指定。

🤔 docker exec 和 docker attach 有什么区别?

  • 一句话:docker attach 是"连接"到容器主进程(PID 1)的 stdin/stdout/stderr,docker exec 是在容器里"新启动"一个进程。最关键的区别是:attach 之后退出(如 Ctrl+C)通常会终止容器主进程(视主进程的信号处理而定),而 exec 退出只结束你自己启动的那个进程,不影响容器。

    • docker attach(附加到主进程)

      • 把当前终端附加到容器 PID 1 的标准输入/输出/错误,看到的是容器主进程的实时输出
      • 退出时:按 Ctrl+C 会向主进程发送信号,通常导致容器停止;Ctrl+P, Ctrl+Q 可"脱离但不退出"(需以 -t/-i TTY 方式运行/attach 才生效,可用 --detach-keys 自定义脱离键)
      • 适合查看容器主进程的实时日志输出(只能看到 attach 之后的流式输出,要看历史需用 docker logs
    • docker exec(启动新进程)

      • 在已运行的容器里启动一个新进程(如 docker exec -it <容器> bash 进入交互 shell)
      • 退出这个进程(exit)不会影响容器主进程,容器继续运行
      • 前提是容器内有对应的可执行文件(如 bash);是进入容器调试、执行命令的常用方式
    • 对比小结

      • attach:连"主进程",退出即影响容器;exec:开"新进程",退出不影响容器
      • 日常调试多用 exec;查看主进程实时输出才用 attach
  • 协助记忆

    • attach 是"坐进驾驶座"(直接接管主进程,松手车就停),exec 是"打开一扇新窗户"(在旁边另起一个操作,关窗不影响开车)。
  • 进阶思考

    • attach 后按 Ctrl+C 为什么容器就停了?
      • 因为 attach 连接的是容器主进程(PID 1)的终端,Ctrl+C 会向主进程发送 SIGINT。但注意:容器内的 PID 1 受内核特殊对待——未安装处理器的信号会被忽略、不执行默认退出,所以只有当主进程为 SIGINT 显式安装了处理器并选择退出时,容器才会停。这也是为什么实践中有时 Ctrl+C 停不掉容器。所以 attach 常用于"我要看主进程输出",而不是"我要进去改东西"。

🤔 如何限制容器资源(–memory/–cpus/–cpuset-cpus)?

  • Docker 通过 cgroup 限制容器资源,常用参数有:--memory(内存上限)、--cpus(CPU 核数配额)、--cpuset-cpus(绑定具体 CPU 核)、--cpu-shares(CPU 相对权重,软限制)。这些参数在 docker run 时指定,底层都写到对应 cgroup。

    • 内存限制

      • -m / --memory:限制容器可用内存,如 -m 512m;超过会被 OOM Kill
      • --memory-swap:限制"内存 + swap"总量;设为 -1 表示不限 swap(需同时设置 --memory 才生效);只设 -m 不设此参数时默认 swap 为内存的 2 倍;与 --memory 同值时等价于"禁用 swap"
      • 建议:限制内存的同时限制 swap,避免超限后疯狂换页拖垮宿主机
    • CPU 限制

      • --cpus:限制能使用的 CPU 核数(硬配额),如 --cpus=1.5 表示最多用 1.5 个核
      • --cpuset-cpus:把容器绑定到指定 CPU 核,如 --cpuset-cpus="0,1"(亲和性)
      • --cpu-shares:CPU 相对权重(软限制),只在 CPU 争抢时按比例分配,默认 1024;cgroup v2 下底层对应 cpu.weight(默认 100),Docker 会自动换算,但生产更推荐用 --cpus 做硬配额
    • 其他常用限制

      • --pids-limit:限制容器内最大进程数(防 fork 炸弹)
      • --memory-reservation:内存软限制(预留值)
    • 验证

      • 查看是否生效:docker inspect <容器>HostConfig 字段,或 docker stats 看实时用量
      • 底层对应 cgroup 的 memory.max / cpu.max 等文件(--cpu-shares 对应 cpu.weight--memory-reservation 对应 memory.low
  • 协助记忆

    • 给容器限资源 = 给租户装"水电表":--memory 是用水上限,--cpus 是用电额度,--cpuset-cpus 是"只能用哪几个插座",--cpu-shares 是"高峰期按合同比例分电"。
  • 进阶思考

    • --cpus=1.5--cpuset-cpus="0,1" 有什么区别?
      • --cpus=1.5 是"配额":允许容器使用相当于 1.5 个核的 CPU 时间,但不绑定具体哪几个核,由调度器在任意核上执行;--cpuset-cpus="0,1" 是"亲和性":把容器固定在 0、1 两个核上运行。前者管"用量",后者管"位置",可配合使用。

🤔 Docker 的 restart policy 有哪几种?

  • restart policy(重启策略)决定容器退出后是否以及如何自动重启,docker run --restart 指定,共四种:no(默认不重启)、on-failure[:N](非零退出码时重启,可限次数)、always(总是重启)、unless-stopped(除非手动停止,否则总是重启)。

    • 四种策略

      • no:默认,容器退出后不自动重启
      • on-failure[:max-retries]:仅在容器以非零退出码退出时重启,可加 :5 限制最多重启 5 次(超过则放弃)
      • always:无论退出码如何都重启;且 Docker daemon 启动时会把它拉起来(即使之前是手动停止的)
      • unless-stopped:类似 always,但若容器在停止前被手动停止(docker stop),则 daemon 重启后不会自动拉起它
    • alwaysunless-stopped 的关键区别

      • 场景:手动 docker stop 一个容器,再重启 docker daemon
      • always:会把该容器重新拉起;unless-stopped:不会(尊重"我手动停过它")
    • 选型建议

      • 常驻无状态服务(nginx、web):alwaysunless-stopped
      • 批处理/一次性任务:noon-failure(任务跑完正常退出不该被重启)
      • 注意:重启策略不适用于 docker run --rm(一次性容器);新版 Docker 中 --rm 与非 no 策略组合会直接报错
      • 补充:重启策略仅在容器成功启动(运行 ≥10s)后才生效,可避免秒退容器陷入无限重启
  • 协助记忆

    • no = 摔倒了就不管;on-failure = 摔伤了才扶(扶 N 次);always = 无论怎么都要扶,甚至开机就扶;unless-stopped = 除非我亲手让你躺下,否则都扶。
  • 进阶思考

    • 为什么批处理任务不适合用 always
      • 因为批处理任务"正常跑完"也会退出(退出码 0),always 会把它无限重启,造成死循环空转;on-failure 只在失败时重启,no 则完全交给外部调度,更适合一次性任务。

🤔 docker save/load 与 docker export/import 有什么区别?

  • 一句话:save/load 操作的是"镜像",export/import 操作的是"容器"。save 会完整保存镜像(含分层、历史、标签元数据),load 原样还原;export 只导出容器的"扁平文件系统",import 生成一个单层、无历史的新镜像。

    • docker save / docker load(镜像级)

      • docker save -o image.tar <镜像>:把镜像连同所有层、构建历史、tag 一起打成 tar(按 name:tag 保存才会带上 tag,按镜像 ID 保存则 load 后无 tag)
      • docker load -i image.tar:把 tar 还原成镜像,保留层结构和历史
      • 用途:镜像备份、离线迁移、跨机器分发(保留完整镜像信息)
    • docker export / docker import(容器级)

      • docker export -o container.tar <容器>:只导出容器的文件系统(扁平化,不保留分层和历史)
      • docker import container.tar <新镜像名>:把该文件系统导入成一个新镜像,只有一个层、无构建历史、无默认启动命令(可用 --change 'CMD [...]' 在导入时追加指令)
      • 用途:把容器"当前状态"固化成镜像(但会丢失分层与历史,通常不推荐用于常规镜像分发)
    • 核心对比

      • 对象:save/load 面向镜像,export/import 面向容器
      • 结果:save 保留多层与历史,export 是扁平单层、无历史
      • 元数据:load 保留 tag、启动命令等,import 生成的新镜像基本无元数据(需重新指定 CMD 等)
  • 协助记忆

    • save/load 是"连家具清单一起整体搬家"(完整镜像),export/import 是"把房间里的东西打个包、搬到新家重新摆"(只剩文件、丢了清单和历史)。
  • 进阶思考

    • 能用 export 出来的 tar 做镜像分发吗?能,但不推荐,为什么?
      • 因为 export 丢失了分层与历史,导入后是一个单层镜像:无法复用基础层、拉取/推送时无法增量传输、体积可能更大,也不利于安全审计(看不到构建历史)。镜像分发应优先用 save(离线)或 registry(在线)。

🤔 Docker 安全最佳实践有哪些?

  • Docker 安全是"纵深防御",核心原则:最小权限 + 最小镜像 + 及时更新。具体从镜像、运行时、网络、密钥、守护进程五个层面收紧——不给容器不必要的权限、不放不必要的组件、不泄露敏感信息。

    • 镜像安全

      • 用官方/可信来源的镜像,固定具体版本 tag(不用 latest
      • 定期做镜像漏洞扫描(如 docker scouttrivyclair),及时升级基础镜像
      • 最小化镜像:多阶段构建、alpine/distroless,减少攻击面
      • 不在镜像里放密钥、密码、证书
    • 运行时安全

      • 以非 root 用户运行(USER 指令、--user
      • 根文件系统设为只读:--read-only(需要写的位置挂 volume)
      • 裁剪 Capabilities:--cap-drop ALL --cap-add NET_BIND_SERVICE(只加必需的能力)
      • 避免使用 --privileged,仅在确有必要时(如 DinD、硬件直通、网络/存储插件)才使用
      • 限制资源:-m--cpus--pids-limit,防资源耗尽
      • 启用 seccomp/AppArmor 等安全策略(Docker 默认有 seccomp 配置)
      • 可选 user namespace--userns-remap)隔离容器 root 与宿主机 root
    • 网络与密钥

      • 端口映射尽量绑定到必要接口(如 127.0.0.1:8080:80),不暴露到 0.0.0.0
      • 密钥用 --secret(BuildKit,构建期)或运行时机制注入(如 Swarm 的 docker secretdocker run --secret,或安全的 env/挂载),绝不写进镜像
    • 守护进程(daemon)安全

      • 保护 /var/run/docker.sock(谁拥有对它的读写访问权限,如加入 docker 组的用户,就等价于宿主机 root)
      • 生产环境给 daemon 配 TLS、限制远程访问
  • 协助记忆

    • Docker 安全 = “把容器当半信半疑的访客”:只给最小权限(非 root、砍能力)、只装最少东西(小镜像)、贵重物品另放(密钥不进镜像)、大门守好(sock 权限)。
  • 进阶思考

    • 为什么"非 root 运行"和"–cap-drop ALL"这么重要?
      • 因为一旦容器被攻破/逃逸,攻击者拿到的权限上限就是"容器进程拥有的权限"。以 root + 全能力运行,逃逸后直接是宿主机 root;而以非 root + 最小能力运行,即使逃逸,攻击者能做的事也极其有限,这是纵深防御最基础也最有效的一层。

🤔 什么是 –privileged 模式?有什么风险?

  • --privileged 会让容器获得接近宿主机的"超级权限":它授予容器几乎所有 Linux Capabilities、绕过 seccomp/AppArmor 等安全限制、并能访问宿主机的所有设备。风险在于——这样的容器几乎等价于宿主机 root,一旦被攻破或逃逸,攻击者可直接控制宿主机。

    • –privileged 到底"特权"在哪里

      • 拥有几乎全部 Capabilities(含 SYS_ADMIN 等危险能力)
      • 可访问宿主机所有设备(/dev 下的磁盘、网卡等)
      • 绕过 seccompAppArmor 等安全配置,并将 SELinux 置为 unconfined
      • 可挂载文件系统、修改内核参数、加载内核模块(授予 CAP_SYS_MODULE 且不再拦截 init_module,但受内核 lockdown 与模块文件是否存在限制)
    • 主要风险

      • 逃逸风险最大化:特权容器与宿主机边界极其薄弱,大量容器逃逸漏洞在特权容器下可被直接利用
      • 数据风险:可读宿主机磁盘、挂载宿主机文件系统
      • 一旦被入侵,等同于宿主机被完全控制
    • 什么时候才需要它

      • 极少数场景:需要在容器内操作宿主机硬件、嵌套虚拟化/Docker-in-Docker(部分方案)、某些网络/存储插件
      • 绝大多数业务容器都不需要
    • 替代方案(最小权限)

      • --cap-add 只加必要的能力(如 NET_ADMIN
      • --device 挂载具体需要的设备,而不是全部
      • 需要 Docker-in-Docker 时,注意挂载 docker.sock 同样等价于宿主机 root、需谨慎;更安全的是 rootless dind、BuildKitkaniko 等专用方案
  • 协助记忆

    • --privileged 是"把保险柜钥匙、所有房间钥匙、监控后台都一次性交给容器"——省事但等于把家交给一个临时访客。正确做法是"只给开它需要的那一扇门的钥匙"(–cap-add / –device)。
  • 进阶思考

    • --privileged--cap-add ALL 一样吗?
      • 不完全一样。--cap-add ALL 只是加了所有 Capabilities,但 --privileged 额外还会绕过 seccomp/AppArmor、放开设备访问(cgroup 设备策略)等,权限更大、更接近宿主机 root。所以"要全部能力"也不该轻易用 --privileged

🤔 如何查看/收集容器日志?docker logs 的原理?

  • 查看单个容器日志用 docker logs;它的原理是:容器主进程的 stdout/stderrcontainerd-shim 捕获,按日志驱动(默认 json-file)写入宿主机文件;生产环境收集日志则靠日志驱动直接对接集中日志系统,或 sidecar 采集。

    • docker logs 查看日志

      • docker logs <容器>:查看容器 stdout/stderr
      • -f:持续跟随;--tail N:只看最后 N 行;--since/--until:按时间过滤
      • 注意:只有输出到 stdout/stderr 的才会被收集,写进文件(如 nginx 的 access.log)的不会出现在 docker logs
    • 日志是怎么被收集的(原理)

      • 容器主进程的 stdout/stderr 由 containerd-shim 捕获,按配置的日志驱动写入
      • 默认 json-file 驱动:写到 /var/lib/docker/containers/<id>/<id>-json.log
      • 日志驱动可换:journaldsyslogfluentdgelfawslogsloki
    • 日志轮转(防膨胀)

      • docker run --log-opt max-size=10m --log-opt max-file=3
      • /etc/docker/daemon.json 全局配置
    • 生产环境日志收集方案

      • 方案一:日志驱动直接对接(如 fluentd/gelf/awslogs),容器日志直接送到集中日志系统
      • 方案二:sidecar 采集容器(读共享日志目录,转发到 ELK/Loki)
      • 方案三:应用主动上报(结构化日志直接发)
  • 协助记忆

    • docker logs 是"翻看容器打印出来的话"(stdout/stderr);写进文件里的日志它管不着;集中收集则是"把每台机器的日志统一收进一个总账本"。
  • 进阶思考

    • 为什么容器日志文件会越来越大,甚至打爆磁盘?
      • 因为默认 json-file 驱动默认不做大小限制,容器打印多少就写多少。需要配置 max-size/max-file 做轮转,或在 daemon.json 全局设置,否则长期运行的高输出容器会无限膨胀。
  • 扩展信息

    • Loki 采集容器日志配置模板(docker 驱动直连,完整可配置项)

      • /etc/docker/daemon.json 全局配置:

         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        
            {
                "log-driver": "loki",
                "log-opts": {
                    "loki-url": "http://loki:3100/loki/api/v1/push",
                    "loki-batch-size": "400",
                    "loki-retries": "5",
                    "loki-timeout": "10s",
                    "loki-min-backoff": "1s",
                    "loki-max-backoff": "5s",
                    "loki-external-labels": "host=prod-web-01,env=production,dc=shanghai",
                    "loki-tenant-id": "",
                    "loki-tls-ca-file": "",
                    "loki-tls-cert-file": "",
                    "loki-tls-key-file": "",
                    "loki-tls-skip-verify": "false",
                    "loki-proxy-url": "",
                    "labels": "container_id,container_name,image_name,com.docker.swarm.service.id",
                    "env": "HOSTNAME,USER",
                    "env-regex": "^(APP_|SERVICE_).*",
                    "loki-pipeline-stage-file": "",
                    "max-size": "50m",
                    "max-file": "3"
                }
            }
      • 配置项详解(按重要程度分类):

        配置项必填/常用说明
        loki-url✅ 必填Loki 推送 API 地址,如 http://loki:3100/loki/api/v1/push
        max-size✅ 强烈推荐单日志文件最大大小,超过后轮转,如 50m
        max-file✅ 强烈推荐保留的日志文件数量,默认 1,建议 3~5
        loki-batch-size常用每批推送的最大日志条数,默认 400,可根据网络调整
        loki-retries常用推送失败最大重试次数,默认 5
        loki-timeout常用单次推送超时时间,默认 10s
        loki-external-labels常用固定标签,所有容器共享,用于标识环境/主机/机房等,多组用逗号分隔(kv 用等号)
        labels常用从容器元数据中抽取的标签,如 container_id,image_name,会自动附加到每行日志
        env常用从容器环境变量中抽取作为标签,如 env=HOSTNAME,USER
        loki-min-backoff一般不常用重试最小退避间隔,默认 1s
        loki-max-backoff一般不常用重试最大退避间隔,默认 5s
        loki-tenant-id一般不常用Loki 多租户模式下的租户 ID,默认空(单租户)
        loki-tls-ca-file一般不常用TLS CA 证书文件路径,Loki 开启 HTTPS 时使用
        loki-tls-cert-file一般不常用TLS 客户端证书文件路径
        loki-tls-key-file一般不常用TLS 客户端私钥文件路径
        loki-tls-skip-verify一般不常用是否跳过 TLS 证书校验,默认 false,测试环境可设 true
        loki-proxy-url一般不常用HTTP 代理地址,如 http://proxy:8080,默认空
        env-regex一般不常用正则匹配环境变量名自动作为标签,如 ^(APP_|SERVICE_).*
        loki-pipeline-stage-file⚠️ 高级自定义管道处理文件路径(YAML 格式),用于日志解析/过滤,默认空
    • 单容器运行示例(覆盖全局配置):

      1
      2
      3
      4
      5
      6
      7
      
      docker run --log-driver=loki \
      --log-opt loki-url="http://loki:3100/loki/api/v1/push" \
      --log-opt loki-external-labels="env=staging,app=myapp" \
      --log-opt labels="container_id,image_name" \
      --log-opt max-size="20m" \
      --log-opt max-file="3" \
      nginx:latest

      ⚠️ 注意: loki-external-labelslabels 是两个不同的概念:

      • loki-external-labels:静态固定标签,所有容器共享,适合标识宿主机、环境等不变信息
      • labels:从 Docker 容器元数据中动态获取,每个容器的值不同,适合区分不同容器
    • ELK(Filebeat → Elasticsearch)采集容器日志配置模板(sidecar 方式)

      • Filebeat 配置文件 filebeat.yml(容器内或宿主机部署):
         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        13
        
        filebeat.inputs:
        - type: container
        paths:
            - /var/lib/docker/containers/*/*.log
        json.keys_under_root: true
        json.add_error_key: true
        
        output.elasticsearch:
        hosts: ["elasticsearch:9200"]
        indices:
            - index: "container-logs-%{+yyyy.MM.dd}"
        setup.template.name: "container-logs"
        setup.template.pattern: "container-logs-*"
      • 说明:Filebeat 读取宿主机 /var/lib/docker/containers/ 下的 JSON 日志文件,解析后直接发往 Elasticsearch;按天建索引便于管理和清理。
      • 若容器日志写在文件里(如 /var/log/app/*.log),将 paths 改为挂载出来的宿主机目录即可。
    • ELK(Logstash 驱动直连)配置模板

      • Docker 日志驱动配置(单容器或 daemon.json):
        1
        2
        3
        4
        5
        6
        7
        
        {
        "log-driver": "gelf",
        "log-opts": {
            "gelf-address": "udp://logstash:12201",
            "tag": "{{.ImageName}}"
        }
        }
      • Logstash 接收端 gelf.conf
         1
         2
         3
         4
         5
         6
         7
         8
         9
        10
        11
        12
        
        input {
        gelf { port => 12201 }
        }
        filter {
        json { source => "message" }
        }
        output {
        elasticsearch {
            hosts => ["elasticsearch:9200"]
            index => "docker-logs-%{+YYYY.MM.dd}"
        }
        }
      • 说明:使用 GELF 驱动(Graylog Extended Log Format)将容器日志直接 UDP 发到 Logstash,Logstash 解析后写入 Elasticsearch;GELF 是 Docker 原生支持的驱动,无需 sidecar。

目录