动手!部署你的第一个 K8s 应用

一、目标说明

上一篇文章把 Docker 和 K8s 的概念地图铺开了。这篇文章要做的是:真正动手,在本地 K8s 集群上部署一个完整的应用

读完这篇文章,读者能:

  • 验证本地 K8s 环境是否可用
  • 写出一个完整的 Deployment YAML(并理解每一行在说什么)
  • 写出 Service 让 Pod 可以稳定访问
  • 用 ConfigMap 和 Secret 把配置从镜像里拆出来
  • kubectl apply 把整套东西一键部署
  • 通过 kubectl port-forward 在浏览器里访问应用

二、前置条件

前置条件要求验证命令
Docker Desktop 已安装4.x+docker version
Kubernetes 已开启Docker Desktop Settings → Kubernetes → Enable Kuberneteskubectl cluster-info
kubectl 已安装Docker Desktop 自带kubectl version --client
上一篇的概念理解知道 Image / Container / Pod / Deployment / Service 是什么脑子过一遍层级:Image → Container → Pod → Deployment

如果 kubectl cluster-info 输出类似以下内容,说明环境就绪:

Kubernetes control plane is running at https://127.0.0.1:6443
CoreDNS is running at https://127.0.0.1:6443/api/v1/...

⚠️ 新手提示:Docker Desktop 的 K8s 是单节点集群(你的电脑就是唯一一个 Node),但不影响学习——Deployment、Service、ConfigMap 的行为跟生产多节点集群完全一致。唯一区别是没地方看"跨 Node 调度"。


三、环境搭建

3.1 确认 Node 就绪

kubectl get nodes

期望输出:

NAME             STATUS   ROLES           AGE   VERSION
docker-desktop   Ready    control-plane   1d    v1.28.x

STATUS 必须是 Ready。如果不是,等一两分钟再试(Docker Desktop 启动 K8s 需要时间)。

3.2 创建一个工作目录

mkdir ~/k8s-first-app
cd ~/k8s-first-app

所有 YAML 文件都放在这个目录下。


四、分步实践

要部署的应用很简单:一个 Spring Boot(或任何 HTTP 服务)暴露 /health 端点。数据库密码从 Secret 注入,环境配置从 ConfigMap 注入。

整个部署涉及 4 个资源:

flowchart TD
  DEP["Deployment: my-app
replicas=2, image=my-app:v1"]
  RS["ReplicaSet (自动管理)"]
  POD1["Pod-1
172.17.0.3"]
  POD2["Pod-2
172.17.0.4"]
  SVC["Service: my-app-svc
ClusterIP: 10.96.0.100"]
  CM["ConfigMap
APP_ENV, LOG_LEVEL"]
  SEC["Secret
DB_PASSWORD"]

  DEP --> RS
  RS --> POD1
  RS --> POD2
  CM -.->|"envFrom"| POD1
  CM -.->|"envFrom"| POD2
  SEC -.->|"secretKeyRef"| POD1
  SEC -.->|"secretKeyRef"| POD2
  SVC -->|"selector: app=my-app"| POD1
  SVC -->|"selector: app=my-app"| POD2
    style DEP fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#ffffff,font-weight:bold
    style RS fill:#2a1147,stroke:#a855f7,stroke-width:2px,color:#ffffff
    style POD1 fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#ffffff,font-weight:bold
    style POD2 fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#ffffff,font-weight:bold
    style SVC fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#ffffff,font-weight:bold
    style CM fill:#2d1a05,stroke:#f59e0b,stroke-width:2px,color:#ffffff,font-weight:bold
    style SEC fill:#2d1a05,stroke:#f59e0b,stroke-width:2px,color:#ffffff,font-weight:bold

4.1 第一步:创建 Namespace(可跳过,但建议做)

把所有实验资源放进独立的 Namespace,避免跟系统资源混在一起,清理也方便:

apiVersion: v1
kind: Namespace
metadata:
  name: my-first-app

保存为 00-namespace.yaml ,执行:

kubectl apply -f 00-namespace.yaml

后续所有命令都加 -n my-first-app

4.2 第二步:创建 ConfigMap —— 非敏感配置

ConfigMap 存的是"换了环境要改、但不涉及密码"的东西:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: my-first-app
data:
  APP_ENV: "production"
  LOG_LEVEL: "info"
  SERVER_PORT: "8080"

保存为 01-configmap.yaml

每一行解释:

字段含义
apiVersion: v1ConfigMap 属于核心 API 组 v1 版本
kind: ConfigMap资源类型
metadata.name给 ConfigMap 起名字,后面 Deployment 通过这个名字引用它
metadata.namespace放在哪个 Namespace(ConfigMap 只在同 Namespace 内可见)
data键值对,key 是大写的环境变量名,value 是值

⚠️ 新手提示:ConfigMap 的大小上限是 1MB。别想把几百 KB 的 JSON 配置文件塞进去——配置文件应该用挂载方式(Volume Mount),后面会讲。

4.3 第三步:创建 Secret —— 敏感信息

apiVersion: v1
kind: Secret
metadata:
  name: app-secret
  namespace: my-first-app
type: Opaque
stringData:
  DB_PASSWORD: "MySecretP@ssw0rd!"
  REDIS_PASSWORD: "RedisP@ss123"

保存为 02-secret.yaml

每一行解释:

字段含义
type: Opaque通用类型 Secret,可以存任意键值对(Opaque = 不透明的,即 K8s 不关心里面是什么)
stringData明文写密码, kubectl apply 时 K8s 自动 Base64 编码存到 data 字段
data vs stringDatadata 要求值已经是 Base64 编码, stringData 可以直接写明文(省去手动 ` echo -n xxx

⚠️ 新手提示: stringData 只是写入时的便利字段。Secret 存到 etcd 后只有 data (Base64 编码)。Base64 ≠ 加密,任何人拿到 kubectl get secret -o yaml 然后 echo "xxx" | base64 -d 就能看到明文。生产环境加密需要 Sealed Secrets 或云 KMS。

4.4 第四步:创建 Deployment —— 主角登场

这是整篇文章最重要的 YAML:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  namespace: my-first-app
  labels:
    app: my-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
      - name: app
        image: nginx:1.25-alpine
        ports:
        - containerPort: 80
        env:
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: app-secret
              key: DB_PASSWORD
        envFrom:
        - configMapRef:
            name: app-config
        resources:
          requests:
            memory: "64Mi"
            cpu: "100m"
          limits:
            memory: "128Mi"
            cpu: "200m"
        livenessProbe:
          httpGet:
            path: /
            port: 80
          initialDelaySeconds: 10
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /
            port: 80
          initialDelaySeconds: 5
          periodSeconds: 5

保存为 03-deployment.yaml

逐段拆解:

metadata —— 资源的"身份证"

metadata:
  name: my-app               # Deployment 的名字
  namespace: my-first-app    # 放在哪个 Namespace
  labels:
    app: my-app              # 标签,Service 用这个标签找到 Pod

⚠️ 新手提示:Deployment 的 labelsspec.selector.matchLabels 以及 spec.template.metadata.labels 是三个东西,但它们通常设置成一样的值。第一次看这段大概率一脸懵——下面展开。

spec.selector —— “这个 Deployment 管哪些 Pod”

spec:
  selector:
    matchLabels:
      app: my-app            # 管所有带 "app=my-app" 标签的 Pod

这行告诉 Deployment:“你的管辖范围是所有带了 app=my-app 标签的 Pod”。如果 Deployment 发现管辖的 Pod 数量不对(少了或多了),就会去修正。

spec.template —— Pod 模板(“如果不够,就照这个模子造”)

spec:
  template:
    metadata:
      labels:
        app: my-app          # 新 Pod 打上这个标签(必须匹配 selector!)
    spec:                    # Pod 的内容规格

⚠️ 新手提示: template.metadata.labels 必须包含 selector.matchLabels 里的所有键值对。如果不匹配,Deployment 会说"我管 3 个 Pod,但我找不到它们"——因为标签对不上。

containers —— Pod 里跑什么

containers:
- name: app
  image: nginx:1.25-alpine   # 镜像名(默认从 Docker Hub 拉)
  ports:
  - containerPort: 80        # 容器监听哪个端口(声明式,不影响实际监听)
字段必须?说明
name必须容器的名字,Pod 内唯一
image必须镜像名 + tag,默认从 Docker Hub 拉
containerPort建议写只是一个"声明"(不写容器也能监听),但写了别人看 YAML 才知道端口

env 和 envFrom —— 环境变量注入

env:
- name: DB_PASSWORD                  # 环境变量名
  valueFrom:                         # 从外部来源拿值
    secretKeyRef:
      name: app-secret               # Secret 的名字
      key: DB_PASSWORD               # Secret 里的哪个 key

envFrom:
- configMapRef:
    name: app-config                 # 把整个 ConfigMap 的所有 key 都注入为环境变量

两种注入方式的区别:

方式适用场景
env[].valueFrom.secretKeyRef只需要 Secret 里的某一个 key,或者想改环境变量名
envFrom[].configMapRef把整个 ConfigMap 的所有 key 都注入(key 名 = 环境变量名)

resources —— CPU 和内存限制

resources:
  requests:             # 调度时"保底"分配的量
    memory: "64Mi"
    cpu: "100m"         # 100m = 0.1 核
  limits:               # 容器能使用的上限
    memory: "128Mi"
    cpu: "200m"
字段含义超限后果
requests调度器保证分配的最低资源没有 requests 的 Pod 可能被调度到资源已满的 Node
limits容器能用的上限CPU 超限→被限流(变慢),内存超限→OOMKilled(直接杀)

⚠️ 新手提示:务必给所有容器设置 memory limits。没有 limits 的容器内存泄漏会吃掉整个 Node 的内存,导致其他 Pod 被 Evicted(驱逐)。这是生产环境最常见的血案。

探针 —— livenessProbe 和 readinessProbe

livenessProbe:          # 存活探针:容器还活着吗?
  httpGet:
    path: /
    port: 80
  initialDelaySeconds: 10   # 启动后等 10 秒再开始检查
  periodSeconds: 10         # 每 10 秒检查一次

readinessProbe:         # 就绪探针:能接流量了吗?
  httpGet:
    path: /
    port: 80
  initialDelaySeconds: 5
  periodSeconds: 5

这两个探针解决完全不同的问题:

flowchart TD
  START["Pod 启动"]
  INIT["容器创建完成
(initialDelaySeconds)"]
  LIVENESS["livenessProbe 检查"]
  READINESS["readinessProbe 检查"]
  READY["Ready: true → 加入 Service 的 Endpoints
开始接收流量"]
  DEAD["liveness 连续失败 → 容器被 kill 重建"]
  NOT_READY["readiness 失败 → 从 Service 摘除
不再接收流量,但不重启"]

  START --> INIT
  INIT --> LIVENESS
  INIT --> READINESS
  LIVENESS -->|"健康"| READY
  LIVENESS -->|"连续失败"| DEAD
  READINESS -->|"健康"| READY
  READINESS -->|"失败"| NOT_READY
    style START fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#ffffff
    style INIT fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#ffffff
    style READY fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#ffffff,font-weight:bold
    style DEAD fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#ffffff,font-weight:bold
    style NOT_READY fill:#2d1a05,stroke:#f59e0b,stroke-width:2px,color:#ffffff,font-weight:bold
    style LIVENESS fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ffffff
    style READINESS fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ffffff
探针失败后果典型用例
livenessProbeKill 容器,重建死锁、无限循环、进程僵死
readinessProbe从 Service 摘除(不重建)依赖还没就绪(数据库还没连上)、预热中、临时过载

⚠️ 新手提示:livenessProbe 的 initialDelaySeconds 一定要大于应用启动时间。如果应用需要 30 秒启动,你把 liveness 设成 5 秒——恭喜,Pod 永远起不来,陷入"启动→探针失败→被 kill→重启→启动→探针失败"的死循环(CrashLoopBackOff)。

4.5 第五步:创建 Service —— 给 Pod 一个不变的联系方式

apiVersion: v1
kind: Service
metadata:
  name: my-app-svc
  namespace: my-first-app
spec:
  type: ClusterIP
  selector:
    app: my-app
  ports:
  - port: 80           # Service 自己监听的端口
    targetPort: 80     # Pod 中容器的端口
    protocol: TCP

保存为 04-service.yaml

每一行解释:

字段含义
type: ClusterIP集群内部 IP(默认),只能集群内访问
selector.app: my-app找所有带 app=my-app 标签的 Pod
port: 80访问 Service 时用的端口
targetPort: 80请求转发到 Pod 容器的哪个端口
protocol: TCP默认 TCP,可不写

4.6 第六步:一键部署

所有 YAML 写好后,目录结构:

~/k8s-first-app/
├── 00-namespace.yaml
├── 01-configmap.yaml
├── 02-secret.yaml
├── 03-deployment.yaml
└── 04-service.yaml

执行(注意文件顺序——Namespace 先创建):

kubectl apply -f 00-namespace.yaml
kubectl apply -f 01-configmap.yaml
kubectl apply -f 02-secret.yaml
kubectl apply -f 03-deployment.yaml
kubectl apply -f 04-service.yaml

或者一次性 apply 整个目录:

kubectl apply -f ~/k8s-first-app/

K8s 会自动按资源依赖排序(先 Namespace,再 ConfigMap/Secret,再 Deployment,最后 Service——虽然 K8s 实际上不严格限制顺序,但建议按依赖关系 apply)。


五、部署验证

5.1 检查 Deployment 状态

kubectl get deploy -n my-first-app

期望输出:

NAME     READY   UP-TO-DATE   AVAILABLE   AGE
my-app   2/2     2            2           30s

READY2/2 表示 2 个 Pod 全部就绪。

5.2 检查 Pod 状态

kubectl get pods -n my-first-app

期望输出:

NAME                      READY   STATUS    RESTARTS   AGE
my-app-5d8f7b6c9-abcde   1/1     Running   0          45s
my-app-5d8f7b6c9-fghij   1/1     Running   0          45s

如果 STATUS 不是 Running ,进入排查模式:

kubectl describe pod <pod-name> -n my-first-app    # 看 Events 区域
kubectl logs <pod-name> -n my-first-app             # 看容器日志

5.3 验证 ConfigMap 和 Secret 注入

kubectl exec -n my-first-app <pod-name> -- env | grep APP_ENV
kubectl exec -n my-first-app <pod-name> -- env | grep DB_PASSWORD

期望分别输出 APP_ENV=productionDB_PASSWORD=MySecretP@ssw0rd!

5.4 用 port-forward 访问应用

Service 类型是 ClusterIP,只能集群内访问。开发调试时用 port-forward 把 Pod 端口映射到本地:

kubectl port-forward -n my-first-app svc/my-app-svc 8080:80

打开浏览器访问 http://localhost:8080 ,看到 nginx 欢迎页即部署成功。

📌 前置知识: port-forward 只在调试阶段用,不适合生产。它的原理是在 kubectl 和 API Server 之间建立一条隧道,流量走 kubectl 进程中转——关了终端就断。

5.5 体验一把滚动更新

修改 03-deployment.yaml 中的镜像版本:

image: nginx:1.25-alpine   →   image: nginx:1.26-alpine

再次 apply:

kubectl apply -f 03-deployment.yaml

在另一个终端窗口实时观察:

kubectl get pods -n my-first-app -w

会看到旧 Pod 一个接一个变成 Terminating,新 Pod 一个接一个变成 Running——整个过程服务不中断(因为有 2 个副本,至少 1 个始终在运行)。

5.6 回滚

kubectl rollout undo deploy/my-app -n my-first-app

回滚到上一个版本。查看历史记录:

kubectl rollout history deploy/my-app -n my-first-app

5.7 清理

kubectl delete namespace my-first-app

删除整个 Namespace,里面的 Deployment、Service、ConfigMap、Secret、Pod 全部自动清理。一行搞定。


六、原理简述

6.1 Deployment 控制器循环

Controller Manager 里的 Deployment Controller 运行着一个调谐循环:

flowchart TD
  W["Watch: 发现 Deployment spec.replicas=2"]
  G["Get: 查询当前有几个 Pod"]
  C["Compare: 2 vs 实际数"]
  EQ["一致, 什么都不做"]
  LESS["当前 Pod < 2 → 创建 Pod"]
  MORE["当前 Pod > 2 → 删除多余的 Pod"]

  W --> G
  G --> C
  C --> EQ
  C --> LESS
  C --> MORE
  LESS --> G
  MORE --> G
    style W fill:#0f172a,stroke:#3b82f6,stroke-width:2px,color:#ffffff,font-weight:bold
    style G fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#ffffff
    style C fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#ffffff
    style EQ fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#ffffff
    style LESS fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#ffffff
    style MORE fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#ffffff

这个循环不是在代码层 for 循环,而是通过 Watch API Server 事件驱动的。任何资源变更都会触发 Controller 重新检查期望状态和实际状态。

6.2 kubectl apply 背后发生了什么

kubectl apply -f 03-deployment.yaml 这条命令的背后:

  1. kubectl 读取 YAML 文件,序列化成 JSON
  2. 向 API Server 发 POST /apis/apps/v1/namespaces/my-first-app/deployments
  3. API Server 认证 → 授权 → 准入控制 → 写入 etcd
  4. Deployment Controller Watch 到新 Deployment → 创建 ReplicaSet
  5. ReplicaSet Controller Watch 到新 ReplicaSet → 创建 Pod(此时 nodeName 为空)
  6. Scheduler Watch 到未调度的 Pod → 选一个 Node → 更新 nodeName
  7. 对应 Node 上的 kubelet Watch 到分配给自己的 Pod → 调用 Container Runtime 拉镜像、创建容器
  8. 容器启动成功 → kubelet 上报 Running → API Server → etcd

这一整套流程通常在 几秒内 完成。


七、总结与下一步

7.1 YAML 清单

写 K8s YAML 的最少必要知识:

资源最少必填字段一句话用途
Deploymentspec.replicas + selector + template管理 Pod 副本、滚动更新、回滚
Servicespec.selector + spec.ports给 Pod 提供稳定 IP 和负载均衡
ConfigMapdata注入非敏感环境变量
SecretstringData (或 data注入密码、Token 等敏感信息
Namespacemetadata.name资源分组(调试/清理方便)

7.2 下一步

下一篇文章《第 2 步:让 Pod 活得久一点》将深入:

  • livenessProbe 和 readinessProbe 的 4 种探测方式(httpGet / tcpSocket / exec / grpc)
  • resources 配错导致的血案(OOMKilled、Evicted、CPU Throttling)
  • 环境变量注入的 3 种方式(env / envFrom / Volume Mount)
  • Pod 为什么一直 Pending / CrashLoopBackOff / ImagePullBackOff