# 运维常见题-Docker容器


## 🤔 简述 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`（更早还有 `aufs`、`devicemapper` 等）；`Docker 29.0` 起镜像存储后端默认转向 `containerd` snapshotters（底层仍是 `overlayfs`）

    - **`Client/Server` 架构与 `OCI` 运行时**
        - `Docker` 采用 `C/S` 架构：用户通过 `docker` 命令行（`Client`）与 `dockerd`（`Daemon`）通信，`dockerd` 负责镜像、容器、网络、存储的统一管理
        - 运行时调用链：`dockerd` → `containerd`（容器运行时管理器）→ `containerd-shim`（进程托管）→ `runc`（`OCI` 运行时，真正调用内核的 `Namespace`/`Cgroup` 创建容器）
        - `OCI`（`Open Container Initiative`）：定义了容器镜像（`image-spec`）与运行时（`runtime-spec`）的开放标准，使 `runc`、`crun` 等底层运行时实现，以及 `containerd`、`Podman`、`Docker` 等上层引擎/管理器能围绕这套标准互通

- **协助记忆**
    - `Namespace` 管"隔"：给每个容器一间独立房间，房号（进程）、网线、门牌（主机名）各归各
    - `Cgroup` 管"限"：给每间房单独装水电表，用多少有上限
    - `UnionFS` 管"分层复用"：公共基础层大家共享，写东西只改自己那一层

- **进阶思考**
    - **容器本质上是宿主机的一个进程，为什么普通进程做不到这种隔离？**
        - 因为普通进程默认运行在宿主机全局的命名空间与资源账本里。Docker 通过 `clone()` 系统调用传入不同的命名空间标志（如 `CLONE_NEWPID`、`CLONE_NEWNET`），并把进程注册到对应的 `Cgroup` 中，才让这个进程拥有了"独立世界"和"资源配额"。所以容器不是新技术，而是内核已有能力被封装后的产物。

    - **`containerd`、`containerd-shim`、`runc` 三者的分工是什么？**
        - `containerd` 是上层容器生命周期管理器，负责镜像拉取/存储、容器元数据、生命周期调度；`runc` 是最底层的 `OCI` 运行时，只根据 `OCI` 配置调用内核创建并启动一个容器进程，创建完就退出；`containerd-shim` 负责持续托管容器进程——维持其 `stdin/stdout`、回报退出状态，并让容器生命周期与 `daemon` 解耦，`dockerd`/`containerd` 重启升级时容器不受影响。

    - **既然容器共享宿主机内核，那宿主机内核升级重启时容器会怎样？**
        - 容器依赖宿主机内核，无法拥有独立内核（这也是它比虚拟机轻量、但隔离性弱于虚拟机的原因）。宿主机内核升级需要重启时，所有容器都会随宿主机一起重启。因此需要"跨内核版本迁移"或"强多租户隔离"的场景，仍应回到虚拟机方案。

- **扩展信息**
    - **运行时的演进**：`Docker` 早期基于 `LXC`（`Linux Containers`）实现，`Docker 0.9`（2014 年）起用自研的 `libcontainer` 替换 `LXC`，之后又把 `libcontainer` 剥离为独立的 `runc` 并捐赠给 `OCI`（2015 年），成为容器运行时的事实标准。这也是为什么 `containerd`、`Podman`、`Kata Containers` 等不同生态都能围绕 `OCI` 标准互通。

## 🤔 容器与虚拟机有什么区别？  
- **一句话：虚拟机是在硬件层面虚拟出整台"计算机"（含独立内核与完整操作系统），而容器只是宿主机内核上一个被隔离的"进程"（共享宿主机内核）。这是二者一切差异的根源——虚拟机隔离更强、更重、启动慢；容器更轻、更快、但隔离性弱于虚拟机。**  
    - **架构差异（根本区别）**
        - 虚拟机：通过 `Hypervisor`（如 `KVM`、`ESXi`）在物理硬件之上虚拟出完整的虚拟硬件（`vCPU`、内存、磁盘、网卡），每个虚拟机都要安装独立的 `Guest OS`（客户操作系统）与内核
        - 容器：直接运行在宿主机内核之上，靠 `Namespace` 做隔离、`Cgroup` 做限制，容器内没有独立内核，本质是宿主机上一个"被隔离的进程"

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

    - **关系与选型**
        - 二者不是"替代"而是"互补"：要强隔离、多系统共存、跨内核版本用虚拟机；要轻量、快速、高密度、快速迭代用容器。生产中常见"裸机/虚拟机 + 容器"的组合

- **协助记忆**
    - 虚拟机 = 每家每户在小区里再各盖一栋独立小楼（地基、水电、墙体各自独立）
    - 容器 = 同一栋楼里用隔断隔出的房间（共享整栋楼的地基与水电总表，靠隔断与门牌区分）

- **进阶思考**
    - **为什么容器比虚拟机启动快、密度高？**
        - 因为容器不需要引导内核和整套操作系统，本质就是一次进程启动（`clone` 创建命名空间 + 挂载文件系统即可运行），而虚拟机要从 `BIOS/UEFI` 引导、加载内核、初始化系统服务，链路长得多；密度高则因为容器共享内核与底层镜像层，单个容器额外的内存/磁盘开销极小。
    - **既然容器共享内核隔离弱，那"容器不安全"的说法对吗？**
        - 不准确。容器安全是"纵深防御"问题：现代运行时配合 `seccomp`（限制系统调用）、`Capabilities`（裁剪特权）、只读根文件系统、`rootless` 与 `user 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`（长期默认）、`aufs`、`devicemapper`、`vfs`、`btrfs`、`zfs` 等，`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` 为什么能取代 `aufs` 和 `devicemapper`？**
        - 因为它基于内核主线的 `overlayfs`，无需额外内核补丁（`aufs` 要打补丁），性能和 `inode` 消耗优于 `devicemapper`，且实现简单可靠，因此自 `Docker 18.09` 起成为默认并被长期推荐。

## 🤔 Docker 有哪些核心组件及其作用？  
- **Docker 采用 `Client/Server` 架构，核心组件可拆成一条清晰的调用链：`docker`（客户端）→ `dockerd`（守护进程）→ `containerd`（容器运行时管理器）→ `containerd-shim`（进程托管）→ `runc`（OCI 运行时），再加上负责镜像构建的 `BuildKit` 与负责镜像存储分发的 `Registry`（镜像仓库）。**  
    - **`docker`（客户端 CLI）**
        - 用户与 Docker 交互的命令行入口，把 `docker run`、`docker build` 等指令通过 `REST API` 发送给 `dockerd`
        - 客户端可以连本地也可以连远程 daemon（如设置 `DOCKER_HOST` 指向远程）

    - **`dockerd`（守护进程 / Daemon）**
        - Docker 的核心服务进程，负责镜像、容器、网络、存储、构建、插件等几乎所有上层管理，并对外暴露 `REST API`
        - 收到指令后，把容器生命周期管理等底层工作委托给 `containerd`

    - **`containerd`（容器运行时管理器）**
        - 独立于 `dockerd` 的守护进程，通过 `gRPC` 与 `dockerd` 通信，负责容器元数据与生命周期调度（`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`/`registry`；`docker pull`/`push` 的对象就是它

- **协助记忆**
    - 把这条链想象成"点外卖"：`docker` 是你（下单），`dockerd` 是外卖平台（接单、调度），`containerd` 是餐厅后厨（备餐），`containerd-shim` 是骑手（盯着配送、反馈进度），`runc` 是最后开火炒菜的那一下（真正把菜做出来）。

- **进阶思考**
    - **为什么要把 containerd、runc 从 dockerd 里拆出来？**
        - 为了解耦与标准化。拆出后，`containerd`/`runc` 成为独立、可复用的底层运行时，`Kubernetes` 等编排系统也能直接对接 `containerd`（通过 `CRI`），而不必依赖 `dockerd`；同时各组件可独立升级、独立演进。

## 🤔 Docker 网络模式有哪些？  
- **Docker 的网络可以从两个层面理解：一是容器挂载的"网络模式"（`--network` 指定，主要有 `bridge`、`host`、`none`、`container` 四种），二是创建网络时用的"网络驱动"（`bridge`、`host`、`overlay`、`macvlan`、`ipvlan`、`none` 等）。日常面试里说的"网络模式"通常指前者。**  
    - **容器网络模式（`--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`：给容器分配独立 `MAC` 与 `IP`，直接暴露到物理网络；`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` 模式下，宿主机是怎么访问到容器端口的？**
        - 通过 `iptables` 的 `DNAT` 规则：`-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` 网桥走到宿主机，再由 `iptables` 的 `SNAT`/`MASQUERADE` 把源地址改写成宿主机地址后出去，回包再由连接跟踪（`conntrack`）还原
    - **宿主机/外部怎么访问容器？**
        - 靠端口映射 `-p 8080:80`，宿主机 `8080` 端口的流量经 `DNAT` 转发到容器 `IP:80`

## 🤔 为什么容器内执行 free 命令看到的是宿主机内存总量？  
- **因为 `free` 读取的是 `/proc/meminfo`，而 `/proc` 里的内存信息默认反映的是宿主机内核的全局内存状态，并不随容器的 `cgroup` 内存限制而"缩小"。容器共享宿主机内核，容器内挂载的是同一内核的 `procfs`，而 `meminfo` 是内核全局统计、不受命名空间隔离，所以容器里看到的 `MemTotal` 就是宿主机物理内存总量。**  
    - **`free` 的数据来源**
        - `free` 命令本质上是解析 `/proc/meminfo` 这个伪文件
        - `/proc/meminfo` 里的 `MemTotal`、`MemAvailable` 等字段由内核统计宿主机物理内存而来

    - **为什么容器看不到"被限制后"的内存**
        - 容器的内存限制（`-m` / `--memory`）是通过 `cgroup` 实现的，`cgroup` 限制的是"容器进程最多能用多少内存"，它不会改写 `/proc/meminfo` 的数值
        - 容器内挂载的是同一个内核的 `procfs`，`meminfo` 是内核全局统计、不受命名空间隔离，因此容器读到的还是全局数据

    - **那怎么看容器真实的限制与用量**
        - `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/meminfo` 的 `MemTotal` 来自动设置堆大小，容器里拿到的是宿主机内存，可能设出远大于 `-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 的 `UseContainerSupport`、`MaxRAMPercentage`），或配合编排系统（`Kubernetes` 的 `downward API`）注入资源限制。

## 🤔 容器网络是怎么实现的？  
- **容器网络的实现，本质上是 Linux 内核网络能力的一次组合：用 `Network Namespace` 给容器一个独立网络栈，用 `veth pair`（虚拟网线）把容器接到宿主机的 `docker0` 网桥（Linux Bridge）上，再用 `iptables` 的 `NAT` 规则完成出网与端口映射。**  
    - **`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`：挂载到内存中，不落磁盘，容器停止即消失，适合临时文件、敏感数据

    - **`volume` 与 `bind 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 持久化配置参考**
        ```yaml
        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 用户有什么权限差异？  
- **默认情况下，容器内的 `root`（`uid 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 个（如 `CHOWN`、`DAC_OVERRIDE`、`NET_BIND_SERVICE`、`SETUID`、`SETGID`、`NET_RAW`、`KILL` 等）
        - 像 `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 插件）。

- **进阶思考**
    - **`overlay` 的 `VXLAN` 隧道是怎么把不同主机容器"连"起来的？**
        - 容器发出的以太网帧被封装进 `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:1010` | entrypoint 中 `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 改 UID | FPM 多进程架构需要 `www-data` 这个用户名及对应的 UID 一致 |
            | **Nginx + PHP-FPM 双容器共享文件** | entrypoint 改 UID | 两个容器都需要以同一个 `www-data`（相同 UID）运行，才能同时读写共享目录 |
            | **镜像内硬编码了用户名的检测逻辑**（如 Laravel 某些命令 `whoami`） | entrypoint 改 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/passwd` 中 `www-data` 也是 1010 → 一切正常，只是冗余。
                - **真正的坑**：如果 entrypoint 改 UID 到 1010，但 `--user` 指定了 33，进程以 33 运行，而 `/etc/passwd` 里 33 现在变成 `old_www-data`（或不存在）→ 权限混乱，难以排查。
            - **最佳实践**：二选一。如果用 entrypoint 改 UID，就不加 `--user`；如果用 `--user`，entrypoint 里就跳过用户修改逻辑（可加 `SKIP_USER_MODIFY=true` 开关）。

        - **生产环境推荐策略**
            ```yaml
            # 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`）协调，谁挂了、怎么重启、信号怎么转发都变得复杂

    - **按应用粒度扩缩容**
        - 一个应用一个容器，扩容就是"多起几个容器"，缩容就是"少几个"，与编排系统（`Swarm`、`Kubernetes`）的副本模型天然匹配
        - 若一个容器塞多个应用，无法对其中某个应用单独扩缩容

    - **故障隔离与定位**
        - 一个容器只影响一个应用，故障域小，日志、监控、资源占用都清晰归属于单个应用
        - 某个应用崩溃不会拖垮同容器内的其他应用

    - **镜像复用与发布**
        - 单应用镜像职责单一、体积可控、易于复用和分层；更新某个应用只需重新构建对应镜像，不影响其他
        - 符合"不可变基础设施"：应用更新 = 换新镜像 + 重建容器，而不是进容器改东西

    - **并非绝对**
        - 确有紧密耦合的辅助进程（如日志采集）需要与主进程共享命名空间，应以独立 `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 为保留值，勿使用）

    - **在编排系统中的作用**
        - `Swarm`：`unhealthy` 的容器会依服务的 restart policy 重调度
        - `Kubernetes`：对应 `liveness`（存活，失败则重启）与 `readiness`（就绪，失败则摘除流量）探针，配合滚动更新实现"先就绪再接入流量"

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

- **协助记忆**
    - 进程状态是"人还坐在工位上"（running），健康检查是"确认他真在干活"（healthy）——有人坐着发呆甚至睡着了，就得靠健康检查发现并换人。

- **进阶思考**
    - **`liveness` 和 `readiness` 有什么区别？**
        - `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/` 会把压缩包自动解压到目标目录（支持 `gzip`、`bzip2`、`xz`，含未压缩的 `.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 运行、版本固定"这几条展开。**  
    - **选对基础镜像**
        - 优先官方镜像，尽量选体积小、攻击面小的基础镜像（如 `alpine`、`distroless`、`slim` 变体；注意 alpine 用 musl libc，部分依赖 glibc 的二进制/原生扩展可能不兼容）
        - 用具体版本标签（如 `node:20-alpine`），不要用 `latest`（不可复现）

    - **利用构建缓存与分层**
        - 把"变化少的"放前面、"变化多的"放后面：先 `COPY package.json` 装依赖，再 `COPY . .` 复制源码，这样改代码不会让依赖层失效
        - 用 `.dockerignore` 排除不需要的文件（`.git`、`node_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 指令（如 `RUN`、`COPY`、`ADD`）都会生成一个新的只读层，层层叠加组成镜像。分层的核心价值是"复用 + 缓存"——共享基础层、加速构建和拉取、节省磁盘。**  
    - **分层是怎么产生的**
        - 每条产生文件系统变更的指令对应一层：`FROM` 是基础层，`RUN apt-get install`、`COPY . .` 等各自生成一层
        - 所有层都是只读的，通过 `UnionFS`（如 `overlay2`）叠加成一个统一的文件系统视图

    - **为什么要分层（好处）**
        - 复用共享：多个镜像可共享同一个基础层（如都基于 `ubuntu:20.04`，底层只存一份）
        - 加速构建：某层没变就命中缓存，不用重新执行；只重建变化的层及之后的部分
        - 加速分发：拉取镜像时，已存在的层可以跳过，只拉缺失的层
        - 节省磁盘：同一层在宿主机上只存一份

    - **与容器可写层的关系**
        - 容器启动时，在镜像层之上再叠加一层可写层；写操作通过"写时复制（`copy-up`）"落到可写层，读操作从上往下逐层查找
        - 删除文件并不是真的从只读层删除，而是在可写层用"白化（whiteout）"文件（overlayfs 中实为字符设备文件）遮挡

    - **分层带来的注意点**
        - 层不宜过多（`overlay2` 驱动对层数有 128 层的硬性上限，且层多也影响性能），也不宜把易变内容放太靠前（缓存失效）
        - 层是不可变的，想"改"某一层只能重新构建

- **协助记忆**
    - 镜像分层像"千层蛋糕"：每一层是固定的一层（只读），要加新料就在最上面叠一层；同一块蛋糕底（基础层）可以给很多个蛋糕复用，换口味（改镜像）只需重做上面的几层。

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

## 🤔 Dockerfile 中 ENV 和 ARG 有什么区别？  
- **一句话：`ENV` 定义的是"环境变量"，构建期和运行期都生效，并会永久写进镜像；`ARG` 定义的是"构建参数"，只在 `docker build` 构建期间生效，不会写进镜像，容器运行时看不到。**  
    - **`ENV`（环境变量）**
        - 在 Dockerfile 中声明，构建时可用于后续指令（`RUN`、`CMD` 等），运行时也会作为容器环境变量存在
        - 会固化到镜像里（`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 的 `--secret`（`RUN --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 重启后不会自动拉起它

    - **`always` 与 `unless-stopped` 的关键区别**
        - 场景：手动 `docker stop` 一个容器，再重启 docker daemon
        - `always`：会把该容器重新拉起；`unless-stopped`：不会（尊重"我手动停过它"）

    - **选型建议**
        - 常驻无状态服务（nginx、web）：`always` 或 `unless-stopped`
        - 批处理/一次性任务：`no` 或 `on-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 scout`、`trivy`、`clair`），及时升级基础镜像
        - 最小化镜像：多阶段构建、`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 secret`、`docker 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` 下的磁盘、网卡等）
        - 绕过 `seccomp`、`AppArmor` 等安全配置，并将 `SELinux` 置为 unconfined
        - 可挂载文件系统、修改内核参数、加载内核模块（授予 `CAP_SYS_MODULE` 且不再拦截 `init_module`，但受内核 lockdown 与模块文件是否存在限制）

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

    - **什么时候才需要它**
        - 极少数场景：需要在容器内操作宿主机硬件、嵌套虚拟化/`Docker-in-Docker`（部分方案）、某些网络/存储插件
        - 绝大多数业务容器都不需要

    - **替代方案（最小权限）**
        - `--cap-add` 只加必要的能力（如 `NET_ADMIN`）
        - `--device` 挂载具体需要的设备，而不是全部
        - 需要 Docker-in-Docker 时，注意挂载 `docker.sock` 同样等价于宿主机 root、需谨慎；更安全的是 rootless dind、`BuildKit`、`kaniko` 等专用方案

- **协助记忆**
    - `--privileged` 是"把保险柜钥匙、所有房间钥匙、监控后台都一次性交给容器"——省事但等于把家交给一个临时访客。正确做法是"只给开它需要的那一扇门的钥匙"（--cap-add / --device）。

- **进阶思考**
    - **`--privileged` 和 `--cap-add ALL` 一样吗？**
        - 不完全一样。`--cap-add ALL` 只是加了所有 Capabilities，但 `--privileged` 额外还会绕过 `seccomp`/`AppArmor`、放开设备访问（cgroup 设备策略）等，权限更大、更接近宿主机 root。所以"要全部能力"也不该轻易用 `--privileged`。

## 🤔 如何查看/收集容器日志？docker logs 的原理？  
- **查看单个容器日志用 `docker logs`；它的原理是：容器主进程的 `stdout`/`stderr` 被 `containerd-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`
        - 日志驱动可换：`journald`、`syslog`、`fluentd`、`gelf`、`awslogs`、`loki` 等

    - **日志轮转（防膨胀）**
        - `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` 全局配置：  
            ```json
                {
                    "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 格式），用于日志解析/过滤，默认空 |

    - **单容器运行示例（覆盖全局配置）：**
        ```bash
        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-labels` 和 `labels` 是两个不同的概念：
        - `loki-external-labels`：静态固定标签，所有容器共享，适合标识宿主机、环境等不变信息
        - `labels`：从 Docker 容器元数据中动态获取，每个容器的值不同，适合区分不同容器

    - **ELK（Filebeat → Elasticsearch）采集容器日志配置模板（sidecar 方式）**
        - Filebeat 配置文件 `filebeat.yml`（容器内或宿主机部署）：
            ```yaml
            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）：
            ```json
            {
            "log-driver": "gelf",
            "log-opts": {
                "gelf-address": "udp://logstash:12201",
                "tag": "{{.ImageName}}"
            }
            }
            ```
        - Logstash 接收端 `gelf.conf`：
            ```conf
            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。

---

> 作者: [0x5c0f](https://blog.0x5c0f.cc)  
> URL: https://blog.0x5c0f.cc/posts/other/%E8%BF%90%E7%BB%B4%E5%B8%B8%E8%A7%81%E9%A2%98-docker/  

