Docker核心概念与实战:从镜像、容器到Compose部署
Docker 是什么我直接说结论Docker 是一个开源的容器化平台它能让开发者和运维人员把应用连同它依赖的环境一起打包成一个标准单元这个单元可以快速分发、移植到任何支持 Docker 的机器上运行。我最早接触 Docker 的时候其实一直被一个问题困扰为什么本地跑得好好的代码一到别人的电脑上就崩环境变量不对、依赖版本冲突、操作系统差异……各种莫名其妙的坑。后来用了 Docker这个问题基本就消失了。因为在 Docker 的世界里你打包的不只是代码还有操作系统底层依赖、运行时环境、配置项整个运行环境都被完整地“固化”下来换一台机器只是把一个镜像拉下来启动而已。如果你是刚入门的开发者或者已经在命令行里折腾过一段时间但始终没搞明白容器和虚拟机的区别这篇内容就是给你准备的。我会从 Docker 的核心原理讲起把镜像、容器、宿主机之间的关系理清楚然后直接上手常用命令最后用一个完整的 Web 应用示例把从打包到部署的流程串起来。看完之后你自己就能独立把一个小项目容器化。1. 先搞懂 Docker 的核心概念1.1 镜像、容器、仓库三个最基础的角色要理解 Docker必须先分清三个角色镜像、容器、仓库。镜像是一个只读的模板里面包含了运行某个应用所需要的全部内容。你可以把它理解成一个“安装光盘”光盘上的内容刻录好了就不会变。镜像由一层一层的只读文件系统组成每一层代表一条指令比如装了什么软件、拷贝了什么文件、设置了什么环境变量。容器是从镜像启动后的运行实例。镜像是一次性的模板而容器是可以被创建、启动、停止、删除的活体。同一个镜像可以同时启动多个容器彼此相互隔离、互不影响。如果你把镜像比作“类”容器就是“对象”。仓库则是存放镜像的地方。最常用的是 Docker Hub一个公共的官方镜像仓库。团队内部也可以搭建私有仓库存放不适合公开的镜像。这三个角色的关系一句话总结镜像定义环境容器运行应用仓库传递镜像。1.2 容器与虚拟机的本质区别很多人第一次看到 Docker 时觉得它和虚拟机差不多都能隔离环境、都能在一台机器上跑多个应用。但底层逻辑完全不同。虚拟机是通过 Hypervisor 虚拟出一整套硬件设备然后在虚拟硬件上安装一个完整的操作系统。举个例子一台物理机装了 VMware然后在上面跑了三台虚拟机每台虚拟机都有自己完整的 Windows 或 Linux 系统都需要占用几个 GB 的内存。容器则完全不一样。容器共享宿主机的操作系统内核没有自己的操作系统而是利用 Linux 内核的 Namespace 做资源隔离、用 Cgroup 做资源限制。容器里跑的进程本质上就是宿主机上的一个进程只不过被“关”在一个独立的空间里认为自己是机器上的唯一用户。这就带来两个最直观的差异容器启动只需要几秒钟虚拟机启动动辄要几十秒甚至几分钟容器镜像体积通常是几十 MB 到几百 MB虚拟机镜像动辄几个 GB。还有一个实际中很关键的差异资源密度。一台 8GB 内存的机器跑 4 台虚拟机已经比较勉强但用容器的话起几十个容器毫无压力。这也正是 DevOps 和微服务架构选择容器作为基础单元的主要原因。1.3 Docker 的架构客户端、守护进程、OCI 规范再往深处看一下 Docker 的整体架构这样你在排查问题的时候才知道是哪个环节出了问题。Docker 采用典型的 C/S 架构。客户端是 docker 命令你敲下的每一条命令都会通过 REST API 发送给宿主机上常驻的守护进程 dockerd。守护进程负责管理镜像、容器、网络、存储卷等核心资源并且真正执行容器生命周期相关操作。早期 Docker 刚流行的时候容器运行时接口是 Docker 自己定义的后来整个行业推动了 OCIOpen Container Initiative规范把镜像格式和容器运行时行为标准化了。现在主流的 Linux 服务器上dockerd 通常并不直接创建容器而是通过 containerd 这个更底层的组件去调用 runc 来真正启动容器进程。这个拆分让整个容器生态的兼容性更强了Kubernetes 可以直接对接 containerd不依赖 Docker 的守护进程。作为普通用户你不需要把这个架构熟到每个细节但你需要知道docker 命令和 dockerd 守护进程是两回事。碰到“Cannot connect to the Docker daemon”这类报错往往是守护进程没启动而不是命令敲错了。2. 镜像的秘密分层存储与 Dockerfile2.1 为什么镜像要做成一层一层镜像是 Docker 最核心的概念而你真正理解镜像之后Dockerfile 就只是一层一层的指令堆叠而已。镜像分层机制用到的技术叫 UnionFS联合文件系统。简单说就是把多个目录叠加在一起最终看到一个合并后的视图。每一层都是只读的修改某个文件时Docker 是在最上面的可写层记录这个修改而不是去改动原始的只读层。这个设计和 Git 的提交有点类似。Git 每次提交记录的是变化的差异Docker 镜像每一层也记录的是文件系统的差异。所以镜像可以复用十个不同项目的镜像都基于同一个 Ubuntu 基础镜像那这十个镜像在机器上只需保存一份 Ubuntu 基础层的实体文件极大节省了存储和带宽。举个例子假设你写了一个 DockerfileFROM ubuntu:22.04 RUN apt-get update apt-get install -y python3 COPY app.py /app/ CMD [python3, /app/app.py]构建过程中Ubuntu 基础层是第一层安装 Python 生成的这一层是第二层拷贝 app.py 生成的层是第三层。如果以后只需要把 app.py 换成另一个文件只是第三层发生变化前面两层完全复用构建速度会非常快。2.2 Dockerfile 指令的优先级与缓存机制Dockerfile 每条指令都会生成一个新的镜像层而且构建时 Docker 会用缓存来加速。缓存机制很简单如果某一行指令没变化它生成的层会直接复用上一次的构建结果。这带出一个非常重要的实践把不常变的指令放在前面把经常变的指令放在后面。以 Node.js 项目为例常见的写法有两种。先看反面例子FROM node:18 COPY . /app WORKDIR /app RUN npm install CMD [npm, start]这个写法的问题在于只要项目里任何一个文件变了哪怕只是改了 READMECOPY . /app 这一层就会失效之后 RUN npm install 也会全部重新执行。npm install 又慢又占网络带宽每次构建都被迫重新下载全部依赖。再看正面例子FROM node:18 WORKDIR /app COPY package.json package-lock.json ./ RUN npm install COPY . . CMD [npm, start]先把两个依赖清单文件拷贝进去执行 npm install再把其余文件拷贝进去。这样只要 package.json 不变npm install 那一层一直命中缓存构建速度通常能快好几倍。还要注意一点每条指令尽量合并 APT 操作并且顺手清理缓存。RUN apt-get update \ apt-get install -y curl \ rm -rf /var/lib/apt/lists/*把 apt-get update 和安装操作放在同一条 RUN 里是因为如果不这样做基础镜像回收之后 update 的缓存层没有实际意义反而可能让镜像里残留过期的软件包索引。最后的 rm 能显著减小镜像体积。2.3 为什么容器里的进程是 PID 1这是容器世界里一个非常反直觉的细节。在 Linux 系统里PID 1 是 init 进程负责回收孤儿进程、处理系统信号。在容器里你写在 CMD 或者 ENTRYPOINT 里的进程也会成为容器内的 PID 1但它没有 init 的能力。这就产生一个经典的踩坑案例你启动一个容器里面跑着 Java 服务然后执行 docker stop结果容器停不掉等超时之后被强制杀掉。原因在于Java 服务不是 PID 1docker stop 发送的 SIGTERM 信号被 PID 1 接收但 PID 1 并没有转发给 Java 进程。Java 进程没有收到任何退出信号自然不退出。解决办法是把启动脚本放在 ENTRYPOINT 中直接以 PID 1 的身份运行 Java 进程例如ENTRYPOINT [java, -jar, /app/app.jar]如果启动命令比较复杂也可以用 exec 形式的 shell 脚本但脚本内部要使用 exec 来替换当前进程。#!/bin/bash exec java -Xmx512m -jar /app/app.jar这样 Java 进程会直接替代 shell 成为 PID 1SIGTERM 就能正确传递给应用实现优雅停服。这个细节在做服务发布、滚动更新的时候特别重要处理不好就会出现明明在移除旧容器流量却还一直在打到旧实例上的问题。3. 容器数据与网络理解 volumn 和端口映射3.1 容器是“一次性”的数据要放在卷里容器运行时产生的文件写在容器的可写层但这个可写层随着容器删除会整个消失。所以一旦容器被删除数据库数据、日志文件、上传附件等全部都会丢失。解决办法是使用 Docker Volume也就是数据卷。数据卷是宿主机上的目录在启动容器时通过 -v 参数挂载进去。docker run -d \ --name mysql-container \ -e MYSQL_ROOT_PASSWORDroot123 \ -v mysql_data:/var/lib/mysql \ mysql:8.0这里的 mysql_data 是 Docker 管理的卷它会持久化存储在宿主机的一个特定目录下。即使你删掉容器卷里的数据还在。下一次启动时挂载同一卷数据就回来了。还有一个容易混淆的参数是 bind mount指定把宿主机上的某个绝对路径直接挂进容器。docker run -d \ -v /host/www:/usr/share/nginx/html \ -p 8080:80 \ nginx:alpine这种写法适合开发场景因为你改了宿主机上的代码容器里立刻就能看到不用重新构建镜像。但生产环境我建议优先用 Docker Volume因为 bind mount 会直接暴露宿主机目录结构权限管理和迁移都更麻烦。3.2 端口映射背后的规则默认情况下容器内部就像一个独立的封闭网络宿主机访问不到外部更访问不到。想让外界访问容器里的服务需要做端口映射。docker run -d --name web -p 8080:80 nginx-p 8080:80 表示把宿主机的 8080 端口映射到容器内的 80 端口。外部请求到达宿主机 8080 端口后会被转发到容器的 80 端口。这个映射关系是单向的容器主动访问外部是可以的但外部如果没有映射就进不来。容器之间的互访通常不是靠端口映射而是通过 Docker 内部的网络桥接。如果只是临时调试不想指定具体端口可以用 -P 随机分配宿主机端口然后通过 docker port 查看映射结果docker port web输出会显示类似 0.0.0.0:32768 - 80/tcp 的信息代表容器 80 端口被映射到了宿主机 32768 端口。随机端口适合本地验证正式环境还是老老实实写明确端口。3.3 Docker 网络模式bridge、host、noneDocker 虚拟机四类网络模式实际用到的其实就两三种。bridge 模式是默认模式。所有容器都接在一个虚拟网桥上容器之间可以通过容器名互相访问前提是它们都在同一个用户自定义网络中。自定义网络比默认网络更推荐因为自定义网络自带内置 DNS容器之间直接 ping 容器名就能通。host 模式直接让容器共享宿主机的网络栈端口不用映射启动快性能也没损耗。缺点是端口容易冲突隔离性弱。none 模式就是完全禁用网络只用于某些对安全性要求极高的离网任务。在 Docker Compose 里你不必手动创建网络Compose 会自动为整个项目创建一个自定义网络服务之间用服务名互相访问即可。4. Docker Compose多容器编排的实战利器4.1 为什么单条 docker run 不够用一个稍微完整一点的应用往往不止一个容器。前端一个容器、后端一个容器、数据库一个容器、Redis 一个容器如果每条命令都手敲 docker run至少几十条参数。出了问题要清理环境的时候更是头大。Docker Compose 就是用来解决这个问题的。它通过一个 docker-compose.yml 文件把多个容器的配置集中在一起然后通过一条 docker compose up 命令全部启动。Compose 文件的思路非常直观services 下面定义每一个服务每个服务里写它的镜像、端口、环境变量、数据卷、依赖关系。4.2 一个真实示例前后端加数据库来看一个完整的示例这是一个简化的全栈项目后端是 Node.js数据库是 Postgres前端是 Nginx。version: 3.9 services: db: image: postgres:15-alpine container_name: app-db environment: POSTGRES_USER: appuser POSTGRES_PASSWORD: apppass POSTGRES_DB: appdb volumes: - db_data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U appuser -d appdb] interval: 5s timeout: 3s retries: 5 backend: build: context: ./backend dockerfile: Dockerfile environment: DB_HOST: db DB_PORT: 5432 DB_USER: appuser DB_PASSWORD: apppass DB_NAME: appdb depends_on: db: condition: service_healthy frontend: build: context: ./frontend dockerfile: Dockerfile ports: - 8080:80 depends_on: - backend volumes: db_data:几个细节值得展开。第一backend 服务连接数据库时数据库地址写的是 db。在 Compose 项目内部服务名就是 DNS 名不需要知道数据库容器的 IP也不需要暴露数据库端口到宿主机。第二这里用了 healthcheck。以前大家习惯写 depends_on: - db 单纯按启动顺序来但 MySQL、Postgres 这类数据库刚启动时可能还没有初始化完成后端立刻连接就会报错。用健康检查加 condition: service_healthy就能真正等数据库“准备好”再启动后端。第三前端服务把自身 80 端口映射到宿主机 8080。整条链路是外部浏览器访问宿主机 8080 端口 → 进入前端 Nginx 容器 → Nginx 反向代理到 backend:3000 → 后端查询 db:5432。启动命令只需要docker compose up -d停止并清理全部资源docker compose down但如果想保留数据卷就不要加 -v直接 docker compose down 只会删除容器和网络卷里的数据还在。4.3 Dockerfile 与 Compose 的分工初学者容易把 Dockerfile 和 Compose 的关系搞混。打个比方Dockerfile 是告诉 Docker“这个应用的环境是什么样的”Compose 是告诉 Docker“整个系统里有哪些服务、它们之间怎么协作”。换句话说Dockerfile 面向单个镜像Compose 面向一组服务。一个服务可以在 Compose 里直接引用现成镜像也可以通过 build 指令指定本地 Dockerfile 现场构建。实际项目里通常每个服务都有自己的 DockerfileCompose 统一编排。5. 常用命令速查与排查技巧5.1 必会命令清单Docker 的命令非常多但日常高频使用的其实就二十条左右。我整理一份速查表按使用场景分类。镜像相关docker pull nginx:alpine docker images docker rmi nginx:alpine docker build -t my-app:1.0 .容器基础操作docker run -d --name web -p 8080:80 nginx docker ps docker ps -a docker start web docker stop web docker restart web docker rm web进入容器和日志docker logs -f web docker exec -it web /bin/bash docker cp web:/etc/nginx/nginx.conf ./nginx.conf清理资源docker system prune -adocker system prune -a 这命令要特别提醒一句它会删除所有没有被容器使用的镜像、所有停止的容器、所有未被使用的网络和构建缓存。在开发机上偶尔清一次没问题生产环境使用之前务必确认没有误删正在使用的镜像。5.2 镜像体积排查与构建优化镜像太大是很多团队的痛。我总结几个最常见的体积刺客。第一个是基础镜像选型。Node 官方镜像有 node:18、node:18-slim、node:18-alpine 等变体。alpine 版本基于精简的 Alpine Linux体积只有几十 MB相比完整版动辄 1GB 以上差距巨大。Java、Python 也一样优先选带 alpine 或 slim 标识的版本。第二个是构建产物残留。比如前端项目构建完成后镜像里存了 node_modules、dist 之外还塞了源码、测试文件、日志这些都是无用重量。解决方案是使用多阶段构建。看一个多阶段构建的例子这是前端项目的典型 Dockerfile# 阶段一构建 FROM node:18-alpine AS builder WORKDIR /app COPY package.json ./ RUN npm install COPY . . RUN npm run build # 阶段二运行 FROM nginx:alpine COPY --frombuilder /app/dist /usr/share/nginx/html EXPOSE 80阶段一的构建环境里需要完整的 Node 工具链但真正发布的运行镜像只需要 Nginx 和构建生成的静态文件。多阶段构建的好处就是最终镜像里只有运行所需的最小内容不携带任何中间产物。第三层是清理包管理器缓存前面提到过的 apt-get 和 npm 缓存清理同样适用。另外还可以考虑用更小的基础镜像比如 distroless 系列但那些镜像里没有 shell连 docker exec 调试都做不到适合非常成熟的业务系统。5.3 常见问题排查实录我挑了几个实际发生过的高频问题结合排查过程说一说。容器启动后立刻退出刚执行 docker run 就退出了docker ps 看不到必须用 docker ps -a 才能看到状态为 Exited。查看日志是最直接的定位手段docker logs 容器名或ID常见原因包括启动命令没有前台运行、依赖的端口被占用、环境变量配置错误。比如 Nginx 的镜像默认会以前台模式启动但如果你自己写的镜像里 CMD 写成了后台启动一个脚本进程 fork 完成后容器里就没有前台进程了容器自然立即退出。记住一个原则容器主进程必须是前台进程。如果你在镜像里写 CMD 启动 MySQL不要加 daemonize 参数直接以前台模式运行否则容器活不过几秒。宿主机端口访问不通端口映射写对了docker ps 也看到端口映射关系了但浏览器就是打不开。排查步骤从内到外先在宿主机上 curl 一下容器映射的端口看看通不通然后 docker exec 进容器里 curl 一下 127.0.0.1 的应用自身端口如果容器内通、宿主机不通检查 -p 参数是不是只绑定了某些 IP。还有种情况把 -p 8080:80 写反了变成 -p 80:8080。这在 nginx 镜像里特别迷惑因为 nginx 容器内监听的是 80映射到宿主机 8080。反过来的写法意味着把宿主机 80 映射到容器 8080但容器内没有进程监听 8080自然全不通。日志文件撑爆磁盘容器不断输出日志默认情况下日志文件会一直增加最终把宿主机磁盘写满。解决方式是在启动容器时配置日志轮转docker run --log-opt max-size10m --log-opt max-file3 nginx更好的方式是通过 Docker daemon 配置文件全局生效在 /etc/docker/daemon.json 里加入{ log-driver: json-file, log-opts: { max-size: 10m, max-file: 3 } }然后重启 Docker 守护进程。注意这台机器上的所有容器都会采用这个日志配置避免日志无限增长。5.4 在非 Linux 平台上的注意事项如果你用的是 Windows 或 macOSDocker 不是原生运行的。桌面版 Docker Desktop 会启动一个轻量级 Linux 虚拟机然后在虚拟机内部跑 Docker。因为实际容器内核环境是 Linux所以镜像里的 Linux 命令、Linux 路径都正常工作。但有两个事情容易踩坑。第一个是文件挂载性能。在 Windows 上把 Windows 目录 bind mount 进容器读写性能通常会有明显损耗因为跨文件系统转发有开销。如果项目里大量读文件、编译文件性能问题会特别明显。解决办法是尽量把数据放在 Docker 管理的 Volume 里避免在 Windows 目录上做高密度读写。第二个是行尾符号。特别是 Windows 环境配置的 shell 脚本挂载进容器时如果文件是 CRLF 换行容器里的 shell 会报错。解决方式是保证 shell 脚本使用 LF 换行或者把脚本直接 COPY 进镜像而不是从宿主机挂载。6. 从零到一完整容器化实战6.1 需求与项目结构最后用一个完整的例子把前面所有内容串起来。假设我们要容器化一个 Python Flask 应用它提供一个简易的 TODO 接口数据存放在 MySQL 里。整个项目分两部分应用本身的 Dockerfile 和统一编排的 Compose 文件。项目结构如下todo-app/ ├── app.py ├── requirements.txt ├── Dockerfile └── docker-compose.ymlapp.py 非常精简from flask import Flask, jsonify, request import pymysql app Flask(__name__) def get_conn(): return pymysql.connect( hostdb, port3306, usertodo, passwordtodopass, databasetodo_db, charsetutf8mb4 ) app.route(/todos, methods[GET]) def list_todos(): conn get_conn() cursor conn.cursor() cursor.execute(SELECT id, content FROM todos) rows cursor.fetchall() return jsonify([{id: r[0], content: r[1]} for r in rows]) app.route(/todos, methods[POST]) def add_todo(): data request.get_json() conn get_conn() cursor conn.cursor() cursor.execute(INSERT INTO todos(content) VALUES (%s), (data[content],)) conn.commit() return jsonify({status: ok}) if __name__ __main__: app.run(host0.0.0.0, port5000)requirements.txtflask3.0.0 pymysql1.1.0注意代码里把数据库地址写成了 db这就是利用了 Compose 网络里服务名即域名这一特性本地开发和生产部署完全不用改代码。不过严格来说实际项目中不会把数据库连接信息硬编码在代码里而是通过环境变量读取。这里为了缩短示例篇幅才直接写死。6.2 Dockerfile 编写与构建应用是 Python 写的我们采用多阶段构建来保证最终镜像足够干净。FROM python:3.12-slim AS base FROM base AS builder WORKDIR /app COPY requirements.txt ./ RUN pip install --prefix/wheels -r requirements.txt FROM base AS runtime WORKDIR /app COPY --frombuilder /wheels /wheels ENV PYTHONPATH/wheels COPY app.py . EXPOSE 5000 CMD [python, app.py]这里有个技巧用 --prefix/wheels 把依赖安装到独立目录运行阶段不需要完整保留 pip 的缓存和中间文件。如果项目更简单也可以直接 pip install 进系统环境最终镜像体积会大一些但代码更直观。构建命令docker build -t todo-app:1.0 .构建完成后可以验证docker run --rm -p 5000:5000 -e DB_HOSTdb todo-app:1.0不过此时数据库还没启动直接运行会连接失败。没关系真正的完整编排交给 Compose。6.3 Compose 编排与数据库初始化docker-compose.yml 内容如下version: 3.9 services: db: image: mysql:8.0 container_name: todo-db environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: todo_db MYSQL_USER: todo MYSQL_PASSWORD: todopass volumes: - todo_db_data:/var/lib/mysql ports: - 3306:3306 app: build: . image: todo-app:1.0 depends_on: - db environment: DB_HOST: db ports: - 5000:5000 volumes: todo_db_data:这里数据库暴露了 3306 端口实际上对外的应用只需要 app 的 5000 端口。数据库端口暴露出来是方便本地调试时用客户端工具连接生产环境通常不建议把数据库端口直接暴露到宿主机。启动整个应用docker compose up -d检查状态docker compose ps然后测试curl http://localhost:5000/todos第一次查询会报错因为表还没创建。最简单的初始化方式是在数据库容器里执行 SQLdocker exec -it todo-db mysql -utodo -ptodopass todo_db -e CREATE TABLE IF NOT EXISTS todos (id INT AUTO_INCREMENT PRIMARY KEY, content VARCHAR(255) NOT NULL)再实验证curl -X POST http://localhost:5000/todos \ -H Content-Type: application/json \ -d {content: 学习 Docker}随后再 GET 一次就能看到刚写入的数据。到这里一个完整的前后端分离部署模型就跑通了。之后无论这个项目换到哪台机器上只要这条 Compose 文件在一条命令就能把服务全部拉起来。这比在一台新服务器上手动安装 Python、MySQL、复制代码、配环境变量要快无数倍而且不会再出“我本机明明是好的”这种问题。7. 生产环境使用 Docker 的几个必要提醒7.1 不要用 root 跑应用容器镜像里默认可能以 root 身份运行。但把生产容器跑成 root 有明确的安全风险如果应用被攻破攻击者直接拿到容器内 root 权限通过内核漏洞还可能影响宿主机。哪怕只是日志文件写错权限都会带来很多麻烦。正确做法是在 Dockerfile 中创建非 root 用户FROM node:18-alpine WORKDIR /app RUN addgroup -S appgroup adduser -S appuser -G appgroup COPY --chownappuser:appgroup . . USER appuser EXPOSE 3000 CMD [node, server.js]这样容器内进程以受限身份运行即使出问题也能减少波及面。7.2 镜像来源可信构建镜像时确保基础镜像来自可信来源。公共镜像仓库里存在大量被发现的恶意镜像它们会用相似的名字出现在搜索结果的比较靠前位置。拉取镜像时核对镜像的完整名称和标签不要盲目执行网上复制来的 docker pull 命令。金融、政务类项目还要考虑对基础镜像做安全扫描。使用镜像时定期更新依赖和基础镜像容器里的软件也不是一劳永逸的。安排一个周期性的更新节奏避免长期停留在存在已知漏洞的版本上。7.3 不要在生产环境使用 docker stop 硬杀不带任何参数执行 docker stop会先给容器主进程发 SIGTERM等待默认 10 秒如果没退出再发 SIGKILL。对现代应用来说优雅停服很重要——比如进程正在处理请求写到一半直接杀掉可能导致数据不一致。所以依赖信号处理的特性之前讲过的 PID 1 问题在这一刻显得格外重要。如果你在容器里用 shell 脚本启动了 Java 或者 Node 应用请在脚本内使用 exec 让应用进程顶替 shell 成为 PID 1从而保证 SIGTERM 能被应用正确处理。对于 signal 处理写得不够完善的历史应用可以试试 --stop-timeout 参数给它一定宽限期但更根本的方案是把信号处理的逻辑在代码层补齐。7.4 关于 daemon.json 的全局配置有些配置项建议在 Docker daemon 层面统一设置不要每开一个容器都手动加参数。daemon.json 常见配置项包括{ data-root: /data/docker, storage-driver: overlay2, log-driver: json-file, log-opts: { max-size: 10m, max-file: 3 } }>