第一章:Go HTTP服务崩溃复盘:1次panic引发的5层链式故障,如何用go tool trace 10分钟定位?
凌晨两点,线上订单服务突然大规模超时,监控显示 CPU 瞬间飙至 98%,随后进程 panic 退出。日志仅留下一行模糊线索:runtime error: invalid memory address or nil pointer dereference。看似简单的空指针,却触发了五层连锁反应:HTTP handler → JSON marshaler → custom time formatter → timezone cache → goroutine local storage 初始化失败。
问题根源藏在一处被忽略的 time.Time 序列化逻辑中:当传入零值 time.Time{} 时,自定义 MarshalJSON() 方法未做零值校验,直接调用 t.Location().String(),而零值 Time 的 Location() 返回 nil。
快速复现并采集 trace 数据:
# 编译时启用运行时追踪(需 Go 1.20+)
go build -gcflags="-l" -o server ./main.go
# 启动服务并注入 trace(生产环境建议通过 pprof endpoint 动态启用)
GOTRACEBACK=crash ./server &
# 模拟请求触发 panic(注意:实际生产中应先用 go tool pprof -http=:8080 http://localhost:6060/debug/pprof/trace?seconds=30)
curl -X POST http://localhost:8080/api/order
# 生成 trace 文件
go tool trace -http=localhost:8080 trace.out
在 go tool trace Web 界面中,重点关注三个视图:
- Goroutine analysis:发现大量 goroutine 卡在
runtime.gopark,等待一个永不返回的锁; - Network blocking profile:揭示
http.Server.Serve被阻塞在readLoop中,因 panic 导致连接未及时关闭; - Scheduler latency:观察到 GC 停顿时间异常增长(>200ms),源于 panic 后大量 goroutine 泄漏堆积。
关键定位技巧:点击任意崩溃 goroutine → 查看其完整调用栈 → 右键「Find related goroutines」→ 发现所有崩溃 goroutine 共享同一 time.format 调用路径。最终在 trace 时间线中定位到 (*CustomTime).MarshalJSON 的第 3 行——正是 t.Location().String() 调用点。
修复方案只需两行防御性代码:
func (t CustomTime) MarshalJSON() ([]byte, error) {
if t.Time.IsZero() { // 零值保护:避免 Location() 返回 nil
return []byte(`"0001-01-01T00:00:00Z"`), nil
}
return t.Time.MarshalJSON()
}
该案例印证:一次未捕获的 panic 不仅终止当前请求,更会污染调度器状态、阻塞网络循环、拖垮 GC,并最终导致整个 HTTP server 失能。
第二章:HTTP服务崩溃的典型链式故障模型解析
2.1 panic传播机制与goroutine泄漏的耦合效应
当panic在goroutine中发生且未被recover捕获时,该goroutine会立即终止,但其持有的资源(如channel发送端、mutex锁、网络连接)若未显式释放,将引发泄漏。
panic触发后的goroutine生命周期
- 运行时调用
gopanic()清理栈帧 - defer链执行(仅当前goroutine内注册的defer)
- 若无recover,goroutine状态转为
_Gdead,但底层OS线程和内存可能滞留
典型耦合场景示例
func leakyHandler(ch chan int) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Println("recovered:", r)
}
}()
ch <- 42 // 若ch已关闭,此处panic
time.Sleep(time.Hour) // 永不执行,goroutine挂起
}()
}
此代码中:
ch <- 42在已关闭channel上触发panic → goroutine崩溃 →time.Sleep未执行 → 无显式资源清理逻辑 → 若ch是带缓冲的长生命周期channel,发送goroutine永久阻塞于sendq,造成goroutine泄漏。
耦合风险等级对照表
| 场景 | panic是否可recover | goroutine是否可回收 | 泄漏风险 |
|---|---|---|---|
| 无defer/recover的HTTP handler | 否 | 否(runtime强制终结) | 高 |
| defer中含阻塞I/O操作 | 是 | 否(defer卡住) | 中高 |
| recover后未关闭关联资源 | 是 | 是(但资源泄漏) | 中 |
graph TD
A[panic发生] --> B{是否有recover?}
B -->|否| C[goroutine强制终止]
B -->|是| D[执行defer链]
C --> E[OS线程/内存可能滞留]
D --> F[若defer含阻塞调用→goroutine卡死]
E & F --> G[goroutine泄漏]
2.2 net/http.ServeMux路由劫持与中间件panic透传实践
net/http.ServeMux 默认不捕获 handler panic,导致进程崩溃。需通过包装 ServeHTTP 实现路由劫持与 panic 捕获。
自定义劫持型 ServeMux
type PanicSafeMux struct {
*http.ServeMux
}
func (p *PanicSafeMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
log.Printf("PANIC in %s %s: %v", r.Method, r.URL.Path, err)
}
}()
p.ServeMux.ServeHTTP(w, r)
}
逻辑分析:包装原 ServeMux,在 ServeHTTP 入口添加 defer/recover;参数 w 和 r 直接透传,确保中间件链兼容性。
中间件 panic 透传关键点
- panic 必须在 handler 执行栈中发生(非 goroutine 内部)
http.Error需在 recover 后立即调用,避免响应头已写入- 日志应包含
r.Method和r.URL.Path用于定位问题路由
| 行为 | 默认 ServeMux | PanicSafeMux |
|---|---|---|
| panic 导致进程退出 | ✅ | ❌ |
| 返回 500 响应 | ❌ | ✅ |
| 保留原始路由匹配 | ✅ | ✅ |
graph TD
A[HTTP Request] --> B{PanicSafeMux.ServeHTTP}
B --> C[defer recover]
C --> D[原ServeMux.ServeHTTP]
D --> E[Handler执行]
E -->|panic| F[recover捕获→500+log]
E -->|正常| G[返回响应]
2.3 context.Context超时取消链在panic下的失效路径验证
当 goroutine 因 panic 而非 context.WithCancel 主动取消退出时,其关联的 context.Context 取消链将无法传播终止信号。
panic 中断取消传播的关键机制
defer函数在 panic 过程中仍执行,但context.cancelFunc若未显式调用则不会触发context.parent.Done()的 channel 关闭依赖显式 cancel,panic 不触发该逻辑
失效复现代码
func panicWithTimeout() {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel() // panic 前未执行!
go func() {
select {
case <-ctx.Done():
fmt.Println("cancelled")
}
}()
panic("boom") // defer cancel 被跳过
}
逻辑分析:
defer cancel()在 panic 后尚未执行即终止栈展开,导致ctx.Done()永不关闭;子 goroutine 持有对ctx的引用,但无取消源,形成“幽灵上下文”。
| 场景 | cancel 是否触发 | ctx.Done() 是否关闭 |
|---|---|---|
| 正常 return | ✅ | ✅ |
| panic + defer cancel | ❌(defer 未执行) | ❌ |
| panic + 手动 cancel | ✅ | ✅ |
graph TD
A[goroutine panic] --> B{defer 栈是否已开始执行?}
B -->|否| C[cancelFunc 跳过]
B -->|是| D[ctx.Done 关闭]
C --> E[子goroutine 永久阻塞]
2.4 sync.Once误用导致的初始化竞态与级联崩溃复现实验
数据同步机制
sync.Once 保证函数仅执行一次,但若其 Do 中触发未受保护的全局状态变更,将引发隐式竞态。
复现代码片段
var once sync.Once
var config *Config
func initConfig() {
once.Do(func() {
config = &Config{Port: loadPort()} // loadPort() 读取环境变量或文件
go startHealthCheck() // 启动异步健康检查
})
}
⚠️ 问题:startHealthCheck() 若依赖 config 但 loadPort() 阻塞或失败,config 为 nil,goroutine panic。
级联崩溃路径
graph TD
A[once.Do] --> B[loadPort]
B --> C{成功?}
C -->|否| D[config=nil]
C -->|是| E[config赋值]
D --> F[startHealthCheck panic]
E --> G[health check正常运行]
关键风险点
once.Do不捕获内部 panic,错误传播至调用栈顶层- 异步 goroutine 与初始化逻辑无错误隔离
| 风险类型 | 表现 | 触发条件 |
|---|---|---|
| 初始化竞态 | 多次调用 initConfig() 时 config 未完全构造即被读取 |
loadPort() 耗时波动 |
| 级联崩溃 | startHealthCheck panic 导致整个服务退出 |
config 为 nil 或字段未初始化 |
2.5 defer recover失效场景:嵌套goroutine与非主协程panic捕获盲区
主协程 vs 子goroutine的recover隔离性
Go中recover()仅对当前goroutine的panic有效。在子goroutine中触发panic,主goroutine的defer-recover无法捕获。
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("主协程捕获:", r) // ❌ 永不执行
}
}()
go func() {
panic("子goroutine panic") // ⚠️ 独立栈,主协程recover不可见
}()
time.Sleep(10 * time.Millisecond)
}
逻辑分析:
go func(){...}启动新协程,其panic作用域完全独立;主goroutine无panic发生,recover()返回nil。time.Sleep仅防止main退出,不建立错误传播通道。
常见失效模式对比
| 场景 | defer位置 | recover是否生效 | 原因 |
|---|---|---|---|
| 主goroutine panic + 同协程recover | ✅ 主协程内 | ✅ | 栈帧连续,作用域匹配 |
| 子goroutine panic + 主协程recover | ❌ 主协程内 | ❌ | goroutine间panic不传递 |
| 子goroutine内defer+recover | ✅ 子协程内 | ✅ | 需显式在子协程中部署 |
正确捕获模式
必须在panic发生的同一goroutine中设置defer-recover:
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("子协程捕获: %v\n", r) // ✅ 正确作用域
}
}()
panic("子协程panic")
}()
参数说明:
recover()无入参,返回interface{}类型panic值;必须在defer函数内直接调用,且仅在panic发生后、栈展开前有效。
第三章:go tool trace核心原理与关键视图解读
3.1 Goroutine生命周期轨迹与阻塞/抢占事件精准定位
Goroutine 的生命周期并非黑盒:从 newproc 创建、gopark 阻塞,到 gosched 主动让出或被 preemptM 抢占,每个关键节点均留下可观测痕迹。
追踪核心事件点
runtime.gopark():进入阻塞(如 channel receive 空等待、mutex 锁竞争)runtime.gosched():协作式让出 CPUruntime.preemptPark():由系统监控线程触发的抢占式暂停
典型阻塞场景分析
func blockedRead() {
ch := make(chan int, 0)
<-ch // 触发 gopark(GWAITING), 记录在 g._traceEvent 中
}
该调用最终进入 park_m,将当前 G 状态设为 _Gwaiting,并写入 g.trace 中的 traceEvGoBlockRecv 事件,含精确纳秒级时间戳与栈快照索引。
关键追踪字段对照表
| 字段 | 类型 | 含义 |
|---|---|---|
g.status |
uint32 | _Grunnable, _Grunning, _Gwaiting 等状态码 |
g.waitreason |
string | 如 "chan receive",标识阻塞语义 |
g.preempt |
bool | 是否已被标记抢占 |
graph TD
A[goroutine 创建] --> B[gopark 阻塞]
A --> C[主动 gosched]
B --> D[waitreason 记录]
C --> E[preemptStop 标记]
D & E --> F[pprof/goroutines trace 输出]
3.2 网络I/O系统调用栈与netpoller事件关联分析实战
Go 运行时通过 netpoller 将底层 epoll/kqueue 事件与 goroutine 调度深度耦合,形成非阻塞 I/O 的核心枢纽。
核心调用链路
net.Conn.Read()→fd.read()→runtime.netpollready()→findrunnable()→ 唤醒等待的 goroutine- 所有网络 fd 在
runtime.pollDesc中注册,绑定pd.waitq与pd.rg/wg
关键数据结构关联表
| 字段 | 类型 | 作用 |
|---|---|---|
pd.rg |
*g |
阻塞读的 goroutine 指针 |
pd.wg |
*g |
阻塞写的 goroutine 指针 |
pd.waitq |
waitq |
等待队列(链表) |
// runtime/netpoll.go 中关键逻辑节选
func netpoll(block bool) gList {
// 调用 epoll_wait 获取就绪 fd 列表
waitEvents := epollWait(epollfd, &events, -1) // -1 表示阻塞等待
for i := range waitEvents {
pd := (*pollDesc)(unsafe.Pointer(&events[i].data))
// 将 pd.rg 对应 goroutine 标记为可运行
list.push(pd.rg)
}
return list
}
epollWait 的 -1 参数使内核无限等待事件,而 pd.rg 是由 netpollblock() 在 Read() 阻塞时写入的当前 goroutine 地址,实现事件就绪后精准唤醒。
graph TD
A[net.Conn.Read] --> B[fd.read]
B --> C[runtime.pollDesc.waitRead]
C --> D[runtime.gopark]
D --> E[netpoller.epoll_wait]
E --> F[事件就绪]
F --> G[runtime.goready pd.rg]
G --> H[golang scheduler]
3.3 GC标记-清除阶段对HTTP长连接goroutine的意外中断复现
Go runtime 的 GC 标记-清除阶段会触发 STW(Stop-The-World) 与 并发标记中的写屏障暂停,当 HTTP 长连接 goroutine 正处于 read 系统调用阻塞态时,若恰好遭遇 GC 安全点检查,可能被强制抢占并迁移至其他 P,导致 netpoller 中的 fd 状态同步延迟。
关键复现条件
- Go 1.21+ 默认启用
GODEBUG=gctrace=1 - 使用
http.Server{IdleTimeout: 0}+Keep-Alive头维持长连接 - 持续高频分配小对象(触发频繁 GC)
典型堆栈片段
// 模拟长连接中被 GC 中断的读操作
func handleLongConn(w http.ResponseWriter, r *http.Request) {
conn, _, _ := w.(http.Hijacker).Hijack()
defer conn.Close()
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf) // ⚠️ 此处可能被 GC 抢占后延迟唤醒
if err != nil {
return
}
conn.Write(buf[:n])
}
}
该 conn.Read 调用底层 syscall.Read,进入内核态;但 Go runtime 在 GC 安全点仍需确保 goroutine 可达性,故在从系统调用返回时插入检查点——若此时 GC 正执行标记终止(mark termination),将延长调度延迟,造成连接假性超时。
GC 与网络协程交互流程
graph TD
A[HTTP goroutine 阻塞于 read] --> B[GC 启动标记阶段]
B --> C[触发安全点检查]
C --> D[goroutine 被标记为可抢占]
D --> E[从 syscall 返回时暂停并入全局队列]
E --> F[延迟重新调度 → 连接空闲超时]
| 因素 | 影响程度 | 触发阈值 |
|---|---|---|
| GC 频率 | 高 | 堆增长 > 25% |
| Goroutine 数量 | 中 | >5k 并发长连接 |
内核 net.core.somaxconn |
低 | 默认 128 不构成瓶颈 |
第四章:基于trace的10分钟故障定位标准化流程
4.1 trace文件采集:生产环境低开销采样策略与pprof联动配置
采样率动态调控机制
为平衡可观测性与性能损耗,采用基于 QPS 的自适应采样策略:
// 根据当前请求速率动态调整 trace 采样率(0.001 ~ 0.1)
func computeTraceRate(qps float64) float64 {
if qps < 100 { return 0.1 }
if qps < 1000 { return 0.01 }
return 0.001 // 高负载时仅千分之一采样
}
该函数将采样率从 0.1 逐步降至 0.001,确保单 trace 开销始终
pprof 与 trace 联动配置
启用 runtime/trace 同时导出 pprof 元数据:
| 配置项 | 值 | 说明 |
|---|---|---|
GODEBUG=traceheap=1 |
环境变量 | 触发堆分配 trace 关联 |
net/http/pprof |
/debug/pprof/trace?seconds=5 |
生成含 goroutine/block/trace 的复合 profile |
数据流转流程
graph TD
A[HTTP 请求] --> B{QPS 计算}
B --> C[动态采样决策]
C --> D[trace.StartRegion]
D --> E[pprof.Labels 注入 traceID]
E --> F[写入 /tmp/trace-*.trace]
F --> G[go tool trace 解析]
- 所有 trace 文件自动附加
pprof可识别的goroutine和timer标签 - 通过
go tool trace -http=:8080 trace-*.trace直接跳转至对应 pprof 页面
4.2 关键时间轴锚定:从HTTP响应超时到首个goroutine阻塞点追踪
在高并发 HTTP 服务中,超时并非终点,而是阻塞链路的起点。当 http.Server.ReadTimeout 触发后,底层连接被标记为“即将关闭”,但 goroutine 可能仍卡在 io.Copy 或 channel 发送等同步操作上。
阻塞点定位三步法
- 使用
runtime.Stack()捕获活跃 goroutine 栈帧 - 过滤含
select,chan send,semacquire的调用栈 - 关联
net/http.(*conn).serve的起始时间戳
典型阻塞代码片段
// 示例:未设缓冲区的 channel 写入(无接收者时永久阻塞)
ch := make(chan string) // 缓冲区大小=0
ch <- "payload" // 此处成为首个阻塞点
逻辑分析:
ch <-在无接收协程时触发gopark,参数ch地址可映射至runtime.hchan结构体;runtime.gopark调用中reason="chan send"是关键诊断标识。
| 阻塞类型 | 触发条件 | 检测信号 |
|---|---|---|
| Channel send | 无接收者且缓冲区满 | chan send in stack |
| Mutex lock | 锁已被持有且无 timeout | semacquire + sync.Mutex |
| Network read | TCP 窗口为0或 FIN 未 ACK | read syscall pending |
graph TD
A[HTTP ReadTimeout 触发] --> B[conn.closeNotify() 广播]
B --> C[goroutine 检测 closeNotify channel]
C --> D{是否已进入 select?}
D -->|是| E[阻塞在 default/case 分支]
D -->|否| F[卡在 io.Copy 或 write]
4.3 跨goroutine panic溯源:通过trace中的goSched/goStart标记反向推导panic源头
Go 运行时 trace 中的 goSched(主动让出)与 goStart(goroutine 启动)事件,构成 goroutine 生命周期的关键锚点。当 panic 发生在非主 goroutine 时,其堆栈常被截断,但 trace 文件保留了完整的调度时序。
核心溯源逻辑
- panic 时刻的
goid可从runtime.gopanic的 trace 事件中提取; - 沿该
goid向前回溯,定位最近一次goStart(即该 goroutine 的创建点); - 继续向上追踪其父 goroutine 的
goSched→goStart链,直至找到main.main或go语句源位置。
关键 trace 事件对照表
| 事件类型 | 参数含义 | 典型用途 |
|---|---|---|
goStart |
goid, pc |
定位 goroutine 创建的函数地址 |
goSched |
goid, pc |
标记调度点,辅助构建调用上下文链 |
// 示例:在 go 语句处插入 trace 标记便于关联
go func() {
trace.Log(ctx, "task-start", "upload") // 与 goStart pc 对齐
panic("network timeout") // 触发 panic
}()
此代码中
trace.Log的ctx来自runtime/trace,其内部绑定当前 goroutine ID;pc在goStart事件中自动捕获,与go语句的编译后地址一致,形成可追溯的因果链。
溯源流程图
graph TD
A[panic event: goid=17] --> B[find latest goStart for goid=17]
B --> C[read pc → symbolize to uploadHandler.go:42]
C --> D[find goSched of parent goid=1 → goStart of main]
D --> E[定位 go uploadHandler(...) 调用点]
4.4 链式故障验证:结合源码+trace+日志三维度交叉印证五层调用断裂点
数据同步机制
当订单服务调用库存服务超时,OpenTelemetry trace 显示 inventory-check span 状态为 STATUS_UNSET,但 error.type=DEADLINE_EXCEEDED。此时需定位是网络层丢包,还是下游服务线程池耗尽。
源码断点追踪
// InventoryClient.java 第87行(gRPC stub 调用)
public CompletableFuture<CheckResponse> check(StockRequest req) {
return blockingStub // ← 此处返回 CompletableFuture,但未设置 timeout
.withDeadlineAfter(3, TimeUnit.SECONDS) // ✅ 显式 deadline
.check(req); // ❌ 实际未触发 cancel,因上游未 propagate CancellationException
}
逻辑分析:withDeadlineAfter 设置了 gRPC 层超时,但上层 CompletableFuture 未注册 cancellation listener,导致超时后仍等待远端响应,阻塞线程池。
三维度印证表
| 维度 | 关键证据 | 断裂层级 |
|---|---|---|
| 日志 | WARN [inventory] Thread pool queue size=2048 |
L3(线程调度) |
| Trace | inventory-check duration=3012ms, status=DEADLINE_EXCEEDED |
L2(RPC传输) |
| 源码 | blockingStub.check() 无 fallback 与 cancel hook |
L1(调用封装) |
故障传播路径
graph TD
A[OrderService] -->|L1: CompletableFuture未cancel| B[InventoryClient]
B -->|L2: gRPC deadline触发| C[gRPC Netty Channel]
C -->|L3: 线程池满| D[InventoryWorker]
D -->|L4: DB连接池耗尽| E[MySQL]
E -->|L5: 主从延迟>60s| F[Slave]
第五章:从故障中重构高可用HTTP服务的工程范式
故障复盘:一次雪崩式超时的真实现场
2023年Q3,某电商促销期间核心订单API集群出现级联超时:上游Nginx返回504达17%,下游支付网关响应延迟从80ms飙升至4.2s。根因定位发现是单点Redis连接池耗尽(maxIdle=10)触发线程阻塞,而服务未配置熔断器,导致Tomcat工作线程全部挂起。日志中高频出现java.net.SocketTimeoutException: Read timed out与org.apache.http.conn.ConnectionPoolTimeoutException交叉堆栈。
架构重构的四层防御体系
- 接入层:在Kong网关部署动态限流插件,基于Prometheus指标实时计算QPS阈值(公式:
min(当前CPU利用率×500, 历史峰值×0.8)) - 应用层:将Hystrix替换为Resilience4j,配置
TimeLimiter.ofDefaults()+CircuitBreaker.ofDefaults()组合策略 - 数据层:Redis集群启用读写分离+本地缓存(Caffeine最大容量10k,expireAfterWrite=10m)
- 基础设施层:Kubernetes Pod启动探针增加
curl -f http://localhost:8080/health/db验证数据库连通性
关键配置代码片段
# resilience4j.yaml
resilience4j.circuitbreaker:
instances:
paymentService:
registerHealthIndicator: true
failureRateThreshold: 50
waitDurationInOpenState: 60s
permittedNumberOfCallsInHalfOpenState: 10
故障注入验证结果对比
| 测试场景 | 旧架构P99延迟 | 新架构P99延迟 | 请求成功率 |
|---|---|---|---|
| Redis全节点宕机 | 3200ms | 187ms | 99.98% |
| 网络抖动(500ms) | 1200ms | 210ms | 100% |
| CPU过载(95%) | 4500ms | 310ms | 99.2% |
监控告警闭环机制
采用OpenTelemetry自动注入HTTP客户端追踪,关键链路埋点包含http.status_code、http.route、redis.command三个维度标签。当circuitbreaker.state == OPEN持续3分钟,自动触发SOP:① Slack通知值班工程师 ② 调用Ansible Playbook扩容Redis副本 ③ 启动Chaos Mesh网络延迟实验验证降级逻辑。
持续交付流水线强化
GitLab CI新增三项强制门禁:
make test-resilience:运行JUnit5模拟断网测试(@Test @Tag("chaos"))helm lint --strict charts/http-service:校验Helm Chart中livenessProbe超时时间≤readinessProbe的1/3kubetest --dry-run --validate:静态检查Pod反亲和性规则是否覆盖所有AZ
生产环境灰度发布策略
采用Istio VirtualService实现流量分层:首阶段向新版本发送0.5%请求并捕获status=5xx率;当错误率kubectl patch deployment http-service -p '{"spec":{"replicas":3}}'扩缩容。2024年1月上线的JWT鉴权模块通过该流程零事故完成全量切换。
成本与可靠性的量化平衡
重构后每月云资源成本下降22%(通过HPA基于http_requests_total{code=~"5.."} > 5指标动态伸缩),同时SLA从99.90%提升至99.992%。关键证据是SLO Dashboard显示“每季度P99延迟>500ms的时段”从平均47分钟降至2.3分钟,且全部发生在跨AZ网络维护窗口期内。
文档即代码实践
所有运维脚本(如redis-failover.sh)均嵌入Bash单元测试,使用Bats框架验证:
@test "failover script exits with code 0 when primary is unreachable" {
stub curl "-s -o /dev/null --connect-timeout 1 http://primary:6379 && exit 1 || exit 0"
run ./redis-failover.sh
[ "$status" -eq 0 ]
} 