学习目标
学完本章你应该能够:
- 讲清容器不是虚拟机,而是一个运行在宿主机上的进程,靠 Linux Namespace(隔离能看什么) 与 Cgroups(限制用多少资源) 两大机制实现隔离,并说出从 1979 年 chroot 到 2013 年 Docker 的演进路线。
- 写出
FROM / RUN / COPY / CMD(ENTRYPOINT)的核心 Dockerfile,理解镜像是一层一层堆叠的、联合文件系统的写时复制(Copy-on-Write)原理,以及 layer 缓存为什么能加速构建。 - 用多阶段构建 + 选择合适基础镜像把镜像体积从几百兆压到几十兆甚至十几兆,并理解为什么生产环境要慎用 Alpine、以及
CGO_ENABLED=0与 scratch 的关系。 - 讲清镜像依赖与运行时底层链路(docker run → dockerd → containerd → runc、K8s 的 CRI 抽象层),理解 multi-arch 多平台镜像的机制,并会用
docker build --platform构建多架构镜像。 - 借助
kubectl create --dry-run=client -o yaml在本地生成 Manifest 模板,并避开 YAML 的保留字、数字精度、多行字符串等经典坑。 - 读懂一个典型微服务示例应用(vote / worker / result + redis + db)的架构,掌握用 initContainer 控制服务启动顺序以解决中间件依赖问题的做法。
- 掌握 Helm 的 chart / release / repository 三大核心概念,会使用
install / upgrade --install / uninstall / template / dependency命令,并会用模板变量与条件控制把示例应用改造成 Helm chart。 - 掌握 Kustomize 的 base / overlay 模式,会用 resources、patches、image、helmCharts 复写任意字段,实现多环境差异化部署,并能在 Helm 与 Kustomize 之间按场景选型。
前置知识:会使用基本命令行(bash);知道 Docker、Kubernetes 的基本概念;对镜像、容器有模糊印象即可;建议先回看上一章「云原生基础」的 IaC 部分,因为本章实验需要开通一台虚拟机。
本章你会动手做的事:
- 在一台 Linux 虚拟机上用
nsenter、mount、runc亲手体验 Namespace 隔离与 OverlayFS 联合文件系统。 - 从一个 Go 应用出发,循序渐进地把镜像从 140MB 压到 11MB(多阶段 + scratch),并体验 Alpine 因 glibc/musl 不匹配而报错。
- 用
kubectl create --dry-run=client -o yaml生成 Manifest,部署一套示例投票应用,并用 initContainer 实现 redis → db → worker → vote → result 的链式启动。 - 把示例应用分别改造成 Helm chart 与 Kustomize 工程,体验一条命令
install / upgrade与kubectl apply -k的部署方式。
深入 Dockerfile 和镜像构建
这一讲是整个云原生方向"贯穿始终的核心基础"。我们要回答一个问题:容器到底是什么?它的隔离是怎么实现的?我们又该怎么把一个应用打包成镜像?
容器并不新鲜:一段 30 多年的历史
很多人以为容器是 Docker 发明的,其实容器底层的核心技术早就在 Linux 内核里存在了,Docker 只是把它工程化、产品化、好用化.我们快速过一遍里程碑:
| 时间 | 事件 | 意义 |
|---|---|---|
| 1979 | chroot | 作用在进程上,改变进程的根目录;chroot 后的程序不能对根目录以外的文件读写 —— 已经有点像容器文件系统隔离 |
| 2002 | Linux namespace 机制 | 限制进程所能"看到"和使用的系统资源,是容器的核心技术之一 |
| 2006 | Google 的 process container | 为了管理庞大集群,隔离进程的 CPU / 内存 / IO / 网络 |
| 2007 | 被社区合并进内核并重命名为 Cgroup(control groups) | 至此 Namespace 与 Cgroup 两大核心都已就绪 |
| 2013 | Docker 发布 | 真正把容器变成工程实践,让容器变得流行 |
容器本质就是宿主机上的一个进程。不管 Namespace 还是 Cgroup,作用对象都是进程;通过它们给这个进程提供各种隔离机制,让每个容器看起来像一台独立的虚拟机。Namespace 有 6 种类型:
- mount(文件系统,能看到什么)
- pid(进程,能看到哪些进程)
- net(网络,网络栈)
- uts(hostname 主机名)
- user(用户)
- ipc(进程间通信)
这张图在讲什么:容器隔离全靠这两套 Linux 内核机制,一个管"视野"、一个管"配额",作用对象都是同一个进程。
flowchart LR
NS[Namespace
管你能看到什么
文件 / 进程 / 网络 / 主机名 / 用户 / 通信]
CG[Cgroups
管你能用多少资源
CPU / 内存 / 网络 IO]
NS --> P[容器 = 宿主机上的一个进程]
CG --> P后面我们会真的在 Linux 里用 nsenter 逐个体验一遍,而不只是停留在概念上。
镜像和容器:互为"定义"与"实例"
对刚接触的同学来说,镜像和容器最容易混淆。记住一句话:
- 镜像:关于一个容器的定义,包括文件系统、环境变量、默认启动命令等。
- 容器:镜像的实例化。用同一个镜像可以启动多个容器,它们互相隔离。
class(或 interface),负责"定义";容器像 new 出来的实例,负责"实现"。一个定义,多个实例。容器还有两个重要特性值得记住:
- 不可变(immutable):解决了"在我电脑上运行正常,生产环境不行"的经典打包交付问题 —— 因为这正是不可变技术解决打包与交付问题的重要基础。
- 无状态:容器理念是一次性、临时的,因此不提供持久化存储能力,持久化数据要存储在容器外部(比如后面会遇到的 K8s PVC)。把容器当虚拟机长期跑是典型的错误用法。
Dockerfile:如何构建一个镜像
构建镜像需要编写 Dockerfile —— 一组指令组成的描述文件。执行这些指令,就可以在一个基础镜像之上创建一个新的镜像,然后用 docker build 构建。基本逻辑是:FROM 一个 Ubuntu 之类的基础镜像 → 安装工具 → COPY 代码进去 → 安装应用依赖 → 设置启动命令。
# 最简 Dockerfile
FROM ubuntu:22.04 # 引用基础镜像
RUN apt-get update && apt-get install -y nginx # 安装应用
COPY . /app # 把本地代码复制进镜像
CMD ["nginx", "-g", "daemon off;"] # 声明镜像的启动命令
docker build 的关键参数:
# -t 指定镜像名:版本;末尾的 . 表示"构建上下文"(当前目录)
docker build -t my-app:1.0 .
# 通过 -f 指定 Dockerfile 文件名(默认在根目录就叫 Dockerfile 时可省略)
.dockerignore:类似 .gitignore,用来忽略不必要进构建上下文的目录(如 node_modules),可以明显提升构建速度、减小上下文。这是很容易被忽略的一个优化点。真实实验:用 Linux Namespace 亲手体验隔离
不开 Docker,纯用 Linux 原生技术体会"容器 = 进程"。先在腾讯云开通一台虚拟机(这一步会用到上一章的 IaC 能力),然后启动一个 Nginx 容器,找出它在宿主机上对应的进程 PID,再 nsenter 进它的 namespace:
docker run -d nginx # 启动容器
docker ps # 找容器 PID
ps aux | grep 13082 # 该 PID 的启动命令就是镜像 Dockerfile 里的 CMD
# 查看该 PID 的全部 namespace 类型(mount/pid/net/uts/user/ipc + cgroup)
ls -l /proc/13082/ns
# 进入该 PID 的 mount namespace,体验文件系统隔离
nsenter --target 13082 --mount
ls / # 你会看到这是"容器里的文件系统",跟宿主机完全不同
exit
# 只进 mount 就改 hostname,宿主机也会被改(因为没进 uts!)
nsenter --target 13082 --mount hostname my-container
hostname # 宿主机变了
# 进入全部 namespace 再改 hostname,宿主机才不受影响
nsenter --target 13082 --mount --uts --pid --net --ipc --user hostname my-container2
镜像的本质:一堆目录文件的打包 + 联合文件系统
如果镜像只是"目录和文件的打包",那么一旦修改或想往里面 copy 业务源码,就得重新传输整个包,效率很低。于是有了联合文件系统(UnionFS):
- 写 Dockerfile 时,每一条命令都会生成一个 layer(层),镜像是逐层堆叠的。
- 要修改某个 layer 时,不会改动旧层,而是创建一个新层。
- 传输时只需传输差异层即可。
- layer 以哈希命名,内容不变哈希就不变,从而能复用构建缓存。
这张图在讲什么:每条 Dockerfile 指令盖一层,越靠上的层覆盖越靠下的层,最顶上那一层就是容器运行时"看到"的文件系统。
flowchart TB
L1[FROM 基础镜像
只读层 1] --> L2[RUN 安装依赖
只读层 2]
L2 --> L3[COPY 代码
只读层 3]
L3 --> L4[容器可写层 upperdir
运行时的修改]常见的联合文件系统有两种:AUFS 和 OverlayFS。OverlayFS 的核心是四个目录:lowerdir(只读层,类似镜像地只读层)、upperdir(可写层)、mergedir(用户真实看到的文件系统)、workdir(中间目录)。
cd /tmp/overlay # 实验目录:lowerdir/ upperdir/ mergedir/ workdir/
ls lower upper merge work
# 组装 OverlayFS:把只读层 + 可写层 合并到 mergedir
sudo mount -t overlay overlay \
-o lowerdir=lower,upperdir=upper,workdir=work mergedir
# lower 有 1.txt 2.txt,upper 有 1.txt(同名)
ls mergedir # 看到同名文件时上层 upper 覆盖下层 lower
写时复制(Copy-on-Write) 是理解容器文件系统变更的关键:
- 在
mergedir里修改一个来自只读lowerdir的文件 → 新内容写入upperdir(upperdir 里"多出来"一个文件),lowerdir 完全不受影响。 - 在
mergedir里删除一个 lowerdir 的文件 → 实际上在upperdir生成一个标记为已删除的占位文件(用ls -la看会显示一个特殊字符),lowerdir 里的原文件仍然在。
lowerdir,容器启动后的可写层就是 upperdir,你在容器里看到的是 mergedir。你在容器里做的修改(写可写层),不会影响镜像本身 —— 这也呼应了镜像"不可变"的特性。想真正把"对容器的修改"固化回镜像,用:
docker commit <容器> 新镜像名:tag
从 Layer 特性看镜像优化
用 Nginx 做例子:镜像构建出来会有 6 个层(FROM 一个层、RUN 一个层、RUN 又一个层……),而 CMD 不会产生新的层,它只影响元数据。
Layer 缓存带来的关键优化点是指令顺序:
- 如果
COPY的源码没变,它下面所有层缓存都能复用。 - 一旦源码变了,
COPY这层及其之后的所有层都失效,即使后面的命令没变也要重新构建。
# 错误示范:把可能频繁变化的 COPY 放前面,坏处是源码一改缓存全失效
FROM node:18
COPY . /app
RUN npm install
# 正确示范:把"不变的依赖安装"放前面,COPY 靠后,源码变更只影响最后几层
FROM node:18
COPY package.json package-lock.json /app/
RUN npm install
COPY . /app
此外还有减少层数:把多条 RUN 合并成一条(用 && 连接),避免生成多个层、减小体积。
一句话总结 Dockerfile 优化原则:最不易变的放最前、最易变的放最后,并合并 RUN 减少层数。
Dockerfile 的最佳实践
这一讲把 Dockerfile 从"能跑"提升到"生产级"。核心围绕四个词:忽略、减体积、安全、多平台。
减体积:选对基础镜像 + 多阶段构建
.dockerignore 用于忽略不必要文件(node_modules 等)以减少构建上下文。而减镜像体积最有效的手段是:
- 选择合适的基础镜像:同样情况下
node:alpine和node:slim体积差别很大。Alpine、distroless 都是出了名的"小"。 - 多阶段构建:把构建环境和运行环境分开。以 Go 为例,构建需要完整的 golang 编译工具链,但运行时只需小镜像(如 debian slim)。多阶段构建能把镜像从 900 多兆直接减到几十兆。
# 多阶段构建:第一阶段编译,第二阶段运行
FROM golang:1.21 AS build # 第一阶段:构建环境
COPY . /src
WORKDIR /src
RUN CGO_ENABLED=0 go build -o bin/app .
FROM debian:slim # 第二阶段:运行环境(无编译工具,很小)
COPY --from=build /src/bin/app /app
CMD ["/app"]
COPY --from=build(或 --from=0)用于把上一阶段构建的产物拷过来。
为什么生产环境要慎用 Alpine
Alpine 以"极小"著称,最小只有几兆。但它的核心坑在于 C 库不同:
- 主流镜像用 glibc(GNU C 库,
libc.so.6)。 - Alpine 用 musl。
如果应用引用 / 调用了 C 库(比如一个依赖 CGO 的 Go 程序、Node 的原生模块),在第二阶段因为没有对应的 glibc 而运行不起来。
# 会报错:no such file or directory,但文件明明在 —— 其实是 C 库不匹配
FROM golang:1.21 AS build
COPY . /src
WORKDIR /src
RUN go build -o app main.go # 默认 CGO_ENABLED=1,链接了 glibc
FROM alpine
COPY --from=build /src/app /app
CMD ["/app"] # 运行报 EXEC no such file,因为缺 glibc
解决办法:Go 编译时关掉 CGO,编译纯静态链接的二进制,让它不再依赖任何 C 库:
FROM golang:1.21 AS build
COPY . /src
WORKDIR /src
RUN CGO_ENABLED=0 go build -o app main.go # 静态链接
FROM alpine
COPY --from=build /src/app /app
CMD ["/app"]
CGO_ENABLED=0 编译成静态二进制。极限压缩:用 scratch 空镜像
scratch 是一个空镜像,镜像就只剩下你的二进制可执行文件。把前面 Go 程序的运行阶段换成 scratch,镜像能压到 11MB 左右(基本等于二进制本身大小)。
FROM golang:1.21 AS build
COPY . /src
WORKDIR /src
RUN CGO_ENABLED=0 go build -o app main.go
FROM scratch # 空镜像
COPY --from=build /src/app /app
CMD ["/app"]
安全属性清单
- 尽量避免使用 root 用户:在
dockerfile里用USER声明非 root 用户,控制权限范围。 - 固定镜像 tag:
FROM时尽量不要用latest,因为上游一直在更新,某天上游更新可能导致你的业务逻辑出问题。 - 使用官方认证的基础镜像:在 Docker Hub 上标有官方认证的镜像相对安全可信;尽量避免第三方镜像,因为你不知道里面装了什么、可能存在安全漏洞或隐患。
- 设置时区:时区不正确,业务获取时间也会不正确。
构建镜像的进阶:DIND 与 BuildKit
docker build 依赖 dockerd 这个 daemon 进程。很多团队在 CI 里构建镜像时,直接把 docker daemon 挂到容器里 —— 这就是 DIND(Docker-in-Docker)。但 docker 权限很大,可能带来安全隐患,严重时甚至导致宿主机被入侵。
解决思路是不依赖 dockerd 构建镜像,即 BuildKit(以及同类工具)。BuildKit 可以让你在容器里(甚至在 K8s 里)构建镜像,不需要本机的 docker daemon,是更安全、更适合 CI 的做法。
循序渐进压缩镜像的完整实验
用一个 Go 例子串起来:最初多阶段构建(golang → 第二阶段基础镜像),镜像约 140MB。优化路径:
- 第二阶段基础镜像换成最小镜像 → 部分场景可行但埋下 C 库隐患。
- 换成写
CGO_ENABLED=0关掉 CGNU C 依赖 → 可在 Alpine 上正常运行。 - 第二阶段用
scratch空镜像 → 压到 约 11MB(几乎就是二进制文件大小)。
镜像真的"一次构建到处运行"吗?
答案是否。现代 CPU 架构有 amd64、arm64,而镜像里包含可执行文件,编译时是与平台相关的,所以镜像本质不是跨平台的。那么 Docker 是怎么实现"找对架构"的?multi-arch(多架构镜像)机制:
- 在 Docker Hub 拉镜像时,会告诉 registry 想要哪个平台(
amd64/arm64)。 - 镜像的架构信息存放在镜像的 manifest 文件里,该 manifest 是一个"索引",记录了各平台对应的 digest。
可以用 Google 开源的镜像工具 crane 查看:
crane manifest alpine # 看到 architecture 字段,以及 amd64/arm/arm64/386 等各平台 digest
构建多架构镜像:
docker build --platform linux/amd64,linux/arm64 -t my-app:1.0 .
# 若用 Docker Desktop 需在设置里打开"多平台"开关,否则会提示无法构建
容器运行时底层链路
运行时涉及的几个层,搞懂它们就知道"谁在干什么":
docker run ──► dockerd ──► containerd ──► runc(真正启动容器的底层工具)
Kubernetes ──► CRI 抽象层 ──► containerd / CRI-O ──► OCI 配置 ──► runc
这张图在讲什么:Docker 和 Kubernetes 虽然入口不同,但最终都会汇到同一条由 containerd → runc 组成的底层链路;runc 是真正拉起进程的那个工具。
flowchart LR
A[docker run] --> C[containerd]
K[kubectl / K8s 节点] --> I[CRI 抽象层
统一 K8s 与底层运行时] --> C
C --> R[runc
OCI runtime 标准实现]
R --> P[启动容器进程
需要 rootfs + config.json]- runc 是整条链路的核心,也是 OCI runtime 的标准实现。它不能直接"运行一个镜像",而是需要两样东西:文件系统(镜像解压出来的 Linux 文件系统)+ config.json(运行配置文件,含容器名、环境变量、启动命令与参数、各种钩子)。
# 用 runc 直接启动容器:需要 rootfs + config.json
runc spec # 生成默认 config.json
runc run my-container # 启动容器,进入后 ps / hostname 都是隔离的
初级应用定义:Manifest
这一讲快速回顾 Kubernetes 的应用定义——用 Manifest(YAML)来描述应用。重点不是学完所有 Kubernetes 对象,而是掌握工程实践里的几个实用技巧。
工程实践一:用 kubectl 在本地生成模板
很多人写 Manifest 会去百度、Google 文档,其实有更简单的办法:用 kubectl 在本地直接生成一个 YAML 模板,再根据模板修改,而不是部署到集群。
kubectl create deployment nginx --image=nginx:1.25 \
--dry-run=client -o yaml # 本地输出部署模板
kubectl create service nodeport nginx --tcp=80:80 \
--dry-run=client -o yaml # 生成 Service 模板
kubectl create configmap my-config \
--from-literal=key=value --dry-run=client -o yaml # 生成 ConfigMap
原理上,凡是工作负载或 Kubernetes 对象,都能用 --dry-run=client -o yaml 在本地拿模板再改。另外在 VS Code 里装 Kubernetes 插件,写 YAML 时会有模板内容提示。
工程实践二:YAML 多文件合并
一个 YAML 文件里可以用三个横杠 --- 分隔一次部署多个对象,可实现"一个文件多个对象"的集中部署:
apiVersion: apps/v1
kind: Deployment
spec: {}
---
apiVersion: v1
kind: Service
spec: {}
工程实践三:YAML 的几个经典坑
- 保留字变布尔值:写
no时渲染会被当成布尔值false而不是字符串"no"。要写字符串,用双引号包起来。 - 数字精度:
1.10会被自动转成1.1。如果 Version 是1.10,同样要加双引号。 - 多行内容的 4 种写法有区别(常用于写 ConfigMap):
|:保留文字间换行,保留末尾换行。|-:保留文字间换行,不保留末尾换行。>:不保留文字间换行(折叠),保留末尾换行。>-:不保留文字间换行,不保留末尾换行。- 横杠代表"不保留末尾换行"。
- 锚点引用:可以用
&定义一个块,再用*粗体关键字引用,不过生产实践中用得较少。
常用工具
- 3Y / YAML Viewer:校验 YAML 写法是否正确。
- yq:命令行修改 YAML,很多团队在 CI 里用它来改镜像版本或任意 key,非常实用:
yq eval '.spec.template.spec.containers[0].image = "app:2.0"' -i deploy.yaml
微服务示例应用的设计和实现
这一讲我们部署一个开源的示例投票应用(典型微服务架构),用它来复习 Manifest 定义,也用它作为后面 Helm / Kustomize 改造的对象。
应用架构:解耦的生产者-消费者
这是一个用 Redis 解耦、异步生产者-消费者的经典设计,非常值得借鉴:
| 服务 | 语言/框架 | 职责 |
|---|---|---|
| vote | Python / Flask | 投票页面,把投票 POST 到 Redis 列表尾部(rpush) |
| worker | .NET / ASP.NET | 消费 Redis 队列,把结果写入 PostgreSQL |
| result | Node.js / Express | 展示投票结果,通过 WebSocket 实时刷新 |
| redis | 中间件 | 用列表做异步队列,解耦生产与消费 |
| db | PostgreSQL | 持久化投票结果 |
关键点:
- vote:只定义了一个根路由,接收 POST 后
rpush到 Redis 列表"尾部"。它用 Python 写的,虽然依赖 Redis,但只有请求到来时才连接,所以能正常启动。 - worker:用
while死循环,每 100ms 从 Redis 列表"头部"(lpop)读数据,因为是"尾部进、头部出",就实现了先进先出(FIFO)队列;最后通过 SQLinsert写入 PG。 - result:不用 HTTP 轮询,而是用 WebSocket(socket.io) 实时推送,所以点完投票结果立即刷新;它用
setInterval每 1 秒循环查询 PG 并写入 socket。
容器化 result 的坑:PID 1 与僵尸进程
result 是 Node.js 服务,容器化后进程以 PID = 1 启动。PID 1 在接收退出信号时有一个经典问题:普通信号处理 + 产生"僵尸进程"(zombie),导致容器无法优雅退出、子进程无法回收。
解决办法是在 drivefile 里用 tini 这种"init 组件"来启动 Node.js:
# result 的 Dockerfile(核心是让 tini 成为 PID 1 去启动 node)
FROM node:18
ENV TINI_VERSION v0.19.0
RUN apt-get update && apt-get install -y tini
COPY . /app
WORKDIR /app
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "server.js"]
服务间依赖:从重启机制到 initContainer
result 依赖 PG、worker 依赖 Redis。如果业务优先于中间件启动、又连不上中间件,会抛异常、Pod 崩溃,然后 K8s 自动重启它:
- 方案一:业务代码里做重试。
result就是这么做的,每秒重试直到连上 PG。但现实中很多业务写不了这么健壮的代码。 - 方案二:靠 K8s 自动重启机制。容器异常退出(状态码非零)时 K8s 自动重启 Pod。但没有强烈依赖,因为它用的是指数退避(下一次重启时间是上一次的两倍),会让应用整体启动时间变得很长,影响到环境拉起速度(尤其开发/测试环境)。
- 方案三(推荐):用 initContainer 控制服务启动顺序,让业务容器"等待被依赖的服务就绪"后才启动,实现真正的启动依赖控制。
initContainer 实战 + RBAC 前置
wait 是开源工具,可在 initContainer 里等待某个 Label 的 Pod 就绪。因为 K8s 对 initContainer 有权限要求,先给 Pod / Service / Deployment 授一个 Role 并绑到 default ServiceAccount:
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: pod-reader
rules:
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list", "watch"]
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: wait-for-db
subjects:
- kind: ServiceAccount
name: default
roleRef:
kind: Role
name: pod-reader
apiGroup: rbac.authorization.k8s.io
然后在 result 的 Deployment 里加一个 initContainer,让它等 app=db 的 Pod 就绪:
spec:
initContainers:
- name: wait-for-db
image: ghcr.io/kyverno/cli/wait:latest # wait 开源工具
args:
- pod
- app=db # 等待 label app=db 的 Pod ready
查看效果:删掉 db 后部署 result,Pod 会一直处于 init 状态(业务容器没启动,0/1);部署 db 后,result 日志里出现 “Pod app=db ready”,业务容器才真正启动。这就实现了不改业务源码、纯 K8s 层的启动依赖控制。
链式启动整个微服务
有了 initContainer,就能控制整个微服务的启动顺序,实现链式依赖启动:依次 redis → db → worker → vote → result。做法就是在每个服务的 Deployment 里加对应的 initContainer:
redis 无依赖,第一个启动
db 等待 app=redis 就绪
worker 等待 app=db 就绪
vote 依赖 redis(请求时才连),可正常启动,但要等 worker
result 等待 app=vote 就绪
这张图在讲什么:每个服务都通过 initContainer 监听"依赖项对应 label 的 ready",于是整条链像多米诺骨牌一样依次被拉起。
flowchart LR
REDIS[redis
无依赖
第一个启动] --> DB[db
等待 app=redis 就绪]
DB --> WORKER[worker
等待 app=db 就绪]
WORKER --> VOTE[vote
等待 worker 就绪]
VOTE --> RESULT[result
等待 app=vote 就绪]一句话:把 initContainer 对应地写进每个工作负载,前端监听后端 label 的 ready,就能控制整条链的启动顺序。
数据表结构与初始化数据怎么处理
两种典型方式:
- 业务代码里直接初始化 + 填充种子数据:小业务、没有专职 DBA 时常用,简单直接(示例投票应用的 worker 服务就是这么初始化 PG 表结构的)。
- 用 Kubernetes Job 做初始化:在 Job 里 clone 一个 SQL Git 仓库,执行 SQL 语句初始化库。这是大项目推荐的方式,DBA 可以维护统一的 SQL schema 仓库,基于它做权限控制、回滚、门禁、review,等等。
中间件的最佳实践
示例应用里 Redis / PG 是用单实例测试方式部署的,生产不推荐——Pod 重启数据就丢了。生产建议:
- 用社区封装好的高可用 Helm chart:这些 chart 提供高可用、多实例(StatefulSet + 分片)部署,一条命令就能拉起。
- 更进一步,中间件尽量使用云服务,自托管是下策。理由:云厂商对中间件的高可用定制更深(自动备份等),出了问题是大规模问题、不是你们运维团队的锅;还能通过工单获得厂商工程师服务。
应用定义:Helm 入门和实战
在 K8s 上部署微服务,三个东西缺一不可:应用代码(源码)、Dockerfile(把代码打包成镜像)、K8s Manifest(定义镜像怎么部署到集群)。当服务数量少时 Manifest 还可控;到十几个、几十个时,原生 Manifest 就难以管理了。
Helm 是什么:K8s 的"应用安装包"
想想在 Windows / Mac 上装软件:你只需要拿到安装包,双击安装,可能暴露少数几个必要参数让你覆盖。Helm 之于 K8s,就是这种"应用安装包":它把复杂的逻辑整体内聚在包里,对用户只暴露必要的参数,一条命令完成安装。
Helm 最大的能力是把一个 K8s 应用打包,并对 Manifest 模板化。原生 Manifest 是硬编码的,无法实现动态逻辑;有了模板,部署时只需覆盖个别参数就能实现动态部署(比如不同环境的参数差异)。
三大核心概念
| 概念 | 含义 | 类比 |
|---|---|---|
| Chart | K8s 应用的安装包,包含应用的所有 K8s 对象,用于创建 release | Windows 的 .msi / Mac 的 .dmg 安装包 |
| Release | Chart 安装后的实例,可在集群不同命名空间安装多次 | “双击执行安装包"这个安装行为 / 结果 |
| Repository | 托管、分发 Chart 的仓库,提供存储与拉取,可存 Git 或 OCI 仓库 | 应用商店 / 软件源 |
这张图在讲什么:chart 是"安装包”、release 是"装好的实例"、repository 是"下载源"——三者构成 Helm 的完整分发与运行闭环。
flowchart LR
RE[Repository
软件源:托管 / 分发 chart] -->|拉取 chart| CH[Chart
应用的安装包]
CH -->|helm install 模板化渲染| RL[Release
安装后的一整套 K8s 对象
可在多个命名空间各装一次]常用命令
helm install <release> <chart路径> # 安装,安装后成为 release
helm uninstall <release> -n <ns> # 卸载
helm upgrade --install <release> <chart> # 有则升级、无则安装(幂等,CI 常用)
helm repo add <name> <url> # 添加远端仓库
helm list -n <ns> # 列出当前 release
helm template . # 本地渲染模板调试(不安装)
helm dependency build / update # 拉取并重新生成依赖
从零创建一个 Chart
helm create demo # 生成一个名为 demo 的 chart
生成的目录结构三大块:
demo/
├── templates/ # 放所有 K8s Manifest(deployment.yaml / service.yaml / ...)
├── chart.yaml # 描述 chart 元数据:名称、版本、依赖 dependency、作者
└── values.yaml # 整个 chart 可配置的参数(默认值),安装时可覆盖
values.yaml 就是模板化的关键:里面配置的每个参数都可在安装时覆盖,不覆盖就用默认值。模板变量在 templates/ 里用 Go template 语法读取:
# values.yaml
image:
repository: nginx
tag: v1.0.0
# templates/deployment.yaml(片段)
image: {{ .Values.image.repository }}:{{ .Values.image.tag }} # 渲染后 = nginx:v1.0.0
安装、验证、卸载
helm install nginx ./demo \
-n myapp \
--create-namespace \
--set image.tag=latest # 临时覆盖 values.yaml 里的字段
helm list -n myapp # 查看 release
helm uninstall nginx -n myapp # 卸载
upgrade –install:CI 里的幂等用法
在 CI 流水线里每次都会跑部署,如果用 helm install,第二次运行时因为 release 已存在会直接抛异常。解决办法是用 helm upgrade --install:没有该 release 就安装,有就升级;若参数不变,升级就相当于什么都不做。这比 install 更常用。
helm upgrade --install nginx ./demo -n myapp --create-namespace
安装远端 Chart
除了本地 chart,还可以直接安装远端仓库的 chart(下面用 Ingress 举例):
helm upgrade --install ingress ingress/ingress-nginx \
-n ingress --create-namespace
Helm 会先从远端 repository 找到对应的 chart,再安装。
实战:把示例应用改造成 Helm chart
改造时需要抓住几个问题:抽哪些变量、如何调试、多环境怎么处理、能否控制中间件部署。
- 建立目录 & 拷入 Manifest:创建
demo6,建好templates/、chart.yaml、values.yaml,把示例应用原来的 K8s YAML 拷进templates/(原生 K8s 对象即使不模板化也能直接放进去)。 - 抽变量:把
vote / worker / result三个服务的image和tag抽到values.yaml,并在对应工作负载里改成模板变量:image: {{ .Values.result.image }}:{{ .Values.result.tag }} - 条件控制中间件部署:给
postgresql.deploy/redis.deploy这类布尔开关,用 Go template 的if控制是否渲染中间件对象。生产环境可能用云厂商中间件,就不该部署自己的。
# templates/db.yaml 开头
{{- if .Values.postgresql.deploy }}
apiVersion: apps/v1
kind: Deployment
# ... PG 的完整定义
{{- end }}
调试渲染(不实际安装)用 helm template .,能直观看到替换后的结果,也可以检查 if 条件是否生效。改造后 helm install vote ./demo6 -n myapp --create-namespace 一条命令完成安装/卸载/升级。
高级主题 1:Chart 依赖(Dependency)
如果一个应用不想自己写 PG / Redis,而想引用社区封装的高可用 chart,就可以用 dependencies。在 chart.yaml 里声明:
# chart.yaml
dependencies:
- name: redis
version: 17.16.0
repository: https://charts.bitnami.com/bitnami
condition: redis.deploy # 用 values 里的开关控制是否启用
- name: postgresql-ha
version: 11.9.0
repository: https://charts.bitnami.com/bitnami
condition: postgresql.deploy
安装前必须从远端拉取依赖:
helm dependency update ./demo7 # 把声明的子 chart 下载到 charts/
dependency 机制其实是借助**子 chart(subchart)**实现的:运行 dependency 命令时会把声明依赖的 chart 下载到 charts/ 目录,安装时一并安装。对于复杂应用,可以拆成多个 chart 再用依赖方式引用。
values.yaml 里用一个与子 chart 同名的根节点来复写。先用 helm inspect values <chart> 查看第三方 chart 有哪些可覆盖参数。# 父 values.yaml:复写 redis / postgresql-ha 子 chart 的配置
redis:
fullnameOverride: redis
auth:
enabled: false # 关掉认证,方便直连
postgresql:
postgresqlUsername: vote
postgresqlPassword: vote123
高级主题 2:内置变量、global 与 Hooks
内置变量:
Release(如Release.Name、Release.Namespace)、Values(读取 values.yaml)、还有 chart.yaml 里的内容也能引用。include/ 模板内置函数:Go template 里有printf等大量内置函数,可用于字符串处理等;需要时查文档即可。global 保留关键字:在
values.yaml里声明一个global根节点,父子 chart 都能读取,适合放跨图表共享的全局值。Hooks(钩子):安装过程中的一系列执行点,比如:
pre-install:模板渲染后、创建任何资源前执行(如先拉起数据库、做前端初始化数据)。post-install:资源部署到集群后执行。pre-delete/post-delete:删除前/后执行。
Hooks 通过
annotation声明用哪个钩子,并可用weight权重控制多个钩子的执行顺序(权重越小越早执行,可为负数)。
kind: Job
metadata:
annotations:
"helm.sh/hook": post-install # 安装完成后执行
"helm.sh/hook-weight": "0"
常用命令速查
helm upgrade --install ... # 安装/升级(幂等)
helm inspect values <chart> # 查看 chart 可复写的 values
helm repo list # 查看本地已添加的仓库
helm repo add <name> <url> # 添加仓库
helm dependency update # 升级应用前更新依赖
helm rollback <release> <rev> # 回滚到上一个版本
应用定义:Kustomize 入门和实战
Helm 之后我们再学 Kustomize。它(这就是后续开发 AIOps 会用到的工具),值得多花点时间。
什么是 Kustomize
Kustomize 是一个扩展 k8s 自带的工具,核心能力是对 Manifest 的任何字段进行复写(override)。和 Helm 不同:Helm 要抽模板变量才能复写,而 Kustomize 不需要抽模板变量,对任意字段都能复写。它尤其适合多环境场景,并且直接内置到了 kubectl 里(kubectl apply -k)。
布局:base + overlay
最基础的 Kustomize 定义有 base 目录 + 若干 overlay 环境目录,每个目录都有自己的 kustomization.yaml:
myapp/
├── base/ # 所有环境通用的基准
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml # 声明 resources 引用哪些文件
└── overlay/
├── dev/
│ ├── redis.yaml # 开发环境自己写的中间件
│ ├── db.yaml
│ └── kustomization.yaml # 声明引用 base + 本目录差异
└── prod/
└── kustomization.yaml # 生产环境:用第三方 helm chart
- 每个环境目录除了公共的基础对象,还可以有自己的特殊对象(如 HPA、更多 ConfigMap)。
base负责公共部分、是所有环境的基准;overlay用kustomization.yaml描述"在这些基础上我叠加 / 覆盖什么"。
这张图在讲什么:一次定义 base,多个 overlay 各自"只写差异",最终 kubectl apply -k 把 base + overlay 拼成各自环境的完整 Manifest。
flowchart TB
BASE[base
所有环境公共的基准对象] --> DEV[overlay/dev
叠加差异 + 自己写的中间件]
BASE --> PROD[overlay/prod
叠加差异 + 第三方 helm chart]
DEV --> K1[kubectl apply -k
dev 完整 Manifest]
PROD --> K2[kubectl apply -k
prod 完整 Manifest]kustomization.yaml 能配置什么
kustomization.yaml 是 Kustomize 的核心,常用配置项:
resources: # 引用要组合哪些 manifest 文件
- deployment.yaml
- service.yaml
secretGenerator: # 从文件/字面量生成 Secret
- name: my-secret
literals:
- password=123456
configMapGenerator: # 生成 ConfigMap
- name: my-config
literals:
- key=value
images: # 快捷复写镜像仓库与 tag
- name: myapp
newName: registry.example.com/myapp
newTag: v2.0.0
patches: # 通用复写:改任意 manifest 里的任意 key
- target:
kind: Deployment
name: vote
patch: |-
- op: replace
path: /spec/replicas
value: 3
helmCharts: # 引第三方 helm chart(Helm + Kustomize 可一起用)
- name: redis
version: 17.16.0
repo: https://charts.bitnami.com/bitnami
实战一:改造成 base + overlay/dev
把示例应用改造成 Kustomize:
- 创建
base/,把 vote / worker / result 这些公共业务工作负载放进去,并在base/kustomization.yaml里声明resources。 - 创建
overlay/dev/,把开发环境需要的、自己写的 redis / db 中间件放进去,在overlay/dev/kustomization.yaml里声明引用../base的资源加上本目录的 redis/db。 - 渲染与调试(Kustomize 内置到 kubectl):
cd overlay/dev
kubectl kustomize . # 只渲染不安装,查看复写后的完整结果
kubectl apply -k . # 一步安装(-k 触发 Kustomize 原生渲染)
这样 myapp/base + overlay/dev 的组合就完成了。“lib层结果安装 dev 环境 = base 的所有对象 + dev 目录的中间件。”
实战二:overlay/prod 引用第三方 Helm chart
生产环境更严肃——不想自己写的中间件,而是引用第三方 helm 的 PG/Redis。方法是在 overlay/prod/kustomization.yaml 里:
resources:
- ../base
helmCharts:
- name: redis
version: 17.16.0
repo: https://charts.bitnami.com/bitnami
- name: postgresql-ha
version: 11.9.0
repo: https://charts.bitnami.com/bitnami
# 通过 valueInline 复写第三方 chart 的 values;也常用 patches 复写任意字段
渲染时用 kubectl kustomize ./overlay/prod --enable-helm(加 --enable-helm 让 Kustomize 帮你下载并启用第三方 helm chart),确认生效后再 kubectl apply -k . 部署。
为什么还需要 Kustomize:选型得失
| 维度 | Helm | Kustomize |
|---|---|---|
| 复写能力 | 需抽模板变量 + 提供值 | 任字段直接复写,不学模板语法 |
| 上手难度 | 需学 Go template 模板语法 | 低,只需 patches / images |
| 多环境 | 通过不同 values 文件 | 通过 base + overlay 复用,只关心差异部分 |
| 对用户友好度 | 对终端用户隐藏应用细节 | 更面向 K8s 开发者 |
| 分发 | 很方便,自带 OCI/仓库、生态大 | 分发不便,生态比 Helm 差 |
| 依赖 | 目录结构相对宽松 | 强依赖目录结构,结构决定布局是否合理 |
| 模板 | 强大、支持动态逻辑 | 无模板变量,纯补丁复写 |
总结
容器和 K8s 是 AIOps 的运算底座。这一章我们从最底层的原理一路走到应用定义工具,主线是:
- 容器本质:不是虚拟机,宿主机上的一个进程;靠 Namespace(隔离视野)+ Cgroups(限额资源)+ UnionFS/OverlayFS(文件系统分层与写时复制)实现。历史脉络 chroot(1979) → namespace(2002) → cgroup(2007) → Docker(2013)。
- 镜像构建:Dockerfile 的核心指令 FROM/RUN/COPY/CMD;镜像逐层堆叠、layer 缓存、
.dockerignore;优化原则是"最不变放最前、合并 RUN 减层"。 - 最佳实践:多阶段构建分离构建/运行环境;慎用 Alpine(glibc vs musl);
CGO_ENABLED=0静态链接 + scratch 极限压缩(Go 可从 900M 压到 11M);安全清单 + 固定 tag + 官方镜像 + 非 root;镜像并非真正跨平台,靠 multi-arch manifest 按平台拉取;底层链路 dockerd→containerd→runc。 - Microservice 应用定义:vote/worker/result+redis+db 的异步生产者-消费者设计;result 的 PID 1 用 tini 解决僵尸进程;用 initContainer 实现链式启动依赖控制;数据初始化用 Job、中间件尽量云服务。
- Helm:chart/release/repository 三大概念;template 模板变量与条件控制;
upgrade --install幂等、dependency 引用子 chart、hooks;把示例应用改造成一条命令可装/卸/升的 chart。 - Kustomize:base/overlay 多环境模式;resources / images / patches / helmCharts 复写任意字段,无需模板;
kubectl apply -k原生支持;与 Helm 按场景互补。
这一章最怕"似懂非懂"——概念翻来覆去听懂了就觉得很对啊,但真要动手写一个 Dockerfile、排一个镜像起不来的错、把一个 Manifest 拆成 base/overlay,每一处都有坑。建议所有命令都亲手敲一遍,从底层原理到 Helm/Kustomize 的改造闭环都走完,后面写 AIOps 应用、调 Operator 时才会游刃有余。
自测题与动手练习
Namespace(6 种:mount/pid/net/uts/user/ipc)管"进程能看到什么",隔离文件系统、进程、网络、hostname 等视角;
Cgroups 管"进程能用多少资源",约束 CPU、内存、网络 IO。
二者作用对象都是"进程",所以容器就是被隔离看视野、被限制配额的进程 —— 每个容器看起来像一台独立虚拟机。
OverlayFS 四个目录:
lowerdir(只读层,= 镜像只读层)、upperdir(可写层,= 容器启动后的可写层)、mergedir(用户看到的文件系统)、workdir(中间目录)。写时复制:在 mergedir 改/删 lowerdir 的文件时,新内容 / 删除标记落在 upperdir,lowerdir 完全不受影响 —— 所以"在容器里的修改不污染镜像",镜像保持不可变。
COPY . /app 放前面会导致每次源码变更都重建缓存?正确的 Dockerfile 排序原则是什么?COPY . /app 打包的是源码,最容易变,放前面会导致依赖安装那几层每次作废。正确原则:最不易变的放最前、最易变的放最后 —— 先
COPY package.json / go.mod 装依赖,最后再 COPY . 拷源码;同时合并多条 RUN 减少层数、用 .dockerignore 忽略不必要文件。解法:Go 编译时设
CGO_ENABLED=0(go build 时禁用 CGO),编译成纯静态链接二进制,不依赖任何 C 库,即可在 Alpine 甚至 scratch 空镜像上运行。用 scratch 可把镜像极限压到约 11MB(基本=单个二进制文件)。构建用
docker build --platform linux/amd64,linux/arm64 ...。运行时链路:
docker run → dockerd → containerd → runc;K8s 侧走 CRI 抽象层 → containerd/CRI-O → OCI 配置 → runc。runc 需"文件系统 + config.json"两个 OCI 产物来真正启动进程,是容器技术最底层工具。kubectl create ... --dry-run=client -o yaml 有什么用?no 这种保留字会被渲染成布尔值而非字符串,需加双引号;② 1.10 会自动变 1.1,Version 要加引号;③ 多行内容 |/|-/>/>- 四种写法在"是否保留文字间换行 / 末尾换行"上不同,写 ConfigMap 常踩。kubectl create <对象> ... --dry-run=client -o yaml 能在本地生成 YAML 模板而不实际部署,再基于模板修改即可写出 Manifest,不用到处去搜文档。result 依赖 PG、worker 依赖 Redis,若业务先于中间件启动会连不上而崩溃、被 K8s 自动重启(指数退避,下次重启时间是上次两倍,导致整体启动很慢)。用 initContainer(如开源 wait 工具)等待
app=db 这类 label 的 Pod 就绪后才启动业务容器,即在 K8s 层、不改源码地控制启动顺序。配合 RBAC 给 default ServiceAccount 授权 Pod/Service 查询权限。同理把 initContainer 链式写进每个工作负载,就能实现
redis→db→worker→vote→result 的链式启动。helm upgrade --install 相比 helm install 有什么好处?helm upgrade --install 在 repo 无 canary 时会自动安装、有则升级;CI 每次运行不会像 helm install 那样因 release 已存在而抛异常,是更推荐的幂等安装方式。resources / images / patches / helmCharts 只描述"差异部分",不用抽模板变量、不用学 Go template 语法,kubectl apply -k 原生支持。若还要"打包分发整套成熟应用 + 动态复杂模板 + 依赖管理",则用 Helm(chart/release/dependency/hooks)。二者可组合:Helm 装第三方中间件、Kustomize 做环境差异。
动手练习(建议真做一遍):
- 在 Linux 虚拟机上启动一个 Nginx 容器,用
ps找到进程 PID,分别只用nsenter --mount和全部 namespace 改 hostname,观察宿主机是否受影响;再用mount -t overlay手动拉一个 OverlayFS,体会写时复制与删除占位文件。 - 用一个 Go HTTP 服务,走完"基础多阶段构建(140M) → 换 Alpine 报 no such file →
CGO_ENABLED=0修复 → 换 scratch 压到 ~11M"的完整压缩链路;再用docker build --platform linux/amd64,linux/arm64构建一次多架构镜像。 - 用
kubectl create deployment/service --dry-run=client -o yaml生成模板改出示例投票应用,并给 redis/db/worker/vote/result 逐个加 initContainer,实现并观察链式启动顺序。 - 把示例应用改造成 Helm chart(抽 image/tag + 条件控制中间件 + dependency 引用第三方 redis/pg),用
helm template .调试、helm upgrade --install部署、helm rollback回滚一次。 - 把同一套 Manifest 改造成 Kustomize 的
base + overlay/dev + overlay/prod,dev 用自己的 redis/db,prod 用第三方 helm chart,分别用kubectl kustomize .和kubectl apply -k .完成渲染与部署。
本章小结
- 容器 = 宿主机上的进程,靠 Namespace(看什么)+ Cgroups(用多少)+ UnionFS/OverlayFS(文件分层与写时复制) 隔离;历史 chroot(1979)→namespace(2002)→cgroup(2007)→Docker(2013),Docker 的创新在工程化与分发。
- 镜像是同类定义、容器是实例化(class vs new);镜像逐层堆叠、layer 按内容哈希缓存,所以 Dockerfile 最不易变放最前、最易变放最后,并合并 RUN 减层、用
.dockerignore。 - 最佳实践:多阶段构建分离构建/运行环境;慎用 Alpine(glibc vs musl),Go 用
CGO_ENABLED=0静态链接后可在 scratch 上把镜像压到约 11M;固定 tag、用官方镜像、非 root、设时区。 - 镜像并非真正跨平台,靠 multi-arch manifest 按平台分发;运行时底层
dockerd → containerd → runc,K8s 走 CRI → OCI → runc;runc 由"文件系统 + config.json"启动进程。 - 示例投票应用是 Redis 异步解耦的生产者-消费者;Node 容器化用 tini 解决 PID 1 僵尸进程;用 initContainer 在 K8s 层实现 redis→db→worker→vote→result 链式依赖;数据初始化可用 Job、中间件尽量云服务。
- Helm:chart/release/repository 三大概念;模板变量 +
if条件控制;upgrade --install幂等、dependency引用子 chart、hooks;适合打包分发成熟应用、复杂模板。 - Kustomize:base/overlay 多环境复用,
resources/images/patches/helmCharts复写任意字段且无需模板语法,kubectl apply -k原生支持;适合多环境打补丁,可与 Helm 组合使用。 - 本章是容器和 K8s 的应用定义底座,后面的 AIOps 故障恢复、Operator、流量预测都会建立在这些能力之上。