第一章:eBPF与Go语言融合的革命性意义
eBPF(extended Berkeley Packet Filter)早已超越其网络包过滤的原始定位,演变为内核可编程的通用运行时——它允许安全、高效、无需重启地注入沙箱化程序到内核关键路径中。而Go语言凭借其静态编译、内存安全、原生协程和卓越的跨平台能力,正成为云原生可观测性、安全策略与性能分析工具链的首选开发语言。二者的融合并非简单绑定,而是构建了一条从用户态逻辑直达内核事件的“零拷贝信道”,彻底重构了系统级软件的开发范式。
为什么是Go而非C?
- C虽为eBPF程序的标准编写语言,但需手动管理加载器、映射(map)生命周期、辅助函数调用约定及版本兼容性;
- Go通过
libbpf-go和官方gobpf(已归档)等成熟库,将eBPF字节码生成、验证、加载、映射访问、事件轮询全部封装为类型安全的Go接口; - 开发者可直接使用结构体定义eBPF map键值,用channel接收perf event数据,用
runtime.LockOSThread()保障CPU亲和性,大幅降低内核交互门槛。
快速体验:用Go加载一个基础跟踪程序
以下代码片段使用 libbpf-go 加载并运行一个统计进程执行次数的eBPF程序:
// main.go —— 使用 libbpf-go 加载 tracepoint 程序
package main
import (
"log"
"github.com/aquasecurity/libbpf-go"
)
func main() {
spec, err := ebpf.LoadCollectionSpec("trace_exec.bpf.o") // 预编译的BPF对象文件
if err != nil {
log.Fatal(err)
}
coll, err := ebpf.NewCollection(spec)
if err != nil {
log.Fatal(err)
}
defer coll.Close()
// 读取名为 "exec_count" 的 map(类型:BPF_MAP_TYPE_PERCPU_HASH)
countMap := coll.Maps["exec_count"]
var count uint64
err = countMap.Lookup(uint32(0), &count) // 查找键为0的计数器
if err == nil {
log.Printf("Total exec calls: %d", count)
}
}
执行前需先用
clang -O2 -target bpf -c trace_exec.c -o trace_exec.bpf.o编译eBPF源码,并确保内核支持tracepoint/syscalls/sys_enter_execve。
关键价值维度对比
| 维度 | 传统方案(C + userspace daemon) | eBPF + Go 方案 |
|---|---|---|
| 开发效率 | 高耦合、重复胶水代码多 | 结构化API、自动资源管理 |
| 安全边界 | 用户态daemon常需root权限 | eBPF验证器强制内存/控制流安全 |
| 部署粒度 | 整体二进制升级 | 热替换eBPF程序,零停机更新 |
| 观测延时 | 采样+日志解析引入毫秒级延迟 | 内核态实时聚合,纳秒级响应 |
这种融合正在催生新一代轻量级安全沙箱、实时服务网格遥测代理与自适应内核调优引擎。
第二章:eBPF核心机制与Go语言适配原理
2.1 eBPF虚拟机架构与指令集精要解析
eBPF 虚拟机采用类 RISC 的 64 位寄存器架构,含 11 个通用寄存器(R0–R10)和 1 个只读栈指针(R10),所有指令均为固定长度 8 字节。
核心寄存器语义
R0: 返回值寄存器(函数调用/辅助函数返回)R1–R5: 调用参数(仅前5个有效)R6–R9: 调用者保存寄存器(跨辅助函数调用保持不变)R10: 只读栈指针(r10 - offset访问栈)
典型加载指令示例
// 加载 map fd 到 R1,准备 bpf_map_lookup_elem 调用
r1 = r10;
r1 += -8; // 栈偏移:指向栈上 key 地址
*(u64*)(r1 + 0) = 0x1234; // 写入 key 值(8字节)
该代码在栈上构造 key 结构体;r10 - 8 确保对齐安全,eBPF 验证器强制所有栈访问必须为常量偏移且不越界。
| 指令类型 | 示例 | 用途 |
|---|---|---|
| ALU64 | add r1, 4 |
寄存器算术运算 |
| LD_ABS | ldabsw [12] |
从网络包绝对偏移加载 |
| CALL | call 12 |
调用辅助函数(如 bpf_trace_printk) |
graph TD
A[用户空间 BPF 程序] --> B[Clang 编译为 ELF]
B --> C[eBPF 验证器校验]
C --> D[JIT 编译为原生机器码]
D --> E[内核中安全执行]
2.2 BPF程序生命周期:加载、验证、执行与卸载全流程实践
BPF程序并非直接运行的二进制,而需经内核严格管控的四阶段闭环流程:
加载:用户态注入
int fd = bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER,
insns, sizeof(insns),
"GPL", 0, log_buf, LOG_BUF_SIZE);
bpf_prog_load() 触发内核加载入口;BPF_PROG_TYPE_SOCKET_FILTER 指定程序类型;log_buf 用于捕获验证失败详情。
验证器:安全守门人
- 检查无环控制流、内存访问越界、寄存器状态一致性
- 强制所有内存访问经
bpf_probe_read_*()等安全辅助函数中转
执行与卸载
graph TD
A[用户调用 bpf_prog_load] --> B[内核验证器静态分析]
B --> C{验证通过?}
C -->|是| D[JIT编译为native指令]
C -->|否| E[返回错误码,log_buf输出违例点]
D --> F[挂载至hook点,如socket bind]
F --> G[事件触发时执行]
G --> H[bpf_prog_unload 或 close(fd) 自动卸载]
| 阶段 | 关键保障机制 | 典型失败原因 |
|---|---|---|
| 加载 | 用户态权限检查 | CAP_SYS_ADMIN缺失 |
| 验证 | 控制流图可达性分析 | 未初始化寄存器引用 |
| 执行 | JIT后受限寄存器模型 | 辅助函数调用参数非法 |
| 卸载 | fd引用计数自动回收 | fd泄漏导致资源滞留 |
2.3 Go运行时与内核BPF子系统交互模型深度剖析
Go程序通过bpf syscall与内核BPF子系统建立双向通道,核心依赖runtime·entersyscall/exitsyscall机制规避GMP调度干扰。
数据同步机制
BPF映射(如BPF_MAP_TYPE_HASH)由Go调用bpf.BPFMap.Update()写入,底层触发bpf_map_update_elem()内核路径。此时Go运行时主动让出P,确保syscall期间M不被抢占。
// 使用libbpf-go封装的典型更新流程
map.Update(unsafe.Pointer(&key), unsafe.Pointer(&value), 0)
// 参数说明:
// - key/value:需按BPF程序定义的结构体对齐(如__u32对齐)
// - flags=0:默认覆盖写入;BPF_ANY/BPF_NOEXIST控制语义
交互时序关键点
- Go协程发起BPF syscall → 运行时切换至sysmon监控模式
- 内核完成eBPF验证/加载后返回fd → Go将其封装为
*ebpf.Program或*ebpf.Map - 所有BPF对象生命周期由Go GC间接管理(通过finalizer绑定close())
| 阶段 | Go运行时行为 | 内核BPF响应 |
|---|---|---|
| 加载程序 | runtime.entersyscall |
bpf_prog_load()验证JIT |
| 映射读取 | M进入网络轮询等待状态 | bpf_map_lookup_elem() |
| 事件回调 | epoll_wait唤醒G |
perf_event_output()触发 |
graph TD
A[Go协程调用bpf.Map.Lookup] --> B{runtime.entersyscall}
B --> C[内核执行bpf_map_lookup_elem]
C --> D[返回值拷贝至用户空间]
D --> E[runtime.exitsyscall]
E --> F[继续协程调度]
2.4 libbpf-go设计哲学:零C依赖的纯Go BPF绑定实现机制
libbpf-go摒弃传统 CGO 桥接模式,通过 syscall 和 unsafe 直接调用内核 BPF 系统调用(SYS_bpf),实现全栈 Go 实现。
核心机制:系统调用直通
// bpfSyscall invokes SYS_bpf with raw arguments
func bpfSyscall(cmd uint32, attr unsafe.Pointer, size uintptr) (int, error) {
// cmd: BPF_PROG_LOAD / BPF_MAP_CREATE etc.
// attr: kernel-expected struct (e.g., bpf_attr)
// size: sizeof(struct bpf_attr) — validated at compile time
return unix.Syscall(unix.SYS_bpf, uintptr(cmd), uintptr(attr), size)
}
该函数绕过 libc 和 libbpf.so,将 Go 构造的 bpf_attr 结构体地址传入内核,由内核解析字段语义。参数 size 是安全关键——错误值将触发 -EINVAL。
关键优势对比
| 特性 | libbpf-go(纯Go) | cgo-based bindings |
|---|---|---|
| 链接依赖 | 无 | libbpf.so + libc |
| 跨平台构建 | ✅(CGO_ENABLED=0) | ❌(需C工具链) |
| 内存安全边界 | 显式 unsafe 审计 |
隐式 C 内存管理 |
graph TD
A[Go程序] -->|构造bpf_attr| B[syscall(SYS_bpf)]
B --> C[内核bpf()入口]
C --> D[验证attr.size]
D --> E[执行对应cmd逻辑]
2.5 BPF对象文件(BTF、ELF)解析与Go结构体自动映射实战
BPF程序编译后生成的ELF文件内嵌BTF(BPF Type Format)段,为类型元数据提供可移植描述。libbpf-go通过btf.LoadSpecFromReader加载BTF,并利用MapSpec.TypeName()关联内核结构体。
BTF驱动的结构体映射流程
spec, err := btf.LoadSpecFromReader(bytes.NewReader(elfBytes))
// 参数说明:elfBytes为完整BPF ELF二进制,含.btf节;返回BTF规范解析结果
关键字段对齐规则
| Go字段名 | BTF类型名 | 对齐要求 |
|---|---|---|
pid |
__u32 |
必须匹配size/offset |
comm |
char[16] |
数组长度需一致 |
自动映射核心逻辑
m, err := NewMapWithOptions(spec, MapOptions{PinPath: "/sys/fs/bpf/my_map"})
// 基于BTF推导key/value结构体布局,跳过手动unsafe.Sizeof计算
graph TD
A[读取ELF文件] –> B[提取.btf节]
B –> C[解析BTF类型树]
C –> D[生成Go struct tag]
D –> E[零拷贝映射到用户态内存]
第三章:libbpf-go开发环境构建与基础API体系
3.1 Ubuntu/AlmaLinux下内核头文件、bpftool、clang-16+LLVM工具链全栈搭建
BPF开发依赖三类核心组件:匹配运行内核版本的头文件、用户态调试工具bpftool,以及支持BPF后端的现代Clang/LLVM。不同发行版安装路径与依赖策略存在差异。
系统适配要点
- Ubuntu 22.04+:默认源含
linux-headers-$(uname -r)、bpftool(linux-tools-common)、clang-16(需添加llvm-toolchain) - AlmaLinux 9:需启用
crb仓库,kernel-devel与bpftool位于baseos,clang-16需从EPEL+llvm.org RPM安装
关键安装命令(AlmaLinux示例)
# 启用必要仓库并安装
sudo dnf install -y epel-release && \
sudo dnf config-manager --set-enabled crb && \
sudo dnf install -y kernel-devel-$(uname -r) bpftool clang-16 llvm-toolset
kernel-devel提供/usr/lib/modules/$(uname -r)/build/符号链接,是libbpf编译和bpftool加载的基础;clang-16启用-target bpf及-O2 -g优化调试能力,LLVM 16起正式稳定支持BPF CO-RE重定位。
工具链验证表
| 工具 | 验证命令 | 预期输出 |
|---|---|---|
bpftool |
bpftool --version |
bpftool v7.0+ |
clang-16 |
clang-16 --target=bpf -x c /dev/null -c -o /dev/null 2>&1 \| head -1 |
clang version 16. |
graph TD
A[内核头文件] --> B[libbpf编译]
C[bpftool] --> D[加载/inspect BPF对象]
E[clang-16+LLVM] --> F[生成BTF/CO-RE兼容字节码]
B --> G[BPF程序构建]
D --> G
F --> G
3.2 libbpf-go模块初始化、BPF对象加载与资源自动管理实践
libbpf-go 通过 NewModule 初始化 BPF 模块,自动绑定 ELF 文件中的程序与映射,并启用 defer-based 资源清理机制。
初始化与加载流程
m, err := NewModule("./prog.o", nil)
if err != nil {
log.Fatal(err)
}
defer m.Close() // 自动卸载程序、关闭映射、释放内存
NewModule 解析 ELF,注册所有 SEC("xdp")/SEC("tracepoint") 程序;defer m.Close() 触发 libbpf 的 bpf_object__close,确保内核资源零泄漏。
映射生命周期管理
| 映射类型 | 是否自动创建 | 是否自动持久化 |
|---|---|---|
BPF_MAP_TYPE_HASH |
是(调用 bpf_map__create) |
否(随模块关闭销毁) |
BPF_MAP_TYPE_PERF_EVENT_ARRAY |
是 | 是(需显式 Close() 或依赖 defer) |
自动资源回收关键路径
graph TD
A[NewModule] --> B[解析ELF节区]
B --> C[调用bpf_object__open]
C --> D[注册prog/map指针]
D --> E[defer m.Close]
E --> F[bpf_object__close → 清理所有fd]
3.3 Map操作抽象层:BPF_MAP_TYPE_HASH、PERCPU_ARRAY等Go接口封装详解
eBPF程序依赖高效内核数据结构,libbpf-go将底层map类型映射为强类型Go接口,屏蔽了bpf_map_def裸结构体的复杂性。
核心Map类型对应关系
| BPF Map 类型 | Go 接口名 | 典型用途 |
|---|---|---|
BPF_MAP_TYPE_HASH |
*Map |
键值查找(如连接跟踪) |
BPF_MAP_TYPE_PERCPU_ARRAY |
*PerfEventArray |
每CPU独立计数,无锁聚合 |
创建Hash Map示例
m, err := ebpf.NewMap(&ebpf.MapSpec{
Name: "conn_map",
Type: ebpf.Hash,
KeySize: 8, // uint64 key (e.g., PID + CPU)
ValueSize: 16, // struct { ts uint64; len uint64 }
MaxEntries: 65536,
})
该代码调用bpf(BPF_MAP_CREATE)系统调用;KeySize/ValueSize必须与eBPF C端定义严格一致,否则bpf_map_lookup_elem()将触发-EINVAL错误。
数据同步机制
PerCpuArray读取需调用Map.LookupWithMultiCPU(),自动合并各CPU槽位值,避免用户态手动遍历——这是零拷贝聚合的关键抽象。
第四章:TC/BPF_PROG_TYPE_SOCKET_FILTER程序全链路开发
4.1 TC ingress/egress钩子机制与Socket Filter语义差异对比分析
TC(Traffic Control)的 ingress 与 egress 钩子运行在内核网络栈的协议层之下,直接作用于 sk_buff;而 Socket Filter(如 SO_ATTACH_BPF)挂载在 socket 对象上,仅捕获该 socket 的收发数据,且发生在协议处理之后。
执行时机与作用域差异
- TC egress:位于
dev_queue_xmit()之前,可修改、丢弃或重定向所有出向报文(含非 socket 流量,如 ICMP echo reply) - TC ingress:在
qdisc_ingress处理,早于路由查找与协议栈入口 - Socket Filter:仅对
sendto()/recvfrom()等系统调用路径生效,无法观测内核自生成报文(如 TCP ACK)
关键语义对比表
| 维度 | TC Hook | Socket Filter |
|---|---|---|
| 触发层级 | QDisc 层(L2/L3之间) | Socket 层(L4以上) |
| 作用范围 | 全接口/类队列 | 单 socket 实例 |
| 可修改字段 | skb->data, skb->len |
仅可读/截断 ctx->data |
| 支持重定向 | ✅ bpf_redirect() |
❌ 仅 BPF_REDIRECT 无效 |
// TC egress 示例:基于 VLAN 标签重定向至 ifb0
SEC("classifier")
int tc_egress_vlan_redirect(struct __sk_buff *skb) {
void *data = (void *)(long)skb->data;
void *data_end = (void *)(long)skb->data_end;
if (data + sizeof(__be16) > data_end)
return TC_ACT_OK;
__be16 *proto = data;
if (*proto == bpf_htons(ETH_P_8021Q)) { // 检测 802.1Q
return bpf_redirect(IFB0_IFINDEX, 0); // 重定向到 ifb0 进行二次整形
}
return TC_ACT_OK;
}
该程序在 dev_queue_xmit() 前执行,IFB0_IFINDEX 需预先创建 ifb 设备;bpf_redirect() 返回值直接控制报文流向,是 TC 钩子独有的底层能力。
graph TD
A[skb 进入网卡] --> B{TC ingress}
B --> C[路由查找/协议处理]
C --> D[Socket 接收队列]
D --> E[Socket Filter]
C --> F[dev_queue_xmit]
F --> G[TC egress]
G --> H[驱动发送]
4.2 基于libbpf-go的TCP连接建立拦截与元数据提取实战
核心原理
利用 BPF_PROG_TYPE_TRACEPOINT 挂载到 tcp:tcp_connect 内核 tracepoint,捕获 SYN 发送瞬间的套接字上下文。
关键代码片段
// 创建并加载 eBPF 程序
prog, err := bpf.NewProgram(&bpf.ProgramSpec{
Type: ebpf.TracePoint,
AttachType: ebpf.AttachTracePoint,
Instructions: asm.Instructions{
asm.Mov.Reg(asm.R1, asm.R1), // 占位,实际由 libbpf 自动注入上下文指针
},
})
该程序无需手动解析 sk_buff;libbpf-go 自动将 struct tcp_connect_args* 映射为 Go 结构体,含 __u64 ts(纳秒级时间戳)、__u32 saddr/daddr、__u16 sport/dport。
元数据字段映射表
| 字段名 | 类型 | 含义 | 来源 |
|---|---|---|---|
saddr |
uint32 |
源 IPv4 地址 | sk->sk_rcv_saddr |
dport |
uint16 |
目标端口(网络序) | inet->inet_dport |
数据同步机制
用户态通过 ringbuf 轮询消费事件,每条记录携带完整五元组+时间戳,支持毫秒级连接画像构建。
4.3 Socket Filter程序中skb解析、协议字段读取与Go侧安全校验逻辑
skb结构关键字段映射
eBPF程序通过skb->data和skb->data_end指针安全访问网络包,需严格边界检查:
// 从skb提取IP头起始地址(含L2头偏移)
struct iphdr *ip = data + ETH_HLEN;
if ((void*)(ip + 1) > data_end) return 0; // 防越界访问
逻辑说明:
ETH_HLEN默认为14字节;ip + 1确保IP头完整(含IHL字段),data_end由eBPF verifier动态推导,保障内存安全。
协议字段提取流程
- 解析
ip->protocol获取上层协议(TCP=6, UDP=17) - 若为TCP,进一步校验
tcp->dport是否在白名单端口范围内 - 所有字段读取前均执行
if (tcp + 1 > data_end) return 0
Go侧校验策略表
| 校验项 | 安全阈值 | 触发动作 |
|---|---|---|
| TCP目的端口 | 仅允许80/443 | 拒绝并上报 |
| IP包长 | ≤ 1500 字节 | 截断并告警 |
| 协议版本 | IPv4 only | 丢弃非IPv4包 |
graph TD
A[skb进入Filter] --> B{IP头完整性检查}
B -->|通过| C[提取protocol字段]
B -->|失败| D[直接丢弃]
C --> E{protocol == TCP?}
E -->|是| F[校验dport白名单]
E -->|否| D
4.4 性能敏感路径优化:零拷贝ringbuf日志采集与Go消费者协程同步模型
在高吞吐日志采集场景中,内核态到用户态的数据拷贝是关键瓶颈。采用 perf_event_open + ring buffer 零拷贝机制,配合 Go runtime 的轻量协程消费模型,可将单核日志吞吐提升 3.2×。
数据同步机制
消费者协程通过 mmap 映射内核 ringbuf 元数据页,轮询 consumer_head 指针获取新数据偏移,避免系统调用开销。
// ringbuf consumer loop (simplified)
for {
head := atomic.LoadUint64(&rb.Meta.ConsumerHead)
tail := atomic.LoadUint64(&rb.Meta.ProduceTail)
if head == tail { runtime.Gosched(); continue }
// read data from rb.Data[head%rb.Size] without copy
processRecord(rb.Data, head)
atomic.StoreUint64(&rb.Meta.ConsumerHead, head+recordLen)
}
ConsumerHead 与 ProduceTail 原子读写确保无锁同步;recordLen 包含头长度与校验字段,由内核预填充。
性能对比(16KB ringbuf,单核)
| 方式 | 吞吐(MB/s) | CPU 占用率 | 系统调用次数/秒 |
|---|---|---|---|
read() + 用户缓冲 |
48 | 31% | 125k |
| 零拷贝 ringbuf | 154 | 12% | 0 |
graph TD
A[Kernel Ringbuf] -->|mmap| B[Go Consumer Goroutine]
B --> C{Has new data?}
C -->|Yes| D[Direct memory access]
C -->|No| E[runtime.Gosched]
D --> F[Decode & forward]
第五章:从原型到生产:eBPF Go程序工程化演进路径
构建可复用的eBPF程序骨架
在某云原生可观测性项目中,团队初始仅用 libbpf-go 编写单文件 trace_open.c 与 main.go,但随着监控指标扩展至12类系统调用(openat, connect, execve, mmap, sendto, recvfrom, close, brk, mprotect, clone, futex, epoll_wait),代码重复率飙升。我们重构为模块化骨架:/bpf/ 存放 .bpf.c 和自动生成的 spec.go;/cmd/ 分离 CLI 入口;/pkg/ 封装 perf event 解析、ring buffer 消费器与 Prometheus metrics 注册逻辑。该结构支撑后续接入 7 个微服务实例的统一 eBPF agent 部署。
CI/CD 流水线中的字节码验证
GitHub Actions 工作流强制执行三项检查:
- 使用
bpftool gen skeleton生成 C 头文件并校验 ABI 稳定性; - 运行
clang -O2 -target bpf -c trace_syscall.c -o /dev/null验证编译通过性; - 在 Ubuntu 20.04/22.04/AlmaLinux 9 容器中执行
bpftool prog load并捕获 verifier 日志,拒绝任何含invalid access to packet或unbounded memory access的程序。
下表为典型流水线阶段耗时统计(单位:秒):
| 阶段 | Ubuntu 20.04 | Ubuntu 22.04 | AlmaLinux 9 |
|---|---|---|---|
| 编译验证 | 8.2 | 7.9 | 9.1 |
| 加载验证 | 12.5 | 13.3 | 11.8 |
| Perf 事件解析测试 | 4.7 | 4.5 | 4.9 |
生产环境热更新机制
为避免重启进程导致监控断点,采用双 map 切换策略:events_v1 与 events_v2 两个 perf ring buffer map 并行运行。当新版本 eBPF 程序加载成功后,通过 bpf_link_update() 替换 tracepoint/syscalls/sys_enter_openat 的 link,并原子切换用户态消费者指向新 map。实测单次热更新耗时稳定在 23–31ms,无丢包(对比 perf_event_open() 原生接口平均丢包率 0.7%)。
资源隔离与内存安全实践
在 Kubernetes DaemonSet 中,每个 eBPF agent 以 --memory-limit=128Mi 启动,并通过 rlimit.Set(rlimit.RLIMIT_MEMLOCK, 64<<20, 64<<20) 锁定内存上限。所有 BPF map 创建时显式指定 MaxEntries: 65536 与 Flags: unix.BPF_F_NO_PREALLOC,防止内核因预分配消耗过多 slab 内存。在 128 核宿主机上压测时,agent RSS 稳定在 92–105 MiB 区间,未触发 OOMKilled。
// pkg/bpf/manager.go 片段:带超时的 map 清理
func (m *Manager) CleanupStaleMaps(ctx context.Context) error {
timeoutCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return m.bpfObjects.Cleanup(timeoutCtx)
}
多内核版本兼容性处理
针对 bpf_probe_read_kernel() 在 5.5+ 与 5.10+ 行为差异,引入编译期条件宏:
#if LINUX_VERSION_CODE >= KERNEL_VERSION(5,10,0)
bpf_probe_read_kernel(&file_path, sizeof(file_path), &file->f_path.dentry->d_name.name);
#else
bpf_probe_read(&file_path, sizeof(file_path), &file->f_path.dentry->d_name.name);
#endif
配合 #define LINUX_VERSION_CODE 自动注入,使同一份源码可在 5.4–6.2 内核集群中零修改部署。
flowchart LR
A[开发者提交 .bpf.c] --> B[CI 触发 clang 编译]
B --> C{verifier 通过?}
C -->|是| D[生成 spec.go + skeleton]
C -->|否| E[失败并输出 verifier log]
D --> F[启动三内核版本加载测试]
F --> G{全部成功?}
G -->|是| H[推送镜像至私有 registry]
G -->|否| E 