第一章:Go怎么发HTTP请求:基础语法与核心结构
Go 语言标准库 net/http 提供了简洁、高效且线程安全的 HTTP 客户端能力,无需引入第三方依赖即可完成绝大多数 HTTP 请求场景。其核心结构围绕 http.Client、http.Request 和 http.Response 三个类型展开,三者共同构成请求生命周期的完整抽象。
创建 HTTP 客户端实例
默认客户端 http.DefaultClient 已预配置合理超时与连接复用策略,适用于多数场景;如需自定义行为(例如设置超时、代理或 TLS 配置),应显式构造 http.Client:
client := &http.Client{
Timeout: 10 * time.Second, // 整个请求生命周期上限
}
构造并发送 GET 请求
最简 GET 请求可直接调用 http.Get(),但该方式无法设置请求头或控制重定向行为。推荐使用 http.NewRequest() 显式构建请求对象:
req, err := http.NewRequest("GET", "https://httpbin.org/get", nil)
if err != nil {
log.Fatal(err) // 处理请求构造失败
}
req.Header.Set("User-Agent", "Go-Client/1.0") // 添加自定义请求头
resp, err := client.Do(req) // 执行请求
if err != nil {
log.Fatal(err) // 处理网络错误或超时
}
defer resp.Body.Close() // 必须关闭响应体以释放连接
响应处理关键要点
| 步骤 | 说明 |
|---|---|
| 检查状态码 | resp.StatusCode 应校验是否为 2xx 范围,避免静默忽略服务端错误 |
| 读取响应体 | 使用 io.ReadAll(resp.Body) 获取全部内容;大响应建议流式处理 |
| 错误处理优先级 | 先检查 err(网络层失败),再检查 resp.StatusCode(应用层语义错误) |
发起 POST 请求时,需将数据编码为字节切片并指定 Content-Type,例如 JSON 数据:
data := map[string]string{"name": "Alice"}
jsonBytes, _ := json.Marshal(data)
req, _ := http.NewRequest("POST", "https://httpbin.org/post", bytes.NewBuffer(jsonBytes))
req.Header.Set("Content-Type", "application/json")
第二章:HTTP客户端超时控制的深度解析与实践
2.1 连接超时(DialTimeout)的底层原理与调优策略
DialTimeout 并非网络协议层超时,而是客户端在发起 TCP 三次握手前,对 DNS 解析 + 建立 socket + 完成 SYN-SYN/ACK-ACK 的总耗时约束。
核心机制
Go net.Dialer 中,DialTimeout 实际等价于:
dialer := &net.Dialer{
Timeout: 5 * time.Second, // ⚠️ 注意:此字段即 DialTimeout 的底层载体
KeepAlive: 30 * time.Second,
}
Timeout 字段被用于 dialContext 中控制 resolveAddrList(DNS)和 dialSingle(TCP 连接)的组合上下文截止时间。
调优关键点
- DNS 解析慢?启用
net.Resolver自定义缓存或并行解析 - 高延迟网络?
Timeout建议 ≥RTT_p99 × 3 + DNS_p99 - 突发连接风暴?配合
context.WithTimeout实现请求级熔断
| 场景 | 推荐值 | 风险 |
|---|---|---|
| 内网服务(同 AZ) | 300–800ms | 过短易误判健康节点 |
| 公网 API | 2–5s | 过长拖累整体响应 P99 |
| IoT 设备直连 | 8–15s | 需容忍弱网重传与 NAT 老化 |
graph TD
A[New Dialer] --> B{Resolve DNS?}
B -->|Yes| C[Start DNS Timer]
B -->|No| D[Start TCP Timer]
C --> E[Initiate TCP Handshake]
D --> E
E --> F{Handshake Done?}
F -->|Yes| G[Return Conn]
F -->|No & Timeout| H[Cancel & Return Error]
2.2 响应体读取超时(ResponseHeaderTimeout)的典型误用场景与修复方案
常见误用:混淆 Header 与 Body 超时语义
ResponseHeaderTimeout 仅控制从连接建立到收到响应首行及全部 header 的最大等待时间,不约束后续 response.Body.Read()。开发者常误将其设为“整体请求超时”,导致长响应体卡死。
典型错误配置示例
client := &http.Client{
Transport: &http.Transport{
ResponseHeaderTimeout: 5 * time.Second, // ❌ 误以为能限制整个响应读取
},
}
逻辑分析:该设置仅确保服务器在 5 秒内返回
HTTP/1.1 200 OK及所有 header;若 body 流式生成耗时 60 秒(如大文件导出),Read()仍会无限阻塞。参数ResponseHeaderTimeout与Timeout(全周期)、IdleConnTimeout(复用连接空闲期)职责严格分离。
正确修复路径
- ✅ 对
Body.Read()单独封装带超时的io.LimitReader或使用context.WithTimeout包裹http.NewRequestWithContext - ✅ 优先启用
Timeout(Go 1.3+)统一管控连接、header、body 全链路
| 超时类型 | 控制阶段 | 是否影响 Body 读取 |
|---|---|---|
Timeout |
连接 + header + body 全周期 | ✅ |
ResponseHeaderTimeout |
仅限 header 接收完成前 | ❌ |
ReadTimeout (net.Conn) |
底层 TCP 读操作(需自定义 DialContext) | ✅(需手动注入) |
2.3 上下文超时(Context.WithTimeout)在长链路请求中的精准控制实践
在微服务长链路调用中,单个环节超时可能引发雪崩。Context.WithTimeout 提供毫秒级精度的截止控制,避免下游阻塞拖垮整条链路。
超时传递的关键实践
- 超时值需逐跳递减(预留网络与序列化开销)
- 永不忽略
ctx.Err()检查 - 优先使用
WithTimeout而非WithDeadline(语义更清晰)
ctx, cancel := context.WithTimeout(parentCtx, 800*time.Millisecond)
defer cancel() // 必须显式调用,防止 goroutine 泄漏
resp, err := httpClient.Do(req.WithContext(ctx))
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Warn("upstream timeout, fallback triggered")
}
}
逻辑说明:
800ms是该跳最大允许耗时;cancel()防止上下文泄漏;errors.Is安全判断超时类型(兼容 Go 1.13+ 错误链)。
典型超时分层建议(单位:ms)
| 环节 | 推荐超时 | 说明 |
|---|---|---|
| RPC 调用 | 300 | 含序列化与网络往返 |
| 数据库查询 | 200 | 预留连接池等待时间 |
| 缓存访问 | 50 | 内存操作基准 |
graph TD
A[Client Request] --> B{WithTimeout 2s}
B --> C[Auth Service 300ms]
C --> D[Order Service 800ms]
D --> E[Payment Service 500ms]
E --> F[Response]
C -.->|timeout 300ms| G[Return 408]
2.4 Transport级超时组合配置:KeepAlive、IdleConnTimeout与TLSHandshakeTimeout协同机制
HTTP/2 与长连接场景下,三类超时参数形成时间依赖链:TLSHandshakeTimeout 必须 ≤ IdleConnTimeout,而 IdleConnTimeout 又需 ≥ KeepAlive 以保障复用可行性。
超时参数语义边界
TLSHandshakeTimeout:控制新建 TLS 连接握手最大耗时(不含 TCP 建连)IdleConnTimeout:空闲连接保活上限,到期后主动关闭底层 net.ConnKeepAlive:TCP 层心跳间隔,仅在连接空闲且未关闭时触发探测包
典型安全配置示例
tr := &http.Transport{
TLSHandshakeTimeout: 10 * time.Second, // 防止 TLS 协商阻塞
IdleConnTimeout: 30 * time.Second, // 给 TLS 复用留出缓冲
KeepAlive: 15 * time.Second, // 小于 IdleConnTimeout,确保探测有效
}
逻辑分析:若 KeepAlive=20s 但 IdleConnTimeout=15s,TCP 探测尚未发出连接已被回收;若 TLSHandshakeTimeout=35s > IdleConnTimeout,新连接可能在握手完成前被误杀。
| 参数 | 推荐范围 | 违规风险 |
|---|---|---|
| TLSHandshakeTimeout | 5–15s | 过长导致 handshake 阻塞队列 |
| IdleConnTimeout | ≥2×KeepAlive | 过短使健康连接频繁重建 |
| KeepAlive | 10–30s | 过长延迟发现网络中断 |
graph TD
A[发起请求] --> B{连接池查可用 conn?}
B -- 是 --> C[校验 conn 是否 idle < IdleConnTimeout]
B -- 否 --> D[新建 TLS 连接]
D --> E[启动 TLSHandshakeTimeout 计时器]
C --> F[发送请求]
F --> G[KeepAlive 定期探测链路健康]
2.5 超时调试技巧:利用httptrace与自定义RoundTripper观测各阶段耗时
HTTP 请求的超时问题常源于某一段(如 DNS 解析、TLS 握手或连接建立)异常延迟,而非整体 Timeout 设置不合理。
使用 httptrace 观测全链路耗时
Go 标准库 httptrace 可细粒度捕获各阶段时间戳:
trace := &httptrace.ClientTrace{
DNSStart: func(info httptrace.DNSStartInfo) {
log.Printf("DNS lookup started for %s", info.Host)
},
TLSHandshakeStart: func() { log.Println("TLS handshake started") },
GotConn: func(info httptrace.GotConnInfo) {
log.Printf("Got connection: reused=%t, wasIdle=%t",
info.Reused, info.WasIdle)
},
}
req, _ := http.NewRequest("GET", "https://api.example.com", nil)
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
此代码通过
ClientTrace钩子注入上下文,在 DNS 查询、TLS 启动、连接获取等关键节点打点。GotConnInfo中WasIdle可判断复用连接是否来自空闲池,辅助识别连接复用失效场景。
自定义 RoundTripper 实现毫秒级阶段计时
| 阶段 | 可观测指标 |
|---|---|
| DialContext | 建连总耗时(含 DNS + TCP) |
| TLSHandshake | 加密协商延迟 |
| RoundTrip | 全流程(含读响应体) |
graph TD
A[http.NewRequest] --> B[WithClientTrace]
B --> C[Transport.RoundTrip]
C --> D{DNS / Dial / TLS / Write / Read}
D --> E[聚合耗时分析]
第三章:错误分类建模与重试决策逻辑设计
3.1 可重试错误识别:网络层、TLS层、HTTP状态码三级错误分类体系
构建鲁棒的客户端重试逻辑,需分层甄别错误根源。底层网络抖动(如 ECONNREFUSED、ETIMEDOUT)属瞬态故障,天然适合重试;TLS握手失败(如 SSL_ERROR_SSL、CERT_HAS_EXPIRED)需区分证书过期(不可重试)与临时握手超时(可重试);HTTP层则依据语义判断:408 Request Timeout、429 Too Many Requests、5xx 系列(除 501 Not Implemented 等语义确定错误外)通常可重试。
三级错误判定伪代码
def is_retryable(error):
# 网络层:系统级连接/超时错误
if error in (errno.ECONNRESET, errno.ETIMEDOUT, errno.EHOSTUNREACH):
return True
# TLS层:仅重试临时性握手失败,排除证书硬错误
if isinstance(error, ssl.SSLError) and "handshake" in str(error).lower():
return "certificate" not in str(error).lower() # 排除 cert verify failure
# HTTP层:按RFC语义过滤
if isinstance(error, HTTPError):
return error.code in (408, 429) or 500 <= error.code < 600
return False
该函数按网络→TLS→HTTP自底向上逐层解析错误本质,避免将证书过期误判为可重试,也防止对 401 Unauthorized 等需认证刷新的错误盲目重试。
可重试HTTP状态码参考表
| 状态码 | 类别 | 是否可重试 | 原因说明 |
|---|---|---|---|
| 408 | Client | ✅ | 请求超时,服务端未处理 |
| 429 | Client | ✅ | 限流响应,退避后可重试 |
| 502 | Server | ✅ | 网关错误,上游临时异常 |
| 503 | Server | ✅ | 服务不可用,常含 Retry-After |
| 501 | Server | ❌ | 语义不支持,重试无意义 |
错误分类决策流程
graph TD
A[原始错误] --> B{是否系统调用错误?}
B -->|是| C[查errno:ECONNREFUSED/ETIMEDOUT等 → ✅]
B -->|否| D{是否SSL/TLS错误?}
D -->|是| E[检查是否握手超时而非证书失效 → ✅/❌]
D -->|否| F{是否HTTPError?}
F -->|是| G[查状态码表 → ✅/❌]
F -->|否| H[默认不可重试]
3.2 幂等性判定与重试边界:GET/HEAD vs POST/PUT/PATCH 的语义约束实践
HTTP 方法的幂等性不是实现特性,而是协议语义契约:GET、HEAD、PUT、DELETE 被定义为幂等,而 POST 和 PATCH 默认非幂等——但可设计为幂等,前提是客户端携带唯一性标识。
幂等性语义对照表
| 方法 | RFC 定义幂等 | 典型重试安全场景 | 风险操作示例 |
|---|---|---|---|
| GET | ✅ | 页面刷新、浏览器前进/后退 | 无副作用 |
| PUT | ✅ | 全量资源覆盖(idempotent key) | 用同一 X-Idempotency-Key: abc123 多次提交 |
| PATCH | ⚠️(条件) | 带 If-Match + ETag |
无条件增量更新(如 {"count": "+1"})❌ |
客户端幂等键注入示例
PUT /api/orders/789 HTTP/1.1
Content-Type: application/json
X-Idempotency-Key: idemp-20240521-8a3f
此头由客户端生成(如 UUIDv4 或业务 ID + 时间戳哈希),服务端据此查重并原子化落库。缺失该头时,
PUT仍幂等,但无法跨请求去重;而POST缺失则必然拒绝重试。
重试决策流程
graph TD
A[请求发起] --> B{方法类型?}
B -->|GET/HEAD| C[无条件重试]
B -->|PUT/DELETE| D[检查Idempotency-Key或ETag]
B -->|POST/PATCH| E[仅当含X-Idempotency-Key且服务端支持时重试]
3.3 退避策略实现:指数退避(Exponential Backoff)与抖动(Jitter)的Go原生封装
在分布式系统中,重试失败请求时若采用固定间隔,易引发“重试风暴”。指数退避通过逐次延长等待时间缓解冲突,而抖动则引入随机性避免同步重试。
核心设计原则
- 初始延迟
base(如 100ms) - 最大重试次数
maxRetries - 退避因子
factor(通常为 2) - 抖动范围:
[0, 1)均匀随机乘数
Go 实现示例
func ExponentialBackoffWithJitter(attempt int, base time.Duration, factor float64) time.Duration {
// 计算基础指数延迟:base * factor^attempt
delay := time.Duration(float64(base) * math.Pow(factor, float64(attempt)))
// 添加 [0, delay) 区间抖动
jitter := time.Duration(rand.Float64() * float64(delay))
return delay + jitter
}
逻辑分析:
attempt从 0 开始计数;math.Pow生成指数增长基值;rand.Float64()提供无偏随机性,防止集群级重试共振。需在调用前rand.Seed(time.Now().UnixNano())或使用rand.New(rand.NewSource(...))。
| 参数 | 类型 | 说明 |
|---|---|---|
attempt |
int |
当前重试序号(0起始) |
base |
time.Duration |
初始延迟,建议 50–200ms |
factor |
float64 |
增长倍率,典型值 2.0 |
graph TD
A[请求失败] --> B{attempt < maxRetries?}
B -->|是| C[计算带抖动延迟]
C --> D[time.Sleep delay]
D --> E[重试请求]
E --> A
B -->|否| F[返回错误]
第四章:生产级HTTP客户端构建实战
4.1 基于http.Client定制化重试中间件:拦截、重试、熔断一体化设计
核心设计思想
将 http.RoundTripper 封装为可组合的中间件链,统一处理请求拦截、指数退避重试与熔断状态判定。
熔断器状态表
| 状态 | 触发条件 | 行为 |
|---|---|---|
| Closed | 连续成功请求数 ≥ 5 | 正常转发 |
| Open | 错误率 > 60% 且持续 30s | 直接返回 ErrCircuitOpen |
| HalfOpen | Open 状态超时后首个试探请求 | 允许一次请求探活 |
type RetryRoundTripper struct {
rt http.RoundTripper
policy *RetryPolicy
}
func (r *RetryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
var lastErr error
for i := 0; i <= r.policy.MaxRetries; i++ {
if !r.circuit.Allow() { // 熔断检查
return nil, ErrCircuitOpen
}
resp, err := r.rt.RoundTrip(req.Clone(req.Context()))
if err == nil && isHealthy(resp.StatusCode) {
return resp, nil
}
lastErr = err
time.Sleep(r.policy.Backoff(i)) // 指数退避:100ms, 200ms, 400ms...
}
return nil, lastErr
}
逻辑分析:每次重试前调用 circuit.Allow() 检查熔断状态;Backoff(i) 返回第 i 次重试的等待时长,避免雪崩。req.Clone() 保证上下文与 body 可重放。
数据同步机制
- 重试间共享
context.WithTimeout - 熔断器状态原子更新(
sync/atomic) - 错误统计通过滑动时间窗口聚合
4.2 结合OpenTelemetry实现请求链路追踪与重试行为可观测性
在分布式系统中,重试逻辑常掩盖真实错误根因。OpenTelemetry 可通过语义约定显式标记重试事件,使链路追踪具备行为上下文。
重试Span的标准化标注
使用 otelhttp.WithClientTrace 拦截 HTTP 客户端调用,并为每次重试注入唯一属性:
span.SetAttributes(
semconv.HTTPRequestResendCount.Key().Int(3), // 当前重试次数(含首次)
attribute.String("retry.attempt_id", uuid.New().String()),
attribute.Bool("retry.is_final", false),
)
逻辑说明:
HTTPRequestResendCount遵循 OpenTelemetry 语义约定(v1.22+),确保后端(如Jaeger、Tempo)能自动识别重试序列;is_final=false标识非终态调用,便于聚合分析失败模式。
重试状态流转示意
graph TD
A[初始请求] -->|失败| B[第一次重试]
B -->|失败| C[第二次重试]
C -->|成功| D[返回响应]
C -->|仍失败| E[抛出RetryExhaustedError]
关键观测维度对比
| 维度 | 普通Span | 重试增强Span |
|---|---|---|
| 错误归因 | 仅标记error=true | 关联原始SpanID + retry_index |
| 延迟分布 | 单点延迟 | 分层统计:attempt_1/2/3延迟 |
| 失败率计算 | 请求级 | 按重试轮次分桶统计 |
4.3 并发安全的Client复用与连接池调优:MaxIdleConns、MaxIdleConnsPerHost实战调参
HTTP Client 复用是高并发场景下避免资源耗尽的关键。默认 http.DefaultClient 的 Transport 未限制空闲连接,易导致 TIME_WAIT 爆增或文件描述符耗尽。
连接池核心参数语义
MaxIdleConns:整个 Client 允许保持的最大空闲连接总数MaxIdleConnsPerHost:每个 Host(如 api.example.com)最多缓存的空闲连接数IdleConnTimeout:空闲连接保活时长(推荐 30–90s)
推荐调参对照表
| 场景 | MaxIdleConns | MaxIdleConnsPerHost | IdleConnTimeout |
|---|---|---|---|
| 内部微服务(QPS | 100 | 50 | 60s |
| 外部API聚合(QPS>2k) | 500 | 100 | 30s |
client := &http.Client{
Transport: &http.Transport{
MaxIdleConns: 500,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 30 * time.Second,
// 启用 keep-alive(默认 true,显式强调)
ForceAttemptHTTP2: true,
},
}
此配置确保单 Client 可复用最多 500 条空闲连接,且对同一目标域名(如 api.pay.com)最多缓存 100 条,避免 DNS 轮询或多实例下连接倾斜;30s 超时兼顾复用率与及时释放异常连接。
连接复用生效路径
graph TD
A[发起 HTTP 请求] --> B{连接池中存在可用空闲连接?}
B -->|是| C[复用连接,跳过 TCP 握手]
B -->|否| D[新建 TCP 连接 + TLS 握手]
D --> E[请求完成后归还至对应 Host 池]
E --> F[超时或满额则关闭最久空闲连接]
4.4 单元测试与混沌工程验证:使用testify/mock和toxiproxy模拟超时与网络分区
在微服务架构中,仅靠单元测试难以暴露分布式系统脆弱性。需结合契约验证与故障注入双轨并行。
模拟依赖超时(testify/mock + toxiproxy)
// 创建带延迟毒性的HTTP客户端
proxy := toxiproxy.NewClient("http://localhost:8474")
proxy.Add("user-service", "127.0.0.1:8081")
proxy.Toxic("user-service", "latency", "upstream", map[string]string{
"latency": "3000", // 毫秒级延迟
"jitter": "500", // 随机抖动
})
latency强制上游响应延时3s,触发调用方超时逻辑;jitter引入不确定性,更贴近真实网络抖动。
故障模式组合对照表
| 混沌类型 | 工具 | 关键参数 | 触发现象 |
|---|---|---|---|
| 网络分区 | toxiproxy | downstream |
连接拒绝/EOF |
| 服务熔断 | testify/mock | mock.On().Return() |
返回预设错误码 |
验证流程
graph TD A[启动toxiproxy代理] –> B[注入延迟/中断毒药] B –> C[运行testify测试套件] C –> D[断言超时处理逻辑是否生效]
第五章:总结与最佳实践清单
核心原则落地验证
在为某金融客户实施微服务可观测性体系时,我们发现单纯堆砌 Prometheus + Grafana 并不能解决真实问题。真正起效的是将“黄金指标(HTTP 错误率、延迟 P95、请求量 QPS)”嵌入每个服务的健康检查端点,并通过 Kubernetes livenessProbe 主动驱逐异常实例。该实践使线上 P0 级故障平均恢复时间从 18 分钟压缩至 2.3 分钟。
配置即代码强制执行
所有基础设施配置(Terraform 模块、ArgoCD 应用清单、Helm values.yaml)必须通过 GitOps 流水线部署,禁止手工 kubectl apply。以下为某生产集群中强制校验的 CI 检查规则:
# .gitleaks.toml 片段:防止密钥泄露
[[rules]]
description = "AWS Access Key"
regex = "(?i)(aws|amazon|amzn).*['\"][0-9a-zA-Z\/+]{40}['\"]"
tags = ["key", "aws"]
日志治理三阶过滤
| 日志不是越多越好,而是分层治理: | 层级 | 过滤动作 | 存储周期 | 示例场景 |
|---|---|---|---|---|
| L1(接入层) | 剥离 trace_id、删除敏感字段(如身份证号正则替换) | 7 天 | Nginx access log 实时脱敏 | |
| L2(处理层) | 按 error/warn/info 分流至不同 ES 索引 | error: 90d, info: 7d | Spring Boot logback 配置多目的地 | |
| L3(归档层) | 压缩为 Parquet 格式存入 S3,供 Spark 离线分析 | 365 天 | 使用 Fluentd + S3 Output 插件 |
安全左移实战清单
- 所有 Dockerfile 必须声明
USER 1001,禁止 root 运行; - GitHub Actions 中启用
truffleHog扫描 PR 提交,命中即阻断合并; - Terraform 代码通过
checkov执行 CIS AWS Benchmark 检查,关键项(如 S3 公共读权限)设为--framework terraform_plan --check CKV_AWS_18;
性能压测基线管理
某电商大促前,团队建立三类压测基线并固化到 Jenkins Pipeline:
- 容量基线:单 Pod 在 4C8G 下支撑 1200 RPS(JMeter 脚本版本 v3.4.2);
- 熔断基线:Resilience4j 的 fallback 触发阈值设为 5s 响应超时且错误率 > 30%;
- 扩容基线:HPA 触发条件为 CPU > 65% 持续 3 分钟,扩容后需在 90 秒内完成就绪探针通过;
flowchart LR
A[CI 流水线触发] --> B{Terraform Plan}
B --> C[Checkov 扫描]
C --> D{合规?}
D -->|否| E[阻断并推送 Slack 告警]
D -->|是| F[自动 Apply + 发送钉钉变更通知]
F --> G[Post-deploy:运行 smoke-test.py]
团队协作契约
SRE 与开发团队签署《可观测性 SLA 协议》:开发方需在每个新服务上线前提供标准 OpenTelemetry SDK 集成文档(含 trace context 透传示例),SRE 方承诺在 4 小时内完成 Grafana Dashboard 模板注入及告警规则部署。上季度协议履约率达 98.7%,未履约项全部为文档缺失导致。
成本优化硬约束
- AWS EC2 实例类型必须匹配 workload profile:批处理任务强制使用 c6i.xlarge(非通用型 m6i);
- CloudWatch Logs 按月统计,对日均写入量 > 5GB 的日志组自动触发 Lambda 调整 retentionDays 为 14;
- 每周五凌晨 2 点执行
aws ec2 describe-instances --filters "Name=tag:AutoStop,Values=true"并关停非生产环境实例;
