diff --git a/notes/Docker.md b/notes/Docker.md
index c67da0a8..df7c1ecb 100644
--- a/notes/Docker.md
+++ b/notes/Docker.md
@@ -5,9 +5,10 @@
* [二、与虚拟机的比较](#二与虚拟机的比较)
* [三、优势](#三优势)
* [四、使用场景](#四使用场景)
- * [五、镜像与容器](#五镜像与容器)
- * [六、实现原理](#六实现原理)
+ * [五、实现原理](#五实现原理)
+ * [六、镜像与容器](#六镜像与容器)
* [七、Kubernetes](#七kubernetes)
+ * [附录:实测](#附录实测)
* [参考资料](#参考资料)
@@ -70,17 +71,7 @@ Docker 具有轻量级以及隔离性的特点,在将代码集成到一个 Doc
Docker 轻量级的特点使得它很适合用于部署、维护、组合微服务。
-## 五、镜像与容器
-
-镜像是一种静态的结构,可以看成面向对象里面的类,而容器是镜像的一个实例。
-
-镜像包含着容器运行时所需要的代码以及其它组件,它是一种分层结构,每一层都是只读的(read-only layers)。构建镜像时,会一层一层构建,前一层是后一层的基础。镜像的这种分层存储结构很适合镜像的复用以及定制。
-
-构建容器时,通过在镜像的基础上添加一个可写层(writable layer),用来保存着容器运行过程中的修改。
-
-
-
-## 六、实现原理
+## 五、实现原理
容器就是宿主机上的普通进程,Docker 用 Linux 内核的两个机制把它和其它进程隔开:
@@ -101,11 +92,178 @@ $ echo $$
因为所有容器共享宿主机的内核,所以容器比虚拟机轻量,但隔离性也更弱:内核漏洞可能让容器中的进程影响宿主机。
+## 六、镜像与容器
+
+镜像是一种静态的结构,可以看成面向对象里面的类,而容器是镜像的一个实例。
+
+镜像包含着容器运行时所需要的代码以及其它组件,它是一种分层结构,每一层都是只读的(read-only layers)。构建镜像时,会一层一层构建,前一层是后一层的基础。镜像的这种分层存储结构很适合镜像的复用以及定制。
+
+构建容器时,通过在镜像的基础上添加一个可写层(writable layer),用来保存着容器运行过程中的修改。
+
+
+
+分层由联合文件系统实现,Docker 默认使用 OverlayFS:镜像的各层是只读的下层目录(lowerdir),容器的可写层是上层目录(upperdir),两者合并后挂载为容器的根目录。
+
+容器第一次修改镜像中的某个文件时,先把整个文件复制到上层再修改,这就是写时复制;删除文件时在上层放一个同名的白障(whiteout)文件把它遮住。镜像本身始终不变,所以同一个镜像可以同时启动多个容器。
+
+例如在订单服务的容器中修改 /etc/alpine-release、删除 /etc/motd 后,docker diff 显示 C /etc/alpine-release 和 D /etc/motd,上层目录中多了 alpine-release 的完整副本和一个名为 motd 的白障文件(Docker 28.4 实测,见[附录 A3](#a3-overlayfs-的可写层))。
+
+可写层随容器一起删除,需要长期保存的数据(例如 MySQL 的数据文件)要放在数据卷中,见下文。
+
+### 用 Dockerfile 构建镜像
+
+Dockerfile 是描述怎样构建镜像的文本文件,每条指令在上一步的结果上执行,修改文件系统的指令(RUN、COPY、ADD)各产生一层。
+
+下面把一个 Spring Boot 3 订单服务打包成镜像,它提供 GET /orders/{id} 接口,返回订单的状态:
+
+```dockerfile
+# 第一阶段:用 Maven 和 JDK 编译打包
+FROM maven:3.9-eclipse-temurin-21 AS build
+WORKDIR /src
+COPY pom.xml .
+RUN mvn -q dependency:go-offline
+COPY src ./src
+RUN mvn -q package -DskipTests
+
+# 第二阶段:只把 jar 复制到 JRE 镜像中
+FROM eclipse-temurin:21-jre-alpine
+WORKDIR /app
+COPY --from=build /src/target/order-service-1.0.jar app.jar
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "app.jar"]
+```
+
+其中用到的指令:
+
+- FROM:指定基础镜像,AS build 给这个构建阶段命名;
+- WORKDIR:设置后续指令的工作目录,目录不存在时自动创建;
+- COPY:把构建上下文(docker build 指定的目录)中的文件复制到镜像中,--from=build 表示从前一个阶段复制;
+- RUN:构建镜像时执行命令,执行结果保存为新的一层;
+- EXPOSE:说明容器监听的端口,只起文档作用,运行时仍要用 -p 发布端口;
+- ENTRYPOINT:容器启动时执行的命令。
+
+在 Dockerfile 所在目录执行 docker build -t order-service:1.0 .(最后的点表示把当前目录作为构建上下文),就得到了名为 order-service、标签为 1.0 的镜像。
+
+#### 1. 构建缓存
+
+构建时 Docker 逐条检查指令:指令本身和它用到的文件(COPY 按文件内容计算校验和)都与上次相同,就直接复用上次构建的层。某一层失效后,它之后的所有层都要重新构建。
+
+所以变化少的步骤要放在前面。上面的 Dockerfile 先只复制 pom.xml 并下载依赖,再复制源码,只改源码时下载依赖的那一层仍然命中缓存。
+
+首次构建用了 214.6 秒,其中下载依赖 209.6 秒;改一行源码后重新构建只用 6.4 秒。如果一开始就复制全部文件再执行 mvn package,每次改源码都要重新下载依赖,用了 107.5 秒(Docker 28.4 实测,见[附录 A1](#a1-构建缓存与多阶段构建))。
+
+
+
+构建上下文中不需要的文件(例如 target、.git 目录)写进 .dockerignore,既减少发送给 Docker 的数据,也避免它们的变化让缓存失效。
+
+#### 2. 多阶段构建
+
+编译需要 Maven 和 JDK,运行只需要 JRE 和 jar 包。多阶段构建(multi-stage build)在一个 Dockerfile 中写多个 FROM,每个 FROM 开始一个阶段,最终的镜像只包含最后一个阶段的内容,前面阶段中的 Maven、源码和下载的依赖都不会进入最终镜像。
+
+上面的订单服务镜像解压后约 236 MB,主要是 JRE 162 MB 和 jar 包 24 MB;在 Maven 镜像中一个阶段完成构建和运行,镜像约 680 MB,多出了 JDK、Maven 和构建时下载的依赖(见[附录 A1](#a1-构建缓存与多阶段构建))。镜像越小拉取越快,包含的工具越少,可被利用的漏洞也越少。
+
+减小镜像的其它做法:
+
+- 选择更小的基础镜像,例如 -alpine、-slim 版本;
+- 把下载、安装和清理写在同一条 RUN 中,因为在后面的层中删除文件只是把它遮住,前面的层并没有变小。
+
+#### 3. 常用指令的区别
+
+- COPY 与 ADD:COPY 只复制本地文件;ADD 还能下载远程 URL 和 Git 仓库、自动解压本地的 tar 包。只是复制文件时用 COPY,行为明确;
+- RUN 与 CMD、ENTRYPOINT:RUN 在构建镜像时执行,结果写入镜像;CMD 和 ENTRYPOINT 只是记录容器启动时要执行的命令;
+- CMD 与 ENTRYPOINT:docker run 镜像名之后的参数会替换 CMD,而追加到 ENTRYPOINT 之后。例如 docker run order-service:1.0 --server.port=9090 启动后,容器中的 1 号进程是 java -jar app.jar --server.port=9090。通常 ENTRYPOINT 写固定的命令,CMD 写可以被覆盖的默认参数。
+
+ENTRYPOINT 和 CMD 应写成 JSON 数组的 exec 形式。写成 shell 形式(例如 ENTRYPOINT java -jar app.jar)时,命令由 /bin/sh -c 启动,Shell 成为 1 号进程时不会把 docker stop 发出的 SIGTERM 转发给 Java,Docker 等待 10 秒后改用 SIGKILL 强制结束,应用来不及优雅停止。
+
+例如 ENTRYPOINT java -jar app.jar; echo "order-service exited" 的容器停止用了 10.3 秒,退出码 137;exec 形式只用 0.7 秒,退出码 143,日志中有 Spring Boot 的 Graceful shutdown complete(见[附录 A2](#a2-exec-形式与-shell-形式))。需要注意的是,Alpine 的 sh 在命令只有一条时会直接用它替换自己,这时 Java 仍然是 1 号进程。
+
+### 运行容器:端口、数据卷与网络
+
+```sh
+docker run -d --name order -p 8080:8080 order-service:1.0
+```
+
+-d 表示在后台运行,--name 给容器命名,-p 宿主机端口:容器端口 把宿主机 8080 端口收到的连接转发到容器的 8080 端口。
+
+每个容器有自己的网络命名空间,两个容器都监听 8080 不会冲突,冲突的是宿主机端口。再用 -p 8080:8080 启动第二个容器时报错 Bind for 0.0.0.0:8080 failed: port is already allocated,改用 -p 8081:8080 即可(见[附录 A4](#a4-端口数据卷网络与-compose))。同样,宿主机上已有 MySQL 占用 3306 时,容器中的 MySQL 可以映射为 -p 3307:3306;端口被哪个进程占用可以用 ss -lntp 或 lsof -i :3306 查看。
+
+#### 1. 数据卷
+
+数据卷(volume)是由 Docker 管理、独立于容器生命周期的目录,用 -v 卷名:容器内路径 挂载。例如把 MySQL 的数据目录放在数据卷 mysql-data 中:
+
+```sh
+docker run -d --name mysql -p 3316:3306 -v mysql-data:/var/lib/mysql \
+ -e MYSQL_ROOT_PASSWORD=Root2026 mysql:8.0
+```
+
+在其中建表并插入订单 1024,执行 docker rm -f mysql 删除容器,再用同样的命令创建新容器,订单 1024 仍然在(见[附录 A4](#a4-端口数据卷网络与-compose))。
+
+-v 后面写宿主机的绝对路径时是绑定挂载(bind mount),例如 -v /data/mysql:/var/lib/mysql,宿主机上可以直接查看和备份这些文件,配置文件也常用这种方式挂载进容器。数据卷不依赖宿主机的目录结构,是官方推荐的持久化方式。
+
+#### 2. 网络
+
+Docker 默认使用 bridge 网络:每个容器得到一个私有 IP,通过宿主机上的虚拟网桥 docker0 互相通信,访问外部时做 NAT,-p 则是在宿主机上添加了一条转发规则。其它常见的网络模式:
+
+- host:容器直接使用宿主机的网络命名空间,不需要端口映射,但没有网络隔离;
+- none:容器只有回环网卡;
+- overlay:把多台宿主机上的容器连成一个网络,用于 Docker Swarm 等跨主机的场景。
+
+容器之间要按名字访问。默认 bridge 网络中的容器只能用 IP 互访,而 IP 在容器重建后会变;用 docker network create shop 创建的网络带有内置的 DNS,加入该网络的容器可以用容器名访问,例如 mysql 解析为 172.19.0.2,在默认网络中则解析失败(见[附录 A4](#a4-端口数据卷网络与-compose))。
+
+需要注意的是,容器中的 localhost 指容器自己,访问宿主机上的服务要用宿主机的 IP(Docker Desktop 中可以用 host.docker.internal)。
+
+#### 3. Docker Compose
+
+多个容器一起部署时,用 Docker Compose 把上面的参数写进一个 YAML 文件,docker compose up -d 一次启动全部容器。Compose 自动创建一个网络,服务名就是容器的主机名:
+
+```yaml
+services:
+ mysql:
+ image: mysql:8.0
+ environment:
+ MYSQL_ROOT_PASSWORD: Root2026
+ volumes:
+ - mysql-data:/var/lib/mysql
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-pRoot2026"]
+ interval: 3s
+ retries: 20
+ order:
+ image: order-service:1.0
+ ports:
+ - "8080:8080"
+ depends_on:
+ mysql:
+ condition: service_healthy
+volumes:
+ mysql-data:
+```
+
+depends_on 默认只保证依赖的容器先启动,不等它能提供服务;加上 condition: service_healthy 后,等 MySQL 的健康检查通过才启动订单服务,实测订单服务比 MySQL 晚约 9.6 秒启动(见[附录 A4](#a4-端口数据卷网络与-compose))。Compose 只管理单台机器上的容器,跨机器的调度、自愈和扩缩容由 Kubernetes 负责。
+
+### 容器的常见问题排查
+
+常用命令:
+
+- docker ps -a:列出包括已退出容器在内的所有容器,状态一栏带有退出码,例如 Exited (1);
+- docker logs -f --tail 100 order:查看容器的标准输出和标准错误,所以容器中的应用应把日志打印到标准输出;
+- docker inspect order:查看容器的完整配置和状态,例如退出码、是否因内存不足被杀(State.OOMKilled)、挂载的目录和 IP;
+- docker exec -it order sh:在运行中的容器里执行命令,例如查看配置文件、测试网络是否连通;
+- docker stats:实时查看每个容器的 CPU、内存、网络和磁盘 I/O,例如订单服务空闲时内存约 160 MiB;
+- docker system df:查看镜像、容器、数据卷和构建缓存占用的磁盘,docker image prune -a 删除没有被任何容器使用的镜像。
+
+常见问题:
+
+- 容器启动后立即退出:容器的生命周期跟随 1 号进程,主进程结束容器就退出。例如启动 MySQL 时没有设置 MYSQL_ROOT_PASSWORD,容器状态为 Exited (1),docker logs 中列出了需要设置的环境变量(见[附录 A4](#a4-端口数据卷网络与-compose));把服务放到后台运行的启动命令(例如 nginx 不加 daemon off)也会让主进程立即结束;
+- 退出码 137:进程被 SIGKILL 杀死(128 + 9),常见原因是超过内存限制被内核杀掉,或者 docker stop 等待超时;143 是收到 SIGTERM 后退出;
+- 宿主机上能运行,容器中不能:常见原因是容器中缺少依赖的库或命令(Alpine 使用 musl 而不是 glibc)、镜像的 CPU 架构与机器不一致(arm64 与 amd64)、程序连接的 localhost 指向了容器自己、文件权限不同;
+- Docker 守护进程重启:默认情况下守护进程退出时会停止所有容器,开启 live-restore 后容器在守护进程重启期间继续运行;需要自动恢复的容器用 --restart unless-stopped 启动。
+
## 七、Kubernetes
Docker 解决的是单台机器上怎样运行容器。服务有几十个、部署在几十台机器上时,还需要决定每个容器放在哪台机器上、挂了谁来重启、怎样扩容、怎样不停机升级,这就是 Kubernetes(K8s)做的事情。
-K8s 的核心思想是声明期望的状态,由控制器不断把实际状态调整为期望状态。例如下面的配置声明 order-service 要运行 3 个副本,并通过一个 Service 对外提供访问:
+K8s 的核心思想是声明期望的状态,由控制器不断把实际状态调整为期望状态。例如下面的配置声明订单服务要运行 3 个副本,并通过一个 Service 提供统一的访问入口:
```yaml
apiVersion: apps/v1
@@ -124,11 +282,16 @@ spec:
spec:
containers:
- name: app
- image: nginx:1.27-alpine
+ image: order-service:1.0
ports:
- - containerPort: 80
+ - containerPort: 8080
+ resources:
+ requests: { cpu: 250m, memory: 256Mi }
+ limits: { cpu: "1", memory: 512Mi }
readinessProbe:
- httpGet: { path: /, port: 80 }
+ httpGet: { path: /actuator/health/readiness, port: 8080 }
+ livenessProbe:
+ httpGet: { path: /actuator/health/liveness, port: 8080 }
---
apiVersion: v1
kind: Service
@@ -139,23 +302,492 @@ spec:
app: order-service
ports:
- port: 80
- targetPort: 80
+ targetPort: 8080
```
其中的几个概念:
-- Pod:K8s 调度的最小单位,包含一个或几个共享网络和存储的容器,每个 Pod 有自己的 IP,重建后 IP 会变;
+- Pod:K8s 调度的最小单位,包含一个或几个容器,每个 Pod 有自己的 IP,重建后 IP 会变。同一个 Pod 中的容器共享网络、IPC 和 UTS 命名空间,可以用 localhost 互相访问、主机名相同,也可以挂载同一个存储卷,但默认不共享 PID 命名空间(见[附录 A6](#a6-pod-的创建资源限制与共享的命名空间));
- Deployment:管理一组相同的 Pod,负责保持副本数、扩缩容和滚动升级;
- Service:为一组 Pod 提供固定的虚拟 IP 和域名,把请求负载均衡到其中就绪的 Pod 上;
- 控制平面:API Server 接收所有请求,etcd 保存集群状态,调度器决定 Pod 放在哪个节点,控制器管理器运行各种控制器;每个节点上的 kubelet 负责启动和检查本节点的容器。
-在单节点 K8s(k3s v1.31)上实际操作的结果如下:
+在两个节点的 K8s 集群(k3s v1.31.4)上实际操作的结果如下(见[附录 A5](#a5-部署自愈与滚动升级)):
-- kubectl apply 之后约 20 秒,3 个 Pod 全部就绪,Service 得到虚拟 IP 10.43.189.89,在集群内用 http://order-service 就能访问;
-- 删除一个 Pod,Deployment 创建的 ReplicaSet 发现副本数少于 3,立即创建新的 Pod,约 1.2 秒后恢复为 3/3,新 Pod 的 IP 与原来不同,Service 自动更新了后端列表;
-- kubectl scale 把副本数改为 5;kubectl set image 修改镜像版本后,Deployment 创建新的 ReplicaSet,逐个用新版本的 Pod 替换旧的,约 12 秒完成滚动升级,期间一直有就绪的 Pod 在提供服务,需要时可以用 kubectl rollout undo 回滚。
+- kubectl apply 之后约 21 秒,3 个 Pod 全部就绪,分布在两个节点上,Service 得到虚拟 IP 10.43.210.38,在集群内用 http://order-service 就能访问,请求被分到不同的 Pod;
+- 删除一个 Pod,Deployment 创建的 ReplicaSet 发现副本数少于 3,0.3 秒内创建了新的 Pod,约 11 秒后新 Pod 启动完成并通过就绪检查,恢复为 3/3,新 Pod 的 IP 与原来不同,Service 自动更新了后端列表;
+- kubectl scale 修改副本数;kubectl set image 把镜像改为 order-service:1.1 后,Deployment 创建新的 ReplicaSet,逐个用新版本的 Pod 替换旧的,约 33 秒完成滚动升级,期间一直有就绪的 Pod 在提供服务,需要时可以用 kubectl rollout undo 回滚。
-readinessProbe 是就绪探针:Pod 启动后要通过检查才会加入 Service 的后端列表,避免把请求转发给还没启动完成的实例。
+readinessProbe 是就绪探针:Pod 启动后要通过检查才会加入 Service 的后端列表,避免把请求转发给还没启动完成的实例。livenessProbe 是存活探针:连续失败 3 次(默认值)时 kubelet 重启容器,用于发现进程还在、但已经不能工作的情况,例如死锁。Spring Boot 的 Actuator 在 K8s 中自动提供 /actuator/health/readiness 和 /actuator/health/liveness 两个端点。
+
+删除 Pod(缩容、滚动升级)时,控制平面把它从 Service 的后端中摘除,同时 kubelet 向容器的 1 号进程发送 SIGTERM,30 秒(terminationGracePeriodSeconds 的默认值)后进程还没退出就发送 SIGKILL。应用收到 SIGTERM 后应停止接收新请求、处理完已有请求再退出,Spring Boot 3.4 起默认开启这种优雅停机。摘除后端需要一点时间才能传到每个节点,所以常用 preStop 钩子先等待几秒(例如 sleep 5),再让应用开始停止。
+
+### Pod 的创建过程
+
+K8s 的各组件不直接互相调用,而是都通过 API Server 协作:每个组件监听(watch)自己关心的对象,发现实际状态与期望不一致就采取行动,再把结果写回 API Server。以订单服务中被调度到节点 d1-agent 的一个 Pod 为例:
+
+
+
+1. kubectl 把 Deployment 发给 API Server,API Server 完成认证、鉴权和准入检查后写入 etcd;
+2. 控制器管理器中的 Deployment 控制器发现新的 Deployment,创建 ReplicaSet;ReplicaSet 控制器发现副本数不足,创建 3 个 Pod 对象,这时的 Pod 还没有指定节点;
+3. 调度器发现未调度的 Pod,先过滤掉不满足条件的节点(资源不足、有不能容忍的污点等),再给剩下的节点打分,选出 d1-agent 写入 Pod 的 nodeName;
+4. d1-agent 上的 kubelet 发现分配给自己的 Pod,通过容器运行时接口(Container Runtime Interface, CRI)让 containerd 创建 Pod 沙箱:先启动一个 pause 容器持有网络命名空间,再调用 CNI 插件为它配置网卡、分配 IP 10.42.1.5;Pod 声明了存储卷时,还会通过 CSI 插件把卷挂载到节点上;
+5. kubelet 拉取镜像(这里镜像已在节点上),创建并启动容器;
+6. kubelet 执行探针,就绪探针通过后把 Pod 的状态更新为 Ready;
+7. EndpointSlice 控制器把 10.42.1.5:8080 加入 order-service 的后端,各节点的 kube-proxy 随之更新转发规则。
+
+从提交到这个 Pod 就绪用了 21 秒:创建对象和调度约 1 秒,创建容器 11 秒,Spring Boot 启动和就绪探针 9 秒(见[附录 A6](#a6-pod-的创建资源限制与共享的命名空间))。创建容器较慢是因为实验中的节点运行在 Docker 容器里,containerd 只能用逐个复制文件的 native 快照准备根文件系统。kubectl describe pod 末尾的 Events 按顺序记录了 Scheduled、Pulled、Created、Started 等事件,Pod 起不来时先看这里。
+
+YAML 中的 resources 声明了 Pod 需要的资源:
+
+- requests 是调度的依据,调度器只把 Pod 放到剩余可分配资源不少于 requests 的节点上,CPU 紧张时也按 requests 的比例分配 CPU 时间;
+- limits 是运行时的上限,由 kubelet 写入容器的 cgroup。订单服务的 limits 是 1 个 CPU 和 512 MiB,容器中的 cpu.max 为 100000 100000,memory.max 为 536870912(见[附录 A6](#a6-pod-的创建资源限制与共享的命名空间))。
+
+超过 CPU 上限的进程被限流,变慢但不会被杀;超过内存上限的进程被内核杀掉,状态为 OOMKilled。
+
+### Service 与 Ingress
+
+Pod 的 IP 会变,调用方不能直接写 Pod 的 IP。Service 用标签选出一组 Pod,提供一个固定的虚拟 IP(ClusterIP)和域名 order-service(完整域名 order-service.default.svc.cluster.local)。EndpointSlice 控制器维护其中就绪 Pod 的地址,每个节点上的 kube-proxy 把它们写成 iptables 或 IPVS 规则。
+
+订单服务所在的节点上,与 order-service 相关的 iptables 规则是:发往 10.43.210.38:80 的包,以 1/3 的概率 DNAT 到 10.42.0.8:8080,剩下的以 1/2 的概率 DNAT 到 10.42.1.4:8080,其余 DNAT 到 10.42.1.5:8080,三个 Pod 被选中的概率相同(见[附录 A7](#a7-service-与-ingress))。虚拟 IP 没有绑定在任何网卡上,只存在于每个节点的转发规则中。
+
+Service 按访问范围分为几种类型:
+
+- ClusterIP:默认类型,只能在集群内访问;
+- NodePort:在 ClusterIP 的基础上,在每个节点上打开同一个端口(默认范围 30000~32767),访问任一节点的这个端口都会转发到后端的 Pod,例如订单服务被分配了 31887 端口;
+- LoadBalancer:在 NodePort 的基础上,由云厂商创建一个外部负载均衡器,把流量转发到各节点;
+- ExternalName:把服务名映射为一个外部域名(DNS 的 CNAME 记录),不做转发。
+
+Service 工作在传输层,只认 IP 和端口。对外提供多个 HTTP 服务时,每个服务一个 LoadBalancer 成本高,也不能按域名和路径分发,这时用 Ingress。Ingress 是一组 HTTP 路由规则,例如把 shop.example.com/orders 转发到 order-service:
+
+```yaml
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: shop
+spec:
+ rules:
+ - host: shop.example.com
+ http:
+ paths:
+ - path: /orders
+ pathType: Prefix
+ backend:
+ service:
+ name: order-service
+ port:
+ number: 80
+```
+
+Ingress 只是规则,真正处理请求的是 Ingress 控制器,例如 Ingress-NGINX、Traefik(k3s 自带),它们本身作为 Pod 运行,通过 LoadBalancer 或 NodePort 类型的 Service 对外暴露。实测中带 Host: shop.example.com 访问 /orders/1024 返回订单数据,访问 /users/1 或换成其它域名都返回 404(见[附录 A7](#a7-service-与-ingress))。
+
+
+
+需要注意的是,Ingress 控制器通常不经过 Service 的虚拟 IP,而是从 EndpointSlice 取得 Pod 的地址后直接转发给 Pod(Traefik 的默认行为),Service 在这里只用来找到后端。Ingress API 已经冻结,不再增加新功能,新功能加在它的后继 Gateway API 中。
+
+Pod 之间的通信由 CNI 插件实现。K8s 的网络模型要求每个 Pod 有集群内唯一的 IP,任意两个 Pod 不经过 NAT 就能直接通信。k3s 默认的 Flannel 给每个节点分配一个网段(实验中 d1-server 上的 Pod 是 10.42.0.x,d1-agent 上的是 10.42.1.x),跨节点的包用 VXLAN 封装后经节点之间的网络发送。
+
+### Pod 异常状态的排查
+
+Pod 的阶段(phase)只有 Pending、Running、Succeeded、Failed、Unknown 五种,kubectl get pods 的 STATUS 一栏显示得更细。常见的异常状态(k3s 实测,见[附录 A8](#a8-pod-的异常状态)):
+
+- Pending:Pod 还没有调度到节点上。describe 的事件中有 FailedScheduling 时是调度失败,例如申请 20 个 CPU 时为 0/2 nodes are available: 2 Insufficient cpu;两个节点都有污点 dedicated=db:NoSchedule、Pod 没有对应的容忍时为 2 node(s) had untolerated taint;Pod 使用的 PVC 没有绑定到存储卷时也会停在这里;
+- ImagePullBackOff:拉取镜像失败,常见原因是镜像名或标签写错、私有仓库没有配置凭证(imagePullSecrets)、节点访问不了镜像仓库,kubelet 在 ErrImagePull 和 ImagePullBackOff 之间反复重试;
+- CrashLoopBackOff:容器启动后反复退出,kubelet 每次重启前的等待时间按 10 秒、20 秒、40 秒翻倍,最长 5 分钟。kubectl logs --previous 查看上一次退出前的日志,例如把 server.port 写成 abc 时,日志中是 APPLICATION FAILED TO START 和具体的配置错误,退出码 1;
+- OOMKilled:容器超过内存上限被内核杀掉,退出码 137,之后同样进入 CrashLoopBackOff。例如内存上限 256 MiB,JVM 堆却设为 300 MB 并在启动时全部占用,Java 应用的堆和堆外内存加起来要小于 limits;
+- Running 但 READY 为 0/1:进程在运行,但就绪探针不通过,Pod 不会收到 Service 转发的请求,例如探针路径写成不存在的 /ready 时,事件中是 Readiness probe failed: HTTP probe failed with statuscode: 404。
+
+排查的顺序通常是:kubectl get pods -o wide 看状态、重启次数和所在节点;kubectl describe pod 看事件;kubectl logs 看应用日志,容器重启过的加 --previous;kubectl exec 进入容器检查配置和网络;kubectl top pod 看资源占用(需要 metrics-server)。
+
+节点 NotReady 指控制平面一段时间没有收到节点上 kubelet 的心跳,这个时间由 node-monitor-grace-period 决定(v1.31 默认 40 秒,v1.32 起 50 秒)。停掉工作节点 d1-agent 后 32 秒,它变为 NotReady,Conditions 中是 Kubelet stopped posting node status,并被加上 node.kubernetes.io/unreachable 污点;上面的 Pod 同时被标记为未就绪,从 Service 的后端中摘除。Pod 默认容忍这个污点 300 秒,又过了约 5 分钟才被驱逐,Deployment 在 d1-server 上补足了副本(见[附录 A9](#a9-节点-notready))。
+
+排查 NotReady 时先用 kubectl describe node 看 Conditions:
+
+- Ready 为 Unknown:kubelet 没有上报状态,登录节点检查 kubelet 和容器运行时是否在运行(systemctl status kubelet、journalctl -u kubelet),以及节点到 API Server 的网络和证书;
+- Ready 为 False:kubelet 在上报,但节点不健康,例如 CNI 插件没有就绪,原因写在 Message 中;
+- MemoryPressure、DiskPressure、PIDPressure 为 True:节点的内存、磁盘或进程数不足,kubelet 会驱逐节点上的 Pod 来回收资源。
+
+## 附录:实测
+
+以下实验的环境为 Apple M1 Pro(10 核)、macOS 上的 Docker Desktop,Docker Engine 28.4.0(使用 containerd 镜像存储),Linux 内核 6.10.14(aarch64)。订单服务基于 Spring Boot 3.5.16、JDK 21,只有一个 GET /orders/{id} 接口,返回订单号、状态和主机名,并引入了 spring-boot-starter-actuator 提供健康检查。
+
+### A1 构建缓存与多阶段构建
+
+正文中的 Dockerfile,冷缓存时首次构建(节选):
+
+```text
+$ time docker build --progress=plain -t order-service:1.0 order-service
+#11 [build 4/6] RUN mvn -q dependency:go-offline
+#11 DONE 209.6s
+#13 [build 6/6] RUN mvn -q package -DskipTests
+#13 DONE 3.2s
+3:34.63 total(zsh 的 time 输出)
+```
+
+把源码中的 "SHIPPED" 改为 "PAID" 后重新构建:
+
+```text
+#8 [build 3/6] COPY pom.xml .
+#8 CACHED
+#10 [build 4/6] RUN mvn -q dependency:go-offline
+#10 CACHED
+#11 [build 5/6] COPY src ./src
+#11 DONE 0.0s
+#12 [build 6/6] RUN mvn -q package -DskipTests
+#12 DONE 4.8s
+6.411 total
+```
+
+对照组只有一个阶段、先复制全部文件:
+
+```dockerfile
+FROM maven:3.9-eclipse-temurin-21
+WORKDIR /src
+COPY . .
+RUN mvn -q package -DskipTests
+ENTRYPOINT ["java", "-jar", "target/order-service-1.0.jar"]
+```
+
+```text
+# 首次构建(--no-cache)
+#8 [4/4] RUN mvn -q package -DskipTests
+#8 DONE 96.2s
+real 1m40.139s
+# 改一行源码后重新构建
+#7 [3/4] COPY . .
+#7 DONE 0.1s
+#8 [4/4] RUN mvn -q package -DskipTests
+#8 DONE 102.4s
+real 1m47.494s
+```
+
+镜像大小取 docker history 中各层大小之和(解压后):多阶段构建的镜像 235.7 MB,其中 Alpine 3.24 根文件系统 9.31 MB、字体和证书等系统包 40.6 MB、JRE 162 MB、jar 包 23.8 MB;单阶段的镜像 680.2 MB,其中 Ubuntu 根文件系统 110 MB、系统包 76.2 MB 与 90.5 MB、JDK 309 MB、Maven 11.1 MB、mvn package 这一层(依赖和编译结果)83.3 MB。
+
+### A2 exec 形式与 shell 形式
+
+在正文的镜像上分别用两种形式启动,6 秒后执行 docker stop:
+
+```text
+# exec 形式:ENTRYPOINT ["java", "-jar", "app.jar"]
+stop took 0.68 s
+ExitCode=143
+... GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
+... GracefulShutdown : Graceful shutdown complete
+
+# shell 形式:ENTRYPOINT java -jar app.jar; echo "order-service exited"
+$ docker exec order-shell ps -o pid,comm,args
+PID COMMAND COMMAND
+ 1 sh /bin/sh -c java -jar app.jar; echo "order-service exited"
+ 6 java java -jar app.jar
+stop took 10.34 s
+ExitCode=137
+(日志中没有 Graceful shutdown)
+```
+
+shell 形式写成 ENTRYPOINT java -jar app.jar 或 ENTRYPOINT echo "starting" && java -jar app.jar 时,ps 显示 1 号进程是 java,停止耗时 0.43 s 和 0.24 s,退出码都是 143。
+
+用 docker run order-service:1.0 --server.port=9090 启动:
+
+```text
+$ docker exec order9090 ps -o pid,args
+PID COMMAND
+ 1 java -jar app.jar --server.port=9090
+... TomcatWebServer : Tomcat started on port 9090 (http) with context path '/'
+```
+
+### A3 OverlayFS 的可写层
+
+```text
+$ docker exec ovl head -1 /proc/mounts
+overlay / overlay rw,relatime,
+ lowerdir=.../snapshots/426/fs:.../snapshots/354/fs:...(共 8 个目录),
+ upperdir=.../snapshots/427/fs,
+ workdir=.../snapshots/427/work 0 0
+$ docker exec ovl sh -c 'echo note >> /etc/alpine-release; rm /etc/motd'
+$ docker diff ovl
+C /etc
+C /etc/alpine-release
+D /etc/motd
+C /tmp
+A /tmp/hsperfdata_root/1
+A /tmp/tomcat.8080.7999032892732268743
+...
+# 在 Docker Desktop 的虚拟机中查看 upperdir/etc
+-rw-r--r-- 1 root root 12 Oct 28 10:19 alpine-release
+c--------- 2 root root 0, 0 Oct 28 10:19 motd
+```
+
+白障文件是主次设备号为 0, 0 的字符设备。/tmp 下新增的文件是 JVM 和 Tomcat 运行时创建的。
+
+### A4 端口、数据卷、网络与 Compose
+
+```text
+$ docker run -d --name order -p 8080:8080 order-service:1.0
+$ curl localhost:8080/orders/1024
+{"host":"cb371bf03045","id":1024,"status":"SHIPPED"}
+$ docker run -d --name order2 -p 8080:8080 order-service:1.0
+docker: Error response from daemon: failed to set up container networking: driver failed programming
+external connectivity on endpoint order2 (...): Bind for 0.0.0.0:8080 failed: port is already allocated
+$ docker run -d --name order2 -p 8081:8080 order-service:1.0
+$ curl localhost:8081/orders/1024
+{"status":"SHIPPED","id":1024,"host":"3a76640c3641"}
+$ docker stats --no-stream
+NAME CPU % MEM USAGE / LIMIT NET I/O PIDS
+order 0.24% 162.2MiB / 7.654GiB 1.72kB / 725B 43
+order2 0.33% 153.3MiB / 7.654GiB 1.47kB / 683B 43
+```
+
+MySQL 8.0:
+
+```text
+$ docker run -d --name mysql-bad mysql:8.0
+$ docker ps -a --filter name=mysql-bad
+mysql-bad Exited (1) 2 seconds ago
+$ docker logs mysql-bad
+ You need to specify one of the following as an environment variable:
+ - MYSQL_ROOT_PASSWORD
+ - MYSQL_ALLOW_EMPTY_PASSWORD
+ - MYSQL_RANDOM_ROOT_PASSWORD
+
+$ docker network create shop
+$ docker run -d --name mysql --network shop -p 3316:3306 -v mysql-data:/var/lib/mysql -e MYSQL_ROOT_PASSWORD=Root2026 mysql:8.0
+$ mysql -h127.0.0.1 -P3316 -uroot -p -e "create database shop; create table shop.orders(id bigint primary key, status varchar(16)); insert into shop.orders values (1024, 'SHIPPED')"
+$ docker rm -f mysql
+$ docker run ...(同上,重新创建容器)
+$ mysql -h127.0.0.1 -P3316 -uroot -p -e "select * from shop.orders"
+id status
+1024 SHIPPED
+
+$ docker run --rm --network shop busybox nslookup mysql
+Name: mysql
+Address: 172.19.0.2
+$ docker run --rm busybox nslookup mysql
+** server can't find mysql: NXDOMAIN
+```
+
+正文中的 compose.yaml:
+
+```text
+$ docker compose up -d
+ Container shop-mysql-1 Started
+ Container shop-mysql-1 Waiting
+ Container shop-mysql-1 Healthy
+ Container shop-order-1 Starting
+ Container shop-order-1 Started
+$ docker inspect -f '{{.Name}} started {{.State.StartedAt}}' shop-mysql-1 shop-order-1
+/shop-mysql-1 started 2026-10-28T10:18:50.539721208Z
+/shop-order-1 started 2026-10-28T10:19:00.181142879Z
+```
+
+### A5 部署、自愈与滚动升级
+
+两个节点的 k3s v1.31.4:server 节点 d1-server(控制平面兼工作节点)和 agent 节点 d1-agent 各运行在一个 Docker 特权容器中,容器内不能再用 OverlayFS,containerd 使用 native 快照(--snapshotter native)。订单服务的 1.0、1.1 两个镜像用 docker save 导出后导入两个节点的 containerd。
+
+```text
+$ kubectl apply -f order.yaml
+all ready after 21.1 s
+$ kubectl get pods -o wide
+NAME READY STATUS RESTARTS AGE IP NODE
+order-service-5ccc589fd8-b76g2 1/1 Running 0 20s 10.42.1.5 d1-agent
+order-service-5ccc589fd8-vb2lx 1/1 Running 0 20s 10.42.1.4 d1-agent
+order-service-5ccc589fd8-vz9hj 1/1 Running 0 20s 10.42.0.8 d1-server
+$ kubectl get svc order-service
+NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
+order-service ClusterIP 10.43.210.38 80/TCP 21s
+$ kubectl exec order-service-5ccc589fd8-b76g2 -- wget -qO- http://order-service/orders/1024(4 次)
+{"host":"order-service-5ccc589fd8-vz9hj","id":1024,"status":"SHIPPED"}
+{"host":"order-service-5ccc589fd8-vb2lx","id":1024,"status":"SHIPPED"}
+{"host":"order-service-5ccc589fd8-vz9hj","id":1024,"status":"SHIPPED"}
+{"status":"SHIPPED","id":1024,"host":"order-service-5ccc589fd8-b76g2"}
+```
+
+删除一个 Pod 后每 0.1~0.2 秒查询一次 Pod 列表:
+
+```text
+$ kubectl delete pod/order-service-5ccc589fd8-b76g2
+new Pod object created after 0.34 s
+3/3 ready again after 11.2 s
+NAME READY STATUS RESTARTS AGE IP NODE
+order-service-5ccc589fd8-494s9 1/1 Running 0 11s 10.42.0.9 d1-server
+...
+NAME ADDRESSTYPE PORTS ENDPOINTS
+order-service-m2pjv IPv4 8080 10.42.1.4,10.42.0.8,10.42.0.9
+$ kubectl set image deploy/order-service app=order-service:1.1
+rolling update finished after 33.2 s
+NAME DESIRED CURRENT READY
+order-service-5b6f68cdcf 3 3 3
+order-service-5ccc589fd8 0 0 0
+$ kubectl rollout undo deploy/order-service
+undo finished after 32.9 s
+```
+
+### A6 Pod 的创建、资源限制与共享的命名空间
+
+A5 中 Pod order-service-5ccc589fd8-b76g2 的事件和状态:
+
+```text
+$ kubectl get events(Deployment 与 ReplicaSet 的事件,节选)
+2026-10-28T10:21:39Z Deployment ScalingReplicaSet deployment-controller Scaled up replica set order-service-5ccc589fd8 to 3
+2026-10-28T10:21:40Z ReplicaSet SuccessfulCreate replicaset-controller Created pod: order-service-5ccc589fd8-b76g2
+$ kubectl get events --field-selector involvedObject.name=order-service-5ccc589fd8-b76g2
+TIME REASON FROM MESSAGE
+ Scheduled Successfully assigned default/order-service-5ccc589fd8-b76g2 to d1-agent
+2026-10-28T10:21:40Z Pulled kubelet Container image "order-service:1.0" already present on machine
+2026-10-28T10:21:51Z Created kubelet Created container app
+2026-10-28T10:21:51Z Started kubelet Started container app
+2026-10-28T10:21:52Z Unhealthy kubelet Readiness probe failed: Get "http://10.42.1.5:8080/actuator/health/readiness": dial tcp 10.42.1.5:8080: connect: connection refused
+$ kubectl get pod order-service-5ccc589fd8-b76g2 -o jsonpath='{range .status.conditions[*]}...{end}'(节选)
+PodScheduled 2026-10-28T10:21:40Z
+Ready 2026-10-28T10:22:00Z
+$ kubectl exec order-service-5ccc589fd8-b76g2 -- cat /sys/fs/cgroup/memory.max /sys/fs/cgroup/cpu.max /sys/fs/cgroup/cpu.weight
+536870912
+100000 100000
+10
+```
+
+Scheduled 事件的 firstTimestamp 为空,PodScheduled 条件的时间为 10:21:40。cpu.weight 10 由 requests 的 250m 换算而来。
+
+一个 Pod 中放订单服务和一个 busybox 容器(sleep 3600),分别查看 1 号进程的命名空间:
+
+```text
+$ kubectl exec sidecar-demo -c sidecar -- wget -qO- localhost:8080/orders/1024
+{"host":"sidecar-demo","id":1024,"status":"SHIPPED"}
+$ kubectl exec sidecar-demo -c sidecar -- ps
+PID USER TIME COMMAND
+ 1 root 0:00 sleep 3600
+[app] [sidecar]
+net net:[4026533666] net net:[4026533666]
+ipc ipc:[4026534201] ipc ipc:[4026534201]
+uts uts:[4026534200] uts uts:[4026534200]
+pid pid:[4026534204] pid pid:[4026534207]
+mnt mnt:[4026534203] mnt mnt:[4026534206]
+```
+
+两个容器的 hostname 都是 sidecar-demo;节点上可以看到这个 Pod 的 /pause 进程。
+
+### A7 Service 与 Ingress
+
+在 d1-agent 上查看 kube-proxy 写入的规则(节选):
+
+```text
+$ iptables-save -t nat | grep default/order-service
+-A KUBE-SERVICES -d 10.43.210.38/32 -p tcp -m comment --comment "default/order-service cluster IP" -m tcp --dport 80 -j KUBE-SVC-QWJNOBD5KHUXZAD6
+-A KUBE-SVC-QWJNOBD5KHUXZAD6 -m comment --comment "default/order-service -> 10.42.0.8:8080" -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-CRY6GKLRKVOMXZX4
+-A KUBE-SVC-QWJNOBD5KHUXZAD6 -m comment --comment "default/order-service -> 10.42.1.4:8080" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-4ZNL5PABF7B3TQCL
+-A KUBE-SVC-QWJNOBD5KHUXZAD6 -m comment --comment "default/order-service -> 10.42.1.5:8080" -j KUBE-SEP-FZGSJQ7SZ4EWXRUD
+-A KUBE-SEP-CRY6GKLRKVOMXZX4 -p tcp -m comment --comment "default/order-service" -m tcp -j DNAT --to-destination 10.42.0.8:8080
+```
+
+正文中的 Ingress,k3s 自带的 Traefik 2.11 处理,在 d1-server 上访问节点 IP 的 80 端口:
+
+```text
+$ kubectl get ingress shop
+NAME CLASS HOSTS ADDRESS PORTS AGE
+shop traefik shop.example.com 172.19.0.2,172.19.0.3 80 5s
+$ wget -qO- --header "Host: shop.example.com" http://172.19.0.2/orders/1024
+{"host":"order-service-5ccc589fd8-vb2lx","id":1024,"status":"SHIPPED"}
+$ wget --header "Host: shop.example.com" http://172.19.0.2/users/1
+ HTTP/1.1 404 Not Found
+$ wget --header "Host: other.example.com" http://172.19.0.2/orders/1024
+ HTTP/1.1 404 Not Found
+```
+
+把 order-service 改为 NodePort:
+
+```text
+$ kubectl patch svc order-service -p '{"spec":{"type":"NodePort"}}'
+NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
+order-service NodePort 10.43.210.38 80:31887/TCP 103s
+$ wget -qO- http://172.19.0.3:31887/orders/1024 (在 d1-server 上访问 d1-agent 的 NodePort)
+{"status":"SHIPPED","id":1024,"host":"order-service-5ccc589fd8-b76g2"}
+```
+
+### A8 Pod 的异常状态
+
+在命名空间 lab 中创建 5 个故意出错的 Pod:
+
+```yaml
+# pending-demo:申请 20 个 CPU
+resources: { requests: { cpu: "20" } }
+# pull-demo:不存在的标签
+image: order-service:9.9
+# crash-demo:错误的启动参数
+args: ["--server.port=abc"]
+# oom-demo:堆比内存上限大,并在启动时全部占用
+env:
+ - { name: JAVA_TOOL_OPTIONS, value: "-Xms300m -Xmx300m -XX:+AlwaysPreTouch" }
+resources: { limits: { memory: 256Mi } }
+# notready-demo:就绪探针的路径不存在
+readinessProbe:
+ httpGet: { path: /ready, port: 8080 }
+```
+
+```text
+$ kubectl get pods -n lab (约 2 分钟后)
+NAME READY STATUS RESTARTS AGE
+crash-demo 0/1 CrashLoopBackOff 4 (35s ago) 2m8s
+notready-demo 0/1 Running 0 2m8s
+oom-demo 0/1 CrashLoopBackOff 4 (32s ago) 2m8s
+pending-demo 0/1 Pending 0 2m8s
+pull-demo 0/1 ImagePullBackOff 0 2m8s
+
+pending-demo Warning FailedScheduling default-scheduler 0/2 nodes are available: 2 Insufficient cpu. preemption: 0/2 nodes are available: 2 No preemption victims found for incoming pod.
+pull-demo Warning Failed kubelet Failed to pull image "order-service:9.9": failed to pull and unpack image "docker.io/library/order-service:9.9": failed to resolve reference ...
+ Warning Failed kubelet Error: ErrImagePull
+ Warning Failed kubelet Error: ImagePullBackOff
+crash-demo Warning BackOff kubelet Back-off restarting failed container app in pod crash-demo_lab(...)
+notready-demo Warning Unhealthy kubelet Readiness probe failed: HTTP probe failed with statuscode: 404
+
+oom-demo lastState: OOMKilled exitCode=137
+crash-demo lastState: Error exitCode=1
+$ kubectl logs -n lab crash-demo --previous
+APPLICATION FAILED TO START
+Description:
+Invalid value 'abc' for configuration property 'server.port' (originating from '"server.port" from property source "commandLineArgs"'). Validation failed for the following reason:
+Failed to convert to type java.lang.Integer
+```
+
+oom-demo 在第一次被杀之前 kubectl get pods 显示的状态是 OOMKilled。给两个节点都加上污点后创建 Pod:
+
+```text
+$ kubectl taint nodes d1-server d1-agent dedicated=db:NoSchedule
+$ kubectl run -n lab taint-demo --image=order-service:1.0
+ Warning FailedScheduling 5s default-scheduler 0/2 nodes are available: 2 node(s) had untolerated taint {dedicated: db}. ...
+$ kubectl taint nodes d1-server d1-agent dedicated=db:NoSchedule-
+(8 秒后 taint-demo 在 d1-server 上 Running)
+```
+
+### A9 节点 NotReady
+
+用 docker stop 停掉 d1-agent 所在的容器,相当于节点宕机:
+
+```text
+10:28:55 $ docker stop d1-k3s-agent
+10:29:28 d1-agent NotReady after 32 s
+ Type Status LastTransitionTime Reason Message
+ MemoryPressure Unknown Wed, 28 Oct 2026 18:29:27 +0800 NodeStatusUnknown Kubelet stopped posting node status.
+ DiskPressure Unknown Wed, 28 Oct 2026 18:29:27 +0800 NodeStatusUnknown Kubelet stopped posting node status.
+ PIDPressure Unknown Wed, 28 Oct 2026 18:29:27 +0800 NodeStatusUnknown Kubelet stopped posting node status.
+ Ready Unknown Wed, 28 Oct 2026 18:29:27 +0800 NodeStatusUnknown Kubelet stopped posting node status.
+Taints: node.kubernetes.io/unreachable:NoSchedule
+10:34:33 pods on d1-agent marked for deletion after 337 s
+NAME READY STATUS IP NODE
+order-service-5ccc589fd8-24sqr 1/1 Terminating 10.42.1.8 d1-agent
+order-service-5ccc589fd8-2f56l 1/1 Terminating 10.42.1.9 d1-agent
+order-service-5ccc589fd8-2vrx4 1/1 Running 10.42.0.11 d1-server
+order-service-5ccc589fd8-4gzps 1/1 Running 10.42.0.17 d1-server
+order-service-5ccc589fd8-gzsbv 1/1 Running 10.42.0.16 d1-server
+$ kubectl get pod -o jsonpath='{.items[0].spec.tolerations}'
+[{"effect":"NoExecute","key":"node.kubernetes.io/not-ready","operator":"Exists","tolerationSeconds":300},
+ {"effect":"NoExecute","key":"node.kubernetes.io/unreachable","operator":"Exists","tolerationSeconds":300}]
+```
+
+节点失联后,Pod 无法确认已经停止,一直显示 Terminating。恢复 d1-agent、重新部署后再停一次,每 4 秒查看 EndpointSlice 中各地址的 ready:
+
+```text
++33s node=Ready endpoints: 10.42.0.19=true 10.42.1.14=true 10.42.1.13=true
++37s node=NotReady endpoints: 10.42.0.19=true 10.42.1.14=false 10.42.1.13=false
+```
## 参考资料
@@ -168,7 +800,29 @@ readinessProbe 是就绪探针:Pod 启动后要通过检查才会加入 Servic
- [What is Docker](https://www.docker.com/what-docker)
- [持续集成是什么?](http://www.ruanyifeng.com/blog/2015/09/continuous-integration.html)
- [Understanding the image layers | Docker Docs](https://docs.docker.com/get-started/docker-concepts/building-images/understanding-image-layers/)
+- [Dockerfile reference | Docker Docs](https://docs.docker.com/reference/dockerfile/)
+- [Building best practices | Docker Docs](https://docs.docker.com/build/building/best-practices/)
+- [Multi-stage builds | Docker Docs](https://docs.docker.com/build/building/multi-stage/)
+- [Docker build cache | Docker Docs](https://docs.docker.com/build/cache/)
+- [OverlayFS storage driver | Docker Docs](https://docs.docker.com/engine/storage/drivers/overlayfs-driver/)
+- [Volumes | Docker Docs](https://docs.docker.com/engine/storage/volumes/)
+- [Bridge network driver | Docker Docs](https://docs.docker.com/engine/network/drivers/bridge/)
+- [Control startup order | Docker Docs](https://docs.docker.com/compose/how-tos/startup-order/)
+- [Live restore | Docker Docs](https://docs.docker.com/engine/daemon/live-restore/)
- [Kubernetes 文档:Deployments](https://kubernetes.io/docs/concepts/workloads/controllers/deployment/)
- [Kubernetes 文档:Service](https://kubernetes.io/docs/concepts/services-networking/service/)
+- [Kubernetes 文档:Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/)
+- [Kubernetes 文档:Pod Lifecycle](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/)
+- [Kubernetes 文档:Liveness, Readiness, and Startup Probes](https://kubernetes.io/docs/concepts/configuration/liveness-readiness-startup-probes/)
+- [Kubernetes 文档:Resource Management for Pods and Containers](https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/)
+- [Kubernetes 文档:Kubernetes Scheduler](https://kubernetes.io/docs/concepts/scheduling-eviction/kube-scheduler/)
+- [Kubernetes 文档:Taints and Tolerations](https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/)
+- [Kubernetes 文档:Node Status](https://kubernetes.io/docs/reference/node/node-status/)
+- [Kubernetes 文档:Services, Load Balancing, and Networking](https://kubernetes.io/docs/concepts/services-networking/)
+- [Kubernetes 文档:Debug Pods](https://kubernetes.io/docs/tasks/debug/debug-application/debug-pods/)
+- [Spring Boot 文档:Graceful Shutdown](https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html)
+- [Spring Boot 文档:Kubernetes Probes](https://docs.spring.io/spring-boot/reference/actuator/endpoints.html#actuator.endpoints.kubernetes-probes)
+- [Traefik 文档:Kubernetes Ingress](https://doc.traefik.io/traefik/v2.11/routing/providers/kubernetes-ingress/)
+- [K3s 文档:Basic Network Options](https://docs.k3s.io/networking/basic-network-options)
- [namespaces(7) — Linux manual page](https://man7.org/linux/man-pages/man7/namespaces.7.html)
- [cgroups(7) — Linux manual page](https://man7.org/linux/man-pages/man7/cgroups.7.html)
diff --git a/notes/pics/docker-build-cache.png b/notes/pics/docker-build-cache.png
new file mode 100644
index 00000000..5896b984
Binary files /dev/null and b/notes/pics/docker-build-cache.png differ
diff --git a/notes/pics/k8s-pod-create.png b/notes/pics/k8s-pod-create.png
new file mode 100644
index 00000000..3c17b867
Binary files /dev/null and b/notes/pics/k8s-pod-create.png differ
diff --git a/notes/pics/k8s-traffic.png b/notes/pics/k8s-traffic.png
new file mode 100644
index 00000000..19a01132
Binary files /dev/null and b/notes/pics/k8s-traffic.png differ