第一章:golang优雅升级
在生产环境中,Go 应用的平滑升级至关重要——既要避免服务中断,又要确保新旧版本逻辑无缝衔接。Go 本身不提供内置热更新机制,但可通过进程管理与信号协作实现优雅升级(Graceful Upgrade)。
信号驱动的进程替换
Go 程序需监听 SIGUSR2(Unix/Linux 常用约定)触发升级流程:
- 主进程收到
SIGUSR2后,启动新版本二进制文件,并通过 Unix domain socket 或文件描述符继承将监听的 TCP listener 传递给子进程; - 子进程完成初始化后,通知父进程可安全退出;
- 父进程等待活跃连接处理完毕(如
http.Server.Shutdown()),再终止自身。
实现关键步骤
- 编译新版本二进制:
go build -o myapp-new ./cmd/myapp - 向运行中的进程发送信号:
kill -USR2 $(pidof myapp) - 主程序中注册信号处理:
func handleUpgrade() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGUSR2)
go func() {
<-sigChan
// 启动新进程,传递 listener 文件描述符
newProc, err := syscall.StartProcess(
"./myapp-new",
[]string{"myapp-new"},
&syscall.SysProcAttr{
Files: []uintptr{int(listener.Fd())}, // 复用 listener fd
},
)
if err == nil {
log.Println("spawned new process:", newProc.Pid)
}
}()
}
升级状态保障要点
- 新进程启动前,必须验证二进制文件存在且可执行(
os.Stat+os.Executable()); - 使用
SO_REUSEPORT可选支持多实例共存,降低升级窗口期风险; - 推荐配合 systemd 的
Restart=on-failure与KillMode=mixed提升健壮性。
| 机制 | 作用 |
|---|---|
http.Server.Shutdown() |
安全关闭 HTTP 连接,阻塞至活跃请求完成 |
syscall.SYS_FCNTL |
锁定升级互斥,防并发多次触发 |
os.Getppid() |
子进程校验父进程是否仍存活 |
第二章:goroutine泄漏——升级失败的隐形杀手
2.1 goroutine生命周期管理原理与pprof诊断实践
Go 运行时通过 G-P-M 模型调度 goroutine:G(goroutine)在 P(processor,逻辑处理器)的本地运行队列中等待执行,由 M(OS 线程)绑定 P 执行。生命周期始于 go f() 创建 G,经就绪、运行、阻塞(如 channel wait、syscall)、唤醒,最终由 runtime GC 回收栈与 G 结构体。
goroutine 阻塞状态诊断示例
# 启动带 pprof 的 HTTP 服务
go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=2
该端点返回所有 goroutine 的栈迹快照(含阻塞点),debug=2 输出完整调用链。
常见阻塞类型与对应 pprof 标签
| 阻塞类型 | pprof 栈关键词 | 典型原因 |
|---|---|---|
| channel receive | chanrecv, selectgo |
无 sender 或缓冲满 |
| mutex lock | semacquire, runtime_lock |
未释放 sync.Mutex |
| network I/O | netpoll, epollwait |
DNS 解析超时或连接阻塞 |
goroutine 泄漏检测流程
graph TD
A[启动应用] --> B[定期采集 /debug/pprof/goroutine?debug=1]
B --> C[解析 goroutine 数量趋势]
C --> D{数量持续增长?}
D -->|是| E[对比栈迹差异定位泄漏源]
D -->|否| F[正常]
关键参数说明:debug=1 返回摘要统计(仅计数),debug=2 返回全栈,生产环境推荐 debug=1 避免性能开销。
2.2 HTTP Handler中隐式goroutine逃逸的典型模式与修复方案
常见逃逸模式:Handler内启动无管控goroutine
func unsafeHandler(w http.ResponseWriter, r *http.Request) {
go func() { // ⚠️ 隐式逃逸:goroutine持有r和w引用,但父请求可能已返回
time.Sleep(5 * time.Second)
log.Printf("Processing for %s", r.URL.Path) // r可能已被回收!
}()
w.WriteHeader(http.StatusOK) // 父goroutine立即返回,r/w生命周期结束
}
逻辑分析:r *http.Request 包含 *http.Request.Body(底层为 net.Conn),其内存由 http.Server 的主goroutine管理;该匿名goroutine未同步等待,导致读取 r.URL.Path 时可能访问已释放内存。参数 r 和 w 均为栈变量,但被闭包捕获后随goroutine逃逸至堆。
安全替代方案对比
| 方案 | 是否阻塞Handler | 资源可控性 | 适用场景 |
|---|---|---|---|
| 同步处理 | 是 | 高 | 快速操作( |
| context.WithTimeout + goroutine + select | 否 | 中(需显式cancel) | 异步任务带超时 |
| worker pool + channel | 否 | 高(限流+背压) | 高频后台任务 |
推荐修复:基于context的受控异步
func safeHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
done <- doAsyncWork(ctx, r.URL.Path) // 仅传递不可变副本或ctx-safe数据
}()
select {
case err := <-done:
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
case <-ctx.Done():
http.Error(w, "timeout", http.StatusRequestTimeout)
}
}
逻辑分析:r.URL.Path 是字符串(不可变值类型),安全传递;ctx 继承请求生命周期,cancel() 确保资源及时释放;channel通信避免共享内存竞争。
2.3 context.WithCancel/WithTimeout在升级场景下的正确传播实践
在服务平滑升级过程中,新旧实例共存,需确保请求上下文的生命周期与连接、处理阶段严格对齐。
升级时的典型风险
- 过早取消导致正在处理的请求被中断
- 超时未随升级阶段动态调整(如预热期应延长)
- context 未沿调用链显式传递,导致子 goroutine 无法响应取消信号
正确传播模式
func handleRequest(ctx context.Context, req *Request) error {
// 升级感知:从配置中心获取当前升级阶段超时策略
stageTimeout := getUpgradeStageTimeout(ctx)
childCtx, cancel := context.WithTimeout(ctx, stageTimeout)
defer cancel() // 确保资源及时释放
return processWithRetry(childCtx, req) // 所有下游调用均接收 childCtx
}
context.WithTimeout(ctx, stageTimeout) 将父上下文与动态超时绑定;defer cancel() 防止 goroutine 泄漏;所有子调用必须显式接收并透传该 childCtx。
上下文传播检查清单
- ✅ 每个 goroutine 启动时接收 context 参数
- ✅ HTTP handler 中通过
r.Context()获取并封装为带取消/超时的新 context - ❌ 禁止在函数内部创建无父 context 的
context.Background()
| 场景 | 推荐方式 | 风险 |
|---|---|---|
| 预热期请求 | WithTimeout(30s) |
过早超时中断健康检查 |
| 流量切换中 | WithCancel() + 监听信号 |
未监听 SIGUSR2 可能卡住 |
2.4 常见第三方库(如database/sql、grpc-go)引发泄漏的溯源与规避
database/sql 连接泄漏典型场景
未显式调用 rows.Close() 或 stmt.Close() 会导致连接长期占用,即使 sql.DB 启用连接池,仍可能耗尽 MaxOpenConns。
// ❌ 危险:defer rows.Close() 被 panic 中断时可能不执行
rows, err := db.Query("SELECT id FROM users WHERE active = ?")
if err != nil {
return err
}
defer rows.Close() // 若 rows.Next() 中 panic,此处不会执行
for rows.Next() {
var id int
if err := rows.Scan(&id); err != nil {
return err // 提前返回 → defer 未触发 → 连接泄漏
}
}
逻辑分析:rows.Close() 必须在所有退出路径上保证调用;推荐使用 rows.Close() 显式清理 + errors.Is(err, sql.ErrNoRows) 判断空结果。
grpc-go 流泄漏关键点
客户端未关闭 ClientStream 或服务端未读取完流数据,会阻塞 gRPC HTTP/2 流并占用内存与连接。
| 泄漏源 | 触发条件 | 规避方式 |
|---|---|---|
ClientStream |
Send() 后未调用 CloseSend() |
defer stream.CloseSend() |
ServerStream |
Recv() 返回 io.EOF 后未退出循环 |
检查 err == io.EOF 并 break |
内存泄漏根因归类
- ✅ 资源生命周期错配:Go 对象无析构函数,依赖显式释放
- ✅ 上下文未传播超时:
context.WithTimeout缺失导致流/查询无限等待 - ❌ 忽略
io.Closer接口契约是共性诱因
2.5 基于go:generate+静态分析工具(如errcheck、govulncheck)的泄漏预防流水线
go:generate 不仅用于代码生成,更是静态检查流水线的轻量触发枢纽。在 go.mod 同级目录下添加:
//go:generate go run golang.org/x/tools/cmd/goimports -w .
//go:generate errcheck -asserts -ignore 'Close|Flush' ./...
//go:generate govulncheck -format template -template '{{range .Results}}{{.Vulnerability.ID}}: {{.Module.Path}}{{end}}' ./...
逻辑说明:第一行格式化导入;第二行
errcheck忽略常见无害错误(-ignore支持正则),聚焦资源泄漏;第三行用自定义模板精简govulncheck输出,避免噪声。
典型检查覆盖项:
| 工具 | 检查目标 | 关键参数作用 |
|---|---|---|
errcheck |
未处理的 error 返回值 | -asserts 检查断言错误,-ignore 白名单防误报 |
govulncheck |
已知 CVE 依赖漏洞 | -format template 实现结构化输出,便于 CI 解析 |
graph TD
A[go generate] --> B[errcheck]
A --> C[govulncheck]
B --> D[报告未关闭的io.ReadCloser]
C --> E[标记含CVE-2023-1234的golang.org/x/crypto]
第三章:HTTP Server阻塞——平滑过渡的断点陷阱
3.1 http.Server.Shutdown()底层信号同步机制与超时竞态剖析
数据同步机制
Shutdown() 使用 sync.WaitGroup 管理活跃连接,配合 atomic.CompareAndSwapInt32 原子切换服务器状态(stateRunning → stateShuttingDown),确保状态变更的可见性与排他性。
超时竞态关键点
// server.go 中核心逻辑节选
srv.mu.Lock()
srv.closeDoneCh = make(chan struct{})
srv.mu.Unlock()
// 启动关闭协程,监听 ctx.Done() 或主动 close(closeDoneCh)
go srv.closeIdleConns() // 非阻塞清理空闲连接
该代码中 closeDoneCh 是无缓冲 channel,其关闭动作与 WaitGroup.Wait() 存在竞态窗口:若所有连接在 closeDoneCh 关闭前已退出,Wait() 可能永久阻塞——除非 ctx.WithTimeout 强制中断。
状态流转与信号协同
| 状态阶段 | 触发条件 | 同步原语 |
|---|---|---|
| Running | ListenAndServe() 启动 |
atomic.StoreInt32(&srv.state, stateRunning) |
| ShuttingDown | Shutdown() 调用 |
atomic.CompareAndSwapInt32(&srv.state, stateRunning, stateShuttingDown) |
| Closed | WaitGroup 归零 + closeDoneCh 关闭 |
close(srv.closeDoneCh) |
graph TD
A[Shutdown(ctx)] --> B[原子切换 stateShuttingDown]
B --> C[关闭 listener fd]
C --> D[通知 idle conn goroutine]
D --> E[WaitGroup.Wait()]
E --> F{ctx.Done?}
F -->|Yes| G[force return with timeout error]
F -->|No| H[等待所有 conn.Close() 完成]
3.2 自定义ServeMux与中间件中阻塞调用(如sync.WaitGroup、time.Sleep)的升级兼容改造
在 Go 1.22+ 引入 net/http 的 ServeMux 并发安全增强后,传统依赖 sync.WaitGroup 或 time.Sleep 实现请求节流/调试阻塞的中间件可能引发 goroutine 泄漏或超时失准。
数据同步机制
需将 WaitGroup 替换为通道协调或 context.WithTimeout 控制生命周期:
func blockingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer cancel()
select {
case <-time.After(200 * time.Millisecond): // 模拟阻塞逻辑
next.ServeHTTP(w, r.WithContext(ctx))
case <-ctx.Done():
http.Error(w, "timeout", http.StatusGatewayTimeout)
}
})
}
逻辑分析:
time.After替代time.Sleep支持上下文取消;r.WithContext(ctx)确保下游 handler 可感知超时。参数500ms为总容忍时长,200ms为模拟处理耗时。
兼容性对比
| 场景 | Go ≤1.21 | Go ≥1.22+ |
|---|---|---|
| WaitGroup.Add() | 需手动配对 Done | 推荐改用 channel sync |
| ServeMux.HandleFunc | 非并发安全 | 原生支持高并发注册 |
graph TD
A[请求进入] --> B{是否启用新上下文模型?}
B -->|是| C[启动带Cancel的ctx]
B -->|否| D[沿用旧WaitGroup]
C --> E[select超时控制]
D --> F[风险:goroutine堆积]
3.3 TLS握手、长连接Keep-Alive及HTTP/2流复用对Shutdown延迟的影响与调优
TLS握手引入的阻塞延迟
TLS 1.3 的0-RTT虽降低首次延迟,但session resumption失败时仍需完整1-RTT握手,导致close_notify无法立即发送。服务端需等待TLS层静默期(默认1秒)后才触发TCP FIN。
Keep-Alive与连接复用权衡
# nginx.conf 片段:控制连接生命周期
keepalive_timeout 30s; # 连接空闲超时,过长加剧TIME_WAIT堆积
keepalive_requests 1000; # 单连接最大请求数,防资源耗尽
逻辑分析:keepalive_timeout设为30s时,客户端在最后一个请求后需等待30s才断连;若并发高且连接复用率低,大量连接滞留于ESTABLISHED→CLOSE_WAIT状态,拖慢整体shutdown。
HTTP/2流复用的双刃效应
| 特性 | Shutdown影响 | 调优建议 |
|---|---|---|
| 多路复用 | 单TCP连接承载多流,FIN需等待所有流完成 | SETTINGS_MAX_CONCURRENT_STREAMS=128 |
| 优先级树 | 流依赖关系可能阻塞低优先级流关闭 | 禁用动态优先级(--http2-disable-push) |
graph TD
A[Client 发送 GOAWAY] --> B{所有流是否已响应?}
B -->|否| C[等待流完成或RST_STREAM]
B -->|是| D[发送FIN, 进入TIME_WAIT]
第四章:syscall.SIGUSR2误用——热重载的认知鸿沟
4.1 Unix信号语义辨析:SIGUSR1 vs SIGUSR2在Go进程模型中的实际行为差异
信号注册与分发机制
Go 运行时将 SIGUSR1 和 SIGUSR2 均视为可捕获的非终止信号,但默认行为不同:
SIGUSR1被 Go 运行时自动注册为调试信号(触发 goroutine stack dump);SIGUSR2无默认处理逻辑,纯粹交由用户signal.Notify显式接管。
行为差异实证代码
package main
import (
"os"
"os/signal"
"syscall"
"time"
)
func main() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGUSR1, syscall.SIGUSR2)
go func() {
for s := range sigs {
println("received:", s.String()) // 区分实际到达的信号
}
}()
time.Sleep(5 * time.Second) // 触发测试:kill -USR1 $PID / kill -USR2 $PID
}
逻辑分析:该程序显式监听双信号,但若未调用
signal.Notify,SIGUSR1仍会打印 goroutine 栈(Go 1.14+ 默认启用),而SIGUSR2静默忽略。os.Signal通道接收的是内核送达的原始信号,不经过 Go 运行时默认 handler 的拦截——除非未被Notify注册。
关键对比表
| 维度 | SIGUSR1 | SIGUSR2 |
|---|---|---|
| 默认 handler | ✅(stack dump) | ❌(无操作) |
| 可阻塞性 | 可被 sigprocmask 阻塞 |
同样可阻塞 |
| Go runtime 干预 | 高(启动时自动注册) | 零(完全用户自治) |
信号生命周期示意
graph TD
A[内核发送信号] --> B{Go runtime 检查注册表}
B -->|SIGUSR1 已注册?| C[执行用户 handler]
B -->|未注册| D[触发默认栈打印]
B -->|SIGUSR2| E[仅投递至 signal channel 或默认忽略]
4.2 net.Listener文件描述符继承与FD传递的跨进程一致性保障实践
核心挑战
Unix域套接字FD传递需确保父子进程对同一监听套接字的引用计数、SO_REUSEADDR状态及accept队列原子性同步,避免EBADF或惊群丢失连接。
FD传递关键步骤
- 父进程调用
unix.File()获取可传递的*os.File - 使用
syscall.Sendmsg配合SCM_RIGHTS控制消息传递FD - 子进程通过
syscall.Recvmsg接收并os.NewFile重建net.Listener
// 父进程:发送监听FD
fd, _ := listener.(*net.TCPListener).File()
_, _, err := syscall.Sendmsg(
sock, nil,
&syscall.Cmsghdr{Level: syscall.SOL_SOCKET, Type: syscall.SCM_RIGHTS, Len: uint32(unsafe.Sizeof(fd))},
nil, 0,
)
// fd:原始监听套接字整型值;sock:用于通信的Unix socket控制通道
// SCM_RIGHTS确保内核复制fd引用而非仅传递数值,保障跨进程句柄有效性
状态一致性保障机制
| 机制 | 作用 |
|---|---|
SO_PASSCRED启用 |
验证发送方UID/GID,防止未授权FD注入 |
AF_UNIX抽象命名空间 |
避免路径竞争,确保控制socket唯一可达性 |
graph TD
A[父进程listener.Accept] --> B[accept队列非空]
B --> C[子进程recvmsg获取FD]
C --> D[os.NewFile重建Listener]
D --> E[调用accept无阻塞/超时]
4.3 基于fdpassing+exec.CommandContext的安全重启协议设计与错误处理
核心设计思想
利用 Unix 域套接字传递监听文件描述符(SCM_RIGHTS),配合 exec.CommandContext 实现零停机重启:父进程保留监听 FD,子进程通过 os.NewFile() 重建 listener,父进程在确认子进程就绪后优雅关闭。
关键错误处理策略
- 超时未响应 →
context.DeadlineExceeded触发回滚 - FD 传递失败 →
syscall.EBADF或EACCES立即中止重启 - 子进程崩溃 →
cmd.Wait()返回非 nil error,触发告警并保活父进程
示例:FD 传递与启动逻辑
// 父进程:传递 listener FD 给新进程
fd := int(listener.(*net.TCPListener).File().Fd())
cmd.ExtraFiles = []*os.File{os.NewFile(uintptr(fd), "listener")}
// 子进程:重建 listener
f := os.NewFile(uintptr(3), "listener") // fd=3 是传递的第1个 ExtraFile
l, err := net.FileListener(f) // 复用原端口,无端口竞争
ExtraFiles[0]对应子进程fd=3(0/1/2 为 stdio);net.FileListener安全封装socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, 0)+setsockopt(SO_REUSEADDR),避免 TIME_WAIT 冲突。
| 阶段 | 超时阈值 | 失败动作 |
|---|---|---|
| 启动子进程 | 5s | 回滚,维持旧服务 |
| 健康检查 | 3s | 杀死子进程,记录 panic |
| FD 传递验证 | — | syscall.Sendmsg 返回 err 即终止 |
graph TD
A[父进程发起重启] --> B[创建子进程+传递FD]
B --> C{子进程是否成功Listen?}
C -->|是| D[启动健康检查]
C -->|否| E[关闭子进程,返回error]
D --> F{/healthz返回200?}
F -->|是| G[父进程Close旧listener]
F -->|否| E
4.4 使用systemd socket activation或supervisord实现信号驱动升级的生产级适配
在高可用服务中,零停机升级需解耦进程生命周期与连接生命周期。systemd socket activation 通过监听套接字按需启动服务,并在新进程就绪后透明移交连接;supervisord 则依赖 SIGUSR2 等信号触发优雅重启。
systemd socket activation 示例
# /etc/systemd/system/myapp.socket
[Socket]
ListenStream=8080
Accept=false
# /etc/systemd/system/myapp.service
[Service]
ExecStart=/usr/local/bin/myapp --config /etc/myapp/conf.yaml
Restart=on-failure
Accept=false启用单实例 socket 激活:systemd 持有监听套接字,新请求唤醒服务;升级时先启动新 service 实例,旧实例处理完存量连接后退出(配合KillMode=mixed和ExecStopPost=清理)。
supervisord 信号控制
| 信号 | 行为 | 适用场景 |
|---|---|---|
SIGUSR2 |
触发预定义 reload 脚本 | 配置热重载 |
SIGTTIN |
优雅停止当前进程 | 进程平滑退出 |
graph TD
A[客户端请求] --> B{systemd socket}
B -->|新连接| C[启动新 myapp.service]
B -->|存量连接| D[旧进程继续服务]
C --> E[健康检查通过]
E --> F[通知旧进程 SIGTERM]
第五章:golang优雅升级
在高可用服务场景中,零停机升级(Zero-downtime Upgrade)是保障业务连续性的核心能力。Go 语言虽无内置热更新机制,但通过 syscall.Exec、net.Listener 文件描述符继承与信号控制三者协同,可构建稳定可靠的优雅升级流程。某支付网关服务(日均请求 2.3 亿次)自 2022 年起采用该方案,已连续 18 个月实现无感知二进制替换。
升级触发机制设计
服务监听 SIGUSR2 信号作为升级入口。主进程收到信号后,执行以下原子操作:
- 调用
listener.File()获取当前net.Listener的文件描述符; - 使用
os.StartProcess启动新版本二进制,将files参数传入包含 listener fd 的[]*os.File切片; - 新进程通过
os.NewFile(uintptr(fd), "listener")恢复 listener,绑定同一端口; - 主进程等待新进程就绪(通过 Unix domain socket 健康探针确认),再调用
srv.Shutdown()开始 graceful 关闭。
进程间状态同步协议
为避免连接中断,需确保旧连接处理完毕后再退出。我们定义如下状态流转:
stateDiagram-v2
[*] --> Idle
Idle --> Upgrading: SIGUSR2 received
Upgrading --> Ready: New process reports health OK
Ready --> Draining: Old process calls Shutdown()
Draining --> Done: All connections closed
Done --> [*]
文件描述符继承关键代码
// 旧进程:传递 listener fd
file, _ := listener.(*net.TCPListener).File()
cmd := exec.Command(os.Args[0], "-upgrade")
cmd.ExtraFiles = []*os.File{file}
cmd.Start()
// 新进程:恢复 listener
if len(os.Args) > 1 && os.Args[1] == "-upgrade" {
f := os.NewFile(3, "listener") // fd=3 来自 ExtraFiles[0]
l, _ := net.FileListener(f)
http.Serve(l, mux)
}
实际部署约束清单
| 约束项 | 说明 | 违反后果 |
|---|---|---|
| 二进制路径一致性 | 新旧版本必须位于相同绝对路径 | exec: "xxx": executable file not found |
| 文件系统挂载选项 | /tmp 必须支持 noexec 以外的执行权限 |
permission denied 错误 |
| systemd 配置 | Restart=on-failure + KillMode=mixed 必须启用 |
旧进程无法被 SIGTERM 终止 |
连接 draining 时间窗口控制
通过 http.Server{ReadTimeout: 30s, WriteTimeout: 60s, IdleTimeout: 90s} 组合设置,确保长轮询连接有足够时间完成响应。生产环境实测平均 draining 耗时 4.2 秒(P95
升级失败回滚策略
新进程启动后 5 秒内未通过健康检查,则旧进程自动取消 Shutdown() 并继续服务。同时向 Prometheus 上报 go_upgrade_failure_total{reason="health_check_timeout"} 指标,触发企业微信告警。
日志隔离实践
新旧进程共用同一日志文件时,需启用 os.O_APPEND 与 os.O_SYNC 标志,避免日志行交错。我们使用 lumberjack.Logger 配置 LocalTime: true 和 Compress: true,确保滚动日志时间戳精确到毫秒级。
容器化适配要点
在 Kubernetes 中,需将 shareProcessNamespace: true 设为 true,并为 Pod 添加 securityContext.procMount: "unmasked",否则 /proc/self/fd/3 在新容器中不可见。某次灰度发布因忽略此配置,导致 7 台节点升级后无法绑定端口,持续 3 分钟服务不可用。
监控指标埋点示例
var (
upgradeDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "go_upgrade_duration_seconds",
Help: "Time spent upgrading binary",
Buckets: []float64{0.1, 0.5, 1, 3, 10},
},
[]string{"result"}, // result="success" or "failure"
)
) 