第一章:Go语言不是银弹:为什么Docker早期用Go重写时返工3次?
Docker最初由dotCloud团队用Python开发原型,2013年决定用Go重写核心引擎。这一决策常被简化为“Go性能好、适合并发”,但实际过程远非一帆风顺——团队在6个月内经历了三次大规模重构,每次均推翻前序设计。
Go的并发模型与现实调度冲突
Docker 0.1版过度依赖goroutine处理容器生命周期事件(如start/stop/log),未设超时与上下文取消机制。当宿主机负载突增时,数千goroutine阻塞在syscall.Wait4上,导致守护进程无法响应SIGTERM。修复方案并非简单加context.WithTimeout,而是重构整个事件驱动层,引入基于channel的有限队列与背压控制:
// 修复后:带背压的事件处理器
func NewEventProcessor(maxQueueSize int) *EventProcessor {
return &EventProcessor{
queue: make(chan Event, maxQueueSize), // 有界缓冲区
done: make(chan struct{}),
}
}
// 若queue满,调用方需主动退避,而非无限goroutine堆积
标准库net/http的隐式状态陷阱
早期版本用http.ServeMux直接暴露容器API,但未隔离不同命名空间(如/containers/{id}/logs与/images/{id})的中间件链。一次安全补丁意外导致所有路由共享同一TLS连接池,引发跨租户证书泄露。最终采用gorilla/mux并显式声明路由作用域:
| 组件 | 问题根源 | 重构动作 |
|---|---|---|
| HTTP路由 | 全局ServeMux状态污染 | 每个API组使用独立Router实例 |
| 日志输出 | log.Printf全局锁竞争 |
替换为zerolog.New(os.Stdout) |
| 配置加载 | flag.Parse()硬编码路径 |
支持环境变量+配置文件双源解析 |
CGO依赖引发的构建断裂
为支持SELinux策略检查,首次引入libselinux绑定,导致交叉编译失败(CGO_ENABLED=0时panic)。第三次返工才确立“纯Go优先”原则:将SELinux逻辑下沉至独立C二进制工具,Go层通过exec.Command调用,确保GOOS=linux GOARCH=arm64可无依赖构建。
第二章:cgroups v1/v2——资源隔离的契约基石
2.1 cgroups v1层级结构与subsystem机制原理剖析
cgroups v1采用单树多挂载模型:每个subsystem(如cpu, memory)可独立挂载到不同目录,形成各自独立的层级树。
核心组织方式
- 每个cgroup目录对应一个进程组,通过
tasks文件添加PID实现归属绑定 - subsystem注册后,内核为其创建专属挂载点(如
/sys/fs/cgroup/cpu) - 所有cgroup目录共享同一路径命名空间,但逻辑隔离
典型挂载示例
# 分别挂载两个subsystem到不同路径
mount -t cgroup -o cpu none /sys/fs/cgroup/cpu
mount -t cgroup -o memory none /sys/fs/cgroup/memory
此命令显式指定
-o cpu/-o memory,触发内核为对应subsystem初始化其控制逻辑及默认根cgroup;none仅为占位,实际由cgroupfs驱动解析选项。
subsystem协作约束
| 特性 | cgroups v1 | cgroups v2(对比) |
|---|---|---|
| 层级拓扑 | 多树并行 | 单统一树 |
| 进程归属 | 可同时属于多个子树 | 仅能位于单一叶子节点 |
| 控制粒度 | subsystem间无继承同步 | 统一资源边界与递归约束 |
graph TD
A[根cgroup] --> B[web-server]
A --> C[db-service]
B --> B1[nginx]
B --> B2[php-fpm]
C --> C1[postgres]
subgraph cpu_subsystem
A; B; B1; B2; C; C1
end
subgraph memory_subsystem
A; B; C; C1
end
2.2 cgroups v2 unified hierarchy实战:从memory.max到cpu.weight配置
cgroups v2 强制采用统一层级(unified hierarchy),所有控制器必须挂载于同一挂载点,彻底摒弃 v1 的多层级混用乱象。
创建并配置 memory 子系统
# 挂载统一 cgroup2 文件系统(若未挂载)
sudo mount -t cgroup2 none /sys/fs/cgroup
# 创建子组并限制内存上限为 512MB
sudo mkdir /sys/fs/cgroup/myapp
echo "536870912" | sudo tee /sys/fs/cgroup/myapp/memory.max
memory.max 是硬性上限,超出时内核将触发 OOM killer;值为 "max" 表示无限制, 无效。
配置 CPU 权重调度
# 设置相对 CPU 时间配额(默认为 100)
echo "300" | sudo tee /sys/fs/cgroup/myapp/cpu.weight
cpu.weight 取值范围 1–10000,决定该组在同级竞争中的调度权重比例,不保证绝对时间片。
| 参数 | 含义 | 典型值 | 是否可动态调整 |
|---|---|---|---|
memory.max |
内存硬上限(字节) | 536870912(512MB) |
✅ |
cpu.weight |
CPU 调度相对权重 | 300(3×默认) |
✅ |
控制器启用状态
graph TD
A[/sys/fs/cgroup] --> B[cpu]
A --> C[memory]
A --> D[io]
B & C & D --> E{统一启用}
2.3 使用runc源码追踪cgroups v2在容器启动中的挂载与约束注入
runc 在 libcontainer/cgroups/fs2/ 中统一处理 cgroups v2 挂载与资源约束注入,核心入口为 Apply() 方法。
挂载点自动发现逻辑
runc 通过 os.ReadDir("/sys/fs/cgroup") 判断是否启用 unified hierarchy,并调用 getMountPoint() 获取挂载路径(默认 /sys/fs/cgroup)。
资源约束写入流程
// pkg/cgroups/v2/fs.go:187
if err := fs.WriteFile(subsystem, "memory.max", "512M"); err != nil {
return err // 写入 memory controller 的硬限制
}
该操作将字符串 "512M" 写入 cgroup.subtree_control 启用的子系统路径下对应控制文件;memory.max 是 cgroups v2 唯一内存上限接口,替代 v1 的 memory.limit_in_bytes。
关键路径映射表
| 子系统 | v2 控制文件 | 等效 v1 接口 |
|---|---|---|
| CPU | cpu.max |
cpu.cfs_quota_us |
| Memory | memory.max |
memory.limit_in_bytes |
| PIDs | pids.max |
pids.max(v1/v2 同名) |
graph TD
A[Start Container] --> B[Detect cgroups v2 mount]
B --> C[Create cgroup path under /sys/fs/cgroup]
C --> D[Write constraints to unified files]
D --> E[Join process into cgroup]
2.4 基于libcontainer手动创建受限进程组:绕过Docker验证底层契约
直接调用 libcontainer API 可跳过 Docker daemon 的镜像校验与 OCI 规范强制约束,暴露容器运行时最原始的控制面。
核心调用链
- 初始化
libcontainer.New()获取 runtime 实例 - 构建
configs.Config结构体,显式定义 cgroups 路径、namespace 类型、rootfs 挂载点 - 调用
container.Start()启动进程,绕过docker run的 shim 层封装
关键参数对照表
| 参数字段 | Docker 默认行为 | libcontainer 手动设定示例 |
|---|---|---|
Rootfs |
隐式解压 OCI bundle | /var/lib/mycontainer/rootfs |
Cgroups.Path |
自动生成 docker/xxx | /sys/fs/cgroup/cpu/mygroup |
Namespaces |
全启用(含 USER) | 仅保留 NEWPID, NEWNET |
// 创建受限容器配置
config := &configs.Config{
Rootfs: "/mnt/rootfs",
Cgroups: &configs.Cgroup{
Path: "/sys/fs/cgroup/cpu/myapp",
Resources: &configs.Resources{CPU: "50000 100000"}, // 50% CPU quota
},
Namespaces: configs.Namespaces([]configs.Namespace{
{Type: configs.NEWPID},
{Type: configs.NEWNET},
}),
}
上述代码显式声明 CPU 配额(cpu.cfs_quota_us=50000, cpu.cfs_period_us=100000),并禁用 NEWUSER namespace,使进程直接以 host UID 运行——这正是绕过 Docker 安全契约的关键切口。
2.5 对比测试:cgroups v1 vs v2在CPU Burst与OOM Kill行为上的语义差异
CPU Burst 行为差异
cgroups v1 无原生 burst 支持,需依赖 cpu.cfs_quota_us/cpu.cfs_period_us 配合 cpu.shares 模拟;v2 引入 cpu.max(如 100000 50000 表示 100ms quota / 50ms period),并支持 隐式突发(burst):当 cpu.max 中第二参数(burst budget)> 0 时,允许短时超额使用 CPU。
# cgroups v2:启用 20ms 突发配额(周期 100ms)
echo "100000 20000" > /sys/fs/cgroup/test/cpu.max
100000是周期内最大可用微秒数(100ms),20000是可累积的突发额度(20ms)。v1 无法表达此语义,需依赖rt_runtime_us或外部调度器模拟。
OOM Kill 触发逻辑对比
| 维度 | cgroups v1 | cgroups v2 |
|---|---|---|
| OOM 判定依据 | 全局内存压力 + memory.limit_in_bytes |
基于 memory.high 的软限制 + memory.max 硬限制 |
| Kill 粒度 | 整个 cgroup 内任一进程(无优先级) | 优先 kill 超过 memory.high 且 RSS 最高的进程 |
OOM 处理流程差异
graph TD
A[内存分配失败] --> B{cgroups v1}
A --> C{cgroups v2}
B --> D[触发全局 OOM killer<br>扫描所有 task]
C --> E[检查 memory.high 是否 breached]
E -->|是| F[选择 RSS 最高且超限进程]
E -->|否| G[尝试内存回收]
F --> H[发送 SIGKILL]
- v2 的
memory.high提供 可预测的降级边界,而 v1 仅靠硬限limit_in_bytes导致突兀 kill; - v2 支持 per-cgroup OOM score adj,v1 仅支持全局
oom_score_adj。
第三章:namespace——进程视图隔离的核心抽象
3.1 六大namespace(mnt/pid/net/uts/user/ipc)的内核注册与clone标志映射
Linux内核通过 ns_operations 结构体统一管理各类namespace,每类均在初始化时调用 register_pernet_subsys() 或类似机制完成注册。
核心注册入口示例
// net/core/net_namespace.c 中的注册片段
static struct pernet_operations __net_initdata net_ns_ops = {
.init = net_ns_net_init,
.exit = net_ns_net_exit,
.id = &net_net_id,
.size = sizeof(struct net),
};
// 注册后,内核可按需分配/销毁 net_namespace 实例
该结构绑定生命周期钩子与ID槽位,size 决定每个namespace实例的内存布局;id 用于全局唯一索引。
clone标志与namespace映射关系
| clone flag | 对应 namespace | 隔离维度 |
|---|---|---|
CLONE_NEWNS |
mnt | 挂载点视图 |
CLONE_NEWPID |
pid | 进程ID编号空间 |
CLONE_NEWNET |
net | 网络设备、协议栈 |
CLONE_NEWUTS |
uts | hostname/domainname |
CLONE_NEWUSER |
user | UID/GID 映射 |
CLONE_NEWIPC |
ipc | SysV IPC / POSIX mq |
namespace创建流程
graph TD
A[clone系统调用] --> B{检查CLONE_NEW*标志}
B --> C[调用create_new_namespaces]
C --> D[遍历六大ns_ops数组]
D --> E[按标志位触发对应alloc函数]
E --> F[初始化ns结构并挂入task_struct]
3.2 手动调用clone(2) + unshare(2)构建最小化PID+mount namespace沙箱
要构建最轻量级的 PID + mount namespace 沙箱,需绕过 unshare 命令封装,直接系统调用协同控制。
核心调用链
- 先
unshare(CLONE_NEWNS | CLONE_NEWPID)创建初始隔离环境 - 再
clone()带CLONE_NEWPID | SIGCHLD启动子进程——仅此子进程及其后代可见独立 PID 空间
// 关键代码片段(简化版)
int pid = clone(child_fn, stack_top, CLONE_NEWPID | SIGCHLD, NULL);
if (pid == 0) {
// 子进程:此时已处于新 PID namespace,且 init 进程号为 1
unshare(CLONE_NEWNS); // 隔离挂载点视图
mount("none", "/", NULL, MS_REC | MS_PRIVATE, NULL); // 屏蔽宿主挂载传播
}
CLONE_NEWPID在clone()中生效才真正启动新 PID namespace;unshare(CLONE_NEWPID)单独调用无效(内核限制)。MS_PRIVATE确保后续mount --bind不向宿主泄漏。
namespace 组合效果对比
| 调用方式 | PID namespace 可见性 | /proc 挂载是否隔离 | 是否需 pivot_root |
|---|---|---|---|
unshare -rmp |
✅(但无 init 进程) | ✅ | ❌ |
clone()+unshare() |
✅(含 1 号 init) | ✅(私有挂载树) | ✅(推荐) |
graph TD
A[父进程] -->|clone with CLONE_NEWPID| B[子进程]
B --> C[自动获得 PID=1]
B --> D[unshare CLONE_NEWNS]
D --> E[mount MS_PRIVATE]
E --> F[安全 bind-mount /tmp]
3.3 用户命名空间嵌套与CAP_SYS_ADMIN权限降级实践:实现rootless容器雏形
用户命名空间嵌套是构建真正 rootless 容器的关键机制——它允许非特权用户创建隔离的 UID/GID 映射,并在其中进一步嵌套新命名空间。
嵌套用户命名空间创建流程
# 创建两层嵌套:外层映射 1000→0,内层映射 0→0(但仅限内层视角)
unshare -r --user --pid --mount-proc=/proc \
sh -c 'echo "outer: $(id -u):$(id -g)"; \
unshare -r --user sh -c "echo inner: \$(id -u):\$(id -g)"'
逻辑分析:外层
unshare -r自动建立 1000→0 的映射(需/etc/subuid配置);内层再次-r触发嵌套,其CAP_SYS_ADMIN仅作用于该嵌套层级,无法突破外层边界。--pid和--mount-proc确保进程与 proc 文件系统隔离。
CAP_SYS_ADMIN 权限降级效果对比
| 场景 | 可执行操作 | 是否需真实 root |
|---|---|---|
| 全局 CAP_SYS_ADMIN | 挂载任意文件系统、修改网络栈 | 是 |
| 嵌套命名空间内 | 仅挂载 tmpfs、bind-mount 自身目录 | 否 |
权限收敛控制流
graph TD
A[非特权用户启动] --> B[创建外层 user+pid ns]
B --> C[drop CAP_SYS_ADMIN 除 namespace 相关子集]
C --> D[嵌套内层 user ns]
D --> E[仅保留 CAP_SYS_CHOWN/CAP_DAC_OVERRIDE]
第四章:seccomp与overlayfs——安全边界与文件系统契约
4.1 seccomp-bpf规则编写与审计:从default-deny策略到细粒度syscall过滤
seccomp-bpf 是 Linux 安全模块中实现系统调用过滤的核心机制,其规则本质是运行在内核 BPF 解释器上的安全策略程序。
default-deny 策略基础
最简安全起点:拒绝所有 syscall,仅显式放行必要调用。
#include <seccomp.h>
int main() {
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL); // 默认拒绝
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
seccomp_load(ctx); // 加载至内核
}
SCMP_ACT_KILL 表示未匹配规则的 syscall 直接终止进程;seccomp_rule_add 不设 arg_cnt 时忽略参数检查,适用于无条件放行。
细粒度过滤示例
对 openat 施加路径白名单(需结合 BPF_JEQ + BPF_LD | BPF_W | BPF_ABS):
- 参数 1(
fd)必须为AT_FDCWD - 参数 2(
pathname)地址需指向只读字符串/dev/null
| 字段 | 类型 | 说明 |
|---|---|---|
arg_idx |
uint32 | 参数索引(0-based) |
arg_type |
uint32 | SCMP_CMP_EQ 等比较类型 |
arg_value |
uint64 | 比较目标值 |
graph TD
A[用户态进程发起 openat] --> B{seccomp BPF 程序执行}
B --> C[提取 arg0 == AT_FDCWD?]
C -->|否| D[KILL]
C -->|是| E[提取 arg1 地址内容]
E --> F[memcmp with “/dev/null”]
F -->|匹配| G[ALLOW]
F -->|不匹配| D
4.2 使用libseccomp生成并注入自定义bpf程序到runc exec流程
libseccomp 提供 C API 将 seccomp-bpf 规则编译为 BPF 指令,并可序列化为二进制 blob,供 runc 在 exec 阶段加载。
构建最小权限过滤器
#include <seccomp.h>
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_ALLOW);
seccomp_rule_add(ctx, SCMP_ACT_KILL, SCMP_SYS(write), 1,
SCMP_A0(SCMP_CMP_EQ, STDOUT_FILENO));
seccomp_export_bpf(ctx, STDOUT); // 输出BPF字节码至标准输出
该代码初始化白名单策略,仅允许向 stdout 写入;SCMP_A0 表示校验第一个参数(fd),SCMP_ACT_KILL 在违规时终止进程。
注入流程示意
graph TD
A[libseccomp生成BPF] --> B[runc config.json: seccompProfile]
B --> C[containerd-shim调用runc exec]
C --> D[内核加载BPF并拦截系统调用]
| 组件 | 作用 |
|---|---|
| libseccomp | 编译高级规则为 eBPF 字节码 |
| runc | 解析 profile 并调用 prctl() 加载 |
| kernel | 在 execve 后挂载 filter 到线程 |
4.3 overlayfs三层结构(lowerdir/upperdir/workdir)的挂载语义与copy-up机制解析
OverlayFS 通过三目录协同实现联合挂载:lowerdir(只读基础层)、upperdir(可写变更层)、workdir(内部元数据工作区)。
挂载语义约束
workdir必须与upperdir位于同一文件系统,且必须为空;lowerdir可指定多个,以:分隔,优先级从左到右递减;upperdir和workdir不可共用同一目录。
copy-up 触发流程
# 示例挂载命令
sudo mount -t overlay overlay \
-o lowerdir=/mnt/lower:/mnt/base,\
upperdir=/mnt/upper,\
workdir=/mnt/work \
/mnt/merged
参数说明:
lowerdir支持多层只读叠加;upperdir存储所有新增/修改/删除操作;workdir是 overlayfs 内部原子重命名所需的暂存点(如work/work),不可手动写入。
| 目录类型 | 可写性 | 作用 |
|---|---|---|
| lowerdir | 只读 | 提供基础镜像或共享只读层 |
| upperdir | 可写 | 记录用户态所有变更 |
| workdir | 可写 | 协助 rename() 原子性保障 |
graph TD
A[访问 merged/a.txt] --> B{文件是否存在 upperdir?}
B -- 否 --> C[从 lowest lowerdir copy-up]
B -- 是 --> D[直接读 upperdir/a.txt]
C --> E[在 upperdir 创建副本并标记]
4.4 构建只读根文件系统+可写upperdir的轻量容器镜像层,验证overlayfs一致性契约
OverlayFS 通过 lowerdir(只读)、upperdir(可写)与 workdir(元数据暂存)三者协同实现分层写时复制。其一致性契约核心在于:所有对挂载点的写操作仅影响 upperdir,lowerdir 内容不可变且原子可见。
数据同步机制
挂载命令示例:
mkdir -p /mnt/overlay/{upper,work,merged} \
/mnt/lower # 只读基础镜像解压目录
mount -t overlay overlay \
-o lowerdir=/mnt/lower,upperdir=/mnt/overlay/upper,workdir=/mnt/overlay/work \
/mnt/overlay/merged
lowerdir: 支持多路径(:分隔),按从左到右优先级查找,必须存在且只读;upperdir与workdir必须位于同一文件系统,且workdir不可被外部修改;merged目录呈现统一视图,覆盖逻辑由内核 overlayfs 驱动实时解析。
关键验证项对比
| 验证维度 | 预期行为 | 实测手段 |
|---|---|---|
| 文件创建 | 出现在 upperdir,lowerdir 无变更 |
ls /mnt/overlay/upper |
| 同名文件覆盖 | lowerdir 文件被“隐藏”,非删除 |
stat 检查 inode 是否分离 |
graph TD
A[应用写入 /merged/foo] --> B{overlayfs 驱动拦截}
B --> C[检查 lowerdir 是否存在 foo]
C -->|存在| D[复制到 upperdir/foo 并标记 whiteout]
C -->|不存在| E[直接创建 upperdir/foo]
D & E --> F[返回成功,用户无感知]
第五章:契约之上:为什么理解这四者比速成Go语法更重要?
在某大型电商订单履约系统重构中,团队曾用两周时间快速上手Go语法,写出“看起来很Go”的HTTP服务——结构体嵌套、defer清理、goroutine并发处理一应俱全。上线后第三天,支付回调接口出现偶发性500错误,日志显示panic: send on closed channel。排查耗时38小时,最终定位到:一个被多个协程共享的chan error在主流程close()后,仍有异步任务试图写入。问题根源并非不会写go func(){...}(),而是未理解Go语言契约的四个核心支柱——它们不写在语法手册里,却决定代码是否能在生产环境存活。
接口即契约:隐式实现的威力与陷阱
Go接口不声明实现,但正因如此,契约责任完全落在开发者肩上。某物流状态同步模块定义了StatusReporter接口:
type StatusReporter interface {
Report(ctx context.Context, orderID string, status string) error
}
第三方SDK实现了该接口,但其Report方法在超时后直接返回nil而非context.DeadlineExceeded错误。调用方依赖errors.Is(err, context.DeadlineExceeded)做重试判断,结果永远不触发重试——接口契约未约定错误类型语义,导致跨团队协作断裂。
并发模型:goroutine不是线程,channel不是队列
一个实时风控服务使用select监听多个channel,但未设置default分支。当所有channel阻塞时,整个goroutine永久挂起,引发连接池耗尽。运维监控显示CPU空闲而QPS归零。根本原因在于忽略Go并发契约:goroutine生命周期由逻辑控制,而非资源自动回收。正确做法是结合context.WithTimeout与非阻塞channel操作:
select {
case result := <-ch:
handle(result)
case <-time.After(500 * time.Millisecond):
log.Warn("timeout waiting for result")
case <-ctx.Done():
return ctx.Err()
}
错误处理:error是值,不是异常
某库存扣减服务将数据库sql.ErrNoRows直接透传给前端,导致客户端解析JSON失败。团队最初用if err != nil { return err }链式传递,却未对error进行语义分类。引入自定义错误类型后,契约明确:
inventory.ErrInsufficientStock→ 返回HTTP 409inventory.ErrDBConnection→ 返回HTTP 503并触发熔断
错误不再只是“发生了什么”,而是“系统承诺如何响应”。
内存管理:逃逸分析决定性能命脉
压测发现GC频繁暂停(STW达120ms)。pprof显示[]byte大量堆分配。代码中func marshal(data interface{}) []byte直接返回局部slice,触发逃逸。修正方案:
- 使用
sync.Pool复用buffer - 将大对象指针传入函数而非复制值
- 通过
go build -gcflags="-m"验证逃逸行为
| 原始代码 | 逃逸分析输出 | 修正策略 |
|---|---|---|
buf := make([]byte, 1024) |
./main.go:12:12: make([]byte, 1024) escapes to heap |
buf := bufferPool.Get().(*bytes.Buffer) |
工具链即契约延伸:go vet与staticcheck不是可选项
CI流水线曾禁用go vet,认为“编译通过即可”。直到一次发布后,fmt.Printf("%s", nil)导致服务崩溃——%s格式化nil指针未被检测。启用go vet -all后,立即捕获37处潜在问题:printf参数不匹配、range变量重用、defer闭包变量捕获等。这些检查本质是编译器对契约履行的强制审计。
某金融系统将sync.Mutex字段嵌入结构体时未加注释,导致并发安全审查失败。静态检查工具报告:field Mutex has unexported fields and is not safe for concurrent access。补上//nolint:structcheck需附带架构设计文档链接,否则PR被拒绝——契约在此处升维为组织级工程纪律。
Kubernetes的client-go库要求所有ListWatch操作必须携带ResourceVersion参数,否则API Server拒绝响应。这个看似简单的字符串字段,实则是分布式一致性的契约锚点:它承诺客户端接受“事件可能乱序,但绝不会丢失”。忽略它,就会在集群扩缩容时出现状态漂移。
一个支付网关的http.Client配置中,Timeout设为30秒,但Transport.IdleConnTimeout保持默认0值。高峰时段出现数千个TIME_WAIT连接,最终触发文件描述符耗尽。问题本质是HTTP客户端契约的完整性缺失:超时控制必须覆盖连接建立、读写、空闲三个维度。
graph LR
A[HTTP Client] --> B[Connect Timeout]
A --> C[Response Header Timeout]
A --> D[Body Read Timeout]
A --> E[Idle Connection Timeout]
B & C & D & E --> F[可靠服务契约] 