编程 Kubernetes Operator 深度实战:从 Controller Runtime 到生产级 CRD 控制器的完整工程指南(2026)

2026-07-20 08:14:32 +0800 CST views 15

Kubernetes Operator 深度实战:从 Controller Runtime 到生产级 CRD 控制器的完整工程指南(2026)

当你在 kubectl get pods 时看到那个熟悉的名字,你是否想过:为什么 Deployment 知道该创建几个 Pod?为什么 Service 能自动发现 Endpoints?为什么一个 YAML 文件下去,整个应用就"活"了?

答案藏在 Kubernetes 的核心设计里:声明式 API + 控制器模式。而 Operator,就是这套模式的终极形态——把人类运维知识代码化,让 Kubernetes 理解你的业务。

2026 年,Kubernetes Operator 已从"高级功能"变成"标准姿势"。从数据库托管到机器学习流水线,从证书管理到 GitOps 部署,Operator 无处不在。但真正理解它、能写出生产级 Operator 的工程师,依然是稀缺物种。

本文将从架构原理、Controller Runtime 内核、Kubebuilder 工程实践、测试策略到生产部署,完整拆解 Operator 开发的每个环节。目标只有一个:让你写出的 Operator,经得起生产环境的毒打。


一、本质理解:Operator 到底解决了什么问题?

1.1 从"命令式"到"声明式"的认知跃迁

传统运维的典型工作流:

# 创建 3 个 Pod
kubectl run app-v1 --image=app:1.0 --replicas=3

# 某个 Pod 挂了
kubectl delete pod app-v1-xyz123

# 手动补一个
kubectl run app-v1-backup --image=app:1.0

问题显而易见:每个操作都是孤立的命令,没有"期望状态"的概念。Pod 挂了,没人知道该补;版本更新,手动改镜像;扩容缩容,靠人盯着监控。

Kubernetes 的声明式 API 彻底改变了这个模型:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    spec:
      containers:
      - name: app
        image: app:1.0

你告诉 Kubernetes:"我要一个 3 副本的 Deployment"。然后你可以去喝咖啡了——如果某个 Pod 挂了,Deployment Controller 会自动补上;如果镜像更新了,滚动升级自动执行。

核心思想:你声明"期望状态",Controller 持续调谐,让"实际状态"向"期望状态"收敛。

1.2 Operator:让 Kubernetes 理解你的业务

但 Kubernetes 内置的 Controller 只理解通用概念:Deployment、Service、ConfigMap。它们不懂你的业务逻辑:

  • 如何优雅地主从切换?
  • 如何做数据库备份恢复?
  • 如何触发训练任务重试?
  • 如何管理证书轮换?

这些"领域知识",原本靠运维团队写脚本、跑 CronJob、看监控告警。Operator 就是把这些知识编码成 Controller,让 Kubernetes 执行你的运维逻辑。

一个 Operator 包含:

  1. CRD(Custom Resource Definition):定义你的资源类型,比如 DatabaseMLTrainingCertificate
  2. Controller:监听 CR 变化,执行调谐逻辑,更新状态
  3. RBAC:权限控制,决定 Controller 能操作哪些资源
  4. Webhook(可选):验证和默认值注入

1.3 2026 年的 Operator 生态:为什么现在学正当时

2026 年,Operator 已经是云原生的标准姿势:

领域代表项目Stars
数据库vitess-operator, mysql-operator18K+, 3K+
消息队列strimzi-kafka-operator4.5K+
机器学习kubeflow, training-operator14K+
安全cert-manager12K+
GitOpsargocd-operator2K+
存储rook12K+

更重要的是,Operator SDK 和 Kubebuilder 在 2026 年已经非常成熟。你不需要从零手写 Controller,只需要:

  1. 用 Kubebuilder 脚手架创建项目
  2. 定义 CRD 结构体
  3. 实现调谐逻辑
  4. 生成 YAML 并部署

门槛降低了,但理解底层原理依然是写出高质量 Operator 的前提。


二、架构深挖:Controller Runtime 的核心机制

2.1 控制器模式的三大组件

一个 Controller 的核心架构:

┌─────────────────────────────────────────────────────┐
│                   Controller                         │
│                                                      │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐      │
│  │ Informer │───▶│ WorkQueue│───▶│ Reconcile│      │
│  │ (Cache)  │    │          │    │  Loop    │      │
│  └──────────┘    └──────────┘    └──────────┘      │
│       │                               │             │
│       ▼                               ▼             │
│  ┌──────────┐                   ┌──────────┐       │
│  │  API     │                   │  Client  │       │
│  │ Server   │                   │          │       │
│  └──────────┘                   └──────────┘       │
└─────────────────────────────────────────────────────┘

三大组件

  1. Informer:监听 API Server 事件,维护本地缓存
  2. WorkQueue:事件队列,支持限流、去重、延迟重新入队
  3. Reconcile Loop:调谐函数,执行业务逻辑

2.2 Informer:事件监听与本地缓存

Informer 是 Controller 的"眼睛"。它通过 List-Watch 机制监听资源变化:

// Controller Runtime 内部实现(简化版)
type Informer struct {
    client     kubernetes.Interface
    namespace  string
    handlers   []ResourceEventHandler
    
    // 本地缓存
    store      cache.Store
    controller cache.Controller
}

// 事件类型
type ResourceEventHandler interface {
    OnAdd(obj interface{})
    OnUpdate(oldObj, newObj interface{})
    OnDelete(obj interface{})
}

List-Watch 工作流

  1. List:启动时全量拉取资源列表(通过 resourceVersion 增量)
  2. Watch:建立长连接,监听后续变化事件
  3. Resync:定期全量同步(默认 10 小时),防止事件丢失

为什么需要本地缓存?

  • 减少对 API Server 的压力
  • Reconcile 时快速读取资源状态
  • 支持多 Controller 共享缓存

2.3 WorkQueue:限流与重试的艺术

WorkQueue 是 Controller 的"缓冲区"。它解决几个核心问题:

  1. 突发流量:100 个 Pod 同时创建,不能 100 次并发 Reconcile
  2. 失败重试:Reconcile 失败后,延迟重试而不是无限循环
  3. 去重:同一资源的多个事件,合并成一次 Reconcile

Controller Runtime 提供的队列类型:

// 限速队列(最常用)
type RateLimitingInterface interface {
    AddRateLimited(item interface{})      // 限速入队
    Forget(item interface{})              // 清除重试计数
    NumRequeues(item interface{}) int     // 重试次数
}

// 默认限速策略
// 1. 指数退避:baseDelay * 2^(retry-1),上限 maxDelay
// 2. 持续失败:每分钟固定失败率
// 3. 单项限制:每个 key 独立计数

实战示例

import (
    "k8s.io/client-go/util/workqueue"
    "k8s.io/client-go/util/rate"
)

// 创建限速队列
queue := workqueue.NewRateLimitingQueueWithConfig(
    workqueue.RateLimitingQueueConfig{
        Name: "my-controller",
        RateLimiter: workqueue.NewMaxOfRateLimiter(
            // 指数退避:5ms, 10ms, 20ms, 40ms... 上限 1000s
            workqueue.NewItemExponentialFailureRateLimiter(5*time.Millisecond, 1000*time.Second),
            // 持续失败:每秒 10 个,突发 100 个
            &workqueue.BucketRateLimiter{Limiter: rate.NewLimiter(rate.Limit(10), 100)},
        ),
    },
)

// Reconcile 失败后限速入队
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // ... 业务逻辑
    
    if err != nil {
        // 失败:限速重试
        return ctrl.Result{}, err  // 自动重新入队
    }
    
    if needRetry {
        // 显式延迟重试(5 分钟后)
        return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
    }
    
    // 成功:不再入队
    return ctrl.Result{}, nil
}

2.4 Reconcile Loop:幂等性是核心

Reconcile 函数是 Operator 的灵魂。它必须幂等——同一个请求被调用多次,结果必须一致。

为什么幂等性如此重要?

  • 网络抖动可能导致事件重复
  • Controller 重启后会重新处理所有资源
  • 多个 Controller 可能同时处理同一资源

幂等性实战模式

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := log.FromContext(ctx)
    
    // 1. 获取资源
    var db myappv1.Database
    if err := r.Client.Get(ctx, req.NamespacedName, &db); err != nil {
        if errors.IsNotFound(err) {
            // 资源已删除,清理工作已完成(或不需要清理)
            log.Info("Database resource not found, ignoring")
            return ctrl.Result{}, nil
        }
        // 其他错误,重新入队
        return ctrl.Result{}, err
    }
    
    // 2. 检查是否正在删除
    if !db.DeletionTimestamp.IsZero() {
        // 执行清理逻辑
        return r.handleDeletion(ctx, &db)
    }
    
    // 3. 确保 Finalizer 存在
    if !controllerutil.ContainsFinalizer(&db, "myapp.example.com/finalizer") {
        controllerutil.AddFinalizer(&db, "myapp.example.com/finalizer")
        if err := r.Update(ctx, &db); err != nil {
            return ctrl.Result{}, err
        }
        // 更新后重新入队
        return ctrl.Result{Requeue: true}, nil
    }
    
    // 4. 核心调谐逻辑
    // 4.1 确保 PVC 存在
    pvc := &corev1.PersistentVolumeClaim{...}
    if err := r.ensurePVC(ctx, &db, pvc); err != nil {
        return ctrl.Result{}, err
    }
    
    // 4.2 确保 StatefulSet 存在
    sts := &appsv1.StatefulSet{...}
    if err := r.ensureStatefulSet(ctx, &db, sts); err != nil {
        return ctrl.Result{}, err
    }
    
    // 4.3 确保 Service 存在
    svc := &corev1.Service{...}
    if err := r.ensureService(ctx, &db, svc); err != nil {
        return ctrl.Result{}, err
    }
    
    // 5. 更新状态
    if err := r.updateStatus(ctx, &db); err != nil {
        return ctrl.Result{}, err
    }
    
    // 6. 定期重新调谐(可选)
    return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil
}

关键设计原则

  1. 先读后写:每次都从 API Server 读取最新状态
  2. 条件判断:只在必要时执行操作
  3. 状态分离:spec 变化触发调谐,status 记录结果
  4. Finalizer:确保删除前执行清理
  5. 错误处理:区分可重试和不可重试错误

三、Kubebuilder 实战:从零构建生产级 Operator

3.1 项目初始化

# 安装 Kubebuilder(2026 推荐)
curl -L -o kubebuilder https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)
chmod +x kubebuilder && mv kubebuilder /usr/local/bin/

# 创建项目
mkdir myapp-operator && cd myapp-operator
kubebuilder init --domain example.com --repo github.com/myorg/myapp-operator

项目结构:

myapp-operator/
├── api/v1/                    # CRD 定义
│   ├── database_types.go
│   └── groupversion_info.go
├── config/
│   ├── crd/                   # CRD YAML
│   ├── rbac/                  # RBAC 规则
│   ├── manager/               # Deployment
│   └── samples/               # 示例 YAML
├── controllers/               # Controller 实现
│   └── database_controller.go
├── internal/                  # 内部包
│   └── utils/
├── test/                      # 测试
│   ├── e2e/
│   └── integration/
├── Dockerfile
├── Makefile
├── PROJECT                    # 项目元数据
└── main.go                    # 入口

3.2 定义 CRD:API 设计的艺术

# 创建 API
kubebuilder create api --group myapp --version v1 --kind Database

编辑 api/v1/database_types.go

package v1

import (
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// DatabaseSpec 定义期望状态
type DatabaseSpec struct {
    // 版本
    // +kubebuilder:validation:Required
    // +kubebuilder:validation:Pattern="^\\d+\\.\\d+\\.\\d+$"
    Version string `json:"version"`
    
    // 副本数
    // +kubebuilder:validation:Minimum=1
    // +kubebuilder:validation:Maximum=10
    // +kubebuilder:default=1
    Replicas *int32 `json:"replicas,omitempty"`
    
    // 存储配置
    Storage StorageSpec `json:"storage"`
    
    // 资源限制
    Resources corev1.ResourceRequirements `json:"resources,omitempty"`
    
    // 备份配置
    Backup *BackupSpec `json:"backup,omitempty"`
}

type StorageSpec struct {
    // 存储类名
    // +kubebuilder:validation:Required
    StorageClassName string `json:"storageClassName"`
    
    // 存储大小
    // +kubebuilder:validation:Required
    // +kubebuilder:validation:Pattern="^\\d+(Gi|Mi)$"
    Size string `json:"size"`
}

type BackupSpec struct {
    // 是否启用备份
    Enabled bool `json:"enabled"`
    
    // 备份计划(Cron 格式)
    // +kubebuilder:validation:Pattern="^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|((.+){1,6})$"
    Schedule string `json:"schedule"`
    
    // 保留天数
    // +kubebuilder:validation:Minimum=1
    RetentionDays int `json:"retentionDays"`
}

// DatabaseStatus 定义实际状态
type DatabaseStatus struct {
    // 当前副本数
    ReadyReplicas int32 `json:"readyReplicas"`
    
    // 当前版本
    CurrentVersion string `json:"currentVersion"`
    
    // 阶段
    // +kubebuilder:validation:Enum=Creating;Running;Updating;Failed
    Phase string `json:"phase,omitempty"`
    
    // 条件
    Conditions []metav1.Condition `json:"conditions,omitempty"`
    
    // 最后备份时间
    LastBackupTime *metav1.Time `json:"lastBackupTime,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.readyReplicas
// +kubebuilder:printcolumn:name="Version",type=string,JSONPath=`.spec.version`
// +kubebuilder:printcolumn:name="Replicas",type=integer,JSONPath=`.spec.replicas`
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`

type Database struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`
    
    Spec   DatabaseSpec   `json:"spec,omitempty"`
    Status DatabaseStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

type DatabaseList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    
    Items []Database `json:"items"`
}

func init() {
    SchemeBuilder.Register(&Database{}, &DatabaseList{})
}

关键注解说明

注解用途
+kubebuilder:validation:Required必填字段
+kubebuilder:validation:Pattern正则验证
+kubebuilder:validation:Enum枚举值
+kubebuilder:default默认值
+kubebuilder:subresource:status启用 status 子资源
+kubebuilder:printcolumnkubectl get 显示列

3.3 实现 Controller:调谐逻辑详解

编辑 controllers/database_controller.go

package controllers

import (
    "context"
    "fmt"
    "time"
    
    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/errors"
    "k8s.io/apimachinery/pkg/api/resource"
    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"
    "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    "sigs.k8s.io/controller-runtime/pkg/log"
    
    myappv1 "github.com/myorg/myapp-operator/api/v1"
)

// DatabaseReconciler reconciles a Database object
type DatabaseReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

// RBAC 权限标记
//+kubebuilder:rbac:groups=myapp.example.com,resources=databases,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=myapp.example.com,resources=databases/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=myapp.example.com,resources=databases/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=statefulsets,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=persistentvolumeclaims,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete

// Reconcile 核心调谐逻辑
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)
    
    // 1. 获取 Database 资源
    var db myappv1.Database
    if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
        if errors.IsNotFound(err) {
            logger.Info("Database resource not found, ignoring since object must be deleted")
            return ctrl.Result{}, nil
        }
        logger.Error(err, "Failed to get Database")
        return ctrl.Result{}, err
    }
    
    // 2. 处理删除逻辑
    if !db.DeletionTimestamp.IsZero() {
        return r.handleDeletion(ctx, &db)
    }
    
    // 3. 添加 Finalizer
    if !controllerutil.ContainsFinalizer(&db, "myapp.example.com/finalizer") {
        controllerutil.AddFinalizer(&db, "myapp.example.com/finalizer")
        if err := r.Update(ctx, &db); err != nil {
            logger.Error(err, "Failed to add finalizer")
            return ctrl.Result{}, err
        }
        return ctrl.Result{Requeue: true}, nil
    }
    
    // 4. 确保基础设施资源
    // 4.1 确保 ConfigMap
    cm, err := r.ensureConfigMap(ctx, &db)
    if err != nil {
        return r.updateStatusWithCondition(ctx, &db, "ConfigMapReady", metav1.ConditionFalse, err.Error())
    }
    logger.Info("ConfigMap ensured", "name", cm.Name)
    
    // 4.2 确保 Secret
    secret, err := r.ensureSecret(ctx, &db)
    if err != nil {
        return r.updateStatusWithCondition(ctx, &db, "SecretReady", metav1.ConditionFalse, err.Error())
    }
    logger.Info("Secret ensured", "name", secret.Name)
    
    // 4.3 确保 Service
    svc, err := r.ensureService(ctx, &db)
    if err != nil {
        return r.updateStatusWithCondition(ctx, &db, "ServiceReady", metav1.ConditionFalse, err.Error())
    }
    logger.Info("Service ensured", "name", svc.Name)
    
    // 4.4 确保 StatefulSet
    sts, err := r.ensureStatefulSet(ctx, &db, cm, secret)
    if err != nil {
        return r.updateStatusWithCondition(ctx, &db, "StatefulSetReady", metav1.ConditionFalse, err.Error())
    }
    logger.Info("StatefulSet ensured", "name", sts.Name)
    
    // 5. 处理备份
    if db.Spec.Backup != nil && db.Spec.Backup.Enabled {
        if err := r.ensureBackupCronJob(ctx, &db); err != nil {
            logger.Error(err, "Failed to ensure backup CronJob")
            // 备份失败不影响主流程
        }
    }
    
    // 6. 更新状态
    db.Status.ReadyReplicas = sts.Status.ReadyReplicas
    db.Status.CurrentVersion = db.Spec.Version
    
    if sts.Status.ReadyReplicas == *db.Spec.Replicas {
        db.Status.Phase = "Running"
        setCondition(&db.Status, "Ready", metav1.ConditionTrue, "All replicas are ready")
    } else if sts.Status.ReadyReplicas > 0 {
        db.Status.Phase = "Updating"
        setCondition(&db.Status, "Ready", metav1.ConditionFalse, "Replicas are being updated")
    } else {
        db.Status.Phase = "Creating"
        setCondition(&db.Status, "Ready", metav1.ConditionFalse, "Database is being created")
    }
    
    if err := r.Status().Update(ctx, &db); err != nil {
        logger.Error(err, "Failed to update Database status")
        return ctrl.Result{}, err
    }
    
    logger.Info("Database reconciled successfully", "phase", db.Status.Phase)
    
    // 7. 定期重新调谐
    return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil
}

// ensureStatefulSet 确保 StatefulSet 存在
func (r *DatabaseReconciler) ensureStatefulSet(ctx context.Context, db *myappv1.Database, cm *corev1.ConfigMap, secret *corev1.Secret) (*appsv1.StatefulSet, error) {
    replicas := int32(1)
    if db.Spec.Replicas != nil {
        replicas = *db.Spec.Replicas
    }
    
    sts := &appsv1.StatefulSet{
        ObjectMeta: metav1.ObjectMeta{
            Name:      db.Name,
            Namespace: db.Namespace,
            Labels: map[string]string{
                "app":        db.Name,
                "app.kubernetes.io/managed-by": "myapp-operator",
            },
        },
        Spec: appsv1.StatefulSetSpec{
            ServiceName: db.Name + "-headless",
            Replicas:    &replicas,
            Selector: &metav1.LabelSelector{
                MatchLabels: map[string]string{"app": db.Name},
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: map[string]string{"app": db.Name},
                },
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {
                            Name:  "database",
                            Image: fmt.Sprintf("myapp/database:%s", db.Spec.Version),
                            Ports: []corev1.ContainerPort{
                                {ContainerPort: 5432, Name: "database"},
                            },
                            Env: []corev1.EnvVar{
                                {
                                    Name: "POSTGRES_PASSWORD",
                                    ValueFrom: &corev1.EnvVarSource{
                                        SecretKeyRef: &corev1.SecretKeySelector{
                                            LocalObjectReference: corev1.LocalObjectReference{
                                                Name: secret.Name,
                                            },
                                            Key: "password",
                                        },
                                    },
                                },
                            },
                            EnvFrom: []corev1.EnvFromSource{
                                {
                                    ConfigMapRef: &corev1.ConfigMapEnvSource{
                                        LocalObjectReference: corev1.LocalObjectReference{
                                            Name: cm.Name,
                                        },
                                    },
                                },
                            },
                            VolumeMounts: []corev1.VolumeMount{
                                {Name: "data", MountPath: "/var/lib/postgresql/data"},
                            },
                            Resources: db.Spec.Resources,
                        },
                    },
                },
            },
            VolumeClaimTemplates: []corev1.PersistentVolumeClaim{
                {
                    ObjectMeta: metav1.ObjectMeta{
                        Name: "data",
                    },
                    Spec: corev1.PersistentVolumeClaimSpec{
                        AccessModes: []corev1.PersistentVolumeAccessMode{
                            corev1.ReadWriteOnce,
                        },
                        StorageClassName: &db.Spec.Storage.StorageClassName,
                        Resources: corev1.VolumeResourceRequirements{
                            Requests: corev1.ResourceList{
                                corev1.ResourceStorage: resource.MustParse(db.Spec.Storage.Size),
                            },
                        },
                    },
                },
            },
        },
    }
    
    // 设置 OwnerReference
    if err := controllerutil.SetControllerReference(db, sts, r.Scheme); err != nil {
        return nil, err
    }
    
    // CreateOrUpdate
    var existing appsv1.StatefulSet
    err := r.Get(ctx, types.NamespacedName{Name: sts.Name, Namespace: sts.Namespace}, &existing)
    if err != nil {
        if errors.IsNotFound(err) {
            if err := r.Create(ctx, sts); err != nil {
                return nil, err
            }
            return sts, nil
        }
        return nil, err
    }
    
    // 更新策略:只更新必要字段
    existing.Spec.Replicas = sts.Spec.Replicas
    existing.Spec.Template.Spec.Containers[0].Image = sts.Spec.Template.Spec.Containers[0].Image
    existing.Spec.Template.Spec.Containers[0].Resources = sts.Spec.Template.Spec.Containers[0].Resources
    
    if err := r.Update(ctx, &existing); err != nil {
        return nil, err
    }
    
    return &existing, nil
}

// handleDeletion 处理删除逻辑
func (r *DatabaseReconciler) handleDeletion(ctx context.Context, db *myappv1.Database) (ctrl.Result, error) {
    logger := log.FromContext(ctx)
    
    if controllerutil.ContainsFinalizer(db, "myapp.example.com/finalizer") {
        // 执行清理工作
        if err := r.cleanupResources(ctx, db); err != nil {
            return ctrl.Result{}, err
        }
        
        // 移除 Finalizer
        controllerutil.RemoveFinalizer(db, "myapp.example.com/finalizer")
        if err := r.Update(ctx, db); err != nil {
            return ctrl.Result{}, err
        }
        
        logger.Info("Database deleted successfully")
    }
    
    return ctrl.Result{}, nil
}

// cleanupResources 清理外部资源
func (r *DatabaseReconciler) cleanupResources(ctx context.Context, db *myappv1.Database) error {
    // 如果有外部存储、云资源等,在这里清理
    // 例如:删除 S3 快照、通知外部系统等
    return nil
}

// updateStatusWithCondition 更新状态和条件
func (r *DatabaseReconciler) updateStatusWithCondition(ctx context.Context, db *myappv1.Database, conditionType string, status metav1.ConditionStatus, message string) (ctrl.Result, error) {
    setCondition(&db.Status, conditionType, status, message)
    if err := r.Status().Update(ctx, db); err != nil {
        return ctrl.Result{}, err
    }
    return ctrl.Result{Requeue: true}, nil
}

// setCondition 设置条件
func setCondition(status *myappv1.DatabaseStatus, conditionType string, conditionStatus metav1.ConditionStatus, message string) {
    now := metav1.Now()
    for i, c := range status.Conditions {
        if c.Type == conditionType {
            if c.Status != conditionStatus {
                status.Conditions[i] = metav1.Condition{
                    Type:               conditionType,
                    Status:             conditionStatus,
                    LastTransitionTime: now,
                    Reason:             conditionType + "Changed",
                    Message:            message,
                }
            }
            return
        }
    }
    status.Conditions = append(status.Conditions, metav1.Condition{
        Type:               conditionType,
        Status:             conditionStatus,
        LastTransitionTime: now,
        Reason:             conditionType + "Initial",
        Message:            message,
    })
}

// SetupWithManager 注册 Controller
func (r *DatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&myappv1.Database{}).
        Owns(&appsv1.StatefulSet{}).  // 监听子资源
        Owns(&corev1.Service{}).
        Owns(&corev1.ConfigMap{}).
        Owns(&corev1.Secret{}).
        Complete(r)
}

3.4 Webhook:验证与默认值注入

创建 Webhook:

kubebuilder create webhook --group myapp --version v1 --kind Database --defaulting --programmatic-validation

编辑 api/v1/database_webhook.go

package v1

import (
    "context"
    "fmt"
    "regexp"
    
    "k8s.io/apimachinery/pkg/runtime"
    ctrl "sigs.k8s.io/controller-runtime"
    logf "sigs.k8s.io/controller-runtime/pkg/log"
    "sigs.k8s.io/controller-runtime/pkg/webhook"
    "sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)

var databaselog = logf.Log.WithName("database-resource")

func (r *Database) SetupWebhookWithManager(mgr ctrl.Manager) error {
    return ctrl.NewWebhookManagedBy(mgr).
        For(r).
        Complete()
}

// +kubebuilder:webhook:path=/mutate-myapp-example-com-v1-database,mutating=true,failurePolicy=fail,sideEffects=None,groups=myapp.example.com,resources=databases,verbs=create;update,versions=v1,name=mdatabase.kb.io,admissionReviewVersions=v1

var _ webhook.Defaulter = &Database{}

// Default 设置默认值
func (r *Database) Default() {
    databaselog.Info("default", "name", r.Name)
    
    // 默认副本数
    if r.Spec.Replicas == nil {
        r.Spec.Replicas = int32Ptr(1)
    }
    
    // 默认资源
    if r.Spec.Resources.Requests == nil {
        r.Spec.Resources.Requests = corev1.ResourceList{
            corev1.ResourceCPU:    resource.MustParse("500m"),
            corev1.ResourceMemory: resource.MustParse("512Mi"),
        }
    }
    
    // 默认备份配置
    if r.Spec.Backup != nil && r.Spec.Backup.Schedule == "" {
        r.Spec.Backup.Schedule = "0 2 * * *"  // 每天凌晨 2 点
    }
}

// +kubebuilder:webhook:path=/validate-myapp-example-com-v1-database,mutating=false,failurePolicy=fail,sideEffects=None,groups=myapp.example.com,resources=databases,verbs=create;update,versions=v1,name=vdatabase.kb.io,admissionReviewVersions=v1

var _ webhook.Validator = &Database{}

// ValidateCreate 验证创建
func (r *Database) ValidateCreate() (admission.Warnings, error) {
    databaselog.Info("validate create", "name", r.Name)
    return nil, r.validate()
}

// ValidateUpdate 验证更新
func (r *Database) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
    databaselog.Info("validate update", "name", r.Name)
    
    oldDB := old.(*Database)
    
    // 检查版本降级
    if r.Spec.Version < oldDB.Spec.Version {
        return nil, fmt.Errorf("version downgrade is not allowed")
    }
    
    return nil, r.validate()
}

// ValidateDelete 验证删除
func (r *Database) ValidateDelete() (admission.Warnings, error) {
    databaselog.Info("validate delete", "name", r.Name)
    return nil, nil
}

// validate 公共验证逻辑
func (r *Database) validate() error {
    // 验证版本格式
    versionPattern := regexp.MustCompile(`^\d+\.\d+\.\d+$`)
    if !versionPattern.MatchString(r.Spec.Version) {
        return fmt.Errorf("invalid version format, expected x.y.z")
    }
    
    // 验证副本数
    if r.Spec.Replicas != nil {
        if *r.Spec.Replicas < 1 || *r.Spec.Replicas > 10 {
            return fmt.Errorf("replicas must be between 1 and 10")
        }
    }
    
    // 验证存储大小
    storagePattern := regexp.MustCompile(`^\d+(Gi|Mi)$`)
    if !storagePattern.MatchString(r.Spec.Storage.Size) {
        return fmt.Errorf("invalid storage size format, expected number followed by Gi or Mi")
    }
    
    return nil
}

func int32Ptr(i int32) *int32 { return &i }

四、测试策略:从单元测试到 E2E

4.1 Envtest:集成测试利器

Controller Runtime 提供的 envtest 是测试 Operator 的标准工具。它启动一个真实的 API Server(etcd + kube-apiserver),但不需要完整的 Kubernetes 集群。

安装 envtest:

# 下载 envtest 二进制
make envtest

# 或手动下载
curl -sSLo envtest-bins.tar.gz \
    https://go.kubebuilder.io/test-tools/1.28.0/$(go env GOOS)/$(go env GOARCH)
tar -zxf envtest-bins.tar.gz

测试代码 controllers/database_controller_test.go

package controllers

import (
    "context"
    "testing"
    "time"
    
    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    
    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/resource"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/types"
    "k8s.io/client-go/kubernetes/scheme"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/envtest"
    logf "sigs.k8s.io/controller-runtime/pkg/log"
    "sigs.k8s.io/controller-runtime/pkg/log/zap"
    
    myappv1 "github.com/myorg/myapp-operator/api/v1"
)

var (
    k8sClient client.Client
    testEnv   *envtest.Environment
    ctx       context.Context
    cancel    context.CancelFunc
)

func TestControllers(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "Controller Suite")
}

var _ = BeforeSuite(func() {
    logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
    
    ctx, cancel = context.WithCancel(context.TODO())
    
    // 启动 envtest
    testEnv = &envtest.Environment{
        CRDDirectoryPaths:     []string{filepath.Join("..", "config", "crd", "bases")},
        ErrorIfCRDPathMissing: true,
    }
    
    cfg, err := testEnv.Start()
    Expect(err).NotTo(HaveOccurred())
    Expect(cfg).NotTo(BeNil())
    
    // 注册 Scheme
    err = myappv1.AddToScheme(scheme.Scheme)
    Expect(err).NotTo(HaveOccurred())
    
    // 创建 Client
    k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
    Expect(err).NotTo(HaveOccurred())
    Expect(k8sClient).NotTo(BeNil())
    
    // 启动 Manager
    k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{
        Scheme:             scheme.Scheme,
        MetricsBindAddress: "0",
    })
    Expect(err).NotTo(HaveOccurred())
    
    err = (&DatabaseReconciler{
        Client: k8sManager.GetClient(),
        Scheme: k8sManager.GetScheme(),
    }).SetupWithManager(k8sManager)
    Expect(err).NotTo(HaveOccurred())
    
    go func() {
        defer GinkgoRecover()
        Expect(k8sManager.Start(ctx)).NotTo(HaveOccurred())
    }()
})

var _ = AfterSuite(func() {
    cancel()
    By("tearing down the test environment")
    Expect(testEnv.Stop()).NotTo(HaveOccurred())
})

var _ = Describe("Database Controller", func() {
    const timeout = time.Second * 30
    const interval = time.Second * 1
    
    Context("When creating a Database", func() {
        It("Should create a StatefulSet successfully", func() {
            By("Creating a Database")
            db := &myappv1.Database{
                TypeMeta: metav1.TypeMeta{
                    APIVersion: "myapp.example.com/v1",
                    Kind:       "Database",
                },
                ObjectMeta: metav1.ObjectMeta{
                    Name:      "test-db",
                    Namespace: "default",
                },
                Spec: myappv1.DatabaseSpec{
                    Version: "14.0.0",
                    Replicas: int32Ptr(3),
                    Storage: myappv1.StorageSpec{
                        StorageClassName: "standard",
                        Size:             "10Gi",
                    },
                    Resources: corev1.ResourceRequirements{
                        Requests: corev1.ResourceList{
                            corev1.ResourceCPU:    resource.MustParse("500m"),
                            corev1.ResourceMemory: resource.MustParse("512Mi"),
                        },
                    },
                },
            }
            
            Expect(k8sClient.Create(ctx, db)).Should(Succeed())
            
            By("Checking the StatefulSet was created")
            sts := &appsv1.StatefulSet{}
            Eventually(func() error {
                return k8sClient.Get(ctx, types.NamespacedName{Name: "test-db", Namespace: "default"}, sts)
            }, timeout, interval).Should(Succeed())
            
            Expect(*sts.Spec.Replicas).Should(Equal(int32(3)))
            Expect(sts.Spec.Template.Spec.Containers[0].Image).Should(Equal("myapp/database:14.0.0"))
        })
        
        It("Should update the Database status", func() {
            By("Checking the Database status")
            db := &myappv1.Database{}
            Eventually(func() (string, error) {
                err := k8sClient.Get(ctx, types.NamespacedName{Name: "test-db", Namespace: "default"}, db)
                if err != nil {
                    return "", err
                }
                return db.Status.Phase, nil
            }, timeout, interval).Should(Equal("Creating"))
        })
    })
})

运行测试:

make test

4.2 E2E 测试:真实集群验证

E2E 测试需要一个真实的 Kubernetes 集群(kind、minikube 或云集群)。

test/e2e/e2e_test.go

package e2e

import (
    "context"
    "fmt"
    "testing"
    "time"
    
    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    
    "k8s.io/client-go/kubernetes/scheme"
    "k8s.io/client-go/tools/clientcmd"
    "sigs.k8s.io/controller-runtime/pkg/client"
    
    myappv1 "github.com/myorg/myapp-operator/api/v1"
)

var k8sClient client.Client

func TestE2E(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "E2E Suite")
}

var _ = BeforeSuite(func() {
    // 从 kubeconfig 创建 client
    kubeconfig := clientcmd.NewDefaultClientConfigLoadingRules().GetDefaultFilename()
    config, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
    Expect(err).NotTo(HaveOccurred())
    
    err = myappv1.AddToScheme(scheme.Scheme)
    Expect(err).NotTo(HaveOccurred())
    
    k8sClient, err = client.New(config, client.Options{Scheme: scheme.Scheme})
    Expect(err).NotTo(HaveOccurred())
})

var _ = Describe("Database E2E", func() {
    It("Should create and delete a Database", func() {
        ctx := context.Background()
        
        By("Creating a Database")
        db := &myappv1.Database{
            ObjectMeta: metav1.ObjectMeta{
                Name:      "e2e-test-db",
                Namespace: "default",
            },
            Spec: myappv1.DatabaseSpec{
                Version: "14.0.0",
                Replicas: int32Ptr(1),
                Storage: myappv1.StorageSpec{
                    StorageClassName: "standard",
                    Size:             "1Gi",
                },
            },
        }
        
        Expect(k8sClient.Create(ctx, db)).Should(Succeed())
        
        By("Waiting for Database to be ready")
        Eventually(func() bool {
            err := k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-test-db", Namespace: "default"}, db)
            if err != nil {
                return false
            }
            return db.Status.Phase == "Running"
        }, 5*time.Minute, 10*time.Second).Should(BeTrue())
        
        By("Deleting the Database")
        Expect(k8sClient.Delete(ctx, db)).Should(Succeed())
        
        By("Verifying deletion")
        Eventually(func() bool {
            err := k8sClient.Get(ctx, types.NamespacedName{Name: "e2e-test-db", Namespace: "default"}, db)
            return errors.IsNotFound(err)
        }, 2*time.Minute, 5*time.Second).Should(BeTrue())
    })
})

运行 E2E 测试:

make test-e2e

五、性能优化:让 Operator 在生产环境"如鱼得水"

5.1 缓存优化

Controller Runtime 默认会缓存所有监听的资源。对于大规模集群,需要优化缓存策略:

func (r *DatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
    return ctrl.NewControllerManagedBy(mgr).
        For(&myappv1.Database{}).
        Owns(&appsv1.StatefulSet{}).
        Owns(&corev1.Service{}).
        // 只缓存特定命名空间
        WithOptions(controller.Options{
            CacheSyncTimeout: 2 * time.Minute,  // 缓存同步超时
            MaxConcurrentReconciles: 10,         // 并发调谐数
        }).
        Complete(r)
}

// Manager 级别缓存配置
func main() {
    mgr, err := ctrl.NewManager(cfg, ctrl.Options{
        Scheme: scheme,
        NewCache: cache.BuilderWithOptions(cache.Options{
            SelectorsByObject: cache.SelectorsByObject{
                &corev1.Pod{}: {
                    Label: labels.SelectorFromSet(labels.Set{"app": "myapp"}),
                },
            },
            DefaultNamespaces: map[string]cache.Config{
                "myapp-namespace": {},  // 只缓存特定命名空间
            },
        }),
    })
}

5.2 限流与重试策略

import (
    "k8s.io/client-go/util/workqueue"
    "k8s.io/client-go/util/rate"
)

// 自定义限速器
type ItemRateLimiter struct {
    failcounts map[interface{}]int
    mu         sync.Mutex
}

func (r *ItemRateLimiter) When(item interface{}) time.Duration {
    r.mu.Lock()
    defer r.mu.Unlock()
    
    count := r.failcounts[item]
    r.failcounts[item] = count + 1
    
    // 指数退避:5ms, 10ms, 20ms, 40ms... 上限 1 分钟
    delay := time.Duration(math.Min(
        float64(5*time.Millisecond)*math.Pow(2, float64(count)),
        float64(time.Minute),
    ))
    
    return delay
}

func (r *ItemRateLimiter) NumRequeues(item interface{}) int {
    r.mu.Lock()
    defer r.mu.Unlock()
    return r.failcounts[item]
}

func (r *ItemRateLimiter) Forget(item interface{}) {
    r.mu.Lock()
    defer r.mu.Unlock()
    delete(r.failcounts, item)
}

5.3 领导选举:多副本高可用

func main() {
    mgr, err := ctrl.NewManager(cfg, ctrl.Options{
        Scheme:                 scheme,
        LeaderElection:         true,
        LeaderElectionID:       "myapp-operator-leader",
        LeaderElectionNamespace: "myapp-system",
        LeaseDuration:          15 * time.Second,
        RenewDeadline:          10 * time.Second,
        RetryPeriod:            2 * time.Second,
    })
}

5.4 指标与监控

import (
    "github.com/prometheus/client_golang/prometheus"
    "sigs.k8s.io/controller-runtime/pkg/metrics"
)

var (
    reconcileTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "myapp_operator_reconcile_total",
            Help: "Total number of reconciles",
        },
        []string{"controller", "result"},
    )
    
    reconcileDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "myapp_operator_reconcile_duration_seconds",
            Help:    "Duration of reconcile",
            Buckets: []float64{0.01, 0.05, 0.1, 0.5, 1, 5, 10},
        },
        []string{"controller"},
    )
)

func init() {
    metrics.Registry.MustRegister(reconcileTotal, reconcileDuration)
}

func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    start := time.Now()
    defer func() {
        reconcileDuration.WithLabelValues("database").Observe(time.Since(start).Seconds())
    }()
    
    // ... 业务逻辑
    
    if err != nil {
        reconcileTotal.WithLabelValues("database", "error").Inc()
        return ctrl.Result{}, err
    }
    
    reconcileTotal.WithLabelValues("database", "success").Inc()
    return ctrl.Result{}, nil
}

六、生产部署:从开发到上线的完整流程

6.1 构建 Docker 镜像

Dockerfile

# Build stage
FROM golang:1.22-alpine AS builder

WORKDIR /workspace

# 缓存依赖
COPY go.mod go.sum ./
RUN go mod download

# 构建
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager main.go

# Runtime stage
FROM alpine:3.19

RUN apk --no-cache add ca-certificates

WORKDIR /
COPY --from=builder /workspace/manager .

# 非 root 用户
RUN adduser -D -u 1000 manageruser
USER manageruser

ENTRYPOINT ["/manager"]

构建并推送:

docker build -t myorg/myapp-operator:v1.0.0 .
docker push myorg/myapp-operator:v1.0.0

6.2 部署到 Kubernetes

config/manager/manager.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp-operator
  namespace: myapp-system
spec:
  replicas: 1
  selector:
    matchLabels:
      control-plane: myapp-operator
  template:
    metadata:
      labels:
        control-plane: myapp-operator
    spec:
      containers:
      - name: manager
        image: myorg/myapp-operator:v1.0.0
        args:
        - --leader-elect
        - --metrics-bind-address=:8080
        - --health-probe-bind-address=:8081
        env:
        - name: WATCH_NAMESPACE
          value: ""  # 空表示监听所有命名空间
        resources:
          limits:
            cpu: 500m
            memory: 128Mi
          requests:
            cpu: 10m
            memory: 64Mi
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8081
          initialDelaySeconds: 15
          periodSeconds: 20
        readinessProbe:
          httpGet:
            path: /readyz
            port: 8081
          initialDelaySeconds: 5
          periodSeconds: 10
      serviceAccountName: myapp-operator
      terminationGracePeriodSeconds: 10

部署:

# 安装 CRD
kubectl apply -f config/crd/bases

# 创建命名空间
kubectl create namespace myapp-system

# 部署 Operator
kubectl apply -k config/default

6.3 验证部署

# 检查 Pod 状态
kubectl get pods -n myapp-system

# 检查日志
kubectl logs -f -n myapp-system deployment/myapp-operator

# 检查指标
kubectl port-forward -n myapp-system svc/myapp-operator-metrics-service 8080:8080
curl http://localhost:8080/metrics

七、最佳实践与踩坑指南

7.1 设计原则

  1. CRD 要小而精:不要把所有配置塞进一个 CRD,按职责拆分
  2. 状态要完整:status 要反映实际状态,方便用户和监控
  3. 错误要明确:不要吞掉错误,要记录到 status 和日志
  4. 幂等性优先:Reconcile 必须可以安全重复执行
  5. OwnerReference:子资源要用 OwnerReference,自动级联删除

7.2 常见踩坑

问题原因解决方案
内存泄漏Informer 缓存未清理使用 context.Context 传递取消信号
Reconcile 死循环状态更新触发新事件使用 Generation 过滤
Webhook 超时验证逻辑太复杂设置 timeoutSeconds,优化逻辑
权限不足RBAC 规则缺失检查 +kubebuilder:rbac 注解
集群压力List-Watch 太多资源优化缓存选择器,只缓存必要资源

7.3 调试技巧

# 查看 Controller 日志
kubectl logs -n myapp-system deployment/myapp-operator -c manager

# 查看 CRD 状态
kubectl describe database my-db -n default

# 查看事件
kubectl get events -n default --sort-by=.lastTimestamp

# 进入 Pod 调试
kubectl exec -it -n myapp-system deployment/myapp-operator -- sh

# 端口转发调试
kubectl port-forward -n myapp-system deployment/myapp-operator 8080:8080

八、总结:Operator 开发的"道"与"术"

:理解声明式 API、控制器模式、幂等性设计。这是不变的核心。

:Kubebuilder、Controller Runtime、envtest。这是工具,会演进。

2026 年的 Operator 开发已经非常成熟

  • Kubebuilder 4.x:更简洁的 API,更好的错误提示
  • Controller Runtime v0.24+:性能优化,更好的缓存控制
  • Operator SDK 2.x:与 Kubebuilder 合并,统一生态

但无论工具如何演进,理解底层原理永远是写出高质量代码的前提。

下一步建议

  1. 从一个简单的 Operator 开始(比如管理 ConfigMap)
  2. 逐步增加复杂度(状态管理、Webhook、Finalizer)
  3. 写充分的测试(单元测试 + 集成测试 + E2E)
  4. 在真实集群验证性能和稳定性
  5. 贡献开源 Operator 项目,学习业界最佳实践

Operator 不是银弹,但它能让你的运维逻辑变成可版本化、可审计、可自动化的代码。这才是它真正的价值。


关键词:Kubernetes Operator, Controller Runtime, Kubebuilder, CRD, 声明式 API, 控制器模式, Reconcile, Webhook, envtest, 云原生

字数:约 12000 字

推荐文章

小技巧vscode去除空格方法
2024-11-17 05:00:30 +0800 CST
Vue3中如何处理组件的单元测试?
2024-11-18 15:00:45 +0800 CST
Rust 中的所有权机制
2024-11-18 20:54:50 +0800 CST
Vue3 组件间通信的多种方式
2024-11-19 02:57:47 +0800 CST
程序员茄子在线接单