上一章结束时,TaskBoard Deployment 保持两个 Ready 副本。我们已经证明其中一个 Pod 被删除后,ReplicaSet 会创建具有新名称、新 UID 和新 IP 的替代实例。控制器解决了“应用实例能不能持续存在”,却让另一个问题变得更明显:调用方到底应该连接哪个地址?
直接保存 Pod IP 看似省事,实际上把调用方绑定到某次短命实例。Pod 重建会换地址,扩容会增加地址,缩容又会让部分地址消失。即便当前只有两个 Pod,每个调用方自行维护地址列表也会迅速演变成一套不一致的服务发现系统。
本章要给 TaskBoard 增加一个 ClusterIP Service。它会提供稳定的虚拟 IP 和集群内 DNS 名称,EndpointSlice 则保存当前后端地址与就绪条件。我们会先验证同一个 DNS 名称可以到达两个副本,再故意破坏 Service 选择器,观察为什么“Service 对象还在、DNS 也能解析”仍然可能没有任何可用后端。
完成本章时,项目应多出 service/taskboard、它对应的 EndpointSlice,以及一个用于集群内诊断的 toolbox-safe Pod。Deployment 与两个 TaskBoard Pod 不会被修改。下一章的 ConfigMap、Secret 和 readiness 实验都会沿用这条稳定访问路径。
章节之间可能隔了一段时间,也可能已经打开了新的终端。Shell 不会自动保留上一章的 PATH、KUBECONFIG 和当前工作目录;本章稍后会读取 k8s/service.yaml,所以必须先证明工具、API 目标和相对路径都仍落在课程边界内。
下面只恢复当前 Shell,不会创建或修改集群对象。它逐字节核对课程根标记,避免重复追加 PATH,确认 context 精确为 kind-welearn-course,再进入 TaskBoard 项目根目录。预期得到一个固定 context 和一个以 welearn-kubernetes-course/taskboard 结尾的动态绝对路径:
COURSE_ROOT="$HOME/welearn-kubernetes-course"
if ! { [ -d "$COURSE_ROOT" ] && \
printf 'welearn-kubernetes-course\n' | cmp -s - "$COURSE_ROOT/.course-owned"; }; then
echo '停止:课程目录不存在或归属标记不匹配' >&2
exit 1
fi
case ":$PATH:" in
*":$COURSE_ROOT/bin:"*) ;;
*) export PATH="$COURSE_ROOT/bin:$PATH" ;;
esac
export KUBECONFIG="$COURSE_ROOT/welearn-course.kubeconfig"
COURSE_CONTEXT="$(kubectl config current-context 2>/dev/null)" || { echo '停止:无法读取课程 context' >&2; exit 1; }
if [ "$COURSE_CONTEXT" != 'kind-welearn-course' ]; then
echo "停止:当前 context 不是 kind-welearn-course,而是 $COURSE_CONTEXT" >&2
exit 1
fi
CLUSTER_NAME='welearn-course'
NODE_NAME='welearn-course-control-plane'
CLUSTER_LABEL='io.x-k8s.kind.cluster=welearn-course'
CLUSTER_MARKER="$COURSE_ROOT/.kind-cluster-owned"
KIND_CLUSTERS="$(kind get clusters 2>&1)" || { echo '停止:无法读取 kind 集群列表' >&2; exit 1; }
printf '%s\n' "$KIND_CLUSTERS" | grep --fixed-strings --line-regexp --quiet "$CLUSTER_NAME" || { echo '停止:课程 kind 集群不存在' >&2; exit 1; }
LABEL_IDS="$(docker ps -aq --no-trunc --filter "label=$CLUSTER_LABEL" 2>&1)" || { echo '停止:无法按 kind 标签读取节点容器' >&2; exit 1; }
NAME_IDS="$(docker ps -aq --no-trunc --filter "name=$NODE_NAME" 2>&1)" || { echo '停止:无法按名称读取节点容器' >&2; exit 1; }
LABEL_COUNT="$(printf '%s\n' "$LABEL_IDS" | awk 'NF { count += 1 } END { print count + 0 }')"
NAME_COUNT="$(printf '%s\n' "$NAME_IDS" | awk 'NF { count += 1 } END { print count + 0 }')"
if [ "$LABEL_COUNT" -ne 1 ] || [ "$NAME_COUNT" -ne 1 ] || [ "$LABEL_IDS" != "$NAME_IDS" ]; then
echo '停止:课程节点容器的名称、标签或数量不一致' >&2
exit 1
fi
COURSE_NODE_ID="$LABEL_IDS"
case "$COURSE_NODE_ID" in
*[!0-9a-f]*|'') echo '停止:课程节点容器 ID 格式无效' >&2; exit 1 ;;
esac
[ "${#COURSE_NODE_ID}" -eq 64 ] || { echo '停止:课程节点容器 ID 不是完整 64 位' >&2; exit 1; }
printf 'cluster=%s\nnode=%s\nnode_id=%s\nlabel=%s\n' "$CLUSTER_NAME" "$NODE_NAME" "$COURSE_NODE_ID" "$CLUSTER_LABEL" | \
cmp -s - "$CLUSTER_MARKER" || { echo '停止:当前节点与集群归属账本不匹配' >&2; exit 1; }
NODE_FACTS="$(docker inspect --format '{{.Id}}|{{.Name}}|{{index .Config.Labels "io.x-k8s.kind.cluster"}}' "$COURSE_NODE_ID" 2>&1)" || { echo '停止:无法检查课程节点身份' >&2; exit 1; }
[ "$NODE_FACTS" = "$COURSE_NODE_ID|/$NODE_NAME|$CLUSTER_NAME" ] || { echo '停止:课程节点不可变身份校验失败' >&2; exit 1; }
cd "$COURSE_ROOT/taskboard" || { echo '停止:TaskBoard 项目目录不存在' >&2; exit 1; }
printf 'context=%s\nproject=%s\n' "$COURSE_CONTEXT" "$(pwd -P)"
printf 'cluster_identity=verified node_id_prefix=%.12s\n' "$COURSE_NODE_ID"context=kind-welearn-course
project=<用户目录>/welearn-kubernetes-course/taskboard
cluster_identity=verified node_id_prefix=<动态 12 位>如果 context、集群身份或项目目录不匹配,分别回到第 2 章核对独立 kubeconfig 与四行节点账本,或回到第 3 章恢复项目文件。不要带着错误边界继续执行相对文件命令。
传统部署常把服务放在一台长期存在的主机上,主机名或 IP 也就自然成为服务身份。Kubernetes 的设计前提不同:Pod 是可替换实例,调度器可以把新实例放到其他节点,控制器可以随副本和版本变化创建或删除它们。把 Pod IP 当服务身份,等于让上层应用绕过了这种可替换性。
Service 的核心想法是把“我要访问 TaskBoard”与“当前哪些 Pod 正在承担 TaskBoard”分开。调用方只依赖稳定的 Service 名称;控制平面根据标签和就绪状态维护后端集合;节点上的数据面再把连接送往其中一个可用地址。后端变化不要求所有调用方同时改配置。
这也解释了为什么 Service 不是另一个应用代理 Pod。ClusterIP 通常是一个虚拟地址,Service 对象本身不承载业务进程。具体数据面可以由 kube-proxy 配置 iptables 或 IPVS,也可以由符合 Kubernetes Service 语义的其他网络实现处理。IPVS 是 Linux 内核中的四层负载均衡能力;eBPF 是 Linux 内核提供的可编程执行机制,一些网络组件用它实现 Service 转发。课程不依赖某一种实现,因为 API 对调用方承诺的是稳定入口与后端选择语义,而不是必须经过某个固定代理进程。
Service 从早期就需要保存“名称到后端地址集合”的映射。旧的 Endpoints API 为每个 Service 聚合一个同名对象,小规模时很直接,但一个巨大对象会带来更新放大,也无法完整表达双栈和后来出现的流量分布信息。按 KEP-752 的发布里程碑,EndpointSlice 在 Kubernetes v1.16 进入 alpha、v1.17 进入 beta,并在 v1.21 以稳定的 discovery.k8s.io/v1 API 发布。它允许一个 Service 对应多个切片,每个切片只保存一部分端点并带有地址族、端口和条件。旧 Endpoints API 从 v1.33 起被标记为 deprecated。
Kubernetes 2025 年的 Endpoints 弃用博文把 alpha 写成 v1.15,与 KEP-752、Kubernetes v1.16 发布公告和实际 alpha API 里程碑不一致。本课程涉及版本时间线时以记录设计与毕业阶段的 KEP-752 为准,同时保留这处来源差异,避免把博文中的 v1.15 继续传播成确定事实。
演化带来一个重要的排障习惯:不要猜某个固定 EndpointSlice 名称,也不要假设一个 Service 永远只有一个切片。应使用 kubernetes.io/service-name 标签列出所有关联切片。双栈、端口迁移或端点数量超过单片容量时,同一个 Service 同时出现多个 EndpointSlice 是正常现象。
这些事实可以从 Kubernetes 官方的 Service 文档、EndpointSlice 文档、KEP-752、Kubernetes v1.16 发布公告 与 v1.33 Endpoints 弃用说明 中核对。
我们先不急着写 YAML。把一次 http://taskboard/api/info 请求拆开,就能知道后面每个对象和命令在验证哪一段。
第一段是名称解析。toolbox-safe 里的进程把短名称 taskboard 交给 Pod 的 DNS 配置。因为请求者与 Service 位于同一 Namespace,搜索域会把短名称补成 taskboard.taskboard.svc.cluster.local。CoreDNS 读取 Kubernetes API 中的 Service 信息,为它返回 ClusterIP。
第二段是 Service 后端发现。EndpointSlice 控制器观察 Service 选择器与 Pod 标签,把匹配 Pod 的 IP、目标端口和就绪条件写入一个或多个 EndpointSlice。这个过程是异步调谐,不是创建 Service 的 API 请求在同一个事务里立即写完所有端点。
第三段是节点数据面。发往 ClusterIP 80 端口的连接,根据节点网络规则被转换或路由到某个就绪 Pod 的 8080 端口。Service 通常在连接级别选择后端,并不承诺六次 HTTP 请求一定严格 A、B、A、B 轮流出现;连接复用、协议、实现与拓扑策略都会影响观察结果。
第四段才是 TaskBoard 进程生成响应。响应里的 pod 字段来自当前实例,因此可以用来证明同一个 Service 名称实际到达了不同后端。
控制面和数据面分开后,故障也可以分层:
后面的故障实验会刻意让第二层断开,同时保留第一层正常。这样你会看到 DNS 成功并不等于后端存在。
Service 自己不运行应用。它通过标签选择器找出一组 Pod,再为这组 Pod 提供稳定入口。对 TaskBoard 来说,这条关系是:
taskboard Service
selector: app=taskboard
↓
EndpointSlice 中的两个就绪地址
↓
taskboard Pod A :8080
taskboard Pod B :8080
这里要分清三个端口:
port: 80 是 Service 对调用方提供的端口。targetPort: http 引用了容器端口的名称。http 的容器端口实际是 8080。使用端口名称比直接把 8080 重复写在多个清单里更稳妥。以后容器监听端口发生变化时,只要 Pod 模板仍提供同名端口,Service 就不必跟着修改。
Service 清单很短,但 selector、port、targetPort 分属不同语义层,不能只凭数字相同来理解。
selector.app=taskboard 是一个标签查询条件。EndpointSlice 控制器在同一 Namespace 中寻找满足条件的 Pod。它不会看 Deployment 名称,也不会因为 Service 与 Deployment 同名就自动关联。名称相同只是便于人理解,真正建立关联的是标签选择。
port=80 是 Service 接口的一部分。调用方访问 taskboard:80,不需要知道容器真实端口。targetPort=http 则要求每个候选 Pod 中存在名为 http 的容器端口,本项目 Deployment 把这个名字映射到 8080。发布期间新旧模板可以让同一端口名对应不同数字,EndpointSlice 会按实际端口分片记录,这也是用端口名比复制数字更灵活的地方。
协议默认是 TCP。Service 的四层转发只看到连接和端口,不理解 /api/info 这样的 HTTP 路径。按路径、Host、TLS 证书做七层路由,需要 Ingress 或 Gateway API 及相应控制器。把 Service type 改成 LoadBalancer 也不会让它自动获得 HTTP 路由规则。
就绪条件来自 Pod readiness。默认情况下,不就绪 Pod 可以出现在 EndpointSlice 中,但 conditions.ready 为 false,Service 数据面不会把普通新流量交给它。本章当前两个 Pod 都 Ready,因此预期得到两个 true;下一章会故意让其中一个 readiness 失败,观察端点条件变化。
还要准确理解“稳定”。ClusterIP 在 Service 对象生命周期内稳定,Pod 重建不会改变它;如果删除 Service 后重新创建而没有显式指定地址,可能得到另一个 ClusterIP。DNS 名称由 Service 名和 Namespace 决定,重建同名 Service 后仍可相同。生产调用方通常保存 DNS 名而不是 ClusterIP 数字,这样既保留语义,也减少跨集群地址池差异。
ClusterIP 是 Service 的默认类型,只在集群网络内提供入口。应用清单提交后,我们预计得到一个固定的 Service IP;这不是某个 Pod 的 IP,也不会因为 Pod 重建而变化。
在项目工作目录创建 k8s/service.yaml。下面是完整清单:
apiVersion: v1
kind: Service
metadata:
name: taskboard
namespace: taskboard
spec:
selector:
app: taskboard
ports:
- name: http
port: 80
targetPort: http
type: ClusterIP应用清单前,先确认两个 TaskBoard Pod 都带有 app=taskboard 标签并处于 Ready;否则创建 Service 仍会成功,却不会得到两个可用端点。下面第一条命令提交对象,第二条读取 API Server 分配的 ClusterIP。-f 是 --filename 的短写,-n 把查询限定在 taskboard Namespace。get service 的输出只证明 Service 接口存在,还不能证明后端已经同步完成。
kubectl apply -f k8s/service.yaml
kubectl get service taskboard -n taskboardservice/taskboard created
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
taskboard ClusterIP 10.96.250.32 <none> 80/TCP <动态时间>这次创建得到的 ClusterIP 是 10.96.250.32。ClusterIP 由集群地址池分配,你执行时看到的地址可能不同。EXTERNAL-IP 为 <none> 是正常结果,因为 ClusterIP 没有向集群外发布地址。
第一行 created 是 API 对象写入结果。表格中的 TYPE=ClusterIP 对应清单类型;CLUSTER-IP=10.96.250.32 是这次实际分配值;PORT(S)=80/TCP 是调用方看到的接口;EXTERNAL-IP 为 none 说明没有外部负载均衡入口。AGE 是动态时长,不参与验收。
如果 CLUSTER-IP 显示 None,应检查是否误写成 headless Service;如果一直看不到 Service,先检查 Namespace 和 kubectl context;如果 Service 存在但稍后的 EndpointSlice 为空,不要把问题归因于 ClusterIP 分配,应该转去核对 selector 与 Pod 状态。
Service IP 是虚拟入口,不对应一块真实网卡。节点上的网络规则会把发往这个地址的流量转交给后端 Pod。实现细节可能使用 iptables、IPVS 或 eBPF,取决于集群的网络组件。
Service 的选择器只描述“应该选谁”,EndpointSlice 才记录当前实际可接收流量的地址。TaskBoard 有两个就绪副本,因此我们预计 EndpointSlice 中出现两个 Pod IP,端口为 8080。
Kubernetes 从 v1.33 起弃用了旧的 Endpoints API。旧命令目前可能仍能返回数据,但新的排查流程应直接查看 EndpointSlice。EndpointSlice 能拆分大量端点,也能记录每个端点的就绪条件。
下面的标签由 Kubernetes 自动加到 EndpointSlice 上,可以准确找到属于 taskboard Service 的对象:
kubectl get endpointslice -n taskboard \
-l kubernetes.io/service-name=taskboard \
-o custom-columns='NAME:.metadata.name,ADDRESSES:.endpoints[*].addresses,PORT:.ports[*].port,READY:.endpoints[*].conditions.ready'NAME ADDRESSES PORT READY
taskboard-xqwzk 10.244.0.6,10.244.0.8 8080 true,trueEndpointSlice 名称和两个 Pod IP 都是动态值,会随创建过程变化。这里真正需要核对的是三件事:有两个地址、端口是 8080、两个端点的 ready 都是 true。这说明 Service 选择器与 Pod 标签匹配,而且 readiness probe 已经通过。
命令里的标签过滤条件不是应用标签 app=taskboard,而是 EndpointSlice 控制器写入的归属标签 kubernetes.io/service-name=taskboard。custom-columns 只是把嵌套 API 字段投影为四列:metadata.name 是切片名,endpoints[].addresses 展开所有端点地址,ports[].port 读取后端端口,conditions.ready 读取就绪条件。它不会修改对象,也不会保证地址排序。
刚创建 Service 后立即查询时,EndpointSlice 可能尚未出现,或者地址尚未填充。控制器需要先观察 Service 与 Pod,再写回切片。短暂为空时可以隔一两秒重试,并查看 kubectl get endpointslice -n taskboard;长时间为空才进入选择器诊断。不要把这种异步窗口当成必须重启 CoreDNS 的证据,CoreDNS 负责名称解析,不负责选择后端。
上一章触发 Pod 自愈前,这两个地址是 10.244.0.6 和 10.244.0.7;替代 Pod 就绪后,第二个地址变成了 10.244.0.8,Service IP 仍是 10.96.250.32。这正是调用方应依赖 Service、而不是保存 Pod IP 的原因。
如果 Service 存在但请求失败,EndpointSlice 是很合适的第一站:
ready=false,应检查 readiness probe 和应用依赖。
同一命名空间里的 Pod 可以用短名称 taskboard 访问 Service。完整域名是:
taskboard.taskboard.svc.cluster.local它依次表示 Service 名、Namespace、Service 域和集群域。完整域名适合排查 DNS,短名称适合同一 Namespace 内的日常调用。
短名称能够工作,是因为 Pod 的 DNS 配置通常带有当前 Namespace 对应的搜索域。taskboard 会依次尝试补全搜索后缀,最终命中 taskboard.taskboard.svc.cluster.local。跨 Namespace 调用时,单写 taskboard 很可能会在调用方自己的 Namespace 中查找;此时至少应使用 taskboard.taskboard,排障时则直接使用完整域名。
Service DNS 记录返回 ClusterIP,不返回“当前选择的 Pod”。因此,nslookup 成功只能验证名称层;后端选择仍要读取 EndpointSlice。Headless Service 是另一个语义:它没有普通 ClusterIP,DNS 会直接返回后端地址。第九章 Redis 会使用这种模式,本章 TaskBoard 使用的是普通 ClusterIP。
为了执行 nslookup 和 wget,我们创建一个专用工具 Pod。这个 Pod 不需要访问 Kubernetes API,因此关闭 ServiceAccount token 自动挂载;它也显式满足 restricted 安全要求,不依赖镜像默认用户。
在项目工作目录创建 k8s/toolbox.yaml:
apiVersion: v1
kind: Pod
metadata:
name: toolbox-safe
namespace: taskboard
labels:
app: toolbox-safe
spec:
automountServiceAccountToken: false
restartPolicy: Always
securityContext:
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 10000
这份工具清单值得逐项读一遍。restartPolicy=Always 让它保持可用,sleep 1d 只是维持容器进程,真正的诊断命令通过 kubectl exec 临时执行。automountServiceAccountToken=false 避免把不需要的 API 凭据放进容器。runAsNonRoot、seccompProfile、禁止提权和丢弃 capabilities 让诊断工具也遵守后续章节会启用的 restricted 安全要求。根文件系统只读,所以额外挂载 emptyDir 到 /tmp,给 BusyBox 必要的临时写入空间。
工具 Pod 与 TaskBoard 位于同一 Namespace,这不是偶然选择。这样可以同时验证短名称搜索域和 Service 的 Namespace 边界,也避免为了诊断创建跨 Namespace 权限或网络条件。
先创建工具 Pod,并等到它处于 Ready 状态。apply 只负责提交对象;wait 监听 Pod Ready condition,超时 60 秒;exec 中的双横线把后续参数交给容器内的 nslookup,而不是交给 kubectl 解析。等待成功后再查询 DNS,可以避免把容器尚未启动误判成 DNS 故障:
kubectl apply -f k8s/toolbox.yaml
kubectl wait -n taskboard --for=condition=Ready pod/toolbox-safe --timeout=60s
kubectl exec -n taskboard toolbox-safe -- \
nslookup taskboard.taskboard.svc.cluster.localpod/toolbox-safe created
pod/toolbox-safe condition met
Server: <集群 DNS IP>
Address: <集群 DNS IP>:53
Name: taskboard.taskboard.svc.cluster.local
Address: 10.96.250.32DNS 服务器地址也由集群分配,可能与这里不同。最后一行解析到前面看到的 Service ClusterIP,说明 CoreDNS 已经把 Service 名称转换成稳定入口。
前两行说明查询被发送到 Pod 配置的集群 DNS 服务,最后两行说明完整域名解析为 10.96.250.32。它与前面 Service 表格中的 CLUSTER-IP 相等,这是关键交叉证据。若地址不同,先确认查询的名称和 Namespace;若出现 NXDOMAIN,读取容器内 /etc/resolv.conf、kubectl get service 和 kube-system 中 CoreDNS Pod;若 nslookup 超时,则检查 DNS Pod、DNS Service 与网络策略。
DNS 解析正常后,再连续发出六次请求。kubectl exec 进入同一个工具 Pod,sh -c 让循环在容器内执行;wget 的 -qO- 表示安静模式并把响应正文写到标准输出。每次请求都访问同一个 Service 名称,但响应里的 pod 字段会告诉我们实际处理请求的 Pod:
kubectl exec -n taskboard toolbox-safe -- sh -c '
for i in 1 2 3 4 5 6; do
wget -qO- http://taskboard/api/info
echo
done
'{"service":"taskboard","version":"1.0.0","message":"你好,这是 Deployment","pod":"taskboard-<模板哈希>-<随机后缀 A>","namespace":"taskboard","redisConfigured":false}
{"service":"taskboard","version":"1.0.0","message":"你好,这是 Deployment","pod":"taskboard-<模板哈希>-<随机后缀 B>","namespace":
Pod 名称会变化,分配顺序也不保证严格轮流。这个结果只需要证明同一个 Service 地址能到达两个不同 Pod。Service 提供的是四层流量分发,不会为每个客户端保证完全平均的轮询序列。
六行 JSON 中,service、version、message、namespace 和 redisConfigured 保持一致,说明两个副本来自同一应用模板;pod 字段出现两个不同值,说明 Service 后端集合包含两个实例。不要用“三次命中 A、三次命中 B”做验收,因为负载均衡不是面向这么小样本的严格公平调度器。若六次都命中同一个 Pod,也先核对 EndpointSlice 是否有两个 ready=true,再考虑连接复用和实现行为,而不是立即删除另一个 Pod。
现在模拟一个很常见的发布错误:Service 的选择器被改成不存在的标签。Deployment 和两个 Pod 仍然健康,但 Service 找不到后端。我们预计 EndpointSlice 的端点变为空,请求连接被拒绝。
下面的 merge patch 只把 Service 的 spec.selector.app 改成 wrong-label,不会修改 Deployment 或 Pod 标签。修改后不能立即请求,因为 EndpointSlice 控制器异步更新后端;我们最多轮询 30 次,每次间隔 1 秒,直到所有关联切片的地址总数为 0。jsonpath 遍历所有切片和端点,awk 只统计非空地址行;test 确保超时后不会带着旧端点继续。最后才读取切片并发请求,把“配置变更”“后端归零”“用户可见故障”连在同一条时间线上:
kubectl patch service taskboard -n taskboard --type=merge -p '{"spec":{"selector":{"app":"wrong-label"}}}'
ENDPOINT_COUNT=-1
for attempt in $(seq 1 30); do
ENDPOINT_ADDRESSES="$(kubectl get endpointslice -n taskboard -l kubernetes.io/service-name=taskboard -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\n"}{end}')" || { echo '读取 EndpointSlice 失败' >&2; exit 1; }
service/taskboard patched
endpoint_count=0
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
taskboard-xqwzk IPv4 <unset> <unset> <动态时间>
wget: can't connect to remote host (10.96.250.32): Connection refused
command terminated with exit code 1
request failed as expectedService IP 仍然存在,DNS 也仍会解析,但没有任何后端能接收连接。这正是“Service 正常、Pod 正常、请求却失败”的典型表现。看到 EndpointSlice 的 ENDPOINTS 为 <unset> 后,应先检查 Service 选择器,而不是重启应用。
三段结果逐层提供证据。patched 说明 Service 对象已接受新选择器;EndpointSlice 仍存在,因为它属于 Service,但 PORTS 和 ENDPOINTS 已被清空;wget 明确连接的是原 ClusterIP 10.96.250.32,连接被拒绝并以退出码 1 结束。退出码不为零在这里是预期结果,不是命令脚本失败。
endpoint_count=0 证明轮询已经越过异步窗口,后续失败请求才与空后端状态对应。if 把预期的非零退出码转成可读验收;若请求意外成功,脚本反而以 1 退出。若 30 秒仍不能归零,检查所有关联 EndpointSlice、它们的 ownerReferences 和 EndpointSlice 控制器状态,不能只查询一个硬编码切片名。

把选择器修回 app=taskboard 后,同样不能把 patch 成功当成后端已恢复。下面分别统计地址数和 ready=true 的端点数,最多等待 30 秒,直到两者都是 2。只有 test 同时通过后才读取切片并请求 Service,这样响应不会抢在控制面收敛之前:
kubectl patch service taskboard -n taskboard --type=merge -p '{"spec":{"selector":{"app":"taskboard"}}}'
ENDPOINT_COUNT=0
READY_COUNT=0
for attempt in $(seq 1 30); do
ENDPOINT_ADDRESSES="$(kubectl get endpointslice -n taskboard -l kubernetes.io/service-name=taskboard -o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{"\n"}{end}')" || { echo '读取 EndpointSlice 地址失败' >&2;
service/taskboard patched
endpoint_count=2 ready_count=2
NAME ENDPOINTS READY
taskboard-xqwzk 10.244.0.6,10.244.0.8 true,true
{"service":"taskboard","version":"1.0.0","message":"你好,这是 Deployment","pod":"taskboard-<模板哈希>-<随机后缀>","namespace":"taskboard","redisConfigured":false}EndpointSlice 地址恢复后,请求随即成功。整个过程中没有重建 Deployment,这说明故障位于服务发现链路,而不是应用进程。
修复结果也要按层解释。patched 证明期望选择器恢复;两个地址和 true,true 证明控制面后端集合恢复;最终 JSON 证明数据面和应用链路都恢复。Pod 名与实际命中的后端是动态的,不要求与示例一致。若端点已恢复但 wget 仍失败,再检查 targetPort、Pod 监听地址、NetworkPolicy 与网络组件;继续重启 Deployment 只会引入无关实例变化。
Service 故障常见的误区是从最远处开始猜:有人先怀疑 CoreDNS,有人先重启 kube-proxy,也有人直接把 ClusterIP 改成 NodePort。更有效的办法是从 API 声明到应用进程逐层对账,每一层只在上一层成立后继续。
第一步确认调用身份。请求使用的名称、Namespace 和端口必须明确。同名 Service 可以存在于不同 Namespace,短名称会受调用方 DNS 搜索域影响。用完整域名可以排除一部分名称歧义。
第二步确认 Service 对象。读取 spec.selector、spec.ports、clusterIP。这里回答“稳定入口声明是什么”,还不能回答“后端是否存在”。
第三步确认 Pod 标签。kubectl get pod -l app=taskboard 只会显示满足选择器的 Pod;如果数量不对,再把 --show-labels 加上查看真实标签。不要只看 Deployment 的 metadata.labels,Service 选择的是 Pod 标签。
第四步确认所有 EndpointSlice。用 kubernetes.io/service-name 标签列出,核对 address、port、targetRef 与 conditions.ready。端点存在但 ready=false 时,先看 Pod readiness 与依赖,不要把 Service 选择器改得更宽。
第五步从同 Namespace 的诊断 Pod访问 Service DNS。如果 DNS 失败,问题在名称解析链路;DNS 成功但连接失败,问题位于端点、端口或网络数据面;HTTP 成功但业务响应异常,才进入应用日志。
第六步核对应用监听。容器端口字段本身不会让进程开始监听,也不会强制绑定 0.0.0.0。应用如果只绑定 127.0.0.1,其他 Pod 无法通过 Pod IP 连接。kubectl logs、容器内的监听信息以及直接访问 Pod IP 可以帮助区分应用监听与 Service 转发。
这条顺序的价值在于每一步都能产生可复核证据。重启组件有时会让症状暂时消失,却不能告诉你是哪个声明错了,也可能把 EndpointSlice 的旧状态和事件清掉。
ClusterIP 适合服务之间的集群内通信,但 Kubernetes 还提供其他类型。选择类型时要看流量从哪里来,不要把“能访问”当成唯一标准。
表中的 CNAME 是 DNS 的规范名称记录:它让一个域名成为另一个域名的别名,本身不创建 Service 转发规则。
后面部署 Redis StatefulSet 时,我们会使用 Headless Service。TaskBoard 的可重复验收继续使用集群内 Service;需要从隔离练习集群外临时访问时,可以使用 kubectl port-forward。生产中的 Ingress 或 Gateway API 还需要相应控制器、入口地址和证书配置,本课程不把“创建一个 Ingress 对象”当成已经具备外部入口。
Service 类型也不是从“小项目”到“大项目”的等级。ClusterIP 是其他类型常用的基础,并不低级;NodePort 是一种节点入口机制,不等于生产负载均衡策略;LoadBalancer 依赖集群外部或集群内安装的负载均衡实现,创建对象后 EXTERNAL-IP 长期 Pending 往往说明控制器或地址池缺失,而不是 TaskBoard 容器故障。
Service 也不替代应用层超时、重试和幂等设计。端点在发布、扩缩容与故障期间变化,已有连接可能继续指向即将终止的 Pod。优雅终止需要应用处理 SIGTERM、停止接收新请求并给进行中的请求留出时间,还要与 readiness、preStop 和 terminationGracePeriodSeconds 配合。这里只验证新连接的发现路径,第八章会继续观察 readiness 怎样约束滚动发布。
不要通过 Pod IP 建立长期依赖。Pod IP 属于某次实例,Service DNS 才是调用方应保存的服务身份。排查后端时读取 EndpointSlice,也不要把其中的动态地址写回应用配置。
下一章会通过 Service 访问 TaskBoard,并用 EndpointSlice 观察 readiness。进入下一章前应同时满足:
下面的检查分别读取 Service、所有关联切片与工具 Pod。它们不依赖固定 ClusterIP、Pod IP 或 EndpointSlice 后缀:
kubectl get service taskboard -n taskboard
kubectl get endpointslice -n taskboard -l kubernetes.io/service-name=taskboard --output custom-columns='NAME:.metadata.name,ENDPOINTS:.endpoints[*].addresses,READY:.endpoints[*].conditions.ready'
kubectl get pod toolbox-safe -n taskboardNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
taskboard ClusterIP 10.96.250.32 <none> 80/TCP <动态时间>
NAME ENDPOINTS READY
taskboard-xqwzk 10.244.0.6,10.244.0.8 true,true
NAME READY STATUS RESTARTS AGE
toolbox-safe 1/1 Running 0 <动态时间>ClusterIP、切片后缀、Pod IP 与 AGE 都是动态值。验收的是对象类型、两个就绪端点和工具 Pod Ready。如果 EndpointSlice 仍为空,先核对本章故障实验修复后的 selector,不能带着断开的服务发现链路进入配置实验。
本章完成了从“调用某个 Pod”到“调用一个服务身份”的转换。下一章即使滚动替换 Pod,toolbox-safe 仍只需要访问 taskboard 这个名称;我们将利用这个稳定入口验证配置注入和 readiness 对流量的影响。