← Blog
·10 blog.minutes

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 是工具,不是身份。当它们有帮助时使用它们,当它们没有帮助时跳过它们。