知序
返回
保存草稿
发布
H1
H2
H3
1
2
3
Excalidraw 图
AI 写作
AI 优化
目录
输入内容后显示目录
Markdown
# 02 - 容器运行时:入口、网络、数据与优雅退出 > 镜像造好了,`docker run` 一敲就完?真实情况是:容器起不来、日志看不到、Ctrl+C 停不掉、数据一删就没——这四类问题几乎全部出自这一篇。 > 这里讲的 ENTRYPOINT、PID 1、信号、卷、网络,正是后面 K8s 里探针、优雅下线、ConfigMap 挂载要先懂的东西。 --- ## 1. ENTRYPOINT 与 CMD 一个镜像"启动之后执行什么",由 `ENTRYPOINT` 和 `CMD` 两个指令共同决定。它们不是"随便写哪个都行",而是有明确的分工。 | 组合 | 最终执行的命令 | 语义 | |---|---|---| | 只有 `CMD ["java","-jar","app.jar"]` | `java -jar app.jar` | CMD 既是入口又是默认参数;`docker run` 后面的参数会**整体替换**它 | | 只有 `ENTRYPOINT ["java","-jar","app.jar"]` | `java -jar app.jar` | 入口固定不可替换;`docker run` 后面的参数会被**追加**到后面 | | 两者都有:`ENTRYPOINT ["java","-jar"]` + `CMD ["app.jar"]` | `java -jar app.jar` | ENTRYPOINT 固定入口,CMD 提供默认参数,run 参数**替换 CMD** | | 都没有 | 继承基础镜像的 ENTRYPOINT / CMD | 例如 `FROM openjdk:21` 后什么都不写,行为取决于基础镜像 | 参数拼接的完整规则,看这张图就够了:  *图:docker run 的参数替换 CMD 而非追加:ENTRYPOINT 放程序、CMD 放默认参数* ```text 镜像里定义: ENTRYPOINT ["java", "-jar", "app.jar"] CMD ["--server.port=8080"] docker run myapp --server.port=9090 --spring.profiles.active=prod │ │ run 后面的参数 → 替换 CMD(是替换,不是追加!) v ┌─────────────────────────────────────────────────────────────────────┐ │ 最终执行: │ │ java -jar app.jar --server.port=9090 --spring.profiles.active=prod │ └─────────────────────────────────────────────────────────────────────┘ 对比:镜像里只有 CMD,没有 ENTRYPOINT CMD ["java","-jar","app.jar"] docker run myapp --server.port=9090 └─ 整条命令被替换成 "--server.port=9090" → 找不到这个可执行文件,容器秒退 ``` **Java 应用的推荐写法**:入口必须固定(否则用户一个参数就把 `java` 顶掉了),默认参数用 CMD 兜底。 ```dockerfile ENTRYPOINT ["java", "-jar", "/app/app.jar"] CMD ["--spring.profiles.active=prod"] ``` > 一句话:**`ENTRYPOINT` 是"这条路不能改",`CMD` 是"默认走这条,但允许被覆盖"。** --- ## 2. shell 形式 vs exec 形式 这是本篇的第一个重点,也是"Ctrl+C 停不掉"的根因。同样一句话,写成两种形式,运行时的进程结构完全不同。 ```dockerfile # shell 形式:实际会被包成 /bin/sh -c "..." ENTRYPOINT java -jar app.jar # exec 形式:直接以 app.jar 为 PID 1 启动,不经过 shell ENTRYPOINT ["java", "-jar", "app.jar"] ``` 进程树对比:  *图:shell 形式与 exec 形式的进程树对比:只有 exec 形式让 java 成为 PID 1,SIGTERM 才到得了应用* ```text shell 形式:ENTRYPOINT java -jar app.jar (等价于 /bin/sh -c "java -jar app.jar") 宿主机 └─ docker run ... └─ 容器内 PID 1:/bin/sh -c "java -jar app.jar" └─ PID 7:java -jar app.jar ← 真正干活的进程是"儿子" 信号(SIGTERM)只能发给 PID 1 └─ sh 收到 SIGTERM,但 sh 默认不转发给子进程 → java 收不到 → 不会优雅关闭 → 只能等被强杀 ``` ```text exec 形式:ENTRYPOINT ["java","-jar","app.jar"] 宿主机 └─ docker run ... └─ 容器内 PID 1:java -jar app.jar ← 就是它本人,没有中间商 信号(SIGTERM)直达 java └─ Spring Boot 收到 → 走优雅关闭 → 处理完在途请求 → 干净退出 ``` 一个真实现象:`docker stop` 每次都刚好卡满 10 秒。 ```bash $ time docker stop demo demo real 0m10.031s # 每次都卡在 10 秒 = SIGTERM 被 sh 吞掉,应用收不到, # 只能等超时后被 SIGKILL 硬杀 ``` > 一句话:**写 Dockerfile 时,`ENTRYPOINT` / `CMD` 一律用 exec 形式(JSON 数组),让应用自己当 PID 1。** --- ## 3. PID 1 与信号:优雅退出 Linux 里 PID 1 是特殊进程:它会接管孤儿进程,且默认会忽略没有安装处理器的信号。容器里的 PID 1 就是镜像的 ENTRYPOINT,所以"谁当 PID 1"直接决定了信号能不能到达应用。 `docker stop` 的完整链路:  *图:docker stop 的信号链路:先发 SIGTERM 给 PID 1,超时后 SIGKILL 强杀,退出码 137* ```text docker stop <容器> (默认超时 10 秒,可用 -t 改) │ │ ① 给容器内 PID 1 发送 SIGTERM v ┌────────────────────── 10 秒倒计时(默认)──────────────────────┐ │ 应用处理了 SIGTERM(有优雅关闭逻辑) │ │ → 停止接收新请求 → 处理完在途请求 → 关闭连接池 → exit(0) │ │ → 容器提前停止,通常 1~2 秒就结束 │ │ │ │ 应用没处理(shell 吞了信号 / 没写关闭钩子) │ │ → 干等,一直耗到 10 秒走完 │ └────────────────────────────────────────────────────────────────┘ │ │ ② 超时后发送 SIGKILL —— 无法捕获,在途请求直接被切断 v 容器 Exited (137) # 128 + 9(SIGKILL) = 137 ``` 退出码速记: | 退出码 | 含义 | 场景 | |---|---|---| | `0` | 正常退出 | 程序自己结束 | | `143` | 被 SIGTERM 终止 | `128 + 15`,优雅关闭被信号打断 | | `137` | 被 SIGKILL 强杀 | `128 + 9`,多半是 `docker stop` 等满 10 秒后强杀,或 OOM | | `1` | 应用报错 | 启动失败、配置错误 | Spring Boot 侧的配置(2.3 之后内置了优雅关闭): ```properties # application.properties server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=20s management.endpoint.shutdown.enabled=true management.endpoints.web.exposure.include=health,info,shutdown ``` ```yaml # 等价 YAML 写法 server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 20s ``` 两个配套手段: ```dockerfile # 1) 指定停止信号(默认就是 SIGTERM;nginx 官方镜像默认是 SIGQUIT,别乱改) STOPSIGNAL SIGTERM ``` ```bash # 2) 你的应用不擅长当 PID 1 时,用 --init 塞一个 tini 进去 # 它负责:转发信号给真正的应用进程 + 回收僵尸进程 docker run --init myapp:1.0.0 ``` 什么时候需要 `--init`:你的 ENTRYPOINT 是个脚本(`start.sh`),脚本又 `exec` 出真正进程,或者会 fork 出一堆子进程(Java 里 `Runtime.exec`、跑 nginx + cron 这种组合)。这时 tini 当 PID 1,信号和僵尸进程都由它处理好。 > 这就是 K8s 里 `terminationGracePeriodSeconds` 的同一条链路:`docker stop -t 10` ≈ Pod 删除时给的宽限期,超时一样是 SIGKILL。前面那门 K8s 课里"优雅下线"要配的东西,根就在这里。 --- ## 4. 数据:容器的可写层是会消失的 容器里的文件系统分两层:下面只读的镜像层,上面一层可写的容器层。**可写层随容器一起被删除**,这是新手丢数据的头号原因。  *图:不挂卷的容器:数据写在可写层里,容器一删就一起消失;挂了卷才留得住* ```text 容器文件系统(默认,不挂任何卷) ┌─────────────────────────────────────────────┐ │ 可写层(容器的"桌面") │ ← docker rm 之后,没了 │ /var/lib/mysql 里写入的数据都在这 │ ├─────────────────────────────────────────────┤ │ 只读镜像层(mysql:8.0 的内容) │ ← 镜像还在,但你的数据不在 └─────────────────────────────────────────────┘ ``` 三种把数据"搬出去"的方式:  *图:volume / bind mount / tmpfs 三种挂载对比:不挂出来的数据会跟容器一起消失* ```text ① volume(数据卷)—— 推荐默认选择 ┌──────────┐ ┌───────────────────────┐ │ 容器 │ │ Docker 管理的存储区 │ │ /data │───────>│ /var/lib/docker/volumes│ └──────────┘ │ /mysql-data/_data │ └───────────────────────┘ docker run -v mysql-data:/data ... ② bind mount(绑定挂载)—— 宿主机上一个你指定的路径 ┌──────────┐ ┌───────────────────────┐ │ 容器 │ │ 宿主机 /opt/app/config │ │ /config │───────>│ (你完全控制这个目录) │ └──────────┘ └───────────────────────┘ docker run -v /opt/app/config:/config ... ③ tmpfs —— 只存在内存里,宿主机磁盘不落 ┌──────────┐ ┌───────────────────────┐ │ 容器 │───────>│ 内存(RAM) │ │ /tmp │ │ 容器停止即消失 │ └──────────┘ └───────────────────────┘ docker run --tmpfs /tmp ... ``` | 方式 | 位置 | 生命周期 | 性能 | 典型用途 | |---|---|---|---|---| | volume | Docker 管理(`/var/lib/docker/volumes`) | 独立于容器,删容器不影响 | 好 | 数据库数据、需要持久化的中间状态 | | bind mount | 宿主机任意路径 | 跟着宿主机路径走 | 好(依赖宿主机盘) | 配置文件挂载、开发时挂代码目录 | | tmpfs | 内存 | 容器停止即消失 | 最快 | 敏感临时数据、不需要落盘的缓存 | MySQL 数据目录的实操: ```bash # 建卷 docker volume create mysql-data # 用卷承载数据目录 docker run -d --name mysql \ -e MYSQL_ROOT_PASSWORD=secret \ -e MYSQL_DATABASE=blog \ -v mysql-data:/var/lib/mysql \ -p 3306:3306 mysql:8.0 # ...(写入若干数据)... # 删掉容器 docker rm -f mysql # 数据还在:卷独立于容器生命周期 docker volume ls docker volume inspect mysql-data # 用同一个卷起新容器,之前的库表原样恢复 docker run -d --name mysql2 -v mysql-data:/var/lib/mysql mysql:8.0 ``` 常用卷命令: ```bash docker volume ls # 列出所有卷 docker volume inspect mysql-data # 看卷在宿主机上的真实路径、挂载点 docker volume prune # 删除所有"没有任何容器在用"的卷(危险,别乱敲) docker volume rm mysql-data # 删指定卷 ``` > 一句话:**只要数据重要,就必须挂出去;`docker rm` 从来不负责替你保留可写层里的东西。** --- ## 5. 网络:容器怎么互相找到对方 四种网络模式,先看选择: | 模式 | 怎么用 | 隔离性 | 典型场景 | |---|---|---|---| | `bridge`(默认) | `docker run`(不加 --network) | 容器有独立 IP,与宿主机隔离 | 绝大多数场景 | | `host` | `--network host` | 容器共享宿主机的网络栈,没有独立 IP | 极致的网络性能、要监听宿主机端口 | | `none` | `--network none` | 只有 loopback,没有网卡 | 完全隔离的批处理任务 | | `container` | `--network container:<另一个容器>` | 复用另一个容器的网络命名空间 | 给已运行容器加 sidecar(少见) | **重点来了:默认 bridge 网络不能用容器名互相解析。** 必须自己建一个自定义 bridge 网络才自带 DNS。  *图:默认 bridge 与自定义 bridge 的差别:后者才有内置 DNS,可用容器名互访* ```text 默认 bridge 网络(docker0) ┌────────────────┐ ┌────────────────┐ │ 容器 app │ │ 容器 mysql │ │ 172.17.0.3 │ │ 172.17.0.2 │ └───────┬────────┘ └───────┬────────┘ │ 只能靠 IP 互访 │ │ ping mysql → 解析失败(没有内置 DNS) └──────────────────────┘ 自定义 bridge 网络(docker network create app-net) ┌────────────────┐ ┌────────────────┐ │ 容器 app │ │ 容器 mysql │ │ 172.20.0.3 │ │ 172.20.0.2 │ └───────┬────────┘ └───────┬────────┘ │ 内置 DNS:容器名可直接解析成 IP │ jdbc:mysql://mysql:3306/blog → 通 └──────────────────────┘ ``` 完整实操: ```bash # 1. 建一个自定义网络 docker network create app-net # 2. 数据库容器接入该网络,并指定容器名 mysql docker run -d --name mysql --network app-net \ -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=blog \ -v mysql-data:/var/lib/mysql mysql:8.0 # 3. 应用容器接入同一个网络 docker run -d --name order-service --network app-net -p 8080:8080 \ -e SPRING_DATASOURCE_URL="jdbc:mysql://mysql:3306/blog?useSSL=false" \ order-service:1.0.0 ``` 注意第 3 步里的 JDBC 地址:主机名直接写容器名 `mysql`,不是 `localhost`、也不是 IP。因为在这个网络里,`mysql` 会被内置 DNS 解析成对应容器的 IP。 网络管理命令: ```bash docker network ls # 列出所有网络 docker network inspect app-net # 看这个网络里有哪些容器、各自 IP docker network connect app-net <容器> # 让一个已在运行的容器加入网络(不用重建) docker network disconnect app-net <容器> ``` > 一句话:**要容器名互访(`jdbc:mysql://mysql:3306/...`),就必须先 `docker network create`,默认 bridge 做不到。** --- ## 6. 日常排障四件套 线上出问题,先记住这四条命令,覆盖八成场景: | 命令 | 用途 | 关键点 | |---|---|---| | `docker logs -f --tail 100 <容器>` | 看标准输出/标准错误 | 只抓 stdout/stderr;应用写进文件里的日志看不到 | | `docker exec -it <容器> sh` | 进容器排查 | 镜像里没有 `sh`(distroless、scratch)就进不去,换 `bash` 或改用调试镜像 | | `docker inspect <容器>` | 看全部元数据 | 重点看 State、ExitCode、Health、Mounts 四个字段 | | `docker stats` | 看实时资源占用 | 内存、CPU、网络、磁盘 IO 一屏全有 | `docker inspect` 里最该看的四个字段: ```text State.Status running / exited / restarting State.ExitCode 正常 0;被 SIGKILL 杀 137;被 SIGTERM 杀 143 State.Health healthy / unhealthy(镜像配了 HEALTHCHECK 才有) Mounts 卷和绑定挂载实际落在宿主机的哪个路径 ``` `docker stats` 的期望输出: ```text $ docker stats --no-stream CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS 3f2a1c9d4e10 order-service 12.34% 486MiB / 1GiB 47.46% 1.2MB / 3.4MB 0B / 12MB 42 ``` 两个补充命令: ```bash docker ps -a # 列出所有容器,包括 Exited 的;重点看 STATUS 列的退出码 docker events --filter container=order-service # 实时事件流:start / stop / die / health_status: unhealthy ``` > 排障顺序:`docker ps -a` 看状态 → `docker logs` 看报错 → `docker inspect` 看 ExitCode 和 Mounts → `docker exec` 进去手动复现。 --- ## 7. 踩过的坑 ```text 坑 1:docker stop 每次都要等满 10 秒 现象 → 敲完 docker stop 卡住 10 秒,最后容器以 137 退出 原因 → ENTRYPOINT 写成 shell 形式,PID 1 是 /bin/sh,SIGTERM 被 sh 吞掉, 应用收不到信号,只能等超时被 SIGKILL 对策 → 改用 exec 形式 ENTRYPOINT ["java","-jar","app.jar"];确认应用有优雅关闭钩子 坑 2:容器启动就 Exited,日志一行都没有 现象 → docker ps -a 显示 Exited (1),docker logs 是空的 原因 → shell 形式的命令拼错 / 路径不对;或 run 后面的参数把 CMD 整个替换掉了 对策 → docker run --entrypoint sh <镜像> 手动进去把命令敲一遍验证;改用 exec 形式 坑 3:JDBC 里写 localhost:3306 连不上数据库 现象 → 应用容器报 Communications link failure,连 127.0.0.1:3306 被拒 原因 → 容器有自己的网络命名空间,localhost 指的是容器自己,不是别的容器 对策 → 两个容器接入同一个自定义网络,JDBC 用容器名 mysql 当主机名 坑 4:docker rm 之后数据库数据没了 现象 → 删掉容器重建,库表全空 原因 → 数据写在容器可写层,随容器一起被删除 对策 → 数据目录用卷挂出去:-v mysql-data:/var/lib/mysql 坑 5:-p 8080:8080 之后宿主机还是连不上 现象 → 容器内 curl localhost:8080 通,宿主机 curl 不通 原因 → 应用只监听了 127.0.0.1(容器内),没监听 0.0.0.0;或端口映射写反 对策 → 应用监听 0.0.0.0;-p 是"宿主机:容器",别写反 坑 6:docker logs 看不到日志 现象 → 容器跑着,日志却是空的 原因 → 应用把日志写进了容器内的文件,而不是 stdout/stderr 对策 → 容器里的应用日志一律打到标准输出(Spring Boot 默认就是) ``` --- ## 8. 本篇小结 ```text docker run │ ├─ ① 入口:ENTRYPOINT(固定)+ CMD(默认参数,run 参数替换它) │ └─ 一律用 exec 形式,保证应用就是 PID 1 │ ├─ ② 进程:PID 1 收信号 │ └─ docker stop → SIGTERM → 优雅退出 →(10 秒后)SIGKILL → 137 │ ├─ ③ 数据:可写层随容器消失 │ └─ volume / bind mount / tmpfs 三选一,重要数据必须挂出去 │ ├─ ④ 网络:默认 bridge 无 DNS,自定义 bridge 才认容器名 │ └─ jdbc:mysql://mysql:3306/blog │ └─ ⑤ 排障:logs / exec / inspect / stats 运行时把这五件事搞懂,K8s 里的探针、优雅下线、ConfigMap 挂载就能直接对上号。 ``` 下一篇:[03 - 镜像仓库:标签规范、推拉与私有仓库]
预览
发布文章
添加摘要让读者更快了解文章内容
取消
确认发布
AI 写作助手
输入一个主题,AI 将为你生成一篇 Markdown 博客文章
AI 正在写作中...
取消
开始生成
AI 优化助手
描述你的修改需求,AI 将在原文基础上进行补充或微调
AI 正在优化中...
取消
开始优化
画布
从已有图里选
✕
图名
保存后生成 /images/excalidraw/<图名>.svg(只能用字母、数字、短横线、下划线、点)
取消
保存并插入