k8s 笔记
好的,我们来详细整理一下关于 Kubernetes Endpoints 资源的笔记。这份笔记将严格按照你的大纲,并力求概念通俗易懂、案例详尽、代码完整且包含输出解释。
Kubernetes Endpoints 资源深度解析
一、什么是 Endpoints 资源?
你可以把 Endpoints 资源理解为 Kubernetes 中的“服务地址簿”或“DNS 白名单”。它是一个核心的服务发现组件,充当了 Service (svc) 和 Pod 之间的桥梁。
具体来说:
Endpoints是一个 Kubernetes 资源对象:就像Pod,Service,Deployment一样,它也有自己的 YAML 定义和生命周期。- 核心功能是存储地址:它的主要数据是一个 IP 地址列表和对应的端口号列表。这些 IP 地址正是后端提供服务的
Pod的 IP。 - 由
Service管理和引用:每个Service资源都会自动关联一个同名的Endpoints资源(在大多数情况下)。Service收到请求后,会查看自己关联的Endpoints列表,然后将请求转发到列表中的一个或多个 IP 上。
工作流程简图:
外部请求 -> Service (例如:my-service) -> Endpoints (my-service) -> [Pod IP 1, Pod IP 2, ...]
|
v
后端应用 Pods
通俗比喻:
Service就像是一家公司的总机电话。Endpoints就是这家公司的员工通讯录,上面有各个部门员工的分机号(Pod IP)。- 当客户(外部请求)拨打总机(Service),接线员(kube-proxy)会查看通讯录(Endpoints),然后把电话转接到具体的员工(Pod)那里。
二、核心案例:Endpoints 的自动与手动管理
Endpoints 的创建和维护主要有两种模式:自动模式和手动模式。
案例 1:自动创建 Endpoints (最常见)
当你创建一个 Service 并为其指定 标签选择器(selector) 时,Kubernetes 会自动为你完成以下工作:
- 创建一个与
Service同名的Endpoints资源。 - 持续监控集群中所有
Pod的标签。 - 当发现有
Pod的标签与Service的selector匹配时,就把这个Pod的 IP 地址和端口(从Pod的containerPort获取)添加到Endpoints列表中。 - 当
Pod被删除、更新或标签改变时,Kubernetes 会自动更新Endpoints列表。
详细实现过程:
第 1 步:创建一个 Deployment 来管理 Pod
我们先创建一些带有特定标签的 Pod。这里我们用 Deployment 来批量管理。
my-app-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app-deployment
spec:
replicas: 3 # 运行 3 个 Pod 副本
selector:
matchLabels:
app: my-app # Deployment 管理带有此标签的 Pod
template:
metadata:
labels:
app: my-app # 给 Pod 打上标签
env: production
spec:
containers:
- name: my-app-container
image: nginx:alpine # 使用一个简单的 nginx 镜像作为示例
ports:
- containerPort: 80 # 容器在 80 端口提供服务
执行命令创建:
kubectl apply -f my-app-deployment.yaml
查看创建的 Pod:
kubectl get pods -o wide --show-labels
输出示例:
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES LABELS
my-app-deployment-7f98d7c6b4-2xqzk 1/1 Running 0 10s 10.244.1.10 minikube <none> <none> app=my-app,env=production,pod-template-hash=7f98d7c6b4
my-app-deployment-7f98d7c6b4-5bmd7 1/1 Running 0 10s 10.244.1.11 minikube <none> <none> app=my-app,env=production,pod-template-hash=7f98d7c6b4
my-app-deployment-7f98d7c6b4-8vgrx 1/1 Running 0 10s 10.244.1.12 minikube <none> <none> app=my-app,env=production,pod-template-hash=7f98d7c6b4
你可以看到 3 个 Pod 已经运行,并且都带有 app=my-app 标签,它们的 IP 地址分别是 10.244.1.10, 10.244.1.11, 10.244.1.12。
第 2 步:创建一个带有 selector 的 Service
现在,我们创建一个 Service,让它通过标签选择器找到上面的 Pod。
my-app-service.yaml:
apiVersion: v1
kind: Service
metadata:
name: my-app-service
spec:
selector:
app: my-app # 关键!Service 将选择所有带有 app=my-app 标签的 Pod
ports:
- protocol: TCP
port: 80 # Service 暴露的端口
targetPort: 80 # 转发到 Pod 的端口
type: ClusterIP # 这是默认类型,只在集群内部可访问
执行命令创建:
kubectl apply -f my-app-service.yaml
查看 Service:
kubectl get svc my-app-service
输出示例:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
my-app-service ClusterIP 10.100.235.147 <none> 80/TCP 5s
Service 被分配了一个集群内部的虚拟 IP 地址(CLUSTER-IP)。
第 3 步:验证自动创建的 Endpoints
此时,Kubernetes 已经自动为我们创建了一个名为 my-app-service 的 Endpoints 资源。
查看 Endpoints:
kubectl get endpoints my-app-service -o wide
输出示例:
NAME ENDPOINTS AGE
my-app-service 10.244.1.10:80,10.244.1.11:80,10.244.1.12:80 15s
奇迹发生了! Endpoints 列表中自动包含了我们刚才创建的 3 个 Pod 的 IP 地址和端口 80。
第 4 步:验证服务发现
我们可以在集群内的另一个 Pod 中(比如一个临时的 busybox Pod)来访问这个 Service,以验证它是否能正常工作。
kubectl run -it --rm --image=busybox:1.28 --restart=Never busybox-test sh
这会打开一个 busybox Pod 的 shell 终端。在终端中执行:
# 使用 wget 或 curl 访问 Service 的名字
wget -qO- my-app-service
输出示例(简化):
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
</html>
你成功地通过 Service 名称 my-app-service 访问到了后端的 nginx Pod 提供的服务。这背后正是 Endpoints 在默默地工作。
案例 2:手动创建 Endpoints
当 Service 没有 selector 时,Kubernetes 不会自动创建和管理 Endpoints。这种情况通常用于:
- 服务集群外部的应用:例如,你有一个数据库运行在 Kubernetes 集群之外的物理机上,你想让集群内的
Pod也能通过Service的方式访问它。 - 精细控制服务 endpoints:在某些特殊场景下,你需要手动决定哪些 IP 被包含。
详细实现过程:
假设我们有一个数据库运行在集群外部,IP 地址是 192.168.1.100,端口是 5432。
第 1 步:创建一个不带 selector 的 Service
my-external-db-service.yaml:
apiVersion: v1
kind: Service
metadata:
name: my-external-db-service
spec:
ports:
- protocol: TCP
port: 5432 # Service 暴露的端口
targetPort: 5432 # 转发到外部服务的端口
# 注意:这里没有 selector 字段
执行命令创建:
kubectl apply -f my-external-db-service.yaml
查看 Service:
kubectl get svc my-external-db-service
输出示例:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
my-external-db-service ClusterIP 10.100.156.78 <none> 5432/TCP 10s
Service 创建成功,但此时如果你查看它的 Endpoints,会发现是空的。
kubectl get endpoints my-external-db-service
输出示例:
NAME ENDPOINTS AGE
my-external-db-service <none> 20s
第 2 步:手动创建对应的 Endpoints 资源
现在,我们需要手动创建一个 Endpoints 资源,将 Service 指向外部的数据库 IP。
my-external-db-endpoints.yaml:
apiVersion: v1
kind: Endpoints
metadata:
name: my-external-db-service # 名称必须和 Service 完全一致
subsets:
- addresses:
- ip: 192.168.1.100 # 外部数据库的 IP 地址
ports:
- port: 5432 # 外部数据库的端口
关键点:
metadata.name必须与Service的名字相同,这样Service才能找到并使用它。subsets.addresses.ip可以是任何可访问的 IP,不一定是集群内的PodIP。- 你可以在
addresses列表中添加多个 IP,实现对多个外部服务的负载均衡。
执行命令创建:
kubectl apply -f my-external-db-endpoints.yaml
第 3 步:验证手动创建的 Endpoints
再次查看 Endpoints:
kubectl get endpoints my-external-db-service
输出示例:
NAME ENDPOINTS AGE
my-external-db-service 192.168.1.100:5432 5s
现在 Endpoints 已经有值了!
第 4 步:验证服务访问
现在,集群内的 Pod 就可以通过 my-external-db-service:5432 这个地址来访问外部的数据库了。
# 再次使用 busybox 进行测试
kubectl run -it --rm --image=busybox:1.28 --restart=Never busybox-test sh
在 shell 中:
# 尝试 telnet 到 Service 地址和端口,验证连通性
telnet my-external-db-service 5432
如果外部数据库可达,你应该会看到类似下面的输出,表示连接成功:
Trying 10.100.156.78...
Connected to my-external-db-service.default.svc.cluster.local.
Escape character is '^]'.
(注意:10.100.156.78 是 Service 的 CLUSTER-IP)
三、Endpoints 的特殊行为与配置
默认行为:Service 如何筛选 Pod?
在案例 1 的自动模式下,Service 并不是把所有标签匹配的 Pod 都加入 Endpoints。它有一套默认的健康检查机制:
一个 Pod 必须满足以下所有条件,才会被 Service 选中并加入到 Endpoints 列表中:
- 标签匹配:
Pod的标签必须与Service的selector完全匹配。 - 状态为 "Running":
Pod的status.phase必须是Running。处于Pending,Succeeded,Failed状态的Pod会被忽略。 - 就绪状态为 "Ready":
Pod必须通过所有的就绪探针(Readiness Probe)检查。Pod的status.conditions中必须有一个类型为Ready,且status为True的条件。这表示Pod内部的应用已经启动并准备好接收请求。
示例:一个未就绪的 Pod
假设我们有一个 Pod,它的就绪探针检查一个需要 30 秒才能启动的服务。在启动后的前 30 秒内,kubectl describe pod <pod-name> 会显示:
Conditions:
Type Status
Ready False
...
在这 30 秒内,这个 Pod 虽然标签匹配且状态是 Running,但因为 Ready 状态是 False,所以它不会出现在 Endpoints 列表中。只有当 Ready 变为 True 后,kube-proxy 才会将其加入 Endpoints,并开始转发流量。
特殊配置:publishNotReadyAddresses
有时候,你可能希望 Service 也能将那些未就绪(Not Ready)的 Pod 暴露出来。例如:
- 进行健康检查或监控:你可能有一个监控系统,需要访问所有
Pod(包括未就绪的)来收集启动时的日志或指标。 - 服务网格(Service Mesh)场景:一些服务网格代理需要在
Pod完全就绪前就与之通信,以注入sidecar代理。
要实现这个需求,你可以在 Service 的定义中设置 publishNotReadyAddresses: true。
详细实现过程:
我们修改案例 1 中的 Service YAML。
my-app-service-include-not-ready.yaml:
apiVersion: v1
kind: Service
metadata:
name: my-app-service-include-not-ready
spec:
selector:
app: my-app
ports:
- protocol: TCP
port: 80
targetPort: 80
publishNotReadyAddresses: true # 关键配置!
执行命令创建或更新:
kubectl apply -f my-app-service-include-not-ready.yaml
效果:
现在,假设我们有一个 Pod 因为就绪探针失败而处于 Not Ready 状态。
kubectl get pods
输出示例:
NAME READY STATUS RESTARTS AGE
my-app-deployment-7f98d7c6b4-2xqzk 1/1 Running 0 5m
my-app-deployment-7f98d7c6b4-5bmd7 0/1 Running 0 30s # 这个 Pod 未就绪
my-app-deployment-7f98d7c6b4-8vgrx 1/1 Running 0 5m
查看新 Service 对应的 Endpoints:
kubectl get endpoints my-app-service-include-not-ready
输出示例:
NAME ENDPOINTS AGE
my-app-service-include-not-ready 10.244.1.10:80,10.244.1.11:80,10.244.1.12:80 1m
(假设 10.244.1.11 是那个未就绪 Pod 的 IP)
你会发现,即使 Pod my-app-deployment-7f98d7c6b4-5bmd7 的 READY 状态是 0/1,它的 IP 地址依然被包含在了 Endpoints 列表中。
警告:启用 publishNotReadyAddresses 后,Service 会将流量转发到所有标签匹配且状态为 Running 的 Pod,无论其是否就绪。这意味着客户端可能会访问到一个无法正常提供服务的 Pod,导致请求失败。因此,在生产环境中使用此配置时,必须确保你的应用程序或客户端有足够的容错能力(如重试机制)。
总结
Endpoints是服务发现的基石,它存储了Service背后真实的Pod(或外部服务) 的网络地址。- 自动模式是常态:通过
Service的selector自动管理Endpoints,是 Kubernetes 服务发现的标准用法。 - 手动模式用于特殊场景:当需要将
Service指向集群外部服务或进行精细控制时,可以手动创建Endpoints。 - 默认行为是安全的:
Service只将流量转发到Running且Ready的Pod,确保了服务的可用性。 publishNotReadyAddresses是一个强大的逃逸阀:它允许你打破默认的安全检查,在特定场景下非常有用,但使用时需谨慎。
K8s存储笔记
存储分类
K8s中的存储主要用于解决容器数据持久化、配置管理、敏感信息存储等问题,常见的存储类型包括ConfigMap(配置存储)、Secret(敏感信息存储)、PV/PVC(持久化存储)、Downward API(Pod元数据注入)等。不同存储类型针对不同场景设计,满足多样化的存储需求。
ConfigMap
概念
ConfigMap是K8s专门用来存储非敏感配置信息的资源,比如应用的配置参数、环境变量配置、配置文件内容等。你可以把它理解成一个“配置字典”,里面装着键值对形式的配置数据。
- 它能被挂载到Pod里,要么当成环境变量用,要么当成配置文件用;
- 多个Pod可以共享同一个ConfigMap,实现配置的统一管理和复用;
- 注意:ConfigMap是在Pod第一次启动时注入的,后续如果ConfigMap改了,Pod不会自动更新,除非删掉Pod重新创建(不过用Volume挂载的方式支持热更新,后面会讲)。
创建
1. 通过YAML文件创建
新建一个my-config.yaml文件,内容如下:
apiVersion: v1
kind: ConfigMap
metadata:
name: my-config # ConfigMap的名字
namespace: default # 所属命名空间,默认default
data:
app.conf: | # 配置文件形式的键值对
server.port=8080
log.level=info
env: "prod" # 简单键值对
max_connections: "1000"
执行创建命令:
kubectl apply -f my-config.yaml
输出:configmap/my-config created
2. 从文件创建
先创建一个本地配置文件app.properties,内容为:
db.url=jdbc:mysql://localhost:3306/mydb
db.username=root
然后执行命令创建ConfigMap:
kubectl create configmap my-config-from-file --from-file=app.properties
输出:configmap/my-config-from-file created
如果有多个文件,可多次指定--from-file,比如--from-file=file1 --from-file=file2。
3. 从字面量创建
直接通过命令行指定键值对创建:
kubectl create configmap my-config-from-literal --from-literal=key1=value1 --from-literal=key2=value2
输出:configmap/my-config-from-literal created
Pod如何使用ConfigMap?
1. 作为环境变量
创建Pod的YAML文件pod-env-config.yaml:
apiVersion: v1
kind: Pod
metadata:
name: pod-env-config
spec:
containers:
- name: nginx-container
image: nginx:alpine
env:
- name: ENV_CONFIG # 容器内的环境变量名
valueFrom:
configMapKeyRef:
name: my-config # 引用的ConfigMap名称
key: env # 取ConfigMap里的env键
- name: MAX_CONN # 另一个环境变量
valueFrom:
configMapKeyRef:
name: my-config
key: max_connections
创建Pod:
kubectl apply -f pod-env-config.yaml
进入Pod查看环境变量:
kubectl exec -it pod-env-config -- sh
# 执行env命令查看
env | grep -E "ENV_CONFIG|MAX_CONN"
输出:
ENV_CONFIG=prod
MAX_CONN=1000
2. 作为启动参数
创建Pod的YAML文件pod-args-config.yaml:
apiVersion: v1
kind: Pod
metadata:
name: pod-args-config
spec:
containers:
- name: busybox-container
image: busybox:latest
command: ["/bin/sh", "-c", "echo $(ENV_VAL) $(MAX_VAL)"] # 启动命令中使用环境变量
env:
- name: ENV_VAL
valueFrom:
configMapKeyRef:
name: my-config
key: env
- name: MAX_VAL
valueFrom:
configMapKeyRef:
name: my-config
key: max_connections
创建Pod后查看日志:
kubectl apply -f pod-args-config.yaml
kubectl logs pod-args-config
输出:prod 1000
3. 作为配置文件(Volume挂载)
创建Pod的YAML文件pod-volume-config.yaml:
apiVersion: v1
kind: Pod
metadata:
name: pod-volume-config
spec:
containers:
- name: nginx-container
image: nginx:alpine
volumeMounts:
- name: config-volume # 卷名,和下面volumes里的name对应
mountPath: /etc/nginx/conf.d # 挂载到容器内的路径
readOnly: true # 只读,ConfigMap挂载默认只读
volumes:
- name: config-volume
configMap:
name: my-config # 引用的ConfigMap名称
items: # 可选,指定要挂载的键,不指定则挂载所有键
- key: app.conf # ConfigMap里的app.conf键
path: app.conf # 挂载到容器内的文件名
创建Pod:
kubectl apply -f pod-volume-config.yaml
进入Pod查看挂载的文件:
kubectl exec -it pod-volume-config -- sh
cat /etc/nginx/conf.d/app.conf
输出:
server.port=8080
log.level=info
再查看目录下的文件:
ls -lh /etc/nginx/conf.d/
输出(类似):
total 0
lrwxrwxrwx 1 root root 13 Mar 20 10:00 app.conf -> ..data/app.conf
可以看到是符号链接,指向..data目录下的实际文件,这是为了支持热更新。
热更新
只有通过Volume挂载为文件的ConfigMap才支持热更新,环境变量方式不支持。下面用Nginx示例演示:
1. 准备Nginx配置文件和ConfigMap
创建nginx.conf文件:
server {
listen 80;
server_name localhost;
location / {
default_type text/plain;
return 200 'Hello from original config!';
}
}
创建ConfigMap:
kubectl create configmap nginx-config --from-file=nginx.conf
2. 创建Deployment挂载ConfigMap
创建nginx-deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deploy
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: nginx-config-volume
mountPath: /etc/nginx/conf.d/default.conf # 直接挂载为Nginx默认配置文件
subPath: nginx.conf # 指定挂载的文件,避免覆盖整个目录
volumes:
- name: nginx-config-volume
configMap:
name: nginx-config
创建Deployment:
kubectl apply -f nginx-deployment.yaml
查看Pod:
kubectl get pods
输出(类似):
NAME READY STATUS RESTARTS AGE
nginx-deploy-7f9d6c8b7d-2xqzk 1/1 Running 0 10s
nginx-deploy-7f9d6c8b7d-5m7tl 1/1 Running 0 10s
nginx-deploy-7f9d6c8b7d-8k4zp 1/1 Running 0 10s
3. 测试初始配置
访问其中一个Pod:
kubectl exec -it nginx-deploy-7f9d6c8b7d-2xqzk -- curl localhost
输出:Hello from original config!
4. 修改ConfigMap
编辑ConfigMap:
kubectl edit configmap nginx-config
把nginx.conf内容改成:
server {
listen 80;
server_name localhost;
location / {
default_type text/plain;
return 200 'Hello from updated config!';
}
}
保存退出。
5. 查看ConfigMap更新后的Pod文件
等待10秒左右,进入Pod查看配置文件:
kubectl exec -it nginx-deploy-7f9d6c8b7d-2xqzk -- cat /etc/nginx/conf.d/default.conf
输出:
server {
listen 80;
server_name localhost;
location / {
default_type text/plain;
return 200 'Hello from updated config!';
}
}
说明ConfigMap已经热更新到Pod的文件里了。
6. 重启Nginx使配置生效
但此时访问Pod还是返回旧内容,因为Nginx没重新加载配置:
kubectl exec -it nginx-deploy-7f9d6c8b7d-2xqzk -- curl localhost
输出:Hello from original config!
需要进入Pod重启Nginx:
kubectl exec -it nginx-deploy-7f9d6c8b7d-2xqzk -- nginx -s reload
再访问:
kubectl exec -it nginx-deploy-7f9d6c8b7d-2xqzk -- curl localhost
输出:Hello from updated config!
7. 滚动重启Deployment(推荐)
手动重启每个Pod太麻烦,可通过修改Deployment的Annotation触发滚动重启:
kubectl patch deployment nginx-deploy -p "{\"spec\":{\"template\":{\"metadata\":{\"annotations\":{\"kubectl.kubernetes.io/restartedAt\":\"$(date +%Y-%m-%dT%H:%M:%S)\"}}}}}"
查看Pod,会发现旧Pod被删除,新Pod创建:
kubectl get pods
新Pod会自动加载最新的ConfigMap配置,无需手动重启Nginx。
不可改变
1. 配置ConfigMap为不可改变
创建ConfigMap时指定immutable: true,或编辑现有ConfigMap添加该字段:
apiVersion: v1
kind: ConfigMap
metadata:
name: my-immutable-config
data:
key1: value1
immutable: true # 设为不可改变
执行创建:
kubectl apply -f immutable-config.yaml
2. 特点
- 设为不可改变后,无法修改ConfigMap的内容,也不能删除后重建同名的ConfigMap(除非先删除这个不可改变的ConfigMap);
- 这个设置不允许回退,一旦设为
immutable: true,就不能改回false,只能删除重建; - 优点是能提高性能(K8s无需监控其变化),并防止误修改配置。
3. 验证不可改变
尝试编辑:
kubectl edit configmap my-immutable-config
修改key1的值后保存,会报错:
error: configmaps "my-immutable-config" is immutable
Secret
概念
Secret是K8s用来存储敏感信息的资源,比如密码、密钥、证书、Token等。它和ConfigMap结构类似,但会对数据进行Base64编码(注意:Base64不是加密,只是编码,仍需通过权限控制保证安全),且只有Pod内的容器能解密访问。
- 同样支持挂载为环境变量或文件;
- 可被多个Pod共享,统一管理敏感信息;
- 相比ConfigMap,Secret的访问权限更严格,默认不会被非授权用户查看。
类型
| 类型 | 用途 |
|---|---|
| Opaque | 默认类型,存储任意键值对的敏感信息(如密码、密钥) |
| kubernetes.io/dockerconfigjson | 存储Docker镜像仓库的认证信息(拉取私有镜像用) |
| kubernetes.io/service-account-token | 存储服务账号的Token,用于Pod访问K8s API Server |
| kubernetes.io/tls | 存储TLS证书和私钥(如HTTPS证书) |
| bootstrap.kubernetes.io/token | 用于K8s集群节点引导的Token |
Opaque类型Secret
Opaque是最常用的Secret类型,数据以Base64编码存储,下面详细讲创建和使用。
创建
1. 通过YAML文件创建
首先把要存储的内容转成Base64编码:
echo -n "my-password" | base64 # 输出:bXktcGFzc3dvcmQ=
echo -n "admin" | base64 # 输出:YWRtaW4=
创建my-secret.yaml文件:
apiVersion: v1
kind: Secret
metadata:
name: my-secret
type: Opaque
data:
username: YWRtaW4= # Base64编码后的admin
password: bXktcGFzc3dvcmQ= # Base64编码后的my-password
执行创建:
kubectl apply -f my-secret.yaml
输出:secret/my-secret created
2. 从文件创建
创建db-pass.txt文件,内容为db-password-123,然后执行:
kubectl create secret generic my-secret-from-file --from-file=db-pass=db-pass.txt
输出:secret/my-secret-from-file created
这里db-pass是Secret里的键,db-pass.txt是本地文件。
3. 从字面量创建
直接指定键值对(K8s会自动转Base64):
kubectl create secret generic my-secret-from-literal --from-literal=redis-pass=redis-123456
输出:secret/my-secret-from-literal created
查看Secret
1. 查看Secret列表
kubectl get secrets
输出(类似):
NAME TYPE DATA AGE
my-secret Opaque 2 20s
my-secret-from-file Opaque 1 10s
my-secret-from-literal Opaque 1 5s
default-token-xxxx kubernetes.io/service-account-token 3 1d
2. 查看Secret详情
kubectl describe secret my-secret
输出(类似):
Name: my-secret
Namespace: default
Labels: <none>
Annotations: <none>
Type: Opaque
Data
====
password: 10 bytes
username: 5 bytes
注意:describe不会显示具体的Base64编码值,保证敏感信息不泄露。
3. 查看Secret的原始数据
如果需要查看具体内容,可执行:
kubectl get secret my-secret -o jsonpath='{.data}'
输出:{"password":"bXktcGFzc3dvcmQ=","username":"YWRtaW4="}
解码查看:
echo "bXktcGFzc3dvcmQ=" | base64 -d # 输出:my-password
echo "YWRtaW4=" | base64 -d # 输出:admin
使用
1. 作为环境变量
创建pod-secret-env.yaml:
apiVersion: v1
kind: Pod
metadata:
name: pod-secret-env
spec:
containers:
- name: busybox-container
image: busybox:latest
command: ["/bin/sh", "-c", "env | grep DB_"]
env:
- name: DB_USERNAME
valueFrom:
secretKeyRef:
name: my-secret
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: my-secret
key: password
创建Pod并查看日志:
kubectl apply -f pod-secret-env.yaml
kubectl logs pod-secret-env
输出:
DB_USERNAME=admin
DB_PASSWORD=my-password
2. 作为配置文件(Volume挂载)
创建pod-secret-volume.yaml:
apiVersion: v1
kind: Pod
metadata:
name: pod-secret-volume
spec:
containers:
- name: nginx-container
image: nginx:alpine
volumeMounts:
- name: secret-volume
mountPath: /etc/secrets # 挂载到容器内的目录
readOnly: true
volumes:
- name: secret-volume
secret:
secretName: my-secret # 引用的Secret名称
创建Pod并查看挂载的文件:
kubectl apply -f pod-secret-volume.yaml
kubectl exec -it pod-secret-volume -- sh
ls /etc/secrets/
输出:password username
查看文件内容:
cat /etc/secrets/username # 输出:admin
cat /etc/secrets/password # 输出:my-password
注意:挂载的文件内容是Base64解码后的原始值,容器可直接使用。
3. 自定义文件权限
默认挂载的Secret文件权限是0644,可通过defaultMode修改:
volumes:
- name: secret-volume
secret:
secretName: my-secret
defaultMode: 0400 # 只有所有者可读
创建Pod后查看权限:
kubectl exec -it pod-secret-volume -- ls -l /etc/secrets/
输出(类似):
-r-------- 1 root root 5 Mar 20 12:00 username
-r-------- 1 root root 10 Mar 20 12:00 password
4. 挂载指定的Key
如果只需要挂载Secret中的部分Key,可通过items指定:
volumes:
- name: secret-volume
secret:
secretName: my-secret
items:
- key: username
path: db/user # 挂载到/etc/secrets/db/user
mode: 0600
创建Pod后查看:
kubectl exec -it pod-secret-volume -- ls /etc/secrets/db/
cat /etc/secrets/db/user # 输出:admin
注意:通过items指定Key挂载的方式不支持热更新,Secret修改后Pod不会自动更新,需重启Pod。
不可更改
和ConfigMap一样,Secret也可设置为不可改变:
apiVersion: v1
kind: Secret
metadata:
name: my-immutable-secret
type: Opaque
data:
key: dmFsdWU=
immutable: true
设为不可改变后,无法修改Secret内容,也不能删除重建同名Secret,除非先删除该Secret。
Downward API
概念
Downward API是K8s提供的一种机制,能把Pod自身的元数据(比如Pod名称、IP、标签、资源限制等)注入到容器内部。简单说,就是让容器能“知道”自己所在Pod的信息,无需通过K8s API Server查询(当然也可以通过API Server查,后面会讲)。
- 支持两种注入方式:环境变量和Volume挂载;
- 注入的是Pod启动时的元数据,部分数据(如Pod IP)会在Pod生命周期中保持不变,部分数据(如资源使用)不会实时更新。
使用案例
1. 作为环境变量注入
创建pod-downward-env.yaml:
apiVersion: v1
kind: Pod
metadata:
name: pod-downward-env
labels:
app: my-app
tier: frontend
annotations:
author: "k8s-user"
version: "1.0"
spec:
containers:
- name: busybox-container
image: busybox:latest
command: ["/bin/sh", "-c", "env | grep POD_"]
resources:
requests:
cpu: "100m" # 0.1核
memory: "128Mi"
limits:
cpu: "200m"
memory: "256Mi"
env:
# Pod名称
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
# Pod命名空间
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# Pod IP
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
# Pod标签(JSON格式)
- name: POD_LABELS
valueFrom:
fieldRef:
fieldPath: metadata.labels
# CPU请求
- name: CPU_REQUEST
valueFrom:
resourceFieldRef:
containerName: busybox-container
resource: requests.cpu
# 内存限制
- name: MEMORY_LIMIT
valueFrom:
resourceFieldRef:
containerName: busybox-container
resource: limits.memory
创建Pod并查看日志:
kubectl apply -f pod-downward-env.yaml
kubectl logs pod-downward-env
输出(类似):
POD_NAME=pod-downward-env
POD_NAMESPACE=default
POD_IP=10.244.1.10
POD_LABELS={"app":"my-app","tier":"frontend"}
CPU_REQUEST=100m
MEMORY_LIMIT=268435456
2. 通过Volume挂载注入
创建pod-downward-volume.yaml:
apiVersion: v1
kind: Pod
metadata:
name: pod-downward-volume
labels:
app: my-app
tier: frontend
annotations:
author: "k8s-user"
version: "1.0"
spec:
containers:
- name: nginx-container
image: nginx:alpine
volumeMounts:
- name: downward-volume
mountPath: /etc/pod-info
readOnly: true
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "200m"
memory: "256Mi"
volumes:
- name: downward-volume
downwardAPI:
items:
# Pod名称
- path: "pod_name"
fieldRef:
fieldPath: metadata.name
# Pod注解
- path: "pod_annotations"
fieldRef:
fieldPath: metadata.annotations
# CPU限制
- path: "cpu_limit"
resourceFieldRef:
containerName: nginx-container
resource: limits.cpu
# 内存请求
- path: "memory_request"
resourceFieldRef:
containerName: nginx-container
resource: requests.memory
创建Pod并查看挂载的文件:
kubectl apply -f pod-downward-volume.yaml
kubectl exec -it pod-downward-volume -- sh
ls /etc/pod-info/
输出:cpu_limit memory_request pod_annotations pod_name
查看文件内容:
cat /etc/pod-info/pod_name # 输出:pod-downward-volume
cat /etc/pod-info/cpu_limit # 输出:200m
cat /etc/pod-info/memory_request # 输出:134217728
cat /etc/pod-info/pod_annotations # 输出:{"author":"k8s-user","version":"1.0"}
通过API Server获取集群元数据
除了Downward API,也可以在Pod内通过K8s API Server获取集群的元数据(比如其他Pod、Service信息)。需要给Pod绑定Service Account并授权:
1. 创建Service Account和Role
创建sa-role.yaml:
apiVersion: v1
kind: ServiceAccount
metadata:
name: pod-reader-sa
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-reader-role
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: pod-reader-binding
subjects:
- kind: ServiceAccount
name: pod-reader-sa
roleRef:
kind: Role
name: pod-reader-role
apiGroup: rbac.authorization.k8s.io
执行创建:
kubectl apply -f sa-role.yaml
2. 创建Pod使用Service Account
创建pod-api-server.yaml:
apiVersion: v1
kind: Pod
metadata:
name: pod-api-server
spec:
serviceAccountName: pod-reader-sa # 使用上面创建的SA
containers:
- name: curl-container
image: curlimages/curl:latest
command: ["/bin/sh", "-c", "while true; do curl -sS --cacert /var/run/secrets/kubernetes.io/serviceaccount/ca.crt https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT/api/v1/namespaces/default/pods; sleep 10; done"]
创建Pod并查看日志:
kubectl apply -f pod-api-server.yaml
kubectl logs -f pod-api-server
输出会包含default命名空间下所有Pod的详细信息(JSON格式)。
其中:
KUBERNETES_SERVICE_HOST和KUBERNETES_SERVICE_PORT是Pod内默认的环境变量,指向API Server;/var/run/secrets/kubernetes.io/serviceaccount/ca.crt是集群CA证书,用于验证API Server身份;/var/run/secrets/kubernetes.io/serviceaccount/token是Service Account的Token,用于认证(curl会自动使用?或需要在Header里指定:-H "Authorization: Bearer $(cat /var/run/secrets/kubernetes.io/serviceaccount/token)")。
补充:Pod访问Service的方式
- 同一命名空间:直接用Service名称访问(比如
curl my-service:80); - 不同命名空间:用全限定名访问(比如
curl my-service.my-namespace.svc.cluster.local:80)。
K8s存储核心笔记:Volume与PV/PVC
第一章 Volume:容器的基础存储机制
1.1 核心概念
Volume是K8s为容器提供的数据存储抽象,本质是将“存储资源”与“容器生命周期”解耦,解决容器重启/迁移后数据丢失的问题。它不是容器内的临时目录,而是独立于容器的存储空间,可被Pod内的多个容器共享。
存在的核心意义:
-
持久化存储:容器终止后,Volume中的数据不会丢失(部分类型除外,如emptyDir);
-
数据共享:同一Pod内的多个容器可挂载同一个Volume,实现数据互通;
-
存储解耦:支持多种底层存储(本地目录、NFS、云存储等),容器无需关注存储实现细节。
1.2 常见Volume类型
Volume类型按用途可分为“特殊配置存储”和“通用数据存储”,前序学习的Secret、ConfigMap、DownwardAPI均属于特殊Volume,核心用于配置/元数据注入;以下为重点学习的通用Volume类型:
1.2.1 emptyDir:临时空目录(Pod级存储)
定义:Pod启动时自动创建的空目录,绑定于Pod生命周期——Pod删除时,emptyDir中的数据会被彻底删除;但容器重启时,数据会保留。
核心特性:
-
绑定Pod级别,而非容器级别;
-
同一Pod内的多个容器可共享同一个emptyDir;
-
无需配置底层存储,K8s自动在节点上分配临时空间;
-
适用场景:容器间临时数据交换(如日志缓存、临时计算结果)。
1.2.2 hostPath:主机路径挂载(节点级存储)
定义:将Pod调度到的“节点主机”上的目录/文件,直接挂载到容器内部,实现容器与主机的文件系统互通。
核心特性:
-
绑定Pod级别,依赖节点主机的文件系统;
-
Pod迁移到其他节点时,挂载的是新节点的对应路径(数据不共享);
-
适用场景:容器需访问主机文件(如监控主机日志、运行依赖主机设备的应用)。
hostPath的8种类型(控制路径存在性校验):
| 类型 | 含义 | 挂载结果 |
|---|---|---|
| 空字符串 | 默认,无校验 | 路径不存在则挂载失败 |
| DirectoryOrCreate | 目录,不存在则创建 | 自动创建目录(权限0755,属主root) |
| Directory | 目录,必须存在 | 路径不存在或非目录则挂载失败 |
| FileOrCreate | 文件,不存在则创建 | 自动创建文件(权限0644,属主root) |
| File | 文件,必须存在 | 路径不存在或非文件则挂载失败 |
| Socket | UNIX域套接字,必须存在 | 路径非套接字则挂载失败 |
| CharDevice | 字符设备,必须存在 | 路径非字符设备则挂载失败 |
| BlockDevice | 块设备,必须存在 | 路径非块设备则挂载失败 |
hostPath使用注意事项:
-
节点差异性:相同配置的Pod在不同节点上,挂载的主机路径内容可能不同;
-
资源调度问题:K8s调度Pod时,不会考虑hostPath占用的主机资源(可能导致节点存储耗尽);
-
权限问题:容器内用户可能无主机路径的读写权限,需通过`securityContext`配置权限。
1.2.3 其他核心类型
-
nfs:挂载NFS服务器的共享目录,支持多节点数据共享(RWX模式),是分布式场景的常用选择;
-
persistentVolumeClaim(PVC):通过PVC挂载PV,是持久化存储的核心方式(详见第二章);
-
local:挂载节点本地的持久化存储(如本地磁盘分区),性能好但不支持Pod迁移;
-
csi:容器存储接口,统一对接外部存储(如AWS EBS、阿里云OSS),是云原生存储的标准方案。
1.3 hostPath实操实验(含详细步骤与输出)
实验目标
创建一个Pod,将主机的`/test`目录挂载到容器内的`/hostpath`,验证容器与主机的文件共享。
实验步骤
-
编写Pod配置文件(hostpath-pod.yaml):
apiVersion: v1kind: Podmetadata:name: hostpath-pod # Pod名称spec:containers:- name: hostpath-container # 容器名称image: nginx:alpine # 轻量Nginx镜像volumeMounts:- name: hostpath-volume # 与volumes.name对应mountPath: /hostpath # 容器内挂载路径volumes:- name: hostpath-volume # 卷名称hostPath:path: /test # 主机上的路径(Pod调度到的节点)type: DirectoryOrCreate # 目录不存在则创建 -
创建Pod并查看状态:
# 提交配置创建Podkubectl apply -f hostpath-pod.yaml# 查看Pod状态(确保Running)kubectl get pods hostpath-pod输出示例:NAME READY STATUS RESTARTS AGEhostpath-pod 1/1 Running 0 30s -
验证主机路径自动创建: 先获取Pod调度到的节点,再登录节点查看`/test`目录:
# 查看Pod所在节点(记下列出的NODE_NAME)kubectl describe pod hostpath-pod | grep "Node:"# 登录节点(假设节点名为node-1,根据实际情况替换)ssh node-1# 查看/test目录是否存在ls -ld /test输出示例:drwxr-xr-x 2 root root 4096 11月 30 18:00 /test说明:`DirectoryOrCreate`类型已自动创建`/test`目录,权限为0755。 -
验证容器与主机的文件共享: ① 主机节点创建测试文件:
echo "This is a test file from host" > /test/host-file.txt② 进入容器查看文件:# 进入容器终端kubectl exec -it hostpath-pod -- sh# 查看容器内/hostpath目录下的文件ls /hostpath# 读取文件内容(验证与主机一致)cat /hostpath/host-file.txt输出示例:/ # ls /hostpathhost-file.txt/ # cat /hostpath/host-file.txtThis is a test file from host③ 容器内创建文件,主机验证:# 容器内创建文件echo "This is from container" > /hostpath/container-file.txt# 退出容器,在主机节点查看exit # 退出容器cat /test/container-file.txt输出示例:This is from container -
清理资源:
# 删除Podkubectl delete pod hostpath-pod# (可选)删除主机/test目录ssh node-1 "rm -rf /test"
实验结论
hostPath实现了容器与主机的文件互通,`DirectoryOrCreate`类型确保路径不存在时自动创建,适合需要访问主机文件系统的场景。
第二章 PV/PVC:持久化存储的核心方案
Volume(如hostPath、emptyDir)存在“存储供给与使用强耦合”的问题——业务工程师需手动指定底层存储(如NFS路径),与存储团队沟通成本高。PV/PVC通过“抽象层”解决此问题,实现存储供给与使用的分工协作。
2.1 核心概念与分工价值
PV(PersistentVolume,持久卷)
由存储工程师/基础设施团队创建,是对底层物理存储(如EMC、Ceph、NFS)的K8s标准化抽象,代表“可用的持久化存储资源”。
核心特点:独立于Pod生命周期,Pod删除后PV仍存在;包含存储容量、访问模式等固定属性。
PVC(PersistentVolumeClaim,持久卷申领)
由业务工程师创建,是Pod对存储的“需求声明”,描述所需存储的容量、访问模式、存储类别等,无需关注底层存储细节。
核心特点:通过匹配PV完成绑定,Pod只需挂载PVC即可使用存储,实现“按需申领”。
分工价值(核心优势)
-
解耦协作:存储团队管PV(供给),业务团队管PVC(使用),无需直接沟通;
-
标准化:PV定义统一存储属性,PVC按标准声明需求,避免配置混乱;
-
资源复用:一个PV可被多个PVC复用(解绑后),提高存储利用率。
2.2 PV与PVC的核心属性
2.2.1 PV的核心属性(存储侧定义)
| 属性 | 含义 | 示例/说明 |
|---|---|---|
| 容量(capacity) | 存储资源大小 | `capacity: {storage: 10Gi}` |
| 访问模式(accessModes) | 存储的读写权限范围 | RWO/ROX/RWX(三选一或多选,需底层存储支持) |
| 存储类别(storageClassName) | PV的“分组标识”,用于PVC精准匹配 | `storageClassName: "gold"`(金牌存储) |
| 回收策略(persistentVolumeReclaimPolicy) | PVC解绑后PV的处理方式 | Retain(保留)/Delete(删除)/Recycle(回收) |
| 存储源(spec) | 关联的底层存储(如NFS、本地存储) | `nfs: {server: 192.168.1.100, path: /nfs/share}` |
2.2.2 PVC的核心属性(业务侧声明)
| 属性 | 含义 | 示例/说明 |
|---|---|---|
| 资源需求(resources) | 所需存储容量 | `resources: {requests: {storage: 5Gi}}` |
| 访问模式(accessModes) | 需与PV完全匹配 | `accessModes: ["ReadWriteOnce"]` |
| 存储类别(storageClassName) | 需与PV完全匹配(空值匹配空值PV) | `storageClassName: "gold"` |
2.3 核心知识点:访问模式(Access Modes)
访问模式决定了PV可被多少节点/容器访问,是PV与PVC匹配的关键条件,必须完全一致。共三种模式,需结合底层存储支持选择:
| 模式 | 缩写 | 含义 | 支持的存储示例 | 限制说明 |
|---|---|---|---|---|
| 单节点读写 | RWO | 仅允许一个节点上的多个容器读写 | hostPath、iSCSI、云盘(AWS EBS) | 不支持多节点共享,Pod迁移后需重新挂载 |
| 多节点只读 | ROX | 允许多个节点上的容器只读访问 | NFS、CephFS、GlusterFS | 所有节点均无写入权限,适合共享配置文件 |
| 多节点读写 | RWX | 允许多个节点上的容器读写访问 | NFS、CephFS、GlusterFS | 需底层存储支持“网络锁”(如NFS的文件锁),本地文件系统(EXT4/XFS)不支持 |
关键提醒:iSCSI、hostPath等块存储/本地存储不支持RWX,因EXT4/XFS等本地文件系统只有“本地锁”,无“网络锁”——多节点同时写入会导致文件损坏或数据覆盖。
2.4 PV与PVC的匹配流程
PVC通过“预选+优选”两步匹配PV,最终建立唯一绑定关系,流程如下:
-
预选阶段:筛选满足基本条件的PVPVC会过滤掉不符合以下条件的PV:PV容量 ≥ PVC声明的容量;
-
PV的访问模式包含PVC声明的模式;
-
PV的存储类别与PVC完全一致;
-
PV状态为“Available”(可用)。
-
优选阶段:选择最优PV在预选通过的PV中,按“资源浪费最少”原则排序,优先选择:容量与PVC完全匹配的PV;
-
容量最接近PVC需求的PV(如PVC需5Gi,优先5Gi而非6Gi)。
-
绑定阶段:建立唯一关联PVC与最优PV绑定后,PV状态变为“Bound”(绑定),仅该PVC可使用此PV;Pod通过挂载PVC,间接使用PV对应的底层存储。
匹配示例
PVC需求:5Gi容量、RWO模式、存储类别“silver”;
可选PV:
-
PV1:4Gi、RWO、silver → 容量不足,预选淘汰;
-
PV2:5Gi、RWO、silver → 完全匹配,优选第一;
-
PV3:6Gi、RWO、silver → 容量过剩,优选第二;
-
PV4:5Gi、RWX、silver → 访问模式不匹配,预选淘汰;
-
PV5:5Gi、RWO、gold → 存储类别不匹配,预选淘汰。
最终结果:PVC与PV2绑定。
2.5 PV的回收策略与状态
2.5.1 回收策略(PVC解绑后PV的处理方式)
回收策略决定了PVC删除后,PV及其数据的命运,需根据数据重要性选择:
| 策略 | 核心行为 | 适用场景 | 支持的存储类型 |
|---|---|---|---|
| Retain(保留) | 1. PV状态变为“Released”(已释放);2. 数据保留,不允许新PVC绑定;3. 需管理员手动清理数据并重置PV状态。 | 高价值数据(如金融、核心业务数据),防止误删 | 所有存储类型 |
| Recycle(回收) | 1. 自动执行`rm -rf /path/*`清理数据;2. 清理成功后PV状态变为“Available”,可复用。 | 低价值临时数据,允许数据清除 | 仅NFS、HostPath |
| Delete(删除) | 1. 自动删除PV;2. 同时删除底层存储资源(如云盘、NFS共享)。 | 按需付费的云存储,节省成本 | 云存储(AWS EBS、阿里云云盘)、动态供给PV |
生产环境建议:优先选择Retain策略,即使误删PVC,数据仍可通过管理员手动恢复;Recycle策略因清理不彻底(可能残留隐藏文件),不建议生产使用。
2.5.2 PV的四种状态
PV的状态反映其生命周期阶段,通过`kubectl get pv`可查看:
| 状态 | 含义 | 触发场景 |
|---|---|---|
| Available(可用) | PV已创建,等待PVC绑定 | PV刚创建完成,无PVC绑定 |
| Bound(绑定) | PV已与PVC成功绑定 | PVC匹配并绑定PV后 |
| Released(已释放) | PVC已删除,但PV数据未清理,不可复用 | PVC删除,PV回收策略为Retain |
| Failed(失败) | PV回收策略执行失败,需人工介入 | Recycle清理文件失败、Delete删除底层存储失败 |
2.6 PVC保护机制
为防止数据丢失,K8s内置PVC保护机制,避免存储资源被误删:
-
PV保护:若PV已绑定PVC,直接执行`kubectl delete pv
`会被禁止,需先删除关联的PVC; -
Pod保护:若Pod正在使用PVC,执行`kubectl delete pvc
`后,PVC状态会变为“Terminating”(终止中),但不会立即删除,需等Pod终止后才彻底删除。
2.7 实操案例:基于NFS的PV/PVC完整流程(含代码与输出)
以NFS为底层存储,实现“存储侧创建PV→业务侧创建PVC→Pod挂载使用”的完整流程。
环境准备
假设NFS服务器地址为`192.168.1.100`,共享目录为`/nfs/k8s`(需提前创建并配置权限)。
步骤1:存储侧创建PV(nfs-pv.yaml)
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-pv # PV名称
spec:
capacity:
storage: 10Gi # PV容量
accessModes:
- ReadWriteMany # 支持多节点读写(NFS特性)
storageClassName: "nfs-storage" # 存储类别
persistentVolumeReclaimPolicy: Retain # 回收策略:保留
nfs: # 关联NFS存储
server: 192.168.1.100 # NFS服务器地址
path: /nfs/k8s # NFS共享目录
执行创建并查看PV状态: kubectl apply -f nfs-pv.yaml kubectl get pv nfs-pv输出示例: NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE nfs-pv 10Gi RWX Retain Available nfs-storage 10s状态为“Available”,表示PV已就绪等待绑定。
步骤2:业务侧创建PVC(nfs-pvc.yaml)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs-pvc # PVC名称
spec:
accessModes:
- ReadWriteMany # 与PV的访问模式匹配
resources:
requests:
storage: 5Gi # 需求容量(≤PV容量)
storageClassName: "nfs-storage" # 与PV的存储类别匹配
执行创建并查看PVC状态: kubectl apply -f nfs-pvc.yaml kubectl get pvc nfs-pvc输出示例: NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE nfs-pvc Bound nfs-pv 10Gi RWX nfs-storage 5s状态为“Bound”,表示PVC已与nfs-pv成功绑定。此时再查看PV状态,会发现“CLAIM”列显示“default/nfs-pvc”。
步骤3:创建Pod挂载PVC(nfs-pod.yaml)
apiVersion: v1
kind: Pod
metadata:
name: nfs-pod
spec:
containers:
- name: nfs-container
image: nginx:alpine
volumeMounts:
- name: nfs-volume # 与volumes.name对应
mountPath: /usr/share/nginx/html # Nginx网页根目录
volumes:
- name: nfs-volume
persistentVolumeClaim:
claimName: nfs-pvc # 关联创建的PVC
执行创建并验证: # 创建Pod kubectl apply -f nfs-pod.yaml # 查看Pod状态 kubectl get pods nfs-pod # 在NFS服务器的共享目录创建测试页面 echo "<h1>Hello PV/PVC!</h1>" > /nfs/k8s/index.html # 访问Pod的Nginx服务(验证数据共享) kubectl exec -it nfs-pod -- curl localhost输出示例: <h1>Hello PV/PVC!</h1>说明Pod通过PVC成功挂载NFS存储,且能读取底层存储的文件。
步骤4:清理资源
# 删除Pod
kubectl delete pod nfs-pod
# 删除PVC(此时PV状态变为Released)
kubectl delete pvc nfs-pvc
# (可选)删除PV(需先确认PVC已删除)
kubectl delete pv nfs-pv
# (可选)删除NFS共享目录内容
rm -rf /nfs/k8s/*
2.8 核心总结
-
Volume:基础存储单元,解决容器数据临时存储与共享,依赖具体存储实现(如hostPath、NFS);
-
PV/PVC:持久化存储的核心抽象,PV是“存储资源”,PVC是“需求声明”,通过匹配机制实现分工解耦;
-
关键匹配条件:容量≥需求、访问模式完全匹配、存储类别完全匹配;
-
核心流程:存储侧建PV→业务侧建PVC→PVC匹配PV→Pod挂载PVC使用存储。
K8s 存储核心知识点笔记
一、Volume 基础
1. 概念通俗理解
K8s 中的 Volume 是 Pod 内容器共享的存储目录,本质是“Pod 级别的存储卷”。它和 Pod 生命周期绑定(Pod 销毁则 Volume 销毁,临时存储除外),可实现容器间数据共享、数据持久化(对接外部存储)。
2. 常见 Volume 类型
- 临时存储:
emptyDir(Pod 内临时目录,Pod 销毁即删)、tmpfs(内存级存储,重启丢失); - 本地存储:
hostPath(挂载节点本地目录,跨节点不可用); - 网络存储:
nfs、cephfs、glusterfs(跨节点共享,支持持久化); - 云厂商存储:
awsElasticBlockStore(AWS EBS)、gcePersistentDisk(GCE 云盘)。
3. 简单案例:Pod 挂载 emptyDir 实现容器共享
# pod-emptyDir.yaml
apiVersion: v1
kind: Pod
metadata:
name: test-emptyDir
spec:
containers:
- name: container-1
image: nginx:1.14-alpine
volumeMounts:
- name: shared-dir
mountPath: /usr/share/nginx/html # 容器1挂载路径
- name: container-2
image: busybox:1.35
command: ["sh", "-c", "echo 'Hello K8s Volume' > /shared/index.html && sleep 3600"]
volumeMounts:
- name: shared-dir
mountPath: /shared # 容器2挂载路径
volumes:
- name: shared-dir
emptyDir: {} # 临时共享目录
执行步骤与输出:
- 创建 Pod:
kubectl apply -f pod-emptyDir.yaml - 查看 Pod 状态:
kubectl get pod test-emptyDir # 输出: # NAME READY STATUS RESTARTS AGE # test-emptyDir 2/2 Running 0 10s - 验证数据共享:
kubectl exec test-emptyDir -c container-1 -- cat /usr/share/nginx/html/index.html # 输出:Hello K8s Volume - 删除 Pod 后,
emptyDir数据随 Pod 销毁。
二、PV/PVC 核心机制
1. 概念通俗理解
- PV(PersistentVolume):集群级别的“存储资源池”,由运维人员提前创建,定义了具体的存储类型(如 NFS、云盘)、容量、访问模式等,与 Pod 解耦。
- PVC(PersistentVolumeClaim):Pod 对存储的“申请单”,由开发人员创建,声明需要的存储容量、访问模式,K8s 会自动匹配符合条件的 PV 进行绑定。
核心价值:业务侧无需关注底层存储细节(如 NFS 服务器地址),仅通过 PVC 声明需求;存储侧通过 PV 统一管理资源,实现存储与业务解耦。
2. PV 关键字段详解
(1)spec.capacity 存储容量声明
- 作用:定义 PV 能提供的最大存储容量,是 PVC 匹配 PV 的核心“容量门槛”。
- 格式要求:
- 固定键为
storage,值为带单位的存储量; - 支持单位:二进制单位(
Ki/Mi/Gi/Ti,推荐)、十进制单位(K/M/G/T); - 示例:
capacity: { storage: 10Gi }。
- 固定键为
- 匹配逻辑:
- PVC 声明的
resources.requests.storage必须 ≤ PV 的capacity.storage,否则无法绑定; - 多 PV 满足条件时,优先匹配容量最接近 PVC 需求的 PV(如 PVC 需 5Gi,优先选 5Gi PV 而非 10Gi PV)。
- PVC 声明的
- 与底层存储的关系:
PV 声明的容量需 ≤ 底层存储实际可用空间(如 NFS 共享目录实际容量为 20Gi,则 PV 容量不能超过 20Gi),否则会出现“存储空间不足”错误。
(2)spec.accessModes 访问模式
| 访问模式 | 含义 |
|---|---|
ReadWriteOnce(RWO) |
仅允许单个节点以读写方式挂载(适合单 Pod 独占存储,如本地磁盘) |
ReadWriteMany(RWX) |
允许多个节点以读写方式挂载(适合多 Pod 共享存储,如 NFS) |
ReadOnlyMany(ROX) |
允许多个节点以只读方式挂载(适合多 Pod 共享只读数据,如配置文件) |
(3)spec.storageClassName 存储类
用于关联 StorageClass,支持动态创建 PV(后续章节详解)。
(4)spec.persistentVolumeReclaimPolicy 回收策略
| 策略 | 含义 |
|---|---|
Retain |
PVC 删除后,PV 保留数据并标记为 Released,需手动清理后可复用 |
Delete |
PVC 删除后,PV 及底层存储数据自动删除(适合云盘等动态存储) |
Recycle |
PVC 删除后,PV 自动清空数据并标记为 Available(已废弃,不推荐) |
3. PV/PVC 实操:基于 NFS 实现存储持久化
步骤 1:搭建 NFS 服务器(CentOS 7/8 环境)
(1)NFS 服务端部署(单节点)
# 1. 安装 NFS 服务
yum install -y nfs-utils rpcbind
# 2. 创建 NFS 共享目录
mkdir -p /exports/nfs
chmod 777 /exports/nfs # 测试环境放宽权限,生产环境需严格控制
# 3. 配置 NFS 共享规则
echo "/exports/nfs *(rw,sync,no_root_squash,no_all_squash)" > /etc/exports
# 4. 启动服务并设置开机自启
systemctl start rpcbind nfs-server
systemctl enable rpcbind nfs-server
# 5. 验证 NFS 共享
showmount -e localhost
# 输出:
# Export list for localhost:
# /exports/nfs *
(2)K8s 所有节点安装 NFS 客户端
yum install -y nfs-utils
# 验证客户端连通性(任选一个节点)
mount -t nfs <NFS服务器IP>:/exports/nfs /mnt
touch /mnt/test.txt
ls /mnt/test.txt # 能看到文件则连通成功
umount /mnt
步骤 2:创建 PV(存储资源池)
# pv-nfs.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-pv-0 # 第一个 PV
spec:
capacity:
storage: 10Gi # 容量 10Gi
accessModes:
- ReadWriteMany # 支持多节点读写
nfs:
path: /exports/nfs
server: <NFS服务器IP> # 替换为实际 NFS 服务器地址
persistentVolumeReclaimPolicy: Retain # 回收策略为保留数据
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-pv-1 # 第二个 PV
spec:
capacity:
storage: 5Gi # 容量 5Gi(用于测试容量匹配逻辑)
accessModes:
- ReadWriteMany
nfs:
path: /exports/nfs
server: <NFS服务器IP>
persistentVolumeReclaimPolicy: Retain
执行与验证:
kubectl apply -f pv-nfs.yaml
# 查看 PV 状态(初始为 Available,未绑定 PVC)
kubectl get pv
# 输出:
# NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
# nfs-pv-0 10Gi RWX Retain Available 10s
# nfs-pv-1 5Gi RWX Retain Available 10s
步骤 3:创建 PVC(存储申请单)
# pvc-nfs.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs-pvc
spec:
accessModes:
- ReadWriteMany # 需与 PV 访问模式匹配
resources:
requests:
storage: 5Gi # 申请 5Gi 存储,优先匹配 nfs-pv-1
执行与验证:
kubectl apply -f pvc-nfs.yaml
# 查看 PVC 与 PV 绑定状态
kubectl get pvc
# 输出:
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
# nfs-pvc Bound nfs-pv-1 5Gi RWX 10s
kubectl get pv
# 输出:
# NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS REASON AGE
# nfs-pv-0 10Gi RWX Retain Available 20s
# nfs-pv-1 5Gi RWX Retain Bound default/nfs-pvc 20s
结论:PVC 会优先匹配容量最接近的 PV(5Gi PVC 绑定了 5Gi 的 nfs-pv-1,而非 10Gi 的 nfs-pv-0)。
步骤 4:Pod 挂载 PVC 实现数据持久化
# pod-pvc-nfs.yaml
apiVersion: v1
kind: Pod
metadata:
name: test-pvc-nfs
spec:
containers:
- name: nginx
image: nginx:1.14-alpine
ports:
- containerPort: 80
volumeMounts:
- name: nfs-storage
mountPath: /usr/share/nginx/html # 挂载到 Nginx 网页根目录
volumes:
- name: nfs-storage
persistentVolumeClaim:
claimName: nfs-pvc # 关联已创建的 PVC
执行与验证:
- 创建 Pod 并写入测试数据:
kubectl apply -f pod-pvc-nfs.yaml kubectl exec test-pvc-nfs -- sh -c "echo 'Hello PV/PVC' > /usr/share/nginx/html/index.html" - 验证 Pod 内数据:
kubectl exec test-pvc-nfs -- curl localhost # 输出:Hello PV/PVC - 验证 NFS 服务端数据(持久化):
cat /exports/nfs/index.html # NFS 服务器上执行 # 输出:Hello PV/PVC - 删除 Pod 后重建,验证数据不丢失:
kubectl delete pod test-pvc-nfs kubectl apply -f pod-pvc-nfs.yaml kubectl exec test-pvc-nfs -- curl localhost # 仍输出:Hello PV/PVC(数据通过 NFS 持久化)
三、有状态服务与 StatefulSet
1. 有状态服务 vs 无状态服务
| 类型 | 核心特征 | 典型应用 |
|---|---|---|
| 无状态服务 | 实例无唯一身份,可随意替换,数据不持久化(或依赖外部存储) | Web 应用、缓存服务(Redis 集群除外) |
| 有状态服务 | 实例有唯一身份(如编号),需持久化数据,实例间有依赖/顺序关系 | 数据库(MySQL)、消息队列(Kafka) |
关键差异:无状态服务 Pod 重建后可直接加入集群;有状态服务 Pod 重建后需保留身份和数据,否则无法正常提供服务。
2. StatefulSet 核心特性
StatefulSet 是 K8s 专门管理有状态服务的控制器,核心特性:
- 稳定的 Pod 身份:Pod 名称为
<StatefulSet名称>-<序号>(如nginx-sts-0、nginx-sts-1),序号从 0 开始递增且唯一; - 有序部署/删除:部署时按序号从 0 到 N 依次创建,删除时按 N 到 0 依次销毁;
- 稳定的网络标识:通过无头服务(Headless Service)为每个 Pod 分配固定域名(
<Pod名称>.<无头服务名>.<命名空间>.svc.cluster.local); - 稳定的存储:通过
volumeClaimTemplates为每个 Pod 自动创建独立 PVC,Pod 重建后仍绑定原 PVC,数据不丢失。
3. StatefulSet 实操:基于 NFS 部署有状态 Nginx 集群
步骤 1:创建无头服务(Headless Service)
无头服务(ClusterIP=None)无负载均衡能力,仅为 Pod 提供稳定 DNS 解析。
# svc-headless.yaml
apiVersion: v1
kind: Service
metadata:
name: nginx-svc
spec:
clusterIP: None # 标记为无头服务
selector:
app: nginx-sts # 匹配 StatefulSet 的 Pod
ports:
- port: 80
targetPort: 80
执行与验证:
kubectl apply -f svc-headless.yaml
# 查看无头服务
kubectl get svc nginx-svc
# 输出:
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# nginx-svc ClusterIP None <none> 80/TCP 10s
步骤 2:创建 StatefulSet(关联 PVC 模板)
# sts-nginx.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nginx-sts
spec:
serviceName: nginx-svc # 关联无头服务
replicas: 3 # 3 个有状态实例
selector:
matchLabels:
app: nginx-sts
template:
metadata:
labels:
app: nginx-sts
spec:
containers:
- name: nginx
image: nginx:1.14-alpine
ports:
- containerPort: 80
name: web
volumeMounts:
- name: www # 关联 PVC 模板
mountPath: /usr/local/nginx/html
volumeClaimTemplates: # PVC 模板,每个 Pod 自动创建独立 PVC
- metadata:
name: www
spec:
accessModes: [ "ReadWriteMany" ] # 与 NFS PV 匹配
resources:
requests:
storage: 1Gi # 每个 Pod 申请 1Gi 存储
执行与验证:
- 创建 StatefulSet:
kubectl apply -f sts-nginx.yaml - 查看 Pod 有序创建过程:
watch kubectl get pod # 输出(依次创建 0→1→2): # NAME READY STATUS RESTARTS AGE # nginx-sts-0 1/1 Running 0 10s # nginx-sts-1 1/1 Running 0 5s # nginx-sts-2 1/1 Running 0 2s - 查看自动创建的 PVC(每个 Pod 对应一个 PVC):
(注:若现有 PV 不足,需提前创建更多 NFS PV)kubectl get pvc # 输出: # NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE # www-nginx-sts-0 Bound nfs-pv-0 10Gi RWX 20s # www-nginx-sts-1 Bound nfs-pv-1 5Gi RWX 15s # www-nginx-sts-2 Bound <新PV> 10Gi RWX 10s
步骤 3:验证 StatefulSet 核心特性
(1)稳定的网络标识(DNS 解析)
在集群内任意 Pod 执行 DNS 解析测试:
# 临时创建测试 Pod
kubectl run -it --rm dns-test --image=busybox:1.35 -- sh
# 解析 Pod 域名
nslookup nginx-sts-0.nginx-svc.default.svc.cluster.local
# 输出(包含 Pod 真实 IP):
# Name: nginx-sts-0.nginx-svc.default.svc.cluster.local
# Address 1: <Pod-0-IP>
nslookup nginx-sts-1.nginx-svc.default.svc.cluster.local
# 输出:Address 1: <Pod-1-IP>
(2)稳定的存储(Pod 重建后数据不丢失)
# 1. 给 nginx-sts-0 写入专属数据
kubectl exec nginx-sts-0 -- sh -c "echo 'I am Pod 0' > /usr/local/nginx/html/index.html"
# 2. 删除 nginx-sts-0 触发重建
kubectl delete pod nginx-sts-0
# 3. 重建后验证数据
kubectl exec nginx-sts-0 -- curl localhost
# 输出:I am Pod 0(数据通过 PVC 持久化)
(3)有序扩容/缩容
# 扩容到 4 个实例(按 0→1→2→3 顺序创建)
kubectl scale sts nginx-sts --replicas=4
# 缩容到 2 个实例(按 3→2 顺序删除)
kubectl scale sts nginx-sts --replicas=2
步骤 4:StatefulSet 与 PVC 清理
# 1. 删除 StatefulSet(Pod 会被删除,但 PVC 保留)
kubectl delete sts nginx-sts
# 2. 手动删除 PVC(PVC 删除后 PV 变为 Released 状态)
kubectl delete pvc www-nginx-sts-0 www-nginx-sts-1 www-nginx-sts-2
# 3. 复用 Released 状态的 PV(编辑 PV 删除 claimRef 字段)
kubectl edit pv nfs-pv-0
# 删除 spec.claimRef 字段后保存,PV 状态恢复为 Available
kubectl get pv nfs-pv-0
# 输出:nfs-pv-0 10Gi RWX Retain Available 10m
四、StorageClass 动态存储供给
1. 概念通俗理解
手动创建 PV 存在“资源浪费、扩缩容麻烦”的问题,StorageClass 实现了 PV 动态创建:当 PVC 声明关联的 StorageClass 时,K8s 会通过 provisioner(存储供应器)自动创建符合需求的 PV,无需运维手动预创建。
2. 核心组件
- StorageClass:定义存储类型(如 NFS、Ceph)、
provisioner、回收策略等; - Provisioner:存储供应器,负责实际创建 PV 及底层存储(如 NFS 客户端供应器、云厂商存储供应器);
- PVC:声明
storageClassName,触发动态 PV 创建。
3. 实操:基于 NFS-Client Provisioner 实现动态存储
步骤 1:部署 NFS-Client Provisioner
NFS-Client Provisioner 是社区提供的 NFS 动态存储供应器,需先部署到 K8s 集群。
(1)创建 ServiceAccount 与权限
# nfs-provisioner-rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: nfs-client-provisioner
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: nfs-client-provisioner-runner
rules:
- apiGroups: [""]
resources: ["persistentvolumes"]
verbs: ["get", "list", "watch", "create", "delete"]
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch", "update"]
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: run-nfs-client-provisioner
subjects:
- kind: ServiceAccount
name: nfs-client-provisioner
namespace: default
roleRef:
kind: ClusterRole
name: nfs-client-provisioner-runner
apiGroup: rbac.authorization.k8s.io
(2)部署 NFS-Client Provisioner
# nfs-provisioner-deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nfs-client-provisioner
spec:
replicas: 1
selector:
matchLabels:
app: nfs-client-provisioner
strategy:
type: Recreate
template:
metadata:
labels:
app: nfs-client-provisioner
spec:
serviceAccountName: nfs-client-provisioner
containers:
- name: nfs-client-provisioner
image: registry.k8s.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2
volumeMounts:
- name: nfs-client-root
mountPath: /persistentvolumes
env:
- name: PROVISIONER_NAME
value: k8s-sigs.io/nfs-subdir-external-provisioner # 供应器名称,需与 StorageClass 一致
- name: NFS_SERVER
value: <NFS服务器IP> # 替换为实际 NFS 地址
- name: NFS_PATH
value: /exports/nfs # NFS 共享目录
volumes:
- name: nfs-client-root
nfs:
server: <NFS服务器IP>
path: /exports/nfs
执行部署:
kubectl apply -f nfs-provisioner-rbac.yaml -f nfs-provisioner-deploy.yaml
# 验证 Provisioner 运行状态
kubectl get pod -l app=nfs-client-provisioner
# 输出:
# NAME READY STATUS RESTARTS AGE
# nfs-client-provisioner-7f968c4d9c-9x2zl 1/1 Running 0 20s
步骤 2:创建 StorageClass
# sc-nfs.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-client-sc # StorageClass 名称
provisioner: k8s-sigs.io/nfs-subdir-external-provisioner # 关联 NFS 供应器
parameters:
pathPattern: "${.PVC.namespace}/${.PVC.name}" # 动态 PV 在 NFS 中的目录格式(按命名空间/PVC 名划分)
reclaimPolicy: Retain # 回收策略为保留数据
执行与验证:
kubectl apply -f sc-nfs.yaml
# 查看 StorageClass
kubectl get sc
# 输出:
# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
# nfs-client-sc k8s-sigs.io/nfs-subdir-external-provisioner Retain Immediate false 10s
步骤 3:创建 PVC 触发动态 PV 创建
# pvc-dynamic.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs-pvc-dynamic
spec:
storageClassName: nfs-client-sc # 关联动态存储类
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Gi # 动态申请 1Gi 存储
执行与验证:
- 创建 PVC:
kubectl apply -f pvc-dynamic.yaml - 查看动态创建的 PV 和 PVC 绑定状态:
kubectl get pvc nfs-pvc-dynamic # 输出: # NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE # nfs-pvc-dynamic Bound pvc-7a8b9c0d-1234-5678-90ab-cdef12345678 1Gi RWX nfs-client-sc 10s kubectl get pv pvc-7a8b9c0d-1234-5678-90ab-cdef12345678 # 输出: # NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGE # pvc-7a8b9c0d-1234-5678-90ab-cdef12345678 1Gi RWX Retain Bound default/nfs-pvc-dynamic nfs-client-sc 10s - 验证 NFS 服务端动态创建的目录:
ls /exports/nfs/default/nfs-pvc-dynamic # 对应 pathPattern 定义的目录 # 输出:(PVC 绑定后自动创建空目录) # lost+found
步骤 4:Pod 挂载动态 PVC 验证
# pod-dynamic-nfs.yaml
apiVersion: v1
kind: Pod
metadata:
name: test-dynamic-nfs
spec:
containers:
- name: nginx
image: nginx:1.14-alpine
volumeMounts:
- name: dynamic-storage
mountPath: /usr/share/nginx/html
volumes:
- name: dynamic-storage
persistentVolumeClaim:
claimName: nfs-pvc-dynamic
执行与验证:
kubectl apply -f pod-dynamic-nfs.yaml
kubectl exec test-dynamic-nfs -- sh -c "echo 'Dynamic PV from StorageClass' > /usr/share/nginx/html/index.html"
# 验证 NFS 服务端数据
cat /exports/nfs/default/nfs-pvc-dynamic/index.html
# 输出:Dynamic PV from StorageClass
五、实用技巧:K8s 命令补全
为提升 K8s 命令操作效率,可配置 bash 命令自动补全:
# 1. 安装 bash-completion 工具
yum install -y bash-completion
# 2. 配置全局生效
echo "source /usr/share/bash-completion/bash_completion" >> /etc/bashrc
echo "source <(kubectl completion bash)" >> /etc/bashrc
# 3. 立即生效(无需重启终端)
source /etc/bashrc
验证补全功能:
kubectl get po[按Tab键] # 自动补全为 pod
kubectl delete sts[按Tab键] # 自动补全为 statefulset
六、核心知识点总结
- Volume:Pod 级存储,生命周期与 Pod 绑定,适合临时存储或简单共享;
- PV/PVC:集群级存储解耦方案,PV 是资源池,PVC 是申请单,实现存储与业务分离;
- StatefulSet:管理有状态服务的核心控制器,提供稳定身份、网络、存储;
- StorageClass:实现 PV 动态创建,解决手动创建 PV 的资源浪费问题。

浙公网安备 33010602011771号