07
06

一、背景

最近在学习 Kubernetes Operator 开发,目标是从零实现一个最小可运行的 Operator。

这个 Operator 的目标很简单:

定义一个自定义资源 AppService,用户只需要声明镜像、端口、副本数,Operator 自动创建对应的 Deployment 和 Service。

最终效果类似这样:


apiVersion: app.nebula.io/v1
kind: AppService
metadata:
  name: appservice-sample
spec:
  image: nginx:1.25
  replicas: 2
  port: 80

应用这个资源后,Operator 自动生成:


Deployment/appservice-sample
Service/appservice-sample
Pod/appservice-sample-xxx

这篇文章记录完整开发过程,包括环境准备、项目初始化、CRD 定义、Controller 编写、Reconcile 逻辑、踩坑排查和最终运行结果。

二、Operator 是什么

Kubernetes 本身已经有很多内置资源,比如:

Pod
Deployment
Service
Ingress
ConfigMap
Secret

但是在实际业务里,我们经常希望用更贴近业务的方式描述应用。

比如平台用户不一定想直接写 Deployment / Service / Ingress,而是希望写:

kind: AppService
spec:
  image: nginx:1.25
  replicas: 2
  port: 80

然后由平台自动生成底层 Kubernetes 资源。

这就是 Operator 的核心思想:

Operator = CRD + Controller + Reconcile Loop

其中:

组件作用
CRD扩展 Kubernetes API,定义一种新资源
CR用户创建的自定义资源实例
Controller监听资源变化
Reconcile对齐期望状态和实际状态
status回写当前观察到的运行状态
ownerReference建立父子资源关系,支持级联管理

本项目的核心链路是:

AppService CR
    ↓
AppService Controller
    ↓
Reconcile
    ↓
Deployment + Service
    ↓
Pod Running

三、本地环境准备

我的本地环境如下:

bash
go version

输出

go version go1.26.2 darwin/arm64
kubectl version

输出

Client Version: v1.34.1
Kustomize Version: v5.7.1
Server Version: v1.33.6+k3s1
docker version

输出

Client Version: 29.4.3
Server Version: 29.4.3
OS/Arch: linux/arm64

一开始本地没有安装 kind 和 kubebuilder

kind version
kubebuilder version

输出:

bash: kind: command not found
bash: kubebuilder: command not found

这里 kind 不是必须的,因为我已经有一个可用的 K3s 集群,kubectl version 能看到 Server Version,说明已经能连接 Kubernetes 集群。

真正需要安装的是 kubebuilder

四、安装 Kubebuilder

使用 Homebrew 安装:

brew install kubebuilder

安装完成后查查看版本:

kubebuilder version

输出:

KubeBuilder:          v4.15.0
Kubernetes:           1.36.0
Git Commit:           034c380389c00396878da8b388d42b17d55f8dd8
Build Date:           2026-06-14T19:16:32Z
Go OS/Arch:           darwin/arm64

到这里,基础工具准备完成

五、初始化 Operator 项目

我的代码目录是:

cd /Users/wenjun/data/project/go
mkdir appservice-operator
cd appservice-operator

初始化 kubebuilder 项目:

kubebuilder init \
  --domain nebula.io \
  --repo github.com/wenjun/appservice-operator

执行完成后,项目结构如下:

appservice-operator
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── cmd
├── config
├── go.mod
├── go.sum
├── hack
├── internal
└── test

其中比较重要的是:

目录作用
api/后面会存放 CRD 的 Go 类型定义
internal/controller/Controller 和 Reconcile 逻辑
config/crd/生成出来的 CRD YAML
config/samples/示例 CR
config/rbac/RBAC 权限
cmd/main.goOperator 启动入口

六、创建 AppService API

执行:

kubebuilder create api \
--group app \
--version v1 \
--kind AppService

中间会提示:

Create Resource [y/n]
Create Controller [y/n]

都选择:

y
y

生成的关键文件:

api/v1/appservice_types.go
api/v1/groupversion_info.go
internal/controller/appservice_controller.go
internal/controller/appservice_controller_test.go
config/samples/app_v1_appservice.yaml

七、第一次生成并安装 CRD

执行:

make manifests

生成 CRD 文件:

config/crd/bases/app.nebula.io_appservices.yaml

安装 CRD 到集群:

make install

输出:

customresourcedefinition.apiextensions.k8s.io/appservices.app.nebula.io created

查看 CRD:

kubectl get crd | grep appservices

输出:

appservices.app.nebula.io

说明 CRD 已经安装成功。

八、第一次启动 Controller

执行:

make run

第一次启动时遇到了端口冲突:

Failed to start manager {"error": "error listening on :8081: listen tcp :8081: bind: address already in use"}

这是因为 8081 端口已经被占用。

使用下面命令查看端口占用:

lsof -i :8081

输出类似:

COMMAND   PID   USER   FD   TYPE  DEVICE SIZE/OFF NODE NAME
main 46331 wenjun 3u IPv6 ... TCP *:sunproxyadmin (LISTEN)

杀掉进程:

kill -9 46331

再次执行:

make run

启动成功:

INFO    setup   Starting manager
INFO starting server {"name": "health probe", "addr": "[::]:8081"}
INFO Starting EventSource {"controller": "appservice", "controllerGroup": "app.nebula.io", "controllerKind": "AppService", "source": "kind source: *v1.AppService"}
INFO Starting Controller {"controller": "appservice", "controllerGroup": "app.nebula.io", "controllerKind": "AppService"}
INFO Starting workers {"controller": "appservice", "controllerGroup": "app.nebula.io", "controllerKind": "AppService", "worker count": 1}

说明 Controller 已经启动,并开始监听 AppService 资源。

九、第一次创建 AppService 遇到的问题

查看默认 sample:

cat config/samples/app_v1_appservice.yaml

默认内容是:

apiVersion: app.nebula.io/v1
kind: AppService
metadata:
labels:
app.kubernetes.io/name: appservice-operator
app.kubernetes.io/managed-by: kustomize
name: appservice-sample
spec:
# TODO(user): Add fields here

直接 apply:

kubectl apply -f config/samples/app_v1_appservice.yaml

报错:

The AppService "appservice-sample" is invalid: spec: Required value

原因是当前 CRD 里 spec 是必填的,但是 sample 里面的 spec 为空。

于是我把 sample 改成:

apiVersion: app.nebula.io/v1
kind: AppService
metadata:
labels:
app.kubernetes.io/name: appservice-operator
app.kubernetes.io/managed-by: kustomize
name: appservice-sample
spec:
image: nginx:1.25
replicas: 2
port: 80

再次执行:

kubectl apply -f config/samples/app_v1_appservice.yaml

又报错:

strict decoding error: unknown field "spec.image", unknown field "spec.port", unknown field "spec.replicas"

这个问题的原因是:

sample 里写了 image / replicas / port,但是 Go 类型定义 AppServiceSpec 里还没有定义这些字段,所以生成的 CRD schema 不认识这些字段。

十、定义 AppService Spec 和 Status

打开:

vim api/v1/appservice_types.go

修改 AppServiceSpec

type AppServiceSpec struct {
// Image is the container image of the application.
Image string `json:"image"`

// Replicas is the desired number of pods.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:default=1
Replicas int32 `json:"replicas,omitempty"`

// Port is the container port exposed by the application.
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=65535
Port int32 `json:"port"`
}

修改 AppServiceStatus

type AppServiceStatus struct {
// ReadyReplicas is the number of ready pods.
ReadyReplicas int32 `json:"readyReplicas,omitempty"`

// Phase represents the current phase of the application.
Phase string `json:"phase,omitempty"`

// Message describes current status details.
Message string `json:"message,omitempty"`
}

AppService 结构体保持 Kubebuilder v4.15 默认风格即可:

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status

// AppService is the Schema for the appservices API.
type AppService struct {
metav1.TypeMeta `json:",inline"`

// metadata is a standard object metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`

// spec defines the desired state of AppService
// +required
Spec AppServiceSpec `json:"spec"`

// status defines the observed state of AppService
// +optional
Status AppServiceStatus `json:"status,omitzero"`
}

注意这里有一行比较重要:

// +kubebuilder:subresource:status

它表示该 CRD 支持 status 子资源,后续 Controller 可以单独更新状态。

十一、重新生成并安装 CRD

修改 Go 类型之后,需要重新生成 CRD:

make manifests

检查 CRD 里是否已经生成新字段:

grep -n "image:" config/crd/bases/app.nebula.io_appservices.yaml
grep -n "replicas:" config/crd/bases/app.nebula.io_appservices.yaml
grep -n "port:" config/crd/bases/app.nebula.io_appservices.yaml

输出:

46:                image:
56: replicas:
53: port:

然后重新安装 CRD:

make install

再次创建 AppService:

kubectl delete appservice appservice-sample --ignore-not-found
kubectl apply -f config/samples/app_v1_appservice.yaml

输出:

appservice.app.nebula.io/appservice-sample created

查看资源:

kubectl get appservice appservice-sample -o yaml

输出中可以看到:

spec:
image: nginx:1.25
port: 80
replicas: 2

到这里,自定义资源已经可以正常创建。

十二、此时还没有创建 Deployment / Service

查看 Deployment:

kubectl get deploy appservice-sample

输出:

Error from server (NotFound): deployments.apps "appservice-sample" not found

查看 Service:

kubectl get svc appservice-sample

输出:

Error from server (NotFound): services "appservice-sample" not found

这是正常的。

因为此时 Controller 还只是 Kubebuilder 默认骨架,Reconcile 里还没有创建 Deployment / Service 的业务逻辑。

十三、编写 Controller import

打开:

vim internal/controller/appservice_controller.go

默认 import 类似:

import (
"context"

"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"

appv1 "github.com/wenjun/appservice-operator/api/v1"
)

为了创建 Deployment 和 Service,需要改成:

import (
"context"

appv1 "github.com/wenjun/appservice-operator/api/v1"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/intstr"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
)

这些包的作用:

作用
appsv1创建 Deployment
corev1创建 Service、PodTemplate、Container
apierrors判断 NotFound
metav1ObjectMeta、LabelSelector
typesNamespacedName
intstrService TargetPort
ctrlcontroller-runtime 核心包
clientKubernetes API 客户端

十四、增加 RBAC 权限

Reconcile 方法上面增加 RBAC marker:

// +kubebuilder:rbac:groups=app.nebula.io,resources=appservices,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=app.nebula.io,resources=appservices/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=app.nebula.io,resources=appservices/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete

这里的意思是:

允许 Controller 读取、监听、更新 AppService
允许 Controller 更新 AppService status
允许 Controller 管理 Deployment
允许 Controller 管理 Service

本地 make run 时使用的是本地 kubeconfig 权限,RBAC 问题不明显。

但后续如果把 Operator 部署到集群里运行,就必须依赖这些 RBAC 权限。

十五、编写 Reconcile 逻辑

Reconcile 是 Operator 最核心的逻辑。

它要做的事情是:

1. 根据 namespace/name 查询 AppService
2. 如果 AppService 不存在,说明被删除了,直接返回
3. 根据 AppService.spec 构造 Deployment
4. 如果 Deployment 不存在,则创建
5. 如果 Deployment 已存在,则检查 replicas、image、port 是否需要更新
6. 根据 AppService.spec 构造 Service
7. 如果 Service 不存在,则创建
8. 如果 Service 已存在,则忽略

第一版代码如下:

func (r *AppServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := logf.FromContext(ctx)

var app appv1.AppService
if err := r.Get(ctx, req.NamespacedName, &app); err != nil {
if apierrors.IsNotFound(err) {
logger.Info("AppService not found, may have been deleted", "name", req.Name, "namespace", req.Namespace)
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}

logger.Info("Reconciling AppService", "name", app.Name, "namespace", app.Namespace)

deploy := buildDeployment(&app)
if err := ctrl.SetControllerReference(&app, deploy, r.Scheme); err != nil {
return ctrl.Result{}, err
}

var existingDeploy appsv1.Deployment
if err := r.Get(ctx, types.NamespacedName{Name: deploy.Name, Namespace: deploy.Namespace}, &existingDeploy); err != nil {
if apierrors.IsNotFound(err) {
if err := r.Create(ctx, deploy); err != nil {
if apierrors.IsAlreadyExists(err) {
logger.Info("Deployment already exists", "name", deploy.Name, "namespace", deploy.Namespace)
} else {
return ctrl.Result{}, err
}
} else {
logger.Info("Created Deployment", "name", deploy.Name, "namespace", deploy.Namespace)
}
} else {
return ctrl.Result{}, err
}
} else {
needUpdate := false

if existingDeploy.Spec.Replicas == nil || *existingDeploy.Spec.Replicas != app.Spec.Replicas {
existingDeploy.Spec.Replicas = &app.Spec.Replicas
needUpdate = true
}

if len(existingDeploy.Spec.Template.Spec.Containers) > 0 {
container := &existingDeploy.Spec.Template.Spec.Containers[0]

if container.Image != app.Spec.Image {
container.Image = app.Spec.Image
needUpdate = true
}

if len(container.Ports) > 0 && container.Ports[0].ContainerPort != app.Spec.Port {
container.Ports[0].ContainerPort = app.Spec.Port
needUpdate = true
}
}

if needUpdate {
if err := r.Update(ctx, &existingDeploy); err != nil {
return ctrl.Result{}, err
}
logger.Info("Updated Deployment", "name", existingDeploy.Name, "namespace", existingDeploy.Namespace)
}
}

svc := buildService(&app)
if err := ctrl.SetControllerReference(&app, svc, r.Scheme); err != nil {
return ctrl.Result{}, err
}

var existingSvc corev1.Service
if err := r.Get(ctx, types.NamespacedName{Name: svc.Name, Namespace: svc.Namespace}, &existingSvc); err != nil {
if apierrors.IsNotFound(err) {
if err := r.Create(ctx, svc); err != nil {
if apierrors.IsAlreadyExists(err) {
logger.Info("Service already exists", "name", svc.Name, "namespace", svc.Namespace)
} else {
return ctrl.Result{}, err
}
} else {
logger.Info("Created Service", "name", svc.Name, "namespace", svc.Namespace)
}
} else {
return ctrl.Result{}, err
}
}

return ctrl.Result{}, nil
}

这里我额外处理了:

apierrors.IsAlreadyExists(err)

因为实际测试时出现过这种日志:

Reconciler error ... services "appservice-sample" already exists

原因通常是 Reconcile 被多次触发,或者创建 Service 后又因为资源变化重新进入 Reconcile。Controller 逻辑应该保持幂等,所以对 AlreadyExists 应该容忍,而不是直接认为失败。

十六、构造 Deployment

增加 helper 方法:

func buildDeployment(app *appv1.AppService) *appsv1.Deployment {
labels := map[string]string{
"app": app.Name,
}

replicas := app.Spec.Replicas

return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: app.Name,
Namespace: app.Namespace,
Labels: labels,
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: labels,
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: labels,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: app.Name,
Image: app.Spec.Image,
Ports: []corev1.ContainerPort{
{
Name: "http",
ContainerPort: app.Spec.Port,
},
},
},
},
},
},
},
}
}

这里做了几件事:

Deployment 名字 = AppService 名字
Deployment namespace = AppService namespace
Pod label = app: AppService 名字
副本数来自 spec.replicas
镜像来自 spec.image
容器端口来自 spec.port

十七、构造 Service

增加 helper 方法:

func buildService(app *appv1.AppService) *corev1.Service {
labels := map[string]string{
"app": app.Name,
}

return &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: app.Name,
Namespace: app.Namespace,
Labels: labels,
},
Spec: corev1.ServiceSpec{
Type: corev1.ServiceTypeClusterIP,
Selector: labels,
Ports: []corev1.ServicePort{
{
Name: "http",
Port: app.Spec.Port,
TargetPort: intstr.FromInt(int(app.Spec.Port)),
},
},
},
}
}

Service 的 selector 是:

selector:
app: appservice-sample

Deployment 生成的 Pod label 也是:

labels:
app: appservice-sample

所以 Service 可以选中对应 Pod。

十八、设置 Watch 关系

修改 SetupWithManager

func (r *AppServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&appv1.AppService{}).
Owns(&appsv1.Deployment{}).
Owns(&corev1.Service{}).
Named("appservice").
Complete(r)
}

这里的意思是:

监听 AppService
同时监听该 AppService 拥有的 Deployment
同时监听该 AppService 拥有的 Service

关键在于前面的:

ctrl.SetControllerReference(&app, deploy, r.Scheme)
ctrl.SetControllerReference(&app, svc, r.Scheme)

它会给 Deployment / Service 加上 ownerReference。

这样有两个好处:

1. AppService 删除后,Deployment / Service 可以被 Kubernetes GC 自动回收;
2. Deployment / Service 发生变化时,也能反向触发 AppService 的 Reconcile。

十九、重新运行 Controller

先停止之前的 make run

Ctrl + C

重新生成:

make manifests

重新运行:

make run

启动日志:

INFO    setup   Starting manager
INFO starting server {"name": "health probe", "addr": "[::]:8081"}
INFO Starting EventSource {"source": "kind source: *v1.Service"}
INFO Starting EventSource {"source": "kind source: *v1.AppService"}
INFO Starting EventSource {"source": "kind source: *v1.Deployment"}
INFO Starting Controller
INFO Starting workers

这里可以看到,Controller 已经监听了:

AppService
Deployment
Service

二十、创建 AppService 并验证

重新创建 AppService:

kubectl delete appservice appservice-sample --ignore-not-found
kubectl apply -f config/samples/app_v1_appservice.yaml

输出:

appservice.app.nebula.io/appservice-sample created

查看 AppService:

kubectl get appservice

输出:

NAME                AGE
appservice-sample 6s

查看 Deployment:

kubectl get deploy appservice-sample

输出:

NAME                READY   UP-TO-DATE   AVAILABLE   AGE
appservice-sample 2/2 2 0 20s

查看 Service:

kubectl get svc appservice-sample

输出:

NAME                TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
appservice-sample ClusterIP 10.43.83.82 80/TCP 30s

查看 Pod:

kubectl get pod -l app=appservice-sample

输出:

NAME                                 READY   STATUS    RESTARTS   AGE
appservice-sample-75dbd48454-lnffg 1/1 Running 0 56s
appservice-sample-75dbd48454-mxtbm 1/1 Running 0 56s

这说明 AppService Operator 已经成功完成了:

AppService

Deployment

ReplicaSet

Pod

Service

二十一、运行日志分析

Controller 日志中可以看到:

Reconciling AppService
Created Deployment
Created Service

也可以看到多次:

Reconciling AppService

这是正常的。

因为 Reconcile 会被多种事件触发:

AppService 创建
Deployment 创建
Service 创建
Pod 状态变化
Deployment 状态变化

Operator 的核心要求是:

Reconcile 必须是幂等的。

也就是说,同一个 AppService 被 Reconcile 多次,最终结果都应该是稳定的:

Deployment 存在就不重复创建
Service 存在就不重复创建
字段不一致才更新
字段一致就不处理

这也是为什么要处理:

apierrors.IsNotFound(err)
apierrors.IsAlreadyExists(err)

二十二、本次踩坑总结

1. 端口 8081 被占用

报错:

error listening on :8081: bind: address already in use

排查:

lsof -i :8081

处理:

kill -9 

也可以换端口启动:

go run ./cmd/main.go --health-probe-bind-address=:18081

2. sample 里 spec 为空

报错:

The AppService "appservice-sample" is invalid: spec: Required value

原因:

spec:
# TODO(user): Add fields here

解决:

spec:
image: nginx:1.25
replicas: 2
port: 80

3. unknown field spec.image

报错:

unknown field "spec.image", unknown field "spec.port", unknown field "spec.replicas"

原因:

YAML 里写了字段
但是 AppServiceSpec 里没有定义字段
CRD schema 里也没有这些字段

解决:

修改 api/v1/appservice_types.go
执行 make manifests
执行 make install
重新 apply sample

4. Service already exists

报错:

services "appservice-sample" already exists

原因:

Reconcile 可能被多次触发
第一次创建 Service 成功
后续某次 Reconcile 又尝试创建

解决:

if err := r.Create(ctx, svc); err != nil {
if apierrors.IsAlreadyExists(err) {
logger.Info("Service already exists", "name", svc.Name, "namespace", svc.Namespace)
} else {
return ctrl.Result{}, err
}
}

更进一步可以使用 controllerutil.CreateOrUpdate 简化创建和更新逻辑。

二十三、当前项目完成度

当前已经完成:

1. 使用 Kubebuilder 初始化 Operator 项目
2. 定义 AppService CRD
3. 定义 spec.image / spec.replicas / spec.port
4. 安装 CRD 到 K3s 集群
5. 编写 Controller Reconcile 逻辑
6. 根据 AppService 自动创建 Deployment
7. 根据 AppService 自动创建 Service
8. 设置 ownerReference
9. 监听 AppService / Deployment / Service
10. 成功拉起 nginx 双副本 Pod

目前还没做:

1. status 状态回写
2. conditions 设计
3. finalizer 删除前清理
4. Deployment 更新策略完善
5. Service 端口变更处理
6. ConfigMap / Secret 注入
7. Ingress 生成
8. 镜像发布状态观测
9. 单元测试和 envtest
10. Operator 镜像构建和集群内部署

二十四、下一步规划

下一阶段可以继续增强:

1. 增加 status 回写

目标:

status:
phase: Ready
readyReplicas: 2
message: All replicas are ready

这样用户可以直接通过:

kubectl get appservice appservice-sample -o yaml

看到业务应用状态。


2. 增加 conditions

比单纯的 phase 更标准:

status:
conditions:
- type: Available
status: "True"
reason: DeploymentAvailable
message: Deployment is available

3. 使用 controllerutil.CreateOrUpdate

当前代码手动判断:

Get
NotFound
Create
Exists
Update

后面可以优化成:

controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error {
// mutate deployment
return ctrl.SetControllerReference(&app, deploy, r.Scheme)
})

这样代码更清晰,也更符合 controller-runtime 常见写法。


4. 增加 Finalizer

用于删除前清理:

AppService 删除

deletionTimestamp 非空

执行外部清理逻辑

移除 finalizer

资源真正删除

5. 和自己的平台结合

后续可以逐步从 demo 变成平台能力:

AppService CRD

Deployment
Service
Ingress
ConfigMap
Secret
HPA
Canary
NetworkProfile

最终可以把平台里的应用发布模型 Kubernetes 原生化。



标题:从零开始写一个 Kubernetes Operator:基于 Kubebuilder 实现 AppService Operator
作者:awen100
地址:https://blog.douwen.top/articles/2026/07/06/1783351110816.html

标签:   浏览数: 0