ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Harvester Seeder 深度解析:基于 Tinkerbell 的裸金属自动化部署与嵌入式硬件生命周期管理

Harvester Seeder 深度解析:基于 Tinkerbell 的裸金属自动化部署与嵌入式硬件生命周期管理
  • 云原生
  • 虚拟化
  • 存储
  • 后端
  • 高可用

【免费下载链接】harvester

Open source hyperconverged infrastructure (HCI) software

项目地址:https://gitcode.com/gh_mirrors/ha/harvester
点击查看免费下载

Harvester Seeder(下文简称 Seeder)是 Harvester 生态中用于裸金属服务器自动纳管与集群自动部署的独立项目:它以一系列 Kubernetes 控制器为基础,借助tinkerbell/boots(DHCP/PXE 引导)与tinkerbell/rufio(BMC 带外管理)等 CNCF 生态组件,通过AddressPool、Inventory、Cluster三类自定义资源(CRD)将"手工装 ISO、手工配 PXE"的繁琐流程收敛为"提交一份 YAML,自动拉起一个多节点 Harvester 集群"。阅读本文后,你将掌握 Seeder 的整体架构、三类 CRD 的设计与协调机制、完整的集群部署 YAML 写法,以及 Seeder 以 Addon 形式内嵌进 Harvester 后如何实现硬件信息发现、硬件事件上报与节点电源管理。

本文的技术骨架来自仓库中的设计文档 enhancements/20220615-seeder.md(Seeder HEP),并辅以 enhancements/20230327-harvester-seeder-integration.md(嵌入式模式集成设计)及 Harvester 主仓库中对应的 Addon 定义、Node API 源码与测试用例加以印证。

为什么需要 Seeder:从"手工装裸机"到"声明式交付"

在 Seeder 出现之前,交付一套 Harvester 环境的常见方式有两条:

  • ISO 镜像安装:需要人工逐台服务器插入介质、选择磁盘、配置网络与 VIP,过程依赖人工值守;
  • PXE 网络安装:虽然可以批量引导,但要正确编排 DHCP、PXE、ks/cloud-init 配置与安装顺序,仍然"需要大量人工干预来保证基础设施能被正确编排和供给"(原文 Motivation)。

对于需要频繁拉起多个 Harvester 版本供开发(development)与质量保障(QA)流程使用的团队而言,这种"手把手"的方式效率过低。Seeder 的目标(Goals)因此非常明确:

Seeder 将是一个独立项目,部署到 Kubernetes 集群后,负责管理多个 Harvester 集群的初始供给(initial provisioning)。

也就是说,Seeder 的职责边界是"把一批裸金属节点编排成一个可用的 Harvester 集群",而非运行 Harvester 本身;它作为外部控制器存在于 Harvester 集群之外(直至 1.2.0 引入嵌入式模式,才作为 Addon 运行在集群内部,详见后文)。

整体架构:boots、rufio 与 seeder 的职责划分

Seeder 由三个"活动部件"(moving parts)协同工作,分别覆盖网络引导、带外电源管理与编排控制三个层次:

tinkerbell/boots:条件式 DHCP/PXE 引导组件

boots是 Tinkerbell 技术栈中的条件式 DHCP/PXE 引导组件,承担以下工作:

  • 处理 DHCP 请求并分配 IP 地址;
  • 对外提供 iPXE 引导服务;
  • 支持 Kubernetes 后端做状态管理:用户可以定义HardwareCRD 对象,用于标识某台节点是否需要被分配 DHCP 地址、是否为其提供 PXE 引导。

正是这个 K8s 后端能力,让 Seeder 可以把"哪台节点该被引导安装"这一决策完全声明化为 CRD 对象。

tinkerbell/rufio:BMC 带外管理控制器

rufio是基板管理控制器(BMC,Baseboard Management Controller)控制器,通过bmctool工具集与绝大多数常见硬件交互,完成两类典型操作:

  • 修改启动设备顺序(如临时从 PXE/网络设备引导);
  • 触发服务器重启。

与用户的交互完全对象化:通过baseboardmanagement(BMC 连接管理)、bmctask(任务定义)与bmcjob(任务执行实例)三类 CRD,用户可以用 Kubernetes 对象操作裸金属节点,而无需直接登录 BMC Web 界面或调用 IPMI 命令行。

seeder:编排控制器

seeder本身引入三类 CRD 及配套控制器,将上述两个底层组件"粘合"成完整的供给流程。三类 CRD 的定位如下表:

CRD资源组/版本职责生命周期特征
AddressPoolmetal.harvesterhci.io/v1alpha1承担 IPAM(IP 地址管理),按 CIDR 分配节点地址与 Harvester VIP用户可定义多个,支持非连续 CIDR 段
Inventorymetal.harvesterhci.io/v1alpha1裸金属节点的抽象,包装baseboardmanagement并补充 boots 与 Harvester 安装器所需信息长期存活(long-lived),与集群绑定期间被占用,集群删除后释放回池
Clustermetal.harvesterhci.io/v1alpha1运行在一组Inventory管理的裸金属节点上的 Harvester 集群抽象可随时通过增删Inventory引用实现集群扩缩容

三类核心 CRD 的职责与控制器协调流程

AddressPool:解决 boots 的 IPAM 难题

原文明确指出,传统使用boots时存在一个 IPAM 挑战:需要为条件式 DHCP 分配一个 CIDR 网段,且非 boots 管理的硬件或虚拟机也必须以Hardware对象形式注册,才能触发 DHCP 应答——这意味着往往需要分配大段连续的 CIDR 块。

AddressPool的设计正是为了破解这一矛盾:

  • 用户可定义多个AddressPool对象,并在Cluster定义中引用;
  • Seeder 会从被引用的AddressPool中分配地址,并将分配结果作为 CRD 的status记录跟踪;
  • 多个AddressPool的存在,使得非连续 CIDR 段(non-continuous CIDR ranges)也能被正常使用,避免必须占用一整段连续地址。

Inventory:BMC 连接 + 引导信息的长期对象

Inventory是baseboardmanagementCRD 的包装,额外携带boots与 Harvester 安装器需要的补充信息。它关联一台真实裸金属节点,并在集群对象存续期间被占用;集群删除时,Inventory被释放回池中以供复用。其控制器核心任务有三条:

  1. 创建即联动:创建Inventory对象时,控制器会创建对应的baseboardManagement对象,并依据rufio上报的 BMC 连通性与电源状态,持续调和(reconcile)该对象的 state;
  2. 分配即重启:当Inventory被分配给某个集群(并满足特定条件,如Hardware对象 generation 条件)时,控制器创建bmcjob对象,由rufio触发关联裸金属节点重启,从而触发 DHCP/PXE 引导服务器kick-start Harvester 安装;
  3. 释放即关机:当Inventory从集群释放时,控制器再触发另一个bmcjob,将裸金属节点断电。

Cluster:集群的声明式抽象与供给编排

Cluster是对运行在一组Inventory管理的裸金属节点之上的真实 Harvester 集群的抽象。集群控制器承担以下核心任务:

  1. VIP 分配:创建Cluster时,从关联的AddressPool中分配一个 Harvester VIP;
  2. Hardware 生成:综合InventoryCRD 的 spec 与HarvesterCRD 的 spec,生成带有效userdata的HardwareCRD——这份userdata会由boots在 PXE 引导流程中以内核参数(kernel arguments)的形式传递,从而通过内核参数完成全部 Harvester 配置;
  3. 状态回写:将正确的 conditions 打到Inventory的 status 上,驱动 Inventory 控制器如前述通过bmcjob重启节点;
  4. 扩缩容:调和Clusterspec 中的Inventory对象列表,用户只需增删Inventory引用即可调整集群规模。

三者合起来构成了完整的供给闭环:提交 Cluster → 分配 IP/生成 userdata → 打条件触发重启 → PXE 引导并内核参数化安装 → 节点就绪加入集群。

实战:一份 YAML 拉起多节点 Harvester 集群

下面完整复现原文档 User Story 1 的示例:用户提交一份 YAML,即可供给出一个可用的多节点 Harvester 集群。

完整部署 YAML

apiVersion: metal.harvesterhci.io/v1alpha1 kind: AddressPool metadata: name: node-pool namespace: default spec: cidr: "172.16.128.11/32" gateway: "172.16.128.1" netmask: "255.255.248.0" --- apiVersion: metal.harvesterhci.io/v1alpha1 kind: AddressPool metadata: name: node2-pool namespace: default spec: cidr: "172.16.128.12/32" gateway: "172.16.128.1" netmask: "255.255.248.0" --- apiVersion: metal.harvesterhci.io/v1alpha1 kind: AddressPool metadata: name: vip-pool namespace: default spec: cidr: "172.16.128.7/32" gateway: "172.16.128.1" netmask: "255.255.248.0" --- apiVersion: v1 kind: Secret metadata: name: node namespace: default stringData: username: "ADMIN" password: "ADMIN" --- apiVersion: metal.harvesterhci.io/v1alpha1 kind: Inventory metadata: name: node namespace: default spec: primaryDisk: "/dev/sda" managementInterfaceMacAddress: "0c:c4:7a:6b:84:20" baseboardSpec: connection: host: "172.16.1.53" port: 623 insecureTLS: true authSecretRef: name: node namespace: default --- apiVersion: v1 kind: Secret metadata: name: node2 namespace: default stringData: username: "ADMIN" password: "ADMIN" --- apiVersion: metal.harvesterhci.io/v1alpha1 kind: Inventory metadata: name: node2 namespace: default spec: primaryDisk: "/dev/sda" managementInterfaceMacAddress: "0c:c4:7a:6b:80:d0" baseboardSpec: connection: host: "172.16.1.52" port: 623 insecureTLS: true authSecretRef: name: node2 namespace: default --- apiVersion: metal.harvesterhci.io/v1alpha1 kind: Cluster metadata: name: first namespace: default spec: version: "v1.0.2" imageURL: "http://172.16.135.50:8080" clusterConfig: nameservers: - 172.16.128.1 nodes: - inventoryReference: name: node namespace: default addressPoolReference: name: node-pool namespace: default - inventoryReference: name: node2 namespace: default addressPoolReference: name: node2-pool namespace: default vipConfig: addressPoolReference: name: vip-pool namespace: default

各资源要点解读

  • AddressPool 三件套:node-pool与node2-pool分别给两台节点分配固定地址(/32单主机段,配合gateway/netmask使用),vip-pool则单独为集群 VIP 预留。三个独立/32池正好演示了"多个AddressPool支持非连续 CIDR"的设计意图。
  • Secret 与 Inventory 一一对应:每个Inventory通过baseboardSpec.connection.authSecretRef引用一个保存 BMC 用户名/密码的Secret;connection.port: 623是 IPMI 默认端口,insecureTLS: true表示允许以非安全 TLS 连接 BMC。
  • Inventory 的关键字段:primaryDisk: "/dev/sda"指定安装目标盘;managementInterfaceMacAddress是管理网口 MAC,用于让boots将 DHCP/PXE 应答精准匹配到该节点(生成Hardware对象时使用)。
  • Cluster 的供给参数:version指定 Harvester 版本;imageURL指向 Harvester ISO/镜像文件服务器地址;clusterConfig.nameservers配置集群 DNS;nodes列表把每台Inventory与对应AddressPool配对;vipConfig引用 VIP 地址池。

调和后的对象状态(reconciled state)

提交后,各控制器自动运转,最终可观察到如下状态(原文测试输出):

NAME CLUSTERSTATUS CLUSTERTOKEN CLUSTERADDRESS cluster.metal.harvesterhci.io/first tinkHardwareCreated iAnHlMWjrm0KF17Y 172.16.128.7 NAME INVENTORYSTATUS GENERATEDPASSWORD ALLOCATEDNODEADDRESS inventory.metal.harvesterhci.io/node inventoryNodeReady yE4d99nP2JIujlIw 172.16.128.11 inventory.metal.harvesterhci.io/node2 inventoryNodeReady lv3xkopziz8r4Mbl 172.16.128.12 NAME AGE baseboardmanagement.bmc.tinkerbell.org/idrac 25h baseboardmanagement.bmc.tinkerbell.org/node 25h baseboardmanagement.bmc.tinkerbell.org/node2 25h NAME AGE bmcjob.bmc.tinkerbell.org/node-reboot 25h bmcjob.bmc.tinkerbell.org/node2-reboot 25h NAME AGE bmctask.bmc.tinkerbell.org/node-reboot-task-0 25h bmctask.bmc.tinkerbell.org/node-reboot-task-1 25h bmctask.bmc.tinkerbell.org/node-reboot-task-2 25h bmctask.bmc.tinkerbell.org/node2-reboot-task-0 25h bmctask.bmc.tinkerbell.org/node2-reboot-task-1 25h bmctask.bmc.tinkerbell.org/node2-reboot-task-2 25h NAME ADDRESSPOOLSTATUS STARTADDRESS LASTADDRESS NETMASK addresspool.metal.harvesterhci.io/node-pool poolExhausted 172.16.128.11 172.16.128.11 255.255.248.0 addresspool.metal.harvesterhci.io/node2-pool poolExhausted 172.16.128.12 172.16.128.12 255.255.248.0 addresspool.metal.harvesterhci.io/vip-pool poolExhausted 172.16.128.7 172.16.128.7 255.255.248.0 NAME STATE hardware.tinkerbell.org/node hardware.tinkerbell.org/node2

从这份状态输出可以清晰看出完整的供给链路:

  1. Cluster状态变为tinkHardwareCreated,并获得集群 Token(iAnHlMWjrm0KF17Y)与 VIP 地址(172.16.128.7);
  2. 两个Inventory均进入inventoryNodeReady,各自生成了随机访问密码(如yE4d99nP2JIujlIw),并拿到分配到的节点地址(172.16.128.11、172.16.128.12);
  3. 出现了baseboardmanagement.bmc.tinkerbell.org/{idrac,node,node2}三个 BMC 连接对象(idrac是 BMC 自身被纳管产生的对象),以及bmcjob/bmctask系列重启任务对象,印证了 rufio 侧的带外操作已经执行;
  4. 三个AddressPool的ADDRESSPOOLSTATUS均为poolExhausted(单地址池已被分配耗尽),STARTADDRESS与LASTADDRESS相同;
  5. hardware.tinkerbell.org/{node,node2}两个Hardware对象已生成,供 boots 做条件式 DHCP/PXE 应答。

用户使用体验:从提交到访问的四步

原文档给出的端到端用户体验为:

  1. 用户提交一份 YAML spec,请求供给一个 Harvester 集群;
  2. 用户可通过BMC 控制台(BMC console)实时查看安装进度;
  3. 集群就绪后,通过Cluster Status中的Cluster VIP访问 Harvester Web UI;
  4. 每个节点可通过Inventory Status中记录的节点地址与生成的随机密码(GENERATEDPASSWORD)登录访问。

API 变更与版本演进:从独立项目到嵌入式 Addon

原 HEP 明确指出:核心 Harvester 无需任何 API 变更——Seeder 是独立项目,CRD 是与其控制器交互的唯一方式。这一状态在后续发生了重要演进:自 Harvester 1.2.0 起,Seeder 引入嵌入式模式(embedded mode),可通过Harvester Addons直接部署到现有集群中,允许终端用户在 Harvester 侧直接定义 Inventory、查看硬件信息并执行电源操作。这部分的详细设计见 enhancements/20230327-harvester-seeder-integration.md。

嵌入式模式的定位与目标

嵌入式模式的三个核心目标:

  • 将硬件信息(propagation of hardware information)传播进 Harvester;
  • 将硬件事件传播为 Harvester 事件(events);
  • 允许用户通过 Harvester 对硬件执行电源循环(power cycle)操作。

明确列为Non-goal的是:不再承担额外硬件的供给(provisioning of additional hardware)——即嵌入式模式下 Seeder 只做"纳管已有节点",不做"装机拉新集群"。

嵌入式模式下使用的 CRD

嵌入式模式只使用以下 CRD 的子集:

  • inventories.metal.harvesterhci.io
  • clusters.metal.harvesterhci.io
  • jobs.bmc.tinkerbell.org(rufio 任务执行实例)
  • machines.bmc.tinkerbell.org(rufio 机器对象)
  • tasks.bmc.tinkerbell.org(rufio 任务定义)

部署形态与控制器裁剪

  • Seeder 以deployment形式运行在harvester-system命名空间;
  • Addon 部署时将 Seeder 的环境变量EMBEDDED_MODE置为true;
  • 在该模式下,供给类控制器(provisioning controllers)不会被引导启动,仅引导以下控制器:
    • local cluster controller(本地集群控制器)
    • local node controller(本地节点控制器)
    • rufio machine controller
    • rufio job controller
    • rufio task controller
    • cluster event controller(集群事件控制器)

在仓库中可找到对应的 Addon 定义:addons/built-in/harvester-seeder/addon-template.yaml 以harvesterhci.io/v1beta1的Addon资源声明了harvester-seeder(命名空间harvester-system),chart 为harvester-seeder,默认enabled: false,可通过Addons.harvester_seeder.Enabled开关启用;addons/built-in/harvester-seeder/metadata.yaml 声明其交付成熟度为ga。镜像与 chart 版本由 addons/version_info 统一管理,例如当前开发版本为HARVESTER_SEEDER_CHART_VERSION="1.10.0-dev.0"、镜像rancher/harvester-seeder:main-head。Addon 的完整目录与交付模型说明见 addons/README.md。

嵌入式模式下的 local Cluster 与 Inventory 绑定

嵌入式模式启动时,Seeder 会创建一个名为local的cluster.metal对象,其集群端点指向K8s 默认 service 地址,作为后续新增Inventory对象的占位容器:

apiVersion: metal.harvesterhci.io/v1alpha1 kind: Cluster metadata: creationTimestamp: "2023-03-26T22:50:15Z" generation: 4 name: local namespace: harvester-system resourceVersion: "31519751" uid: 44ad4855-65c7-46ba-a321-04c54ab69337 spec: clusterConfig: {} nodes: - addressPoolReference: name: "" namespace: "" inventoryReference: name: harvester-659jw namespace: harvester-system version: local vipConfig: addressPoolReference: name: "" namespace: "" status: clusterAddress: 10.53.0.1 status: clusterRunning

Harvester UI 允许用户为现有节点创建 Secret 与 Inventory 定义。典型的 Secret 创建请求(调用${ENDPOINT}/v1/harvester/secrets):

{ "type": "Opaque", "metadata": { "namespace": "harvester-system", "name": "harvester-659jw" }, "_type": "Opaque", "data": { "password": "TjBJTE80dSEh", "username": "RXRob3M=" } }

典型的 Inventory 创建请求(调用${ENDPOINT}/v1/harvester/metal.harvesterhci.io.inventories):

{ "kind": "Inventory", "metadata": { "name": "harvester-659jw", "namespace": "harvester-system", "annotations": { "metal.harvesterhci.io/localInventory": "true", "metal.harvesterhci.io/localNodeName": "harvester-659jw" } }, "spec": { "baseboardSpec": { "connection": { "authSecretRef": { "name": "harvester-659jw", "namespace": "harvester-system" }, "host": "172.19.1.172", "insecureTLS": true, "port": 623 } }, "events": { "enabled": true, "pollingInterval": "1h" } } }

这里的两个注解非常关键:

  • metal.harvesterhci.io/localInventory: "true"标记该 Inventory 属于嵌入式模式的本地纳管对象;
  • metal.harvesterhci.io/localNodeName指定 Inventory 关联的 K8s 节点名——由于嵌入式模式下 Seeder 不执行真实供给,本地集群控制器正是依据该注解去查询 K8s node 对象并生成正确的 Inventory status。

Inventory 被创建后,其 status 大致如下(其中pxeBootConfig.address来自节点实际地址):

{ "ownerCluster": { "name": "local", "namespace": "harvester-system" }, "pxeBootConfig": { "address": "172.19.109.10", "gateway": "", "netmask": "" }, "status": "inventoryNodeReady", "generatedPassword": "", "hardwareID": "" }

后续的数据流为:local cluster controller 将 Inventory 加入harvester-system命名空间下的local集群对象 → cluster event controller 周期性调和 Inventory,查询底层硬件细节与事件并触发对 Harvester 节点的更新 → inventory controller 监听节点的电源操作请求(通过 Inventory status 中的powerActionStatus变化)→ 电源操作完成后,powerStatus字段的LastPowerAction被更新为对应状态。

硬件信息与事件的 UI 呈现

启用 Addon 并定义 Inventory 后,用户即可在 Harvester UI 中查看底层硬件信息,包括但不限于:硬件厂商(manufacture)、硬件序列号(serial number)、硬件型号(model)以及硬件事件。

源码印证:Harvester 侧的电源操作 API 实现

嵌入式模式的"通过 Harvester UI 执行电源操作"能力,在 Harvester 主仓库中有完整的源码落地。核心实现在 pkg/api/node/formatter.go:

  • 常量定义(L49-L58):seederAddonName = "harvester-seeder"、defaultAddonNamespace = "harvester-system"、nodeReady = "inventoryNodeReady",以及电源操作白名单possiblePowerActions = []string{"shutdown", "poweron", "reboot"};动态客户端通过seederGVR(metal.harvesterhci.io/v1alpha1的inventories资源)操作 Inventory 对象;
  • Action 注册(L62-L108):所有节点默认暴露powerActionPossibleaction;powerAction仅在节点维护模式排空完成(MaintenanceMode=True/Evacuating或True/Completed)后才会注册到资源上,从而保证"先进入维护模式、排空 VM 后再执行电源操作"的安全语义;
  • powerActionPossible(L357-L407):先检查harvester-seederAddon 是否存在且spec.enabled == true(否则返回HTTP 424 Failed Dependency),再通过动态客户端按节点名获取 Inventory 对象,并校验其status.status == "inventoryNodeReady"(不满足则返回HTTP 409 Conflict),全部通过后返回HTTP 204 No Content;
  • powerAction(L409-L468):校验操作在白名单内、且节点处于维护模式排空完成状态;若上一次powerActionRequested尚未完成(status 中powerAction.actionStatus为空),则拒绝新请求;随后将操作写入 Inventory 的spec.powerActionRequested并清理status.powerAction.lastJobName,交由 Seeder 侧控制器执行真实的 BMC 操作。

对应的单元测试位于 pkg/api/node/formatter_test.go:Test_powerActionNotPossible(L526-L549)验证 Addon 不存在时返回424;Test_powerActionPossible(L551-L571)验证 Addon 启用且 Inventory 就绪时返回204;Test_powerAction(L573-L596)验证执行shutdown后 Inventory 的spec.powerActionRequested被正确写入"shutdown";Test_invalidPowerAction与TestPowerActionRequiresMaintenancePhase则验证非法操作与非维护模式状态会被拒绝。

测试计划与升级策略

  • 独立模式测试:原 HEP 的 Test plan 指出,代码库中已存在大量集成测试(integration tests),用于模拟并验证绝大部分 reconcile 功能(地址分配、Hardware 生成、BMC 任务触发等);升级策略标注为 N/A。
  • 嵌入式模式测试(来自 enhancements/20230327-harvester-seeder-integration.md):
    1. 启用 Seeder Addon 集成;
    2. 为节点定义合法的 Inventory 与 Secret;
    3. 等待额外的节点标签(node labels)被传播到节点上;
    4. 等待为该节点生成额外的硬件事件;
    5. 通过 Harvester UI 对节点执行电源操作。

总结

Seeder 的设计核心可以概括为一句话:把"裸金属节点"变成可声明式管理的 Kubernetes 资源,让"拉起一个 Harvester 集群"从需要人工值守的运维操作变成幂等、可审计的控制器调和过程。在独立模式下,AddressPool、Inventory、Cluster三驾马车配合 boots 与 rufio 完成了从 IPAM、userdata 生成、BMC 重启到 PXE 安装的完整闭环;在嵌入式模式下,它又收敛为运行在harvester-system内的 Addon,专注硬件纳管、事件上报与安全的电源操作。无论是开发/QA 团队批量交付测试环境,还是生产集群的裸金属可观测性与远程管理,Seeder 都提供了一条值得参考的、以 CRD 为中心的自动化路径。

若需继续深入,可阅读仓库内的原始设计文档 enhancements/20220615-seeder.md 与 enhancements/20230327-harvester-seeder-integration.md,并结合 pkg/api/node/formatter.go、addons/built-in/harvester-seeder/addon-template.yaml 及 addons/version_info 追踪最新实现。

  • 云原生
  • 虚拟化
  • 存储
  • 后端
  • 高可用

【免费下载链接】harvester

Open source hyperconverged infrastructure (HCI) software

项目地址:https://gitcode.com/gh_mirrors/ha/harvester
点击查看免费下载

相关推荐

上一篇:Channels 3.0.5 发布说明解读:Django 4.0 信号兼容修复与 Python/Django 旧版本支持终止
下一篇:iOS激活锁怎么解:3分钟判断值不值得动手

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进