restartmanager-重启管理子系统

1. 子系统定位

restartmanager 是 Moby(Docker daemon)中负责容器自动重启策略的最小子系统。它本身不负责"启动容器",只回答一个问题:

"给定当前退出码、是否被手动停止、本次运行了多久 ------ 这个容器该不该重启?什么时候重启?"

真正的重启动作由 daemon/monitor.gohandleContainerExit 在收到 containerd 的 exit 事件后发起。RestartManager 只给出决策退避等待

这种"决策与执行分离"的设计让重启策略可独立单测,也方便 daemon 在 shutdown 时统一 Cancel() 所有重启等待。


2. 上游数据结构:RestartPolicy

定义在 api/types/container/hostconfig.go:275

Go 复制代码
type RestartPolicy struct {
    Name              RestartPolicyMode
    MaximumRetryCount int
}

type RestartPolicyMode string

const (
    RestartPolicyDisabled      RestartPolicyMode = "no"
    RestartPolicyAlways        RestartPolicyMode = "always"
    RestartPolicyOnFailure     RestartPolicyMode = "on-failure"
    RestartPolicyUnlessStopped RestartPolicyMode = "unless-stopped"
)

四种模式语义(由 IsNone/IsAlways/IsOnFailure/IsUnlessStopped 方法封装):

|------------------|----------------------|------------------------|
| Name | 何时重启 | MaximumRetryCount 是否生效 |
| no / "" | 永不(默认) | 否(必须为 0) |
| always | 总是,无论退出码、无论是否手动停止 | 否(必须为 0) |
| unless-stopped | 总是,除非容器被用户手动停止 | 否(必须为 0) |
| on-failure | 退出码非 0 时重启,受最大重试次数限制 | 是 |

ValidateRestartPolicyhostconfig.go:320)做上述约束校验:always/unless-stopped/no 模式下 MaximumRetryCount 必须为 0;on-failure 下不能为负。

兼容性细节:v25.0.0 之前默认创建的容器 Name 为空字符串,IsNone()"" 也当作 no 处理(hostconfig.go:291-293)。


3. RestartManager 结构

restartmanager.go:22

Go 复制代码
type RestartManager struct {
    sync.Mutex
    sync.Once
    policy       container.RestartPolicy
    restartCount int
    timeout      time.Duration
    active       bool
    cancel       chan struct{}
    canceled     bool
}

字段逐项:

|----------------|-------------------------------------------------------------------------------|
| 字段 | 含义 |
| sync.Mutex | 保护 policy / restartCount / timeout / active / canceled。 |
| sync.Once | 嵌入以便 Cancel()Do(...) 保证幂等(多次调用只关闭一次 channel)。 |
| policy | 当前生效的重启策略,可通过 SetPolicy 在容器 docker update 时动态修改。 |
| restartCount | 已重启次数。New() 时从容器持久化字段初始化,每次决策"要重启"时自增。 |
| timeout | 当前次重启前要等待的退避时长(指数增长)。 |
| active | 是否正处于一次"等待退避超时"的过程中。防止并发重入 ShouldRestart。 |
| cancel | 一个 chan struct{}Cancel() 时关闭,让正在等待的 goroutine 立刻返回 ErrRestartCanceled。 |
| canceled | 已取消标志,使后续 ShouldRestart 调用直接返回 ErrRestartCanceled。 |

常量

Go 复制代码
const (
    backoffMultiplier = 2
    defaultTimeout    = 100 * time.Millisecond
    maxRestartTimeout = 1 * time.Minute
)

这三者定义了指数退避曲线:100ms → 200ms → 400ms → ... → 1min(封顶)。


4. 构造与生命周期

New(restartmanager.go:34

Go 复制代码
func New(policy container.RestartPolicy, restartCount int) *RestartManager {
    return &RestartManager{policy: policy, restartCount: restartCount, cancel: make(chan struct{})}
}

注意 timeout 初始为 0(首次决策时才被置为 defaultTimeout)。restartCount 由调用方传入,对应容器持久化的 RestartCount 字段------daemon 重启后也能恢复计数。

SetPolicy(restartmanager.go:39

Go 复制代码
func (rm *RestartManager) SetPolicy(policy container.RestartPolicy) {
    rm.Lock()
    rm.policy = policy
    rm.Unlock()
}

container.UpdateMonitorcontainer.go:707)调用,支撑 docker update --restart 在线变更策略。只换策略,不重置 timeout/ restartCount------这是后续要讨论的一个细节。

Cancel(restartmanager.go:125

Go 复制代码
func (rm *RestartManager) Cancel() {
    rm.Do(func() {
        rm.Lock()
        rm.canceled = true
        close(rm.cancel)
        rm.Unlock()
    })
}

借助嵌入的 sync.Once,无论调用多少次都只会关闭一次 cancel channel(关闭已关闭的 channel 会 panic,这是关键保护)。调用方:

  • container.ResetRestartManager(容器被删除/重建时)
  • daemon/shutdown 路径(container.go:458:daemon 关闭时遍历所有容器,逐个 Cancel(),避免重启风暴)
  • daemon 启动恢复阶段,重建 manager 前先 Cancel 旧的

5. 核心方法:ShouldRestart

签名(restartmanager.go:46):

Go 复制代码
func (rm *RestartManager) ShouldRestart(
    exitCode uint32,
    hasBeenManuallyStopped bool,
    executionDuration time.Duration,
) (bool, chan error, error)

返回值三段:

  1. bool shouldRestart ------ 决策结果
  1. chan error ------ 退避等待通道 :调用方读它会阻塞到退避时间到(或被 Cancel);收到 nil 表示可以重启,收到 ErrRestartCanceled 表示取消
  1. error ------ 同步错误(如 manager 已 Cancel / 重入)

5.1 算法流程

按代码顺序拆解:

① 快速否决restartmanager.go:47-49

Go 复制代码
if rm.policy.IsNone() {
    return false, nil, nil
}

策略是 no 时,不加锁直接返回。高频路径的优化。

② 加锁 + 防重入 + 防 Cancel

Go 复制代码
rm.Lock()
...
if rm.canceled { return false, nil, ErrRestartCanceled }
if rm.active   { return false, nil, errors.New("invalid call on an active restart manager") }

active=true 表示上一次的退避 goroutine 还没结束(还没到 timeout),此时再调 ShouldRestart 是逻辑错误。

③ 退避时长更新 (关键部分,restartmanager.go:67-78

Go 复制代码
if executionDuration.Seconds() >= 10 {
    rm.timeout = 0
}
switch {
case rm.timeout == 0:
    rm.timeout = defaultTimeout                 // 100ms
case rm.timeout < maxRestartTimeout:
    rm.timeout *= backoffMultiplier             // ×2
}
if rm.timeout > maxRestartTimeout {
    rm.timeout = maxRestartTimeout              // 封顶 1min
}

两条核心规则:

  • 规则 A:容器存活 ≥ 10 秒就重置退避。 即"这次跑得够久,说明不是 init 崩溃循环",下次重启从 100ms 重新开始数。
  • 规则 B:退避指数翻倍,封顶 1 分钟。 100ms → 200ms → 400ms → 800ms → 1.6s → 3.2s → 6.4s → 12.8s → 25.6s → 51.2s → 60s(封顶)。

注意 rm.timeout实例状态而非本次决策的临时变量------下次调用会基于上一次的值继续翻倍。这是实现"连续失败累计退避"的关键。

④ 策略判定restartmanager.go:80-91

Go 复制代码
var restart bool
switch {
case rm.policy.IsAlways():
    restart = true
case rm.policy.IsUnlessStopped() && !hasBeenManuallyStopped:
    restart = true
case rm.policy.IsOnFailure():
    if maxRetryCount := rm.policy.MaximumRetryCount; maxRetryCount == 0 || rm.restartCount < maxRetryCount {
        restart = exitCode != 0
    }
}

要点:

  • always 模式无视退出码、无视手动停止标志,一律重启。
  • unless-stopped 模式只看 hasBeenManuallyStopped------HasBeenManuallyStoppeddocker stop / docker kill 等显式停止设置(持久化到容器 JSON)。
  • on-failure 模式三重判断:退出码非 0 (未设上限 当前重启计数 < 上限)。maxRetryCount == 0 是"不设上限"的哨兵值。
  • no 模式在 ① 已提前返回,这里不会进来。

⑤ 不重启:清理 active 并返回restartmanager.go:93-96

Go 复制代码
if !restart {
    rm.active = false
    return false, nil, nil
}

⑥ 决定重启:计数 +1、解锁、启动退避 goroutinerestartmanager.go:98-121

Go 复制代码
rm.restartCount++

unlockOnExit = false
rm.active = true
rm.Unlock()

ch := make(chan error)
go func() {
    timeout := time.NewTimer(rm.timeout)
    defer timeout.Stop()

    select {
    case <-rm.cancel:
        ch <- ErrRestartCanceled
        close(ch)
    case <-timeout.C:
        rm.Lock()
        close(ch)
        rm.active = false
        rm.Unlock()
    }
}()

return true, ch, nil

这段是子系统的并发心脏,几个值得注意的细节:

  • unlockOnExit = false:在 goroutine 启动前已手动 Unlock(),避免 defer 二次解锁 panic。
  • goroutine 在 select 上等两个事件:Cancelrm.cancel 被 close)或 退避超时timeout.C)。
  • Cancel 路径 :往 ch 发送 ErrRestartCanceled 再 close;active 没被改(manager 已废,无所谓)。
  • 超时路径 :直接 close(ch)(让 receiver 收到零值 nil,表示"等待结束,可以重启"),然后加锁把 active 置回 false
  • 两种路径都 close(ch),确保 channel 不会被泄漏;调用方一旦读到值就说明等待结束。

调用方读 channel 的语义:

  • <ch> == nil → 退避到时,可以重启
  • <ch> 收到 ErrRestartCanceled → 被取消,放弃重启
  • channel close 后读取零值 → 等同 nil,可重启

6. 调用方:与 daemon 的集成

6.1 持有:container.RestartManager()container.go:720

Go 复制代码
func (container *Container) RestartManager() *restartmanager.RestartManager {
    if container.restartManager == nil {
        container.restartManager = restartmanager.New(
            container.HostConfig.RestartPolicy,
            container.RestartCount,
        )
    }
    return container.restartManager
}

懒初始化 :第一次访问时按当前 HostConfig.RestartPolicy 和持久化的 RestartCount 创建实例。这是 daemon 重启后恢复重启计数的关键------RestartCount 是持久化在容器 JSON 里的。

6.2 决策消费:daemon/monitor.go:99

Go 复制代码
execDuration := time.Since(c.State.StartedAt)
restart, wait, err := c.RestartManager().ShouldRestart(
    uint32(ctrExitStatus.ExitCode),
    daemonShutdown || c.HasBeenManuallyStopped,
    execDuration,
)

handleContainerExit 的关键拼装:

  • exitCode:来自 containerd 的 exit 事件
  • hasBeenManuallyStoppeddaemon 正在关闭容器被手动停止 ,二者任一为真就当作"手动停止"(影响 unless-stopped 判定)
  • execDuration:从 State.StartedAt 到 now

6.3 执行重启:daemon/monitor.go:148-177

Go 复制代码
if restart {
    go func() {
        waitErr := <-wait           // 阻塞等退避
        if waitErr == nil {
            daemon.waitForStartupDone()
            if err := daemon.containerStart(...); err != nil {
                waitErr = err
                ...
            }
        }
        if waitErr != nil {
            // 重启失败:回退到 stopped 状态、可能 auto-remove
            ...
        }
    }()
}

异步启动新 goroutine 等待退避结束再触发 containerStart。这是为什么 ShouldRestart 要返回 channel 而不是同步阻塞------daemon 主循环不能为退避卡住。

6.4 在线更新策略:container.UpdateMonitor

Go 复制代码
func (container *Container) UpdateMonitor(restartPolicy containertypes.RestartPolicy) {
    container.RestartManager().SetPolicy(restartPolicy)
}

docker update --restart=... 时调用。注意只换 policy不重置 timeout/ restartCount------如果容器正在崩溃循环退避中,更新策略后仍按原退避节奏继续。


7. 状态机与并发模型

7.1 RestartManager 状态

Go 复制代码
                   New()
                    │
                    ▼
              ┌─────────── active=false ───────────┐
              │                                    │
              │  ShouldRestart() 判定要重启          │
              │                                    │
              ▼                                    │
       active=true                                 │
       启动 goroutine                              │
       等待 cancel 或 timeout                       │
              │                                    │
       ┌──────┴───────┐                            │
       │             │                            │
   timeout 到      Cancel()                        │
       │             │                            │
       ▼             ▼                            │
   active=false   canceled=true                   │
                                          (永不再重启)

7.2 并发安全要点

  1. policy/ restartCount/ timeout/ active/ canceled 全程在锁保护下访问。
  1. 退避 goroutine 在超时分支里才取锁(设 active=false);Cancel 分支不取锁(manager 已废弃)。
  1. ShouldRestart的锁释放路径 :成功路径手动 Unlock() 后启动 goroutine;失败路径由 defer 释放------靠 unlockOnExit 标志二选一。
  1. Cancel() sync.Once 防止重复 close channel 的 panic。
  1. active防重入:避免同一容器多次 exit 事件并发触发两次退避 goroutine。

8. 边界条件与"陷阱"

理解这个子系统时,下面几条最容易踩坑:

8.1 "存活 ≥ 10 秒重置退避"是单边决策

executionDuration >= 10s 时把 rm.timeout = 0然后 switch 里又把它设为 defaultTimeout。所以"重置"的真实含义是"下次退避从 100ms 重新开始",而不是"不退避立即重启"。一个跑了 10s 后崩溃的容器,第一次重启仍要等 100ms。

8.2 on-failure 的退出码 0 也会消耗一次"等待"

如果 exitCode == 0 且策略是 on-failurerestart 保持 false------但前面的退避 timeout 已经被更新过 了。也就是说"成功结束"也会改 rm.timeout。不过因为接下来 active=false 直接返回,没有 goroutine 真正等这个 timeout,所以实践中无影响。但下次容器再启动并失败时,新一次 ShouldRestart 会基于一个已经被"重置或翻倍"过的 timeout。这通常无害(因为同时也会走 10 秒重置逻辑),但属于隐式状态。

8.3 always 模式下 hasBeenManuallyStopped 也重启

Go 复制代码
case rm.policy.IsAlways():
    restart = true   // 不检查 hasBeenManuallyStopped

所以 docker stop 一个 --restart=always 的容器后,daemon 仍会尝试重启它。这就是为什么 CLI 要先 Cancel() 容器的 RestartManager(或把它标 stopped)再 stop。对比之下 unless-stopped 才真正"记住用户主动停止过"。

8.4 MaximumRetryCount == 0 的双重含义

Go 复制代码
if maxRetryCount := rm.policy.MaximumRetryCount; maxRetryCount == 0 || rm.restartCount < maxRetryCount {
    restart = exitCode != 0
}

0 在这里是"哨兵值 = 不限次"。所以 --restart=on-failure:0 等价于无限重试,而非"不重试"。CLI 默认 on-failure 不带数字时填 0。

8.5 daemon shutdown 路径的 Cancel

container.go:458 在 daemon 关闭时遍历所有容器 Cancel()。这会让正在退避 goroutine 里的 <-rm.cancel 立刻触发,channel 收到 ErrRestartCanceledmonitor.go:172 显式判断 errors.Is(waitErr, restartmanager.ErrRestartCanceled)不打错误日志------这是预期行为。

8.6 restartCountMaximumRetryCount 的比较时机

rm.restartCount < maxRetryCount 在自增之前 比较。所以 on-failure:3 实际允许 3 次重启(count: 0→1, 1→2, 2→3 都满足 < 3;count: 3 不满足,停止)。命名上"MaximumRetryCount = 3"语义就是"最多重试 3 次",一致。

8.7 docker update --restart 不重置状态

SetPolicy 只换策略字段。如果容器当前 restartCount 已经是 5、timeout 已经退避到 30s,把策略从 on-failure:10 改成 always,下一次崩溃仍按 30s 退避,仍带 count=6。这是有意为之:避免用户通过 update 重置来绕过崩溃循环保护。


9. 测试视角

restartmanager_test.go 只有两个用例(系统足够小):

  • TestRestartManagerTimeout:策略 always + executionDuration=1s → 应该重启,且 rm.timeoutdefaultTimeout(100ms)。
  • TestRestartManagerTimeoutReset:手动把 rm.timeout 设为 5s,调用 ShouldRestart(_, _, 10s) → 由于 executionDuration ≥ 10s,rm.timeout 应回到 defaultTimeout

未覆盖但值得补测的场景:

  • on-failure 模式下 MaximumRetryCount 边界(count == max-1 / count == max
  • unless-stopped 模式下 hasBeenManuallyStopped=true
  • Cancel() 后再调 ShouldRestart 返回 ErrRestartCanceled
  • active=true 时重入返回同步 error
  • 退避翻倍曲线(多次连续失败 timeout 演化)
  • Cancel goroutine 让 <-ch 收到 ErrRestartCanceled

10. 学习路径建议

  1. 先读 hostconfig.go:275-344:理解 RestartPolicy 四种模式与校验。
  1. 再读 restartmanager.go全文 (仅 ~130 行,一气呵成):聚焦 ShouldRestart 的三段------退避更新、策略判定、goroutine 启动。
  1. 接着读 daemon/container/container.go:720-740:看 manager 如何被懒初始化、持久化字段如何注入。
  1. 最后读 daemon/monitor.go:34-180:看决策如何被消费、退避 channel 如何驱动 containerStart
  1. 动手实验docker run --restart=on-failure:3 ... 一个会 exit 1 的脚本,docker inspect 观察 RestartCountState,对照退避时间表(100ms→200ms→...)验证。

关键调用关系图

Go 复制代码
container.RestartManager()                 ── 懒初始化 ──> restartmanager.New()
                                                  │
                                                  ├── policy        ← HostConfig.RestartPolicy
                                                  └── restartCount  ← Container.RestartCount (持久化)

container.UpdateMonitor(newPolicy)         ──> SetPolicy()

daemon.handleContainerExit(c, e)           [daemon/monitor.go:34]
        │
        ├── c.RestartManager().ShouldRestart(exitCode, manual, dur)
        │       │
        │       ├── 退避更新(指数 +2 / 封顶 1min / 10s 重置)
        │       ├── 策略判定(always / unless-stopped / on-failure)
        │       └── 启动 goroutine 等 cancel 或 timeout
        │
        ├── if restart:  c.State.SetRestarting(...) + 启动新 goroutine 等 wait
        │                   └── <-wait == nil 时调 daemon.containerStart(...)
        │
        └── else:        c.State.SetStopped(...) + 可能 autoRemove

daemon shutdown / container 删除            ──> RestartManager.Cancel()
                                                  └── Do(close(rm.cancel))  ← goroutine 收到 ErrRestartCanceled
相关推荐
Java内核笔记1 小时前
告别第三方库!Spring Boot 4 原生 API 版本控制全解析:4 种策略 + 实战案例
java·后端
小小洋洋1 小时前
OpenWrt 从U盘迁移到内置 eMMC,并完成扩容与 Docker 安装
java·docker·eureka
SomeB1oody2 小时前
【RustyML入门】2.0. 经典机器学习
开发语言·后端·机器学习·rust·教程
就改了2 小时前
SpringBoot 自定义线程池 + 实时监控指标
java·spring boot·后端
plainGeekDev2 小时前
运行时获取依赖 → 编译时注入
android·java·kotlin
0566463 小时前
Python高级——解释器执行原理与虚拟环境工程化实战
开发语言·python
HJX_07243 小时前
嵌入式软件C语言八股文复习笔记5——编译、链接与底层硬核机制
c语言·开发语言·笔记
玖石书4 小时前
ASP.NET Core 迁移至 Spring系列:类库框架篇
java·后端·asp.net
笨蛋不要掉眼泪4 小时前
RabbitMQ消息队列:延迟消息
java·rabbitmq·java-rabbitmq