课程目录(第 17 章 / 共 33 章)
课程/综合实战

综合实战:部署一个完整的三层应用

17 章 / 共 33·30 分钟·入门+实战Ingress存储kubectl

从零把 web、api、redis 三层应用部署到 demo 命名空间,用 ConfigMap 与 Secret 注入配置,通过 Ingress 用域名访问,每一步都给完整 YAML 和验证方法。

学完这一章,你将能够

  • 独立完成一次从命名空间到 Ingress 的完整部署
  • 把 ConfigMap、Secret、PVC、探针与资源限制串成一套可用的清单
  • 用 endpointslices 与 curl 逐层验证流量是否打通
  • 出问题时能按固定顺序判断是哪一层断了

目标与整体架构

前面每一章都在解决一个单独的问题,这一章不引入任何新概念,只做一件事:把学过的东西按真实项目的方式拼起来,跑通一次完整部署。

场景是一个最小但完整的三层应用:nginx 做前端,hashicorp/http-echo 做后端 API,redis 做数据库并带持久化卷。三者都在 demo 命名空间,通过 Ingress 用域名 kube101.local 暴露:根路径给前端,/api 给后端。配置走 ConfigMap,密码走 Secret。

text
                    浏览器  http://kube101.local

                ┌─────────────▼──────────────┐
                │  Ingress(ingress-nginx)   │
                │  / → web:80   /api → api:5678
                └───────┬────────────┬────────┘
                        │            │
          ┌─────────────▼──┐    ┌────▼────────────┐
          │ web: nginx     │    │ api: http-echo  │
2 副本 + Svc   │    │ 2 副本 + Svc    │
          └────────────────┘    └────┬────────────┘
                                     │ REDIS_HOST
                             ┌───────▼────────┐
                             │ redis + PVC    │
                             └────────────────┘

前置条件:一个能用的集群(第 3 章 搭一个本地集群的 kind 单节点集群就够),并装好 Ingress 控制器。

bash
kubectl get nodes
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx wait --for=condition=Ready pod -l app.kubernetes.io/component=controller --timeout=180s

如果上面的 tag 已被官方更新,用 ingress-nginx 安装文档 里的最新命令。

让 kube101.local 真的能访问

kind 集群要在创建时把节点 80/443 映射到宿主机(extraPortMappings),否则域名解析通了也连不上。同时在 /etc/hosts 里加一行:

bash
echo '127.0.0.1 kube101.local' | sudo tee -a /etc/hosts

没有端口映射就用 port-forward 兜底,效果一样:

bash
kubectl port-forward -n ingress-nginx svc/ingress-nginx-controller 8080:80
curl -s -H 'Host: kube101.local' http://localhost:8080/api

目录结构

清单按资源类型拆文件,编号前缀保证 kubectl apply -f manifests/ 按顺序执行:

text
manifests/
├── 01-namespace.yaml   Namespace
├── 02-config.yaml      ConfigMap app-config + Secret db-secret
├── 03-redis.yaml       PVC + Deployment + Service
├── 04-api.yaml         Deployment + Service
├── 05-web.yaml         Deployment + Service
└── 06-ingress.yaml     Ingress

下面每一步都是「保存文件 → apply → 验证」。不要跳过验证,出问题时它就是你的对照基线。

第 1 步:创建命名空间

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: demo
bash
kubectl apply -f manifests/01-namespace.yaml

验证kubectl get ns demoSTATUSActive

第 2 步:配置与密码(ConfigMap / Secret)

配置和密码分开管理:前者是明文、可以进 git,后者是敏感信息、不该进版本库。

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: demo
data:
  APP_ENV: production
  LOG_LEVEL: info
  REDIS_HOST: redis
  API_GREETING: hello from api
---
apiVersion: v1
kind: Secret
metadata:
  name: db-secret
  namespace: demo
type: Opaque
stringData:
  REDIS_PASSWORD: kube101-redis
bash
kubectl apply -f manifests/02-config.yaml

验证

bash
kubectl get configmap app-config -n demo -o jsonpath='{.data.APP_ENV}{"\n"}'
kubectl get secret db-secret -n demo -o jsonpath='{.data.REDIS_PASSWORD}' | base64 -d; echo

期望分别输出 productionkube101-rediskubectl get secret 默认只显示字节数,要 base64 -d 解码;stringData 是写入时的便捷字段,读回来统一变成 data

第 3 步:Redis(PVC + Deployment + Service)

数据库先起,它依赖存储,后面的 api 也要用它的地址。

yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-data
  namespace: demo
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: demo
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
        - name: redis
          image: redis:7.2
          args:
            - redis-server
            - --requirepass
            - $(REDIS_PASSWORD)
          ports:
            - containerPort: 6379
          env:
            - name: REDIS_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: REDIS_PASSWORD
          volumeMounts:
            - name: data
              mountPath: /data
          readinessProbe:
            tcpSocket:
              port: 6379
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: redis-data
---
apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: demo
spec:
  selector:
    app: redis
  ports:
    - port: 6379
      targetPort: 6379
bash
kubectl apply -f manifests/03-redis.yaml
kubectl rollout status deployment/redis -n demo

验证

bash
kubectl get pvc redis-data -n demo          # STATUS 期望 Bound
kubectl get pods -n demo -l app=redis       # READY 期望 1/1
kubectl exec -n demo deploy/redis -- sh -c 'redis-cli -a "$REDIS_PASSWORD" set kube101 ok'
kubectl exec -n demo deploy/redis -- sh -c 'redis-cli -a "$REDIS_PASSWORD" get kube101'

最后一行期望输出 okstrategy: Recreate 是刻意的:ReadWriteOnce 的 PVC 同一时间只能挂到一个节点,滚动更新时新旧 Pod 会抢卷,先停旧的再起新的更稳妥。删掉 Pod 让 Deployment 重建,再 get kube101 一次,数据应该还在:

bash
kubectl delete pod -n demo -l app=redis
kubectl rollout status deployment/redis -n demo
kubectl exec -n demo deploy/redis -- sh -c 'redis-cli -a "$REDIS_PASSWORD" get kube101'

第 4 步:api(Deployment + Service)

后端用 hashicorp/http-echo,它会把启动参数里的文本原样返回,正好用来验证「请求确实打到了后端」。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: hashicorp/http-echo:1.0
          args:
            - -listen=:5678
            - -text=$(API_GREETING)
          ports:
            - containerPort: 5678
          env:
            - name: APP_ENV
              valueFrom:
                configMapKeyRef:
                  name: app-config
                  key: APP_ENV
            - name: API_GREETING
              valueFrom:
                configMapKeyRef:
                  name: app-config
                  key: API_GREETING
            - name: REDIS_HOST
              valueFrom:
                configMapKeyRef:
                  name: app-config
                  key: REDIS_HOST
          readinessProbe:
            httpGet:
              path: /healthz
              port: 5678
            initialDelaySeconds: 3
            periodSeconds: 5
          resources:
            requests:
              cpu: 50m
              memory: 32Mi
            limits:
              cpu: 200m
              memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: demo
spec:
  selector:
    app: api
  ports:
    - port: 5678
      targetPort: 5678
bash
kubectl apply -f manifests/04-api.yaml
kubectl rollout status deployment/api -n demo

验证

bash
kubectl get pods -n demo -l app=api         # READY 期望 2/2
kubectl get endpointslices -n demo -l kubernetes.io/service-name=api
kubectl run tmp -n demo --rm -it --restart=Never --image=busybox:1.36 \
  -- wget -qO- -T 3 http://api.demo.svc.cluster.local

期望 EndpointSlice 里有两个地址,最后一条输出 hello from api$(API_GREETING) 是 Kubernetes 的环境变量展开语法,只展开同一容器内先定义的变量,所以 env 的声明顺序很关键。

http-echo 不真的连数据库

真实项目里 api 会用 REDIS_HOST 去连 redis,这里只演示「配置从 ConfigMap 流进容器」的路径。要验证 redis 通不通,用第 3 步的 redis-cli

第 5 步:web(Deployment + Service)

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: web
          image: nginx:1.27
          ports:
            - containerPort: 80
          env:
            - name: APP_ENV
              valueFrom:
                configMapKeyRef:
                  name: app-config
                  key: APP_ENV
          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 3
            periodSeconds: 5
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
  name: web
  namespace: demo
spec:
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80
bash
kubectl apply -f manifests/05-web.yaml
kubectl rollout status deployment/web -n demo

验证

bash
kubectl get pods -n demo -l app=web         # READY 期望 2/2
kubectl port-forward svc/web 8080:80 -n demo &
sleep 2
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080
kill %1

期望输出 200,说明 web 已经能独立对外服务——此时还没经过 Ingress,正好为下一步做对照。

第 6 步:Ingress

把两个 Service 挂到同一个域名下,靠路径区分:/api 给后端,/ 给前端。

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: kube101
  namespace: demo
spec:
  ingressClassName: nginx
  rules:
    - host: kube101.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 80
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: api
                port:
                  number: 5678
bash
kubectl apply -f manifests/06-ingress.yaml

验证

bash
kubectl get ingress -n demo                 # ADDRESS 列期望有 IP
curl -s -o /dev/null -w '%{http_code}\n' http://kube101.local/
curl -s http://kube101.local/api

期望依次是 200hello from api。路径按长度匹配,/api/ 长所以优先命中;别把这两条规则拆到不同 Ingress 里,行为取决于具体控制器实现。

第 7 步:探针与资源限制

前面每个 Deployment 都写了 readinessProberesources,这里解释为什么。

字段作用配错的后果
readinessProbe判断 Pod 能否接流量失败时不进 Endpoints,Ingress 返回 503
livenessProbe判断容器是否还活着失败时 kubelet 重启容器,配错会反复重启
startupProbe慢启动应用的宽限期不配它而用 liveness 卡启动,会陷入重启循环
requests调度依据与保底资源写太大 Pod 直接 Pending
limits资源上限超过内存 limit 会被 OOMKilled

启动特别慢的应用,加一段 startupProbe 更安全,含义是「最多给 60 秒启动,这期间 liveness 与 readiness 都不生效」:

yaml
          startupProbe:
            httpGet:
              path: /
              port: 80
            failureThreshold: 30
            periodSeconds: 2

验证

bash
kubectl describe pod -n demo -l app=api | grep -E 'Liveness|Readiness|Limits|Requests'
kubectl top pods -n demo                    # 需要 metrics-server

期望看到探针地址和 cpu/memory 的 requests 与 limits;没有 metrics-server 时 kubectl top 会报错,忽略即可。

第 8 步:端到端验证

从外到内走一遍,任何一层断了都能立刻定位。先把「一次请求到底走了哪几跳」画清楚,验证时你才知道每条命令对应哪一跳。

text
一次 curl 请求走过的完整路径

curl http://kube101.local/api

   │ ① 域名解析:/etc/hosts 里 kube101.local → 127.0.0.1

宿主机 80 端口(kind 的 extraPortMappings 把它映射进节点)

   │ ② ingress-nginx controller 收到请求,按 Host 与 path 选后端
   │    /api 比 / 更长,所以 /api 优先命中

Service api(ClusterIP:5678,Endpoints 指向 2 个 Pod)

   │ ③ kube-proxy 或 eBPF 做 DNAT,把 ClusterIP 换成某个 Pod 的 IP

Pod api(hashicorp/http-echo 监听 :5678

   │ ④ 容器把 ConfigMap 里的 API_GREETING 当作响应体返回

hello from api

对应关系是:① 靠 /etc/hosts 与端口映射,② 靠 kubectl get ingress 与控制器日志,③ 靠 kubectl get endpointslices,④ 靠 kubectl logs。哪一跳出问题,就查哪一跳的证据。

端到端验证

bash
kubectl get all -n demo
kubectl get pvc -n demo
kubectl get ingress -n demo
kubectl get endpointslices -n demo
curl -s -o /dev/null -w 'web: %{http_code}\n' http://kube101.local/
curl -s http://kube101.local/api

期望:web 与 api 都是 2/2、redis 是 1/1redis-dataBound;Ingress 有地址;两个 Service 的 Endpoints 都有地址;最后两行输出 web: 200hello from api

恭喜,这一章没有新知识

你刚刚用到的 Namespace、ConfigMap、Secret、PVC、Deployment、Service、Ingress、探针、资源限制,都在前面章节单独学过。这一章证明的是:你已经能把它们组合成一个能对外服务的系统。

把 redis 换成生产可用的有状态方案

第 3 步的 redis 是 Deployment + 一块 PVC + Recreate 策略,练习够用,但生产上有三个短板:只有 1 个副本,Pod 或节点挂了就中断;没有自动故障切换,重建期间应用只能等;没有备份机制redis-data 被误删就真没了。所以「上生产」不是把 replicas 改成 3——三个 Redis 副本互相不认识,谁都不是主。

方案适用场景代价
单实例 Deployment + PVC(本章)缓存、会话,丢了能重建无高可用,重启期间不可用
StatefulSet + 主从复制(自建)想完全掌控复制与卷选主、追平、切换都要自己写脚本
Redis Operator(KubeBlocks 等)要自动切换与统一运维多一套 CRD 与控制器要维护
云托管 Redis团队没有专职 DBA成本与数据合规要评估

换法很直接:删掉 03-redis.yaml 里的 Deployment + PVC + Service,改成 KubeBlocks 的 Cluster 资源(清单见数据库 Operator)。api 侧只改一个值——REDIS_HOSTredis 换成 Operator 给出的读写端点,密码继续用 Secret 注入:

bash
# 1. 确认可用版本,再建一套一主一从 + 哨兵
kbcli clusterversion list | grep redis
kbcli cluster create demo-redis --cluster-definition redis --cluster-version redis-7.2.4 \
  --pvc type=redis,name=data,mode=ReadWriteOnce,size=10Gi \
  --set cpu=1,memory=1Gi,replicas=2 --namespace demo
# 2. 从 Endpoints 的 ReadWrite 行取真实域名
kbcli cluster describe demo-redis --namespace demo | sed -n '/Endpoints/,/Topology/p'
kubectl -n demo patch configmap app-config --type merge \
  -p '{"data":{"REDIS_HOST":"demo-redis-redis.demo.svc.cluster.local"}}'
# 3. ConfigMap 改完必须滚动重启,env 不会自动更新
kubectl -n demo rollout restart deployment/api

三个必须一起改的地方:应用连读写端点,不连具体 Pod(切换后旧主变从库,连上去只能读);客户端要能处理主从切换(或统一走哨兵/代理端点),否则重连还会命中旧地址;配好 BackupRepo 并演练恢复,否则高可用只覆盖「节点挂了」,没覆盖「数据删错了」。练习环境想省事就保留本章的单实例版本,但要在文档里写清它是「练习配置」。

参考:reference/k8s-in-action/db/kubeblocks/kbcli-0.9.3/day-3-redis.mdreference/k8s-in-action/db/redis/README.md

常见坑与排错

排障手册的顺序,先看 Pod 状态、再看 Events、最后看日志。这张表是本章专属的快速对照:

现象原因怎么确认
连不上 kube101.localhosts 或端口映射没配curl -v http://kube101.local
Ingress 返回 404host 或 path 不匹配kubectl get ingress -n demo -o yaml
Ingress 返回 503Service 没有就绪的 Endpointskubectl get endpointslices -n demo
/api 内容不对ConfigMap 改了没重启 Podkubectl rollout restart deployment/api -n demo
Pod 一直 PendingPVC 没绑定或资源不足kubectl describe pod -n demo -l app=redis
Pod 一直 ContainerCreating卷挂载慢或镜像在拉取kubectl describe pod -n demo -l app=web
Pod CrashLoopBackOff启动命令或配置错误kubectl logs -n demo -l app=api --previous

一个高频陷阱:改了 ConfigMap 后 Pod 不会自动重启,改完记得滚动重启:

bash
kubectl rollout restart deployment/web -n demo
kubectl rollout restart deployment/api -n demo

清理环境

练完就删,避免占着端口和存储:

bash
kubectl delete namespace demo
kubectl get pv                              # 若还有 Released 的残留卷,手动清理

删除命名空间会连带删掉其中的 Pod、Service、Ingress、ConfigMap、Secret 和 PVC;PV 是否回收取决于 StorageClass 的 reclaimPolicy。Ingress 控制器不再需要也可以删掉:

bash
kubectl delete -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/kind/deploy.yaml

自测题

自测:为什么 redis 要用 `strategy: Recreate` 而不是默认的滚动更新?(点击展开答案)

因为 redis-dataReadWriteOnce 的 PVC:同一时间只能被一个节点上的一个 Pod 挂载。默认的滚动更新会先起新 Pod、再停旧 Pod,两个 Pod 会同时抢这块卷,新 Pod 就卡在 ContainerCreating 等挂载,整个发布卡死。Recreate 的顺序是「先删旧 Pod,再建新 Pod」,中间会有短暂不可用,但不会争抢卷。这就是有状态服务和无状态服务的根本差别:无状态可以并行替换,有状态必须保证同一份数据只有一个写者。真要减少中断,正确方向是换用支持多挂载的存储或用 StatefulSet 配合副本级复制,而不是改回滚动更新。

自测:为什么改了 ConfigMap,Pod 里的值还是旧的?(点击展开答案)

因为通过 env 注入的配置是在容器启动那一刻读一次的,之后 ConfigMap 再怎么改,运行中的容器也不会重新读取。kubectl exec 进去 env | grep APP_ENV 看到的仍是旧值。想让它生效必须重建 Pod,最省事的做法是 kubectl rollout restart deployment/web -n demo。用卷挂载方式(把 ConfigMap 作为文件挂进容器)的情况会好一些,文件内容最终会被更新,但应用通常需要自己监听文件变化,或者配合 reload 信号——所以「配置变更怎么生效」一定要在部署时就设计好。

自测:为什么 Ingress 返回的是 503,而不是 404?(点击展开答案)

404 的意思是「网关收到了请求,但没有任何规则匹配这个 host 或 path」,属于路由层没匹配上;503 的意思是「路由匹配成功了,但后端没有可用的实例」,属于后端层不可用。所以看到 503,说明 Ingress 规则是对的、Service 也存在,问题在于这个 Service 的 Endpoints 是空的——通常是 Pod 没就绪(readinessProbe 失败)、标签选择器写错,或者 Pod 还在启动。验证方法就是 kubectl get endpointslices -n demo:没有地址就是 503 的根因,有地址还 503 才需要去看 Pod 内部。

小结

  • 用 Namespace 给一套应用划出独立边界,所有命令都带 -n demo
  • 用 ConfigMap 注入普通配置、用 Secret 注入密码,理解 configMapKeyRefsecretKeyRef 的区别。
  • 用 PVC 让 redis 数据在 Pod 重建后依然存在,并知道 ReadWriteOnce 为什么要配 Recreate
  • 用 Deployment 管无状态副本,用 Service 给一组 Pod 一个稳定入口,用 Ingress 按域名和路径分发流量。
  • 用 readinessProbe 控流量、livenessProbe 控重启、requests/limits 控资源。
  • endpointslicescurllogs --previous 逐层验证和定位问题。
  • 用 Helm 的思路(Chart + Values)理解同一套清单如何支撑多环境部署。
  • 这次演练真正练到的能力是逐层定位:域名、Ingress、Service、Endpoints、Pod 各有独立的验证命令,出问题时能立刻判断断在哪一层,而不是盲目重启。
  • 这套清单可以直接改成你自己项目的骨架:换掉镜像、域名与资源规格,其余结构与验证方法原样可用。

练习

  1. 把 web 副本数改成 3,给 api 加 podAntiAffinity 让副本尽量分散,再验证 Endpoints 数量。
  2. 第 16 章 Helm 入门里的 chart 把这套清单重写一遍,用 values-prod.yaml 把域名改成 kube101.example.com、副本数改成 3,再 helm install 一次。
  3. 给 redis 加一个 backup CronJob(batch/v1),每天把 /data 打包到新 PVC 里,先只写 YAML 并用 kubectl apply --dry-run=server -f 验证语法。

到这里,kube101 的主线就走完了:你从「为什么需要 Kubernetes」一路走到能独立部署并排障一套完整应用。接下来该往哪走?翻到进阶路线,那里按集群、网络、存储、可观测性和平台化五个方向列出了下一步的学习地图。