Kubernetes Service 从入门到实践:服务发现与基本管理

适合读者:已经能用 Deployment 跑起 Pod,但还在为“怎么访问 Pod”发愁的 K8s 初学者。

在 Kubernetes 里跑应用,第一步往往是创建 Deployment、拉镜像、等 Pod 变成 Running。但紧接着就会遇到一个灵魂拷问:我的应用到底该怎么被别人访问?

直接用 Pod IP?不行。Pod 是会“死”的,也是会“换”的——Deployment 滚动更新、Pod 崩溃重启、节点故障重新调度,都会让 Pod 的 IP 发生变化。更麻烦的是,集群外部的客户端甚至不知道 Pod 跑在哪台节点上。

这篇文章我们就从两个“翻车现场”开始,理解 Kubernetes 为什么需要 Service,然后完整走一遍 Service 的创建、验证与服务发现。

一、先看两个“反面教材”

示例 1:用 hostPort 暴露 Pod

先创建一个最简单的 Pod:

kubectl run web --image=hub.shaka.cn/library/httpd --image-pull-policy=IfNotPresent -o yaml --dry-run=client > pod-web.yml
kubectl apply -f pod-web.yml
kubectl get pod -o wide
NAME   READY   STATUS    RESTARTS   AGE   IP              NODE
web    1/1     Running   0          10s   10.224.51.149   worker31.shaka.cn

Pod 有了自己的 IP,但集群外部的节点访问这个 IP 时,根本不通:

curl http://10.224.193.65

解决办法是在 Pod 上加上 hostPort,把 Pod 的 80 端口映射到所在节点的 8080 端口:

apiVersion: v1
kind: Pod
metadata:
  labels:
    run: web
  name: web
spec:
  containers:
  - image: hub.shaka.cn/library/httpd
    name: web
    ports:
    - containerPort: 80
      hostPort: 8080

这次外部就能访问了:

curl http://worker31.shaka.cn:8080
<html><body><h1>It works!</h1></body></html>

但代价很明显:

  • 外部客户端必须事先知道 Pod 在哪个主机上;
  • 如果主机上再起一个同样使用 8080 的 Pod,端口直接冲突。

示例 2:多副本 Deployment + hostPort

创建一个 4 副本的 Deployment,同样加上 hostPort: 8080

kubectl create deployment web --image=hub.shaka.cn/library/httpd --replicas=4 --dry-run=client -o yaml > deploy-web.yml
kubectl apply -f deploy-web.yml
kubectl get pod
NAME                   READY   STATUS    RESTARTS   AGE
web-79fc679949-2tr7x   0/1     Pending   0          7s
web-79fc679949-msbc5   1/1     Running   0          7s
web-79fc679949-t6twf   0/1     Pending   0          7s
web-79fc679949-vqfr8   1/1     Running   0          7s

两个 Pod 一直 Pending。kubectl describe 会告诉我们原因:

Warning  FailedScheduling  49s   default-scheduler  0/3 nodes are available:
1 node(s) had taint {node-role.kubernetes.io/master: }, that the pod didn't tolerate,
2 node(s) didn't have free ports for the requested pod ports.

问题总结:

  1. 集群外部客户端需要知道 Pod 运行在哪个主机,才能访问到 Pod;
  2. Pod 由控制器管理、成批创建时,同一个主机上无法创建多个使用相同 hostPort 的 Pod。

hostPort 只适合调试和个别特殊场景,绝不适合作为常规的服务暴露方式

二、Service 是什么

Deployment 可以动态创建和销毁 Pod。在任何时刻,你都不知道有多少个 Pod 在工作、它们健不健康,甚至不知道它们的具体 IP——Pod 是临时资源,不应该指望某个 Pod 既可靠又耐用。

这就带来一个问题:如果一组 Pod(后端)要为另一组 Pod(前端)提供功能,前端如何发现并跟踪要连接的 IP?

答案是 Service

Service 是 Kubernetes 中的一种资源,它把运行在一个或一组 Pod 上的网络应用,暴露为一个稳定的网络服务:

  • Service 有自己的 IP 和端口,并且这个 IP 不变
  • Service 通过**标签选择器(selector)**动态关联后端 Pod;
  • Service 自动为后端 Pod 做负载均衡
  • 无论后端 Pod 如何增删、重启、迁移,客户端始终只访问 Service,不受任何影响。

Service 的一个关键设计目标是:让应用无需修改,就能被服务发现。不管你的代码是为云原生设计的,还是被容器化的老应用,都可以通过 Service 让一组 Pod 在网络上被访问。

三、Service 基本管理

1. 准备后端 Pod

创建一个 3 副本的 Deployment:

kubectl create deployment web --image=hub.shaka.cn/library/httpd:2.4.58 --replicas=3
kubectl get pods --show-labels
NAME                   READY   STATUS    RESTARTS   AGE     LABELS
web-5646dd6f6c-5hs6f   1/1     Running   0          8m43s   app=web,pod-template-hash=5646dd6f6c
web-5646dd6f6c-6fjqs   1/1     Running   0          8m43s   app=web,pod-template-hash=5646dd6f6c
web-5646dd6f6c-tvw78   1/1     Running   0          8m43s   app=web,pod-template-hash=5646dd6f6c

注意 Pod 上都带有 app=web 标签,后面 Service 就是靠它来“认人”的。

2. 用一条命令创建 Service

kubectl create service clusterip web --tcp=8080:80
kubectl get svc
NAME   TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
web    ClusterIP   10.103.19.150   <none>        8080/TCP   6m58s

这里 --tcp=8080:80 的含义是:访问 ClusterIP 的 8080 端口,会转发到后端 Pod 的 80 端口

kubectl create service 自动生成的 Service 默认标签是 app=<svc-name>,所以它刚好和 Deployment 创建的 Pod 标签 app=web 匹配上。

3. 查看 Service 详情

kubectl describe service web
Name:              web
Namespace:         shaka
Labels:            app=web
Selector:          app=web
Type:              ClusterIP
IP:                10.103.19.150
Port:              8080-80  8080/TCP
TargetPort:        80/TCP
Endpoints:         10.224.193.67:80,10.224.193.68:80,10.224.41.131:80
Session Affinity:  None

关键信息是 Endpoints:Service 已经自动把三个 Pod 的 IP 都纳入后端了。

curl 10.103.19.150:8080
<html><body><h1>It works!</h1></body></html>

4. 验证 Service 的负载均衡

给每个 Pod 写入不同的主页,再反复访问 Service:

for pod in $(kubectl get pods -o name | awk -F/ '{print $2}'); do
  kubectl exec -it $pod -- bash -c "echo $pod > htdocs/index.html"
done

for i in {1..60}; do curl -s 10.103.19.150:8080; done | sort | uniq -c
    19 web-5646dd6f6c-5hs6f
    19 web-5646dd6f6c-6fjqs
    22 web-5646dd6f6c-tvw78

流量被大致平均地分发到了三个 Pod 上——Service 天然具备负载均衡能力

5. 验证动态发现

Service 的后端是动态的,不是创建时“拍板定死”的:

  • 手动创建一个带相同标签 app=web 的 Pod,它也会立刻被 Service 纳入后端;
  • 执行 kubectl rollout restart deployment web,Pod 全部重建后,Service 依然能自动发现新 Pod,客户端访问不受影响。

Service 匹配的只是标签,它并不关心后端是谁创建的。甚至 Deployment 的 selector 和 Service 的 selector 可以不一样:

# Deployment 的 selector 匹配 app1=web1
# Service 通过 expose 指定匹配 app2=web2
kubectl expose deployment web --port=8080 --target-port=80 --selector=app2=web2

只要 Pod 上同时带有这两个标签,Service 和 Deployment 就各管各的,互不干扰。

6. 一个小细节:ping 不通 ClusterIP

ping 10.103.19.150   # 不通

ClusterIP 是 kube-proxy 通过 iptables/IPVS 规则“虚拟”出来的,只对配置过的端口(如 80、8080)生效。Service 只做了 HTTP 端口映射,没有为 ICMP 配置规则,所以 ping 不通 Service IP,但可以 ping 通 Pod IP

7. 用 YAML 创建 Service

命令行适合快速创建,正式环境一般还是把 YAML 固化到文件里。先获取模板:

kubectl create service clusterip web --tcp=8080:80 -o yaml --dry-run=client > svc-web.yml
apiVersion: v1
kind: Service
metadata:
  labels:
    app: web
  name: web
spec:
  ports:
  - name: 8080-80
    port: 8080
    protocol: TCP
    targetPort: 80
  selector:
    app: web
  type: ClusterIP

核心字段就三个:

  • port:Service 对外监听的端口;
  • targetPort:后端 Pod 的端口;
  • selector:选择后端 Pod 的标签。

四、Service 的三种发现方式

“发现 Service”是指集群内部的应用如何访问 Service。官方推荐 DNS,但我们从最简单的 IP 方式开始,逐一感受演进过程。

实验环境用经典的 mysql + wordpress 组合:WordPress 需要连接 MySQL,正好模拟“前端找后端”。

方式一:通过 IP 访问

先创建 MySQL Pod 并暴露成 Service:

kubectl run mysql --image=hub.shaka.cn/library/mysql \
  --image-pull-policy=IfNotPresent \
  --env=MYSQL_ROOT_PASSWORD=redhat \
  --env=MYSQL_USER=tom \
  --env=MYSQL_PASSWORD=redhat \
  --env=MYSQL_DATABASE=blog \
  --dry-run=client -o yaml > pod-mysql.yaml
kubectl apply -f pod-mysql.yaml
kubectl expose pod mysql --port=3306 --target-port=3306

kubectl get service
NAME    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
mysql   ClusterIP   10.111.69.45   <none>        3306/TCP   25m

然后直接使用 Service IP 连接数据库:

mysql -u tom -predhat -h 10.111.69.45 --ssl-mode=DISABLED -e 'show databases;'

能连上,但问题是:IP 是 Kubernetes 分配的,应用代码里写死 IP 并不优雅,而且换环境(测试→生产)就得改配置。

方式二:通过环境变量访问

Kubernetes 会在创建 Pod 时,把同 Namespace 中已存在的 Service 信息注入为环境变量。看一个 busybox 里的效果:

kubectl run test --rm -it --image=hub.shaka.cn/library/busybox --image-pull-policy=IfNotPresent sh
/ # env | grep MYSQL
MYSQL_PORT_3306_TCP_ADDR=10.111.69.45
MYSQL_PORT_3306_TCP_PORT=3306
MYSQL_SERVICE_HOST=10.111.69.45
MYSQL_SERVICE_PORT=3306
MYSQL_PORT=tcp://10.111.69.45:3306

所以创建 WordPress 时,可以直接引用环境变量,而不是写死 IP:

kubectl run wordpress \
  --image=hub.shaka.cn/library/wordpress \
  --image-pull-policy=IfNotPresent \
  --env=WORDPRESS_DB_USER=tom \
  --env=WORDPRESS_DB_PASSWORD=redhat \
  --env=WORDPRESS_DB_NAME=blog \
  --env=WORDPRESS_DB_HOST='$(MYSQL_SERVICE_HOST)'

使用环境变量有三个注意事项:

  1. Service 必须先创建,后创建 Pod,环境变量才会注入。顺序反了,Pod 里就没有这个变量;
  2. 环境变量只对同一个 Namespace 中的 Pod 生效;
  3. 名字是固定的 {SERVICE_NAME}_SERVICE_HOST / {SERVICE_NAME}_SERVICE_PORT,大小写有讲究。

方式三:通过 DNS 名称访问(推荐)

kubeadm 部署的集群默认装有 CoreDNS:

kubectl get deployments.apps --namespace=kube-system
NAME                      READY   UP-TO-DATE   AVAILABLE   AGE
calico-kube-controllers   1/1     1            1           2d23h
coredns                   2/2     2            2           3d

CoreDNS 是一个 DNS 服务器,每当有新的 Service 创建,它就会自动添加对应的 DNS 记录。Pod 内可以通过名称访问 Service:

<SERVICE_NAME>.<NAMESPACE_NAME>

比如 WordPress 和 MySQL 在同一个 Namespace,直接写 mysql 就能访问:

kubectl run wordpress \
  --image=hub.shaka.cn/library/wordpress \
  --image-pull-policy=IfNotPresent \
  --env=WORDPRESS_DB_USER=tom \
  --env=WORDPRESS_DB_PASSWORD=redhat \
  --env=WORDPRESS_DB_NAME=blog \
  --env=WORDPRESS_DB_HOST=mysql

用 busybox 实测一下 DNS 解析效果:

kubectl run busybox --rm -it --image=hub.shaka.cn/library/busybox /bin/sh
/ # cat /etc/resolv.conf
nameserver 10.96.0.10
search service.svc.cluster.local svc.cluster.local cluster.local

/ # wget wordpress:80
Connecting to wordpress:80 (10.106.106.246:80)
index.html           100% |************************| 11607  0:00:00 ETA

10.96.0.10 就是 kube-system 里的 kube-dns Service:

NAME       TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                  AGE
kube-dns   ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP,9153/TCP   3d2h

完整的 DNS 名称其实是 wordpress.service.svc.cluster.local。因为 resolv.conf 里配置了 search 域,所以同 Namespace 下可以省略 Namespace 直接写 wordpress;跨 Namespace 时则要写成 svc名.命名空间名

三种方式对比:

方式易用性抗变化能力适用场景
Service IP一般换集群就要改 IP临时测试、调试
环境变量较好依赖创建顺序老应用快速迁移
DNS 名称最好完全透明生产环境首选

五、小结

这一篇我们解决了两个问题:

  1. 为什么需要 Service:Pod 是临时的,IP 会漂移;hostPort 有端口冲突和主机依赖问题。Service 用稳定的 IP + 标签选择器,把“一组随时变化的 Pod”封装成了“一个固定不变的入口”。
  2. 怎么管理和发现 Service:创建、验证、动态发现,以及 IP / 环境变量 / DNS 三种发现方式。

Service 有稳定入口之后,下一个问题自然就是:外部用户怎么访问集群? 这就轮到 Service 的四种类型出场了——ClusterIP、NodePort、LoadBalancer、ExternalName,还有特殊的 Headless Service。我们下一篇文章见。

更多推荐