首页
/ etcd 退出码完全参考:0/1/2 与信号退出 143/130 的源码级解析

etcd 退出码完全参考:0/1/2 与信号退出 143/130 的源码级解析

2026-09-05 21:47:59作者:秋泉律Samson

etcd server 的退出码(exit code)是容器编排、systemd 与自动化脚本判断其运行状态的核心依据。本文基于仓库中的退出码参考文档 exit_codes.md,逐场景核对当前仓库源码中的实际 os.Exit 调用点,讲清 etcd 显式使用的 0(成功)、1(一般错误)、2(参数错误)三类退出码,以及 Linux/Unix 下由信号终止产生的 128+信号号(143/130)行为的完整来源,帮助你在运维排障与监控接入时准确解读每一次进程退出。

退出码总览

etcd server 显式使用三个退出码,信号终止时则遵循 Unix 通用惯例:

退出码 含义 典型触发场景
0 成功 --help--version、正常停止、PID 1 进程收到 SIGTERM/SIGINT
1 一般错误 启动阶段任何致命失败:logger 创建失败、URL 配置非法、datadir 校验失败、监听失败等
2 参数错误 命令行 flag 解析失败(未知 flag、参数格式错误)
143 非 PID 1 进程收到 SIGTERM(128+15) Linux 下 kill 默认信号
130 非 PID 1 进程收到 SIGINT(128+2) 终端 Ctrl-C

需要特别注意的是:信号终止行为是 Linux 平台特有的。按参考文档说明,在其他平台上,etcd 若无错误退出通常返回 0,否则返回 1。这一差异的根源在 pkg/osutil/signal_linux.gopkg/osutil/signal.go 的构建标签划分(//go:build linux//go:build !linux)。

退出码 0:成功退出的四个场景

所有“成功”路径在源码中都对应一次显式的 os.Exit(0),共四类:

1. 帮助标志 --help

server/etcdmain/config.goparse() 中,flag 解析器返回 flag.ErrHelp 时直接打印帮助并退出:

perr := cfg.cf.flagSet.Parse(arguments)
switch {
case perr == nil:
case errors.Is(perr, flag.ErrHelp):
    fmt.Println(flagsline)
    os.Exit(0)
default:
    os.Exit(2)
}

注意这个 switch 同时定义了退出码 2 的路径:任何不是“成功”或“帮助”的解析错误都落入 default 分支,以 os.Exit(2) 结束(详见后文)。

2. 版本标志 --version

同样位于 server/etcdmain/config.go--version 打印版本信息后退出 0:

if cfg.printVersion {
    fmt.Printf("etcd Version: %s\n", version.Version)
    fmt.Printf("Git SHA: %s\n", version.GitSHA)
    fmt.Printf("Go Version: %s\n", runtime.Version())
    fmt.Printf("Go OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
    os.Exit(0)
}

版本字符串来自 api/version/version.goversion.Version / version.GitSHA 变量。

3. 正常停止(graceful shutdown)

服务运行完毕后,server/etcdmain/etcd.go 的主循环退出时调用 osutil.Exit(0)

select {
case lerr := <-errc:
    // fatal out on listener errors
    lg.Fatal("listener failed", zap.Error(lerr))
case <-stopped:
}

osutil.Exit(0)

osutil.Exit 并非无条件退出——见 pkg/osutil/interrupt_unix.go

// Exit relays to os.Exit if no interrupt handlers are running, blocks otherwise.
func Exit(code int) {
    interruptExitMu.Lock()
    os.Exit(code)
}

它先获取 interruptExitMu。如果此时一个信号处理器正在执行(该互斥量由信号处理 goroutine 持有),Exit(0) 会阻塞等待,直到优雅关闭序列完成,从而避免“退出码 0”与信号处理并发竞争。stopped 通道来自 startEtcd,它等待 e.Server.ReadyNotify()(成员加入集群)之后返回 e.Server.StopNotify(),即 etcd server 自身完成停止后才触发退出。

4. PID 1 进程收到信号的优雅退出

容器内以 PID 1 运行的 etcd 收到 SIGTERM/SIGINT 时直接以 0 退出,代码在 pkg/osutil/interrupt_unix.go

pid := syscall.Getpid()
// exit directly if it is the "init" process, since the kernel will not help to kill pid 1.
if pid == 1 {
    os.Exit(0)
}

源码注释解释了原因:PID 1 的默认信号处置与子进程不同,内核不会按常规替它终止,因此必须在完成 handler 后显式 os.Exit(0)。这就是容器化部署中 docker stop(发 SIGTERM)后 etcd 容器以 0 退出的原因,也是 systemd 单元或 Kubernetes 认为进程“正常停止”的依据。

信号终止:非 PID 1 进程为什么退出 143 或 130

对非 PID 1 进程,信号处理流程在 pkg/osutil/interrupt_unix.goHandleInterrupts 中实现:

  1. 注册监听signal.Notify(notifier, syscall.SIGINT, syscall.SIGTERM)interrupt_unix.go#L52-L53),捕获 Go 运行时层面的 SIGINT/SIGTERM。
  2. 执行注册的 handlerstartEtcd 通过 osutil.RegisterInterruptHandler(e.Close) 注册了 server 的 Close 作为优雅关闭钩子(server/etcdmain/etcd.go#L185)。handler 按注册顺序执行,期间 interruptExitMu 被持有,阻塞一切并发的 osutil.Exit
  3. 复位信号并自我重发
setDflSignal(sig.(syscall.Signal))
syscall.Kill(pid, sig.(syscall.Signal))

Linux 上的 dflSignalpkg/osutil/signal_linux.go)通过 rt_sigaction 把该信号处置方式清零,恢复为 SIG_DFL

func dflSignal(sig syscall.Signal) {
    // clearing out the sigact sets the signal to SIG_DFL
    var sigactBuf [32]uint64
    ptr := unsafe.Pointer(&sigactBuf)
    syscall.Syscall6(uintptr(syscall.SYS_RT_SIGACTION), uintptr(sig), uintptr(ptr), 0, 8, 0, 0)
}

随后 syscall.Kill(pid, sig) 向自身重发同一信号,此时进程按内核默认处置被终止,最终退出码由内核按 128 + 信号号 约定呈现给父进程:

信号 退出码 说明
SIGINT(Ctrl-C) 130(128 + 2) 终端交互式中断
SIGTERM 143(128 + 15) kill <pid> / 编排系统默认停止信号

这个设计的意义在于:优雅关闭(handler 执行 Close、刷盘)先行完成,退出码再由内核按信号惯例生成,父进程(shell、supervisord 等)既能看到“被信号终止”的语义,又不丢失 etcd 的干净落盘。而非 Linux 平台上,pkg/osutil/signal.godflSignal 是空实现(nop),无法复位信号处置方式,因此参考文档明确标注:其他平台 etcd 无错误退出返回 0,否则返回 1。

值得对照的是仓库自身的测试框架:tests/framework/e2e/etcd_process.goStop() 会调用进程封装的 Stop()(发 SIGTERM)再 Close(),并把 unexpected exit code 视为可容忍结果之一——因为信号终止产生的非 0 退出码属于预期行为,而非故障。E2E 用例 tests/e2e/graceful_shutdown_test.go 则验证了 SIGTERM 触发的 leader 停止在 1.5 秒内完成且 leadership 正常转移。

退出码 1:启动阶段的一般错误

etcd 约定“一切 server 错误以 1 退出”。参考文档列出的场景在当前源码中的对应位置如下(行号以当前仓库为准;参考文档中锚定旧 commit 的行号可能有漂移):

场景 源码位置
创建 logger 失败 server/etcdmain/etcd.go#L60
校验 flags 失败 server/etcdmain/etcd.go#L69
Discovery token 已被使用 server/etcdmain/etcd.go#L141
Initial cluster 配置错误 server/etcdmain/etcd.go#L155
Discovery 失败(Fatal) server/etcdmain/etcd.go#L157
监听失败(Fatal) server/etcdmain/etcd.go#L172
无法列出数据目录 server/etcdmain/etcd.go#L201
datadir 非法(member 与 proxy 并存) server/etcdmain/etcd.go#L221
不受支持的 CPU 架构 server/etcdmain/etcd.go#L252
SRV 端点发现失败 server/etcdmain/util.go#L34
非法 listen-peer-urls server/embed/config.go#L809-L812
非法 listen-client-urls server/embed/config.go#L817-L821
非法 listen-client-http-urls server/embed/config.go#L826-L830
非法 initial-advertise-peer-urls server/embed/config.go#L835-L838
非法 advertise-client-urls server/embed/config.go#L844-L847
非法 listen-metrics-urls server/embed/config.go#L853-L856

其中几个值得深入看:

URL 类配置错误集中在 server/embed/config.go。六处均为相同模式:把逗号分隔的字符串交给 types.NewURLs 解析,失败即打印到 stderr 并 os.Exit(1)

if cfg.configJSON.ListenClientURLs != "" {
    u, err := types.NewURLs(strings.Split(cfg.configJSON.ListenClientURLs, ","))
    if err != nil {
        fmt.Fprintf(os.Stderr, "unexpected error setting up listen-client-urls: %v\n", err)
        os.Exit(1)
    }
    cfg.Config.ListenClientUrls = u
}

types.NewURLs(位于 client/pkg/types 包)会拒绝含空白、非 http/https 等不合法形式。这意味着把 --listen-client-urls 写成 127.0.0.1:2379(缺 scheme)这类错误,进程会以 1 退出并在 stderr 给出 unexpected error setting up listen-client-urls 提示,而不是静默回退默认值。

Discovery token 复用server/etcdmain/etcd.go#L130-L142):bootstrap 时若 errors.DiscoveryError 表明 token 已被消费,日志会明确提示 “do not reuse discovery token; generate a new one to bootstrap a cluster”,然后 os.Exit(1)。这是用 v2 discovery 服务组集群时最常见的“换 token 重跑”场景。

datadir 校验server/etcdmain/etcd.go#L195-L230):identifyDataDirOrDie 读取数据目录内容,目录不存在视为 dirEmpty(新实例);若 memberproxy 子目录同时存在则 Fatal(退出 1);目录既存在又无法读取权限时同样 Fatal。此外若目录被识别为 dirProxy,进程会直接 lg.Panic——v2 HTTP proxy 已在 3.6 起弃用。

架构检查server/etcdmain/etcd.go#L232-L253):checkSupportArch 仅放行 amd64arm64ppc64les390x 四种 GOARCH;其余架构必须显式设置环境变量 ETCD_UNSUPPORTED_ARCH=<架构> 才可继续,否则以 1 退出。

监听失败server/etcdmain/etcd.go#L170-L174):端口被占用等监听错误走 lg.Fatal("listener failed", ...),zap 的 Fatal 在记录后调用 os.Exit(1),因此所有 lg.Fatal/lg.Panic 类路径最终都收敛到退出码 1。

退出码 2:参数错误

参考文档将“flag 解析错误”单独归为退出码 2,源码即前文 parse()default 分支(server/etcdmain/config.go#L116-L118):

default:
    os.Exit(2)

触发条件包括:出现未定义的 flag、flag 值类型不匹配、布尔 flag 误加参数等。这与 cobra/pflag 生态的通用约定一致(如 shell 工具的 $? 检查),便于把“用户写错命令行”(2)与“etcd 运行环境/配置有问题”(1)在监控中区分开:前者应提示使用者检查命令,后者通常意味着端口、证书、datadir、集群发现等运维层面的故障。

与之相邻的还有一类“参数存在但不合法”的情况:flag 解析成功但 flagSet.Args() 非空时返回错误 "%q" is not a valid flagserver/etcdmain/config.go#L119-L121),该错误在 server/etcdmain/etcd.go#L64-L70 被判定为 “failed to verify flags” 后以 1 退出——即“多余的裸参数”归入一般错误而非参数错误。

实践指引:如何依据退出码判断 etcd 状态

结合上文,可以在运维脚本与编排配置中这样解读 $?

  • 0:进程正常结束。容器场景下 SIGTERM 优雅关闭也属于此类(PID 1 路径),systemd/Kubernetes 视为正常停止,不会触发“异常重启”告警。
  • 1:启动期致命失败。优先检查 stderr 与日志关键字:failed to verify flags(配置校验)、discovery token was already used(token 复用)、failed to list data directory(datadir 权限/路径)、invalid datadir(member/proxy 并存)、listener failed(端口占用)、unexpected error setting up listen-*-urls(URL 格式)。
  • 2:命令行写法错误,检查 flag 名称与参数。
  • 143/130(仅 Linux):进程被 SIGTERM/SIGINT 终止。注意这是“被外部要求停止”,若由你主动 kill 属预期;若非预期发生,应排查是谁发出了信号(编排系统、OOM 后的清理流程、killall 等),而非 etcd 自身故障。

仓库的测试基础设施也依赖同一套语义:tests/framework/e2e/etcd_process.goWait()/IsRunning() 中读取 ep.proc.ExitCode() 并记录日志,多处把 unexpected exit code 作为信号终止的容忍结果处理;robustness 框架的 tests/robustness/failpoint/kill.go 同样遵循该判断模式。

平台差异与适用前提

  • 143/130 的 128+信号号 行为仅适用于 Linux。其他 Unix 平台 dflSignal 为空实现(pkg/osutil/signal.go),信号重发无法恢复内核默认处置,参考文档将其概括为“无错误退出 0,否则 1”。
  • Windows 构建不适用 pkg/osutil/interrupt_unix.go(构建标签 //go:build !windows && !plan9)。
  • 退出码语义由 server/main.go 入口经 etcdmain 包驱动;本文所有行号均以当前仓库版本为准,与参考文档 Documentation/contributor-guide/exit_codes.md 锚定的旧 commit 相比,个别行号存在漂移,建议以本文给出的相对路径定位为准。
登录后查看全文
热门项目推荐
相关项目推荐