Docker 和 Kubernetes:现代开发者实用指南
一份无废话的容器化、Dockerfile 最佳实践、Kubernetes 基础知识以及判断何时真正需要编排器的指南。
每个开发者最终都会碰到同样的问题。你在一台机器上编写代码,它完美运行,你推送到预发布环境,然后它用一个关于缺少系统依赖或不同库版本的晦涩错误爆炸了。经典的“在我机器上能运行”问题困扰了软件开发数十年,Docker 不仅解决了它——它让解决方案变得如此简单,以至于现在没有理由不使用它。
但 Docker 只解决了打包问题。一旦你将应用容器化,你仍然需要将其运行在生产环境中——可能跨多台服务器,需要负载均衡、零停机部署、健康检查和自动恢复。这就是 Kubernetes 发挥作用的地方。这也是大多数开发者迷失在复杂性中的地方,因为 Kubernetes 引入了一整套新的抽象词汇表,需要时间来内化。
本指南删繁就简。它涵盖容器的底层原理、如何编写高效安全的 Dockerfile、部署真实应用所需的 Kubernetes 概念、Docker Compose 和 Kubernetes 在本地开发中的权衡,以及最重要的是——何时应该使用每个工具,何时应该不使用。
容器到底是什么
容器不是轻量级虚拟机。这是最常见的误解,会导致错误的思维模型。虚拟机在管理程序之上运行完整的客户操作系统,拥有自己的内核、自己的内存分配和自己的设备驱动程序。容器共享主机的内核,并作为隔离的用户空间进程运行。隔离由 Linux 内核功能提供——命名空间用于进程隔离,cgroups 用于资源限制,overlay 文件系统用于高效镜像层。
这种区别很重要,因为它解释了你将会观察到的行为。容器在毫秒内启动,因为没有内核需要引导。它们使用更少的内存,因为没有重复的内核和多余的系统进程。但它们也意味着在 Linux 上运行的容器不能运行与主机不同的内核版本,而 Windows 容器需要 Windows 主机。在 macOS 上,Docker Desktop 正是出于这个原因在轻量级虚拟机内运行 Linux 容器。
镜像是只读模板——文件系统快照加上元数据。容器是镜像的运行实例,上面有一个可写层。你可以构建一次镜像并从它运行数十个容器。这是 Docker 世界中的基本操作单元,清晰理解它能让其他一切变得更简单。
Dockerfile 最佳实践
Dockerfile 是构建镜像的配方。每条指令创建一个新层,层会被缓存。这意味着指令的顺序直接影响构建速度、镜像大小和安全性。以下是在实际项目中最重要的原则。
按变更频率排序层
Docker 在构建后会缓存每一层。如果自上次构建以来某一层没有变化,Docker 重用缓存的版本。这意味着你应该将变更不频繁的指令放在顶部,变更频繁的指令放在底部。系统依赖(apt-get、apk add)几乎从不变化。应用依赖(npm install、pip install)在更新锁定文件时变化。应用源代码每次提交都会变化。
# Bad: source code before dependencies
FROM node:20-alpine
WORKDIR /app
COPY . . # busts the cache for everything below
RUN npm ci # runs on every build, even if package.json did not change
EXPOSE 3000
CMD ["node", "dist/index.js"]
# Good: stable-first layer ordering
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # cached unless package.json changes
COPY . . # only the source changes bust this layer
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]差别是巨大的。糟糕的 Dockerfile 每次提交都要重建所有依赖。好的 Dockerfile 仅在锁定文件变化时重建依赖,通常每拉取请求一次而不是每次提交。在一个有 500 个依赖的 Node.js 项目中,这可以减少每次构建两分钟。
多阶段构建
多阶段构建让你可以用一个 Dockerfile 构建应用并生成一个最小的运行时镜像。构建阶段包含编译器、开发依赖和构建工具。运行时阶段只复制编译后的输出。这保持了生产镜像的小尺寸并减少了攻击面。
# Build stage
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage — starts from a fresh, minimal base
FROM node:20-alpine AS runner
WORKDIR /app
# Only what is needed to run
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/package.json ./
COPY --from=builder /build/node_modules ./node_modules
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]运行时阶段不包含 TypeScript 编译器、源文件或任何开发依赖。对于典型的应用,这可以将镜像从 800 MB 缩小到 200 MB 以下。COPY --from=builder 语法是关键——它从前一阶段拉取文件而不携带该阶段的层。
以非 root 用户运行
容器默认以 root 运行。这是一个安全风险:如果攻击者利用了你的应用,他们在容器内拥有 root 权限。修复方法是在 Dockerfile 中添加一行切换到非 root 用户。大多数基础镜像为此目的附带了一个 node 或 nobody 用户。
除了安全性之外,多阶段构建和正确的层排序还能提高 CI/CD 管道的速度。镜像构建上节省的每一分钟都是你的开发者不用等待部署的一分钟。在一个每天部署五次、拥有十名开发者的团队中,每次构建节省两分钟每年可以回收超过六十小时的开发者时间。
Kubernetes 基础
Kubernetes 是一个容器编排器。它接收一组机器(节点),将容器调度到它们上面,保持它们运行,处理网络连接,并提供一个声明式 API 来描述系统所需状态。你告诉 Kubernetes 你想要什么——三个 API 服务器副本、暴露 8080 端口、滚动更新策略——然后它来实现。
学习曲线是真实存在的,因为 Kubernetes 引入了一组分层的抽象概念。你最常与之交互的三个是 Pod、Deployment 和 Service。
Pod
Pod 是 Kubernetes 中最小的可部署单元。它代表一个或多个共享网络命名空间和存储卷的容器。在实践中,大多数 Pod 运行单个容器。Sidecar 模式(主容器加上日志或代理容器)使用多容器 Pod,但对于日常应用部署,你会使用每个 Pod 一个容器。
你很少直接创建 Pod。Pod 是短暂的——它们随时可能被终止和重新调度。如果你手动创建了一个 Pod 并且它所运行的节点故障了,这个 Pod 就永远消失了。这就是 Deployment 发挥作用的地方。
Deployment
Deployment 管理一组相同的 Pod(一个 ReplicaSet)。它处理滚动更新、扩缩容、自愈和回滚。这是你将用来部署无状态应用的资源。
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
labels:
app: api-server
spec:
replicas: 3
selector:
matchLabels:
app: api-server
template:
metadata:
labels:
app: api-server
spec:
containers:
- name: api
image: myregistry/api-server:v1.2.3
ports:
- containerPort: 3000
resources:
requests:
cpu: 250m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
livenessProbe:
httpGet:
path: /healthz
port: 3000
readinessProbe:
httpGet:
path: /ready
port: 3000这个 Deployment 声明了 API 服务器的三个副本。Kubernetes 会确保三个 Pod 始终在运行。如果 Pod 崩溃,Kubernetes 会创建一个替代品。在滚动更新期间(更改镜像标签),Kubernetes 逐个替换 Pod,确保零停机。存活检查告诉 Kubernetes Pod 何时健康;就绪检查告诉它 Pod 何时准备好接收流量。
Service
Pod 有动态 IP 地址。每次 Pod 被重新创建时,它都会获得一个新的 IP。Service 提供了一个稳定的网络端点,可以在匹配其选择器的 Pod 之间负载均衡流量。这就是系统其他部分找到并与你的应用通信的方式。
apiVersion: v1
kind: Service
metadata:
name: api-server
spec:
selector:
app: api-server
ports:
- port: 80
targetPort: 3000
type: ClusterIP这个 Service 将稳定集群内部 IP 上的端口 80 映射到具有标签 app: api-server 的 Pod 上的端口 3000。集群内的其他服务可以通过 DNS 名称 api-server 到达它。对于外部流量,你需要在 Service 之上使用 LoadBalancer 或 Ingress 资源。
Kubernetes 不是你部署到的平台。是你向之描述部署的平台。命令式和声明式之间的区别是你能够做出的最重要的思维转变。
本地开发:Docker Compose vs Kubernetes
团队犯的最大错误是假设他们需要在本地开发中使用 Kubernetes,因为他们在生产环境中使用它。Docker Compose 和 Kubernetes 服务于不同的目的,在本地工作中选择错误的工具会带来不必要的摩擦。
Docker Compose 专为本地开发设计。它在单台机器上运行,在几秒内启动容器,并有一个简单的 YAML 格式直接映射到你所需要的:Web 服务器、数据库、Redis 实例,可能还有一个队列工作者。你定义服务,docker compose up 启动一切,日志流式传输到终端,端口映射到 localhost,热重载开箱即用。
version: "3.8"
services:
api:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- DATABASE_URL=postgres://user:pass@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:这个 Compose 文件为你提供了一个功能齐全的开发环境,具有热重载、持久化存储的本地数据库以及服务间正确的网络连接。只需要大约三十行 YAML,并在十秒内启动。
Minikube、Kind 和 k3s 可以在本地运行 Kubernetes,但它们会增加显著的额外开销。它们需要更多内存,需要更长时间启动,并引入你迭代单个功能时根本不需要的复杂性(Ingress 控制器、服务网格、存储类)。在本地运行 Kubernetes 对测试 Kubernetes 特定的行为很有用——比如 Pod 驱逐策略、水平 Pod 自动扩缩或自定义资源定义——但它不能替代 Compose 在日常开发中的角色。
- 使用 Docker Compose 进行本地开发。它快速、简单,直接映射到你所运行的容器。
- 当你的生产基础设施使用 ConfigMaps、Secrets 或自定义控制器等 Kubernetes 功能时,使用 Kubernetes(通过 Minikube 或 Kind)进行集成测试。
- 只有当你需要 GPU 访问、专用硬件或与生产环境完全一致的共享预发布环境时,才使用远程开发集群。
- 不要仅仅因为你有两个环境就在本地运行两个 Kubernetes 集群。Compose 用单个 --profile 标志就能处理。
- 如果你的团队花更多时间调试 Kubernetes 配置而不是编写应用代码,你已经超出了你的头灯范围。退回使用 Compose,只有在没有某个功能带来的痛苦超过维护它的痛苦时才添加复杂性。
常见陷阱及如何避免
即使你理解了概念,某些错误在不同团队中反复出现。以下是一些值得记住的错误,以便你可以跳过那些两天的调试会话。
镜像标签混乱是最常见的生产问题。在 Kubernetes Deployment 中使用 latest 作为镜像标签意味着你无法判断哪个版本正在任何给定节点上运行。Kubernetes 只在镜像不存在时才拉取,所以一个节点上的 latest 可能与另一个节点上的 latest 是不同的版本。始终使用语义版本标签或提交 SHA。更好的是,使用完全合格的镜像摘要——这是唯一保证不可变的东西。
资源请求和限制经常被省略或随意设置。如果你不设置请求,Kubernetes 无法智能地调度你的 Pod,节点就会过载。如果你不设置限制,一个 Pod 的内存泄漏可能会使同一节点上的其他 Pod 崩溃。两者都要设置,并使用推荐模式下的 Vertical Pod Autoscaler 等工具基于实际使用情况来调整它们。
ConfigMaps 和 Secrets 以环境变量或文件的形式挂载。环境变量很方便,但任何更改都需要重启 Pod 才能生效。基于文件的挂载可以不用重启就更新(Pod 在访问文件时读取新内容),但许多应用在启动时缓存配置。了解你的应用使用哪种模式,并相应地设计你的配置方法。
Kubernetes 中的持久卷不是魔法。PersistentVolumeClaim 请求存储,但底层的存储类必须为你的云提供商配置。默认的存储类可能使用与本地 SSD 性能特性不同的网络附加存储。如果你的数据库性能很重要,在生产之前对你的存储类进行基准测试。
在 Kubernetes 中日志记录和调试比在单台服务器上更难。Pod 是短暂的,所以日志在 Pod 被删除时消失。使用 kubectl logs --tail=50 -f pod-name 进行实时跟踪,但对于生产调试,你需要一个集中式日志解决方案(Loki、Elasticsearch 或云日志服务)。同样,用 kubectl exec -it pod-name -- sh 进入 Pod 检查运行中的容器,但请记住你在运行中的容器内做出的任何更改在重启后都会丢失。
你真的需要 Kubernetes 吗?
这是一个没人愿意问的问题,因为 Kubernetes 在工程简历上看起来不错并且标志着操作成熟度。但 Kubernetes 是对一个特定问题的解决方案:跨多台机器运行多个容器化服务,具有自动恢复、扩展和滚动部署的能力。如果你只有一两个服务运行在单台服务器上,Kubernetes 是大材小用。
这是一个直截了当的决策框架。如果你部署的服务少于每秒一万个请求,单台服务器在生产中使用 Docker Compose(是的,Compose 对许多工作负载在生产中运行良好)就能很好地满足你的需求。添加 Caddy 或 Nginx 作为反向代理,设置自动备份,你就有了一个单个开发者可以完全理解和维护的生产系统。
在以下情况下迁移到 Kubernetes:你拥有需要独立部署的多个服务,你需要按服务扩缩容(你的 API 需要十个副本但你的工作者只需要两个),你需要零停机部署作为常规操作,或者你的团队中至少有一人主要负责基础设施。在这些条件满足之前,Kubernetes 的操作成本——无论是集群管理还是开发者的认知负担——都是净负值。
许多团队受益于中间方案。使用 Docker Compose 进行本地开发,使用 AWS App Runner、Google Cloud Run 或 Fly.io 等托管容器平台进行生产。这些平台提供容器部署、自动 HTTPS 和扩展,而无需你管理 Kubernetes 控制面。你获得容器化的大部分好处,而没有 Kubernetes 的学习曲线。
最好的基础设施策略是让你的团队能够交付功能的策略。Docker 和 Kubernetes 是工具,不是身份。当它们有帮助时使用它们,当它们没有帮助时跳过它们。
