6 min read
Docker LiveRestore 特性

LiveRestore 特性从 Docker Engine 1.12 版本开始支持(不支持 Windows),在 dockerd 停止时保证已启动的 Running 容器持续运行,并在 daemon 进程启动后重新接管。

Docker LiveRestore 特性分析

本文基于 Docker Version 1.13.1 进行代码分析

Live Restore ???

该特性从 Docker Engine 1.12 版本开始支持(不支持 Windows),主要且唯一的作用就是:在 dockerd 停止时保证已启动的 Running 容器持续运行,并在 daemon 进程启动后重新接管。几个应用场景

  • dockerd 异常退出时保证业务容器持续运行
  • 升级 Docker Engine 时(不跨 Release 版本)保证业务容器持续运行

必备条件

  • 跨版本升级时,不建议使用:在 Docker Engine 跨版本升级时启用可能因为代码变动而导致新版本 Engine 启动后不接管先前的容器
  • 调整 daemon 启动参数时,不建议使用:例如调整 Graph 等将导致 DockerRepository/containers 目录变更从而丢失原始容器信息进而无法接管先前的容器

Docker daemon 实现

docker/daemon/config_unix.go 中提供了 --live-restore 参数并针对于 Swarm 模式的兼容性进行判断(注:LiveRestore 不能在 Swarm 模式下工作)。

func (config *Config) InstallFlags(flags *pflag.FlagSet) {
    // 其他参数
    flags.BoolVar(&config.LiveRestoreEnabled, "live-restore", false, "Enable live restore of docker when containers are still running")
}

func (config *Config) isSwarmCompatible() error {
	if config.LiveRestoreEnabled {
		return fmt.Errorf("--live-restore daemon configuration is incompatible with swarm mode")
	}
	return nil
}

随后在 docker/cmd/dockerd/daemon.go 中将该参数通过 containerd.RemoteOption 作为 containerdRemote 初始化的参数

func (cli *DaemonCli) start(opts daemonOptions) (err error) {
    // 省略无数
    // cli.getPlatformRemoteOptions() 中含有 LiveRestore 参数
    containerdRemote, err := libcontainerd.New(cli.getLibcontainerdRoot(), cli.getPlatformRemoteOptions()...)
	if err != nil {
		return err
	}
    
    // ...
    // 初始化完了总该用起来了吧
    // 拿刚才初始化了的 containerdRemote 传给 daemon.NewDaemon 来初始化一个真正的 Docker Daemon
    d, err := daemon.NewDaemon(cli.Config, registryService, containerdRemote)
	if err != nil {
		return fmt.Errorf("Error starting daemon: %v", err)
	}
}

containerd.Remote 初始化细节

初始化 containerdRemote 的时候会对所有的 Option 进行 Apply,其中 LiveRestore 的特性会加载到当前 Remote 的所有 Client 中

docker/libcontainerd/remote_unix.go

// 向 Remote 增加 Client 并使用预先初始化的 remote.LiveRestore 配置设置 Client
func (r *remote) Client(b Backend) (Client, error) {
	c := &client{
		clientCommon: clientCommon{
			backend:    b,
			containers: make(map[string]*container),
			locker:     locker.New(),
		},
		remote:        r,
		exitNotifiers: make(map[string]*exitNotifier),
		liveRestore:   r.liveRestore,
	}

	r.Lock()
	r.clients = append(r.clients, c)
	r.Unlock()
	return c, nil
}

// WithLiveRestore defines if containers are stopped on shutdown or restored.
func WithLiveRestore(v bool) RemoteOption {
	return liveRestore(v)
}

type liveRestore bool

func (l liveRestore) Apply(r Remote) error {
	if remote, ok := r.(*remote); ok {
        // 保留该配置到当前的 containerdRemote,以确保后续新增 Client 的时候能够从 Remote 中读取并设置
        remote.liveRestore = bool(l)
		for _, c := range remote.clients {
			c.liveRestore = bool(l)
		}
		return nil
	}
	return fmt.Errorf("WithLiveRestore option not supported for this remote")
}

daemon.NewDaemon 做了什么

// NewDaemon sets up everything for the daemon to be able to service
// requests from the webserver.
func NewDaemon(config *Config, registryService registry.Service, containerdRemote libcontainerd.Remote) (daemon *Daemon, err error) {

从这段注释来看,启动了各种各样所需要的东西让 daemon 可以开始工作,继续看这段函数

	d.nameIndex = registrar.NewRegistrar()
	d.linkIndex = newLinkIndex()
    // 这不就是刚才初始化传入的 containerdRemote 嘛
	d.containerdRemote = containerdRemote

	...
    
    // 诶,这个操作是加了一个 Client
	d.containerd, err = containerdRemote.Client(d)
	if err != nil {
		return nil, err
	}
    
    // 好家伙,开始 restore 了恢复或者清理整个先前留下的现场
	if err := d.restore(); err != nil {
		return nil, err
	}

Restore 具体实现

daemon.restore

docker/daemon/daemon.go

func (daemon *Daemon) restore() error {
	var (
		currentDriver = daemon.GraphDriverName()
		containers    = make(map[string]*container.Container)
	)

	logrus.Info("Loading containers: start.")

	dir, err := ioutil.ReadDir(daemon.repository)
	if err != nil {
		return err
	}

	for _, v := range dir {
		id := v.Name()
		container, err := daemon.load(id)
		if err != nil {
			logrus.Errorf("Failed to load container %v: %v", id, err)
			continue
		}
        // Ignore the container if it does not support the current driver being used by the graph
		if (container.Driver == "" && currentDriver == "aufs") || container.Driver == currentDriver {
			// 设置 RWLayer
			logrus.Debugf("Loaded container %v", container.ID)

			containers[container.ID] = container
		} else {
			logrus.Debugf("Cannot load container %s because it was created with another graph driver.", container.ID)
		}
    }
    ...
    
}

首先从 dockerd 启动的参数 --graph(默认是 /var/lib/docker)所对应的目录下的 containers 目录读取当前所有的容器 ID,根据存储类型选择,忽略掉当前 daemon 设置的存储驱动不支持的容器。然后将上面筛选出的容器注册到 daemon。

接下来是重头戏,开始对容器进行恢复操作

for _, c := range containers {
    wg.Add(1)
    go func(c *container.Container) {
        defer wg.Done()
        // 对于运行中的或者被暂停的容器**异步**进行 Restore
        if c.IsRunning() || c.IsPaused() {
            // 调用 libcontainerd.Client.Restore
            if err := daemon.containerd.Restore(c.ID, c.InitializeStdio); err != nil {
                logrus.Errorf("Failed to restore %s with containerd: %s", c.ID, err)
                return
            }
        }
        if !c.IsRunning() && !c.IsPaused() {
        }
        if c.RemovalInProgress {
        }
    }
}

docker/libcontainerd/client_linix.go
func (clnt *client) Restore(containerID string, attachStdio StdioCallback, options ...CreateOption) error {
	// Synchronize with live events
	clnt.remote.Lock()
	defer clnt.remote.Unlock()
	// Check that containerd still knows this container.
	//
	// In the unlikely event that Restore for this container process
	// the its past event before the main loop, the event will be
	// processed twice. However, this is not an issue as all those
	// events will do is change the state of the container to be
	// exactly the same.
	cont, err := clnt.getContainerdContainer(containerID)
	// Get its last event
	ev, eerr := clnt.getContainerLastEvent(containerID)
	if err != nil || cont.Status == "Stopped" {
        // 针对于获取异常或者需要停止的容器,执行退出(stop)的操作
        
    }
    
    // 走到这里的都是状态 Running 或者 Paused 的容器
    
    // 如果开启了 LiveRestore 特性,那么开始 restore 这个容器
    if clnt.liveRestore {
        if err := clnt.restore(cont, ev, attachStdio, options...); err != nil {
			logrus.Errorf("libcontainerd: error restoring %s: %v", containerID, err)
		}
		return nil
    }
    
    // 否则干掉这个容器
    // 执行退出(stop)操作
}

具体到某个容器的 restore 由 containerd.Client 执行

func (clnt *client) restore(cont *containerd.Container, lastEvent *containerd.Event, attachStdio StdioCallback, options ...CreateOption) (err error) {
	clnt.lock(cont.Id)
	defer clnt.unlock(cont.Id)

	logrus.Debugf("libcontainerd: restore container %s state %s", cont.Id, cont.Status)

	containerID := cont.Id
	if _, err := clnt.getContainer(containerID); err == nil {
		return fmt.Errorf("container %s is already active", containerID)
	}

	defer func() {
        // 如果最后出错了,我们就不管这个容器了,把它从当前的 client 容器列表中移除(注:不是真的删除容器)
		if err != nil {
			clnt.deleteContainer(cont.Id)
		}
	}()

	container := clnt.newContainer(cont.BundlePath, options...)
	container.systemPid = systemPid(cont)

    // 找到容器内 PID 的进程状态
	var terminal bool
	for _, p := range cont.Processes {
		if p.Pid == InitFriendlyName {
			terminal = p.Terminal
		}
	}

    // 以下创建一个 FIFO 的管道用于日志收集
	fifoCtx, cancel := context.WithCancel(context.Background())
	defer func() {
		if err != nil {
			cancel()
		}
	}()

	iopipe, err := container.openFifos(fifoCtx, terminal)
	if err != nil {
		return err
	}
	var stdinOnce sync.Once
	stdin := iopipe.Stdin
	iopipe.Stdin = ioutils.NewWriteCloserWrapper(stdin, func() error {
		var err error
		stdinOnce.Do(func() { // on error from attach we don't know if stdin was already closed
			err = stdin.Close()
		})
		return err
	})

    // 将 STD IO 转到管道中
	if err := attachStdio(*iopipe); err != nil {
		container.closeFifos(iopipe)
		return err
	}

    // 看起来都没问题了,这个容器归 Client 管了
	clnt.appendContainer(container)

    // 更新下 event,告知这容器被 Restore 了
	err = clnt.backend.StateChanged(containerID, StateInfo{
		CommonStateInfo: CommonStateInfo{
			State: StateRestore,
			Pid:   container.systemPid,
		}})

	if err != nil {
		container.closeFifos(iopipe)
		return err
	}
    
    // 发现有 event 要处理,那就改改状态保持下一致性
	if lastEvent != nil {
		// This should only be a pause or resume event
		if lastEvent.Type == StatePause || lastEvent.Type == StateResume {
			return clnt.backend.StateChanged(containerID, StateInfo{
				CommonStateInfo: CommonStateInfo{
					State: lastEvent.Type,
					Pid:   container.systemPid,
				}})
		}

		logrus.Warnf("libcontainerd: unexpected backlog event: %#v", lastEvent)
	}

	return nil
}

Daemon 退出时的特殊处理

在 dockerd 启动方法 docker/cmd/dockerd/daemon.go -> DaemonCli.start 最后处理了退出的场景

func (cli *DaemonCli) start(opts daemonOptions) (err error) {
    // ...
   	// Wait for serve API to complete
	errAPI := <-serveAPIWait
	c.Cleanup()
    // 关闭 daemon
	shutdownDaemon(d)
    // 断开 containerd
	containerdRemote.Cleanup()
	if errAPI != nil {
		return fmt.Errorf("Shutting down due to ServeAPI error: %v", errAPI)
	}
    return nil
}


// shutdownDaemon just wraps daemon.Shutdown() to handle a timeout in case
// d.Shutdown() is waiting too long to kill container or worst it's
// blocked there
func shutdownDaemon(d *daemon.Daemon) {
    // 算算合理的超时时间
    shutdownTimeout := d.ShutdownTimeout()
	ch := make(chan struct{})
	go func() {
        // 开始真的 shutdown daemon
		d.Shutdown()
		close(ch)
	}()
    // 一坨的超时处理,无视
    if shutdownTimeout < 0 {
		<-ch
		logrus.Debug("Clean shutdown succeeded")
		return
	}
	select {
	case <-ch:
		logrus.Debug("Clean shutdown succeeded")
	case <-time.After(time.Duration(shutdownTimeout) * time.Second):
		logrus.Error("Force shutdown daemon")
	}
}



// Shutdown stops the daemon.
func (daemon *Daemon) Shutdown() error {
	daemon.shutdown = true
	// Keep mounts and networking running on daemon shutdown if
	// we are to keep containers running and restore them.

    // 针对于 LiveRestore 开启的状态,不做清理
	if daemon.configStore.LiveRestoreEnabled && daemon.containers != nil {
		// check if there are any running containers, if none we should do some cleanup
		if ls, err := daemon.Containers(&types.ContainerListOptions{}); len(ls) != 0 || err != nil {
			return nil
		}
	}
    // .....
    // 清理挂载
    // 清理网络
}

至此,dockerd 的 LiveRestore 特性相关代码已经分析完成。

隐藏点

std 日志 buffer 问题

containerd-shim 中的应用服务向 FIFO Pipe 输出日志(如果 dockerd 活着就会从这里面取出日志),buffer 的大小受限于 Pipe Size。在日志 buffer 满了之后将阻塞容器内日志输出

systemd

在设置 KillMode 为 control-group mixed 时,containerd-shim 会被 kill 掉但容器内的进程还在。在恢复启动的时旧进程才会被回收。因此这部分要确保 KillMode 设置为 process(只 kill 主进程)。

升级的时候应该怎么做?

假定在 Kubernetes 场景下

跨 Release 版本

例如从 17.03 -> 17.06,这种属于跨 Release 版本升级的操作,建议操作前先将节点置为不可调度,让上面的业务容器漂移到别的节点上。然后手动将该节点上的其他容器关闭,接着进行升级操作

小版本升级

例如从 17.03.0 -> 17.03.2,保险起见,先将节点置为不可调度,而后直接升级

参考