要论云计算领域中,开发者需要具备哪些基本技能?那么 Docker 必是其一。 作为一个开源的应用容器引擎,Docker 能够让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化,方便快捷。
那么你真的足够了解 Docker 吗?
作者 | Cameron Lonsdale
译者 | 苏本如,责编 | 屠敏
以下为译文:
Docker是如何工作的?这是一个简单的问题,但答案却是出乎意料的复杂。你可能很多次地听说过“守护进程(daemon)”和“运行时(runtime)”这两个术语,但可能从未真正理解它们的含义以及它们是如何配合在一起的。如果你像我一样,涉过源头去发现真相,那么,在你沉溺于代码之海时,你并不孤单。让我们面对现实吧,假想Docker的源代码是一顿意式大餐,而你正在狼吞虎咽你的美味意面。
就像一把叉子可以把面条送到你的口中,这篇文章会将Docker的技术的方方面面组织在一起并导入你饥饿的大脑。
为了更好地理解现在,我们首先需要回顾过去。2013年,dotCloud公司的Solomon Hykes在那年的Python大会上发表了Linux容器的未来的演讲(https://www.youtube.com/watch?v=wW9CAH9nSLs,需科学上网),第一次将Docker带入了公众的视线。让我们将他的git代码库回溯到2013年1月,这个Docker开发更轻松的时间。
Docker 2013是如何工作的?
Docker由两个主要组件组成,一个供用户使用的命令行应用程序和一个管理容器的守护进程。这个守护进程依赖两个子组件来执行它的任务:在宿主主机文件系统上用来存储镜像和容器数据的存储组件;以及用于抽象原始内核调用来构建Linux容器的LXC接口。
命令行应用程序
Docker命令行应用程序是管理你的Docker运行副本已知的所有镜像和容器的人工界面。它相对简单,因为所有的管理都是由守护进程完成的。应用程序开始于一个main 函数:
funcmain() {var err error ...// Example: "/var/run/docker.sock", "run" conn, err := rcli.CallTCP(os.Getenv("DOCKER"), os.Args[1:]...) ... receive_stdout := future.Go(func()error { _, err := io.Copy(os.Stdout, conn)return err }) ...}
它会立即建立一个TCP连接,指向存储在DOCKER这个环境变量中的地址,这是Docker守护进程的地址。用户提供的参数发送给守护进程,然后命令行应用程序等待打印成功答复的结果。
dockerd
Docker守护进程的代码存放在同一个代码库中,这个进程称为dockerd。它运行在后台来处理用户请求和容器清理工作。启动后,dockerd将监听在8080端口上传入的HTTP连接和在4242端口上的TCP连接。
funcmain() { ...// d is the server, it will process requests made by the user d, err := New() ...gofunc() {if err := rcli.ListenAndServeHTTP(":8080", d); err != nil { log.Fatal(err) } }()if err := rcli.ListenAndServeTCP(":4242", d); err != nil { log.Fatal(err) }}
一旦命令接收到后,dockerd将使用反射机制查找并调用要运行的函数。
docker run命令
其中一个运行的函数是CmdRun,它对应这个docker run(注:这个命令创建一个新的容器并运行一个命令)命令。
func(srv *Server)CmdRun(stdin io.ReadCloser, stdout io.Writer, args ...string)error { flags := rcli.Subcmd(stdout, "run", "[OPTIONS] IMAGE COMMAND [ARG...]", "Run a command in a new container") ...// Choose a default image if neededif name == "" { name = "base" }// Choose a default command if needediflen(cmd) == 0 { *fl_stdin = true *fl_tty = true *fl_attach = true cmd = []string{"/bin/bash", "-i"} } ...// Find the image img := srv.images.Find(name) ...// Create Container container := srv.CreateContainer(img, *fl_tty, *fl_stdin, *fl_comment, cmd[0], cmd[1:]...) ...// Start Container container.Start() ...}
用户通常会提供一个镜像和一个命令,以便dockerd运行。当它们被省略时,将默认使用镜像base和命令/bin/bash。
查找镜像
然后,我们通过将名称(或ID)映射到文件系统上的一个位置来找到指定的镜像(假设前面已经执行过docker pull (注:这个命令从镜像仓库中拉取或者更新指定镜像)命令,镜像已经生成)。
type Index struct { Path string// "/var/lib/docker/images/index.json" ByName map[string]*History ById map[string]*Image}func(index *Index)Find(idOrName string) *Image { ...// Lookup by IDif image, exists := index.ById[idOrName]; exists {return image }// Lookup by nameif history, exists := index.ByName[idOrName]; exists && history.Len() > 0 {return (*history)[0] }returnnil}
在这个版本的Docker中,所有镜像都存储在/var/lib/docker/images文件夹中。想进一步了解Docker镜像中有什么,请参阅我以前的博客文章(https://cameronlonsdale.com/2018/11/26/whats-in-a-docker-image/)。
创建容器
接着我们开始创建容器。dockerd创建了一个结构(struct)来保存与这个容器相关的所有元数据,并将它存储在一个列表中以便于访问。
container := &Container{ // Examples Id: id, // "09906fa3" Root: root, // /var/lib/docker/containers/09906fa3/" Created: time.Now(), Path: command, // "/bin/bash" Args: args, // ["-i"] Config: config,// "/var/lib/docker/containers/09906fa3/rootfs"// "/var/lib/docker/containers/09906fa3/rw" Filesystem: newFilesystem(path.Join(root, "rootfs"), path.Join(root, "rw"), layers), State: newState(),// "/var/lib/docker/containers/09906fa3/config.lxc" lxcConfigPath: path.Join(root, "config.lxc"),stdout: newWriteBroadcaster(),stderr: newWriteBroadcaster(), stdoutLog: new(bytes.Buffer), stderrLog: new(bytes.Buffer),}...// Create directoriesos.Mkdir(root, 0700);container.Filesystem.createMountPoints();...// Generate LXC Config filecontainer.generateLXCConfig();
当创建这个struct时,dockerd 会在下列路径为容器创建下面这个唯一目录:/var/lib/docker/containers/<ID>。在这个目录下中有两个子目录:/rootfs 只读根文件系统层(来自联合挂载(union mounted)的镜像),/rw提供一个单独的读写层,供容器来创建临时文件。
最后,使用我们新创建的容器数据填充一个模板,用以生成LXC 配置文件。更多关于LXC的信息,请参见下一节。
运行容器
我们的容器终于创建出来了!但它还没有运行,现在让我们启动它。
func(container *Container)Start()error {// Mount file system if not mounted container.Filesystem.EnsureMounted(); params := []string{"-n", container.Id,"-f", container.lxcConfigPath,"--", container.Path, } params = append(params, container.Args...)// /usr/bin/lxc-start -n 09906fa3 -f /var/lib/docker/containers/09906fa3/config.lxc -- /bin/bash -i container.cmd = exec.Command("/usr/bin/lxc-start", params...) ...}
第一步是确保容器的文件系统已挂载。
func(fs *Filesystem)Mount()error { ... rwBranch := fmt.Sprintf("%v=rw", fs.RWPath) roBranches := ""for _, layer := range fs.Layers { roBranches += fmt.Sprintf("%v=ro:", layer) } branches := fmt.Sprintf("br:%v:%v", rwBranch, roBranches)// Mount the branches onto "/var/lib/docker/containers/09906fa3/rootfs" syscall.Mount("none", fs.RootFS, "aufs", 0, branches); ...}
使用AUFS 联合挂载文件系统(union mount file system),镜像的各个层以read-only方式挂载在彼此的顶部,以向容器呈现一个一致的视图。read-write路径被挂载在最顶层,为容器提供临时存储。
然后,为了启动容器,dockerd使用刚才生成的LXC模板来运行另一个程序lxc-start。
LXC
LXC(Linux Containers)是一个抽象层,它为用户空间应用程序提供了一个简单的API来创建和管理容器。事实是,容器不是真实的东西,在Linux内核中没有称作容器的对象。容器是一组内核对象的集合,它们协同工作以提供进程隔离。因此,简单的lxc-start命令实际上被翻译成下面的设置和应用:
内核名字空间 (ipc, uts, mount, pid, network 和 user)Apparmor和 SELinux profilesSeccomp policiesChroots (使用 pivot_root)Kernel capabilitiesCGroups (control groups)容器清理
func(container *Container)monitor() {// Wait for the program to exit container.cmd.Wait() exitCode := container.cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus()// Cleanup container.stdout.Close() container.stderr.Close() container.Filesystem.Umount();// Report status back container.State.setStopped(exitCode) container.save()}
最后,dockerd将监控容器直到完成,并在容器完成后清理不必要的数据。
总结
概括起来,使用Docker 2013来管理一个容器的运行涉及以下步骤:
dockerd is sent the run command (using the command-line application or otherwise) dockerd finds the specified image on the file system A container struct is created and stored for future use Directories on the filesystemare setup foruseby the container LXC is instructed tostart the container dockerd monitors the containeruntil completion
Docker发生了什么变化?
Docker自从被引入以来已经过去6年了,容器化模式在此期间已经得到了迅猛发展。无论是大小企业都采用了Docker,特别在它和容器编排系统Kubernetes的结合之后。
通过开源的力量,起初的3位贡献者已经发展到1800多人,每个人都为项目带来了新的想法。为了促进可扩展性,Open Container Initiative(OCI)标准于2015年发布,它定义了容器的镜像和运行时规范的开放标准。镜像规范概述了容器镜像的结构,运行时规范则描述了在其平台上运行容器时的实现应该遵循的接口和行为标准。因此,社区开发了广泛的容器管理项目,涵盖了从原生容器到被虚拟机隔离的容器。在微软的支持下,该行业现在也拥有了符合OCI标准的原生Windows容器。
所有这些变化都反映在moby代码库中。基于这种历史背景,我们可以开始分析Docker 2019及其组件了。
Docker 2019是如何工作的?
经过6年和36207次的代码提交,moby代码库已经发展成为一个大型的合作项目,它正在影响和依赖许多组件。
从一个非常简单的角度来看,Moby 2019有两个新的主要组件,一个是在容器的生命周期中管理容器的containerd 组件,另一个是符合OCI标准的运行时(例如runc)组件,它是用于创建容器的最低用户级抽象(替换LXC)。
命令行应用程序
命令行应用程序的控制流程大部分没有改变。今天,JSON格式的HTTP(S)报文是与 dockerd通信的标准。
为了实现可扩展性,API和docker二进制文件是分开的。程序代码位于docker/cli目录,它依赖moby/moby/client包作为接口与dockerd对话。
dockerd
dockerd负责监听用户请求,并根据预先定义的路由对这些请求进行处理。
// Container Routesfunc(r *containerRouter)initRoutes() { r.routes = []router.Route{// HEAD router.NewHeadRoute("/containers/{name:.*}/archive", r.headContainersArchive),// GET router.NewGetRoute("/containers/json", r.getContainersJSON), router.NewGetRoute("/containers/{name:.*}/export", r.getContainersExport), router.NewGetRoute("/containers/{name:.*}/changes", r.getContainersChanges), ... }}
这个引擎仍然负责各种各样的任务,例如与镜像注册项的交互,并在文件系统上设置目录供容器使用。默认驱动程序会把一个镜像联合挂载到/var/lib/docker/overlay2/下的目录。
dockerd不再负责管理运行容器的生命周期。随着项目的发展,决定将容器监管分开到一个名为containerd的单独项目。这样docker守护进程可以继续创新,而不必担心破坏运行时的实现。
尽管 docker/engine是从moby/moby分支出来的,考虑到可能的代码差异,到目前为止,它们共享相同的代码提交树。
docker run命令
funcrunContainer(dockerCli command.Cli, opts *runOptions, copts *containerOptions, containerConfig *containerConfig)error { ...// Create the container createResponse = createContainer(ctx, dockerCli, containerConfig, &opts.createOptions) ...// Start the container client.ContainerStart(ctx, createResponse.ID, types.ContainerStartOptions{}); ...}
docker run命令首先请求守护进程创建一个容器。此请求被路由到postContainersCreate命令。
创建容器
稍后调用几个函数,我们来创建一个容器。
func (daemon *Daemon) create(opts createOpts) (retC *container.Container, retErr error) { ...// Find the Image img = daemon.imageService.GetImage(params.Config.Image) ...// Create container object container = daemon.newContainer(opts.params.Name, os, opts.params.Config, opts.params.HostConfig, imgID, opts.managed); ...// Set RWLayer for container after mount labels have been set rwLayer = daemon.imageService.CreateLayer(container, setupInitLayer(daemon.idMapping)) container.RWLayer = rwLayer ...// Create root directory idtools.MkdirAndChown(container.Root, 0700, rootIDs); ...// Windows or Linux specific setup daemon.createContainerOSSpecificSettings(container, opts.params.Config, opts.params.HostConfig); ...// Store in a map for future lookup daemon.Register(container); ...}
首先,我们创建一个对象来存储容器的元数据。
然后像以前一样,我们创建一个根目录,其中包含镜像数据和读写(read-write)层,供容器使用。现在的区别在于联合挂载文件系统(union mount file system)已经成长到能够支持btrfs, OverlayFS和其它文件系统。为了方便这一点,驱动系统抽象了实现。
最后,容器对象被添加到守护进程的容器列表(map)中,以供将来使用。
启动容器
现在容器已创建成功,但尚未运行。接下来,我们请求启动它。
func(daemon *Daemon)containerStart(container *container.Container, checkpoint string, checkpointDir string, resetRestartManager bool)(err error) { ...// Create OCI spec for container spec = daemon.createSpec(container); ...// Call containerd to create the container according to spec daemon.containerd.Create(ctx, container.ID, spec, createOptions) ...// Call containerd to start running process inside of container pid = daemon.containerd.Start(context.Background(), container.ID, checkpointDir, container.StreamConfig.Stdin() != nil || container.Config.Tty, container.InitializeStdio); ...}
这就是containerd 起作用的地方,首先我们请求按照OCI规格(spec)来创建一个容器。然后,开始在这个容器内运行一个进程。然后把随后的监管交给containerd来处理。
containerd
containerd这个术语有令人困惑的地方。它被认为是一个运行时(runtime),但是又不实现OCI 运行时规范,因此它是一个和runc不一样的的运行时。containerd 是一个守护进程,它使用了符合OCI规格的运行时来监管容器的生命周期。正如Michael Crosby所描述的,containerd是容器的监管者。
(Source: Docker)
containerd被设计成监控容器的通用基础层,专注于速度和简单性。
很简单,创建一个容器所需要的只是它的规格(spec)和一个对根文件系统所在的位置进行编码的包。
创建容器
func(l *local)Create(ctx context.Context, req *api.CreateContainerRequest, _ ...grpc.CallOption)(*api.CreateContainerResponse, error) { l.withStoreUpdate(ctx, func(ctx context.Context, store containers.Store)error {// Update container data store with new container record container := containerFromProto(&req.Container) created := store.Create(ctx, container) resp.Container = containerToProto(&created)returnnil }); ...}
dockerd(通过一个GRPC客户端)请求containerd创建一个容器。containerd 接收这个请求后,将容器规格(spec)存储在位于/var/lib/containerd/下的文件系统支持的数据库中。
启动容器
// Start create and start a task for the specified containerd idfunc(c *client)Start(ctx context.Context, id, checkpointDir string, withStdin bool, attachStdio libcontainerdtypes.StdioCallback)(int, error) { ctr, err := c.getContainer(ctx, id) ... t, err = ctr.NewTask(ctx, ...) ... t.Start(ctx); ...returnint(t.Pid()), nil}
启动一个容器涉及创建和启动一个称为Task的新对象,该对象代表着容器内的一个进程。
创建Task
func (l *local) Create(ctxcontext.Context, r *api.CreateTaskRequest, _ ...grpc.CallOption) (*api.CreateTaskResponse, error) {container := l.getContainer(ctx, r.ContainerID) ... rtime l.getRuntime(container.Runtime.Name) ... c = rtime.Create(ctx, r.ContainerID, opts) ...}
Task的创建由底层容器的运行时负责。containerd 复用了OCI运行时,因此我们需要查找哪个运行时被用来创建这个Task。第一个和默认的运行时是runc。这个运行时的创建命令最终是运行一个外部进程runc,但它是通过间接调用docker-shim进程来完成的。
如果containerd崩溃了,运行中的容器的信息将会丢失。为了防止这种情况发生,containerd为每个容器创建一个称为垫片(shim)的管理进程。shim进程将调用一个OCI运行时来创建和启动一个容器,然后执行监视容器的职责,以捕获退出代码并管理标准IO。
在嵌套代码中,shim将在执行create命令时使用go-runc bindings库来启动/run/containerd/runc命令。。有关runc的更多信息,请参见下一节。
如果containerd 真的崩溃了,可以通过与shim通信并从/var/run/containerd/目录读取状态信息来恢复。
启动Task
既然容器已经创建了,启动Task需要做的事情就是简单地指示shim程序通过调用runc start命令来启动进程。
Runc
runc是一个命令行工具,用于根据OCI规格生成和运行容器。当它执行与LXC类似的工作时,它抽象出创建容器所需的Linux内核调用。
(这只可爱的仓鼠属于runc)
runc 只是OCI运行时规格(spec)的一个实现,更多的可用于在各种系统上创建容器的实现可以在这里找到(https://github.com/opencontainers/runtime-spec/blob/master/implementations.md)。
创建容器(runc create)
var createCommand = cli.Command{ Name: "create", Description: `The create command creates an instance of a container for a bundle. The bundleis a directory with a specification file named "` + specConfig + `" and a rootfilesystem.`, ... Action: func(context *cli.Context)error { spec := setupSpec(context) ... status := startContainer(context, spec, CT_ACT_CREATE, nil)// exit with the container's exit status so any external supervisor is// notified of the exit with the correct exit status. os.Exit(status)returnnil },
当runc创建一个容器时,它会在容器内设置名字空间、cgroups甚至init进程。当创建结束时,进程暂停,等待信号开始运行。
启动容器(runc start)
var startCommand = cli.Command{ Name: "start", Usage: "executes the user defined process in a created container", ... Action: func(context *cli.Context)error { container := getContainer(context) status := container.Status() ...switch status {case libcontainer.Created:return container.Exec()case libcontainer.Stopped:return errors.New("cannot start a container that has stopped")case libcontainer.Running:return errors.New("cannot start an already running container") } },}
最后,runc向暂停的进程发送一个信号以开始容器的启动。
直观总结
概括起来,使用Docker 2019来管理一个容器的运行涉及以下步骤:
一个创建容器的POST请求发送给dockerd;dockerd查找被请求的镜像;一个容器对象被创建,并存储起来以供将来使用设置文件系统的目录结构供容器使用;一个启动容器的POST请求发送给dockerd;为容器创建OCI规格(spec);containerd被调用来创建容器;containerd将容器规格储存在数据库中;containerd被调用来启动容器;containerd为容器创建一个TaskTask使用shim来调用runc create指令containerd启动这个TaskTask使用shim来调用runc start指令shim/containerd继续监控容器直到任务完成借助containerd的架构图,可以让我们直观地理解整个过程。
结论
表面上看,Docker及其配套项目显得杂乱无章,但实际上其底层的结构清晰并且已经实现了模块化。也就是说,发现所有上面的这些信息不是一件轻松的事。它散落在代码、博客帖子、会议讨论、文档和会议笔记中。拥有清晰的“自文档化”的代码是一个伟大的目标,但当涉及到大型系统时,我认为这还不够。有时,你只需要用简单的语言写下系统的外观,以及每个组件的职责。
非常感谢这些项目的所有贡献者,特别是那些编写解释这些系统的文档的人。
希望这篇文章这有助于帮助你理解Docker是如何运行容器的。我知道将来我会多次使用这篇文章作为参考。
原文:https://cameronlonsdale.com/2019/03/25/how-does-docker-work/#
本文为 CSDN 翻译,转载请注明来源出处。
【End】
作为码一代,想教码二代却无从下手:
听说少儿编程很火,可它有哪些好处呢?
孩子多大开始学习比较好呢?又该如何学习呢?
最新的编程教育政策又有哪些呢?
下面给大家介绍CSDN新成员:极客宝宝(ID:geek_baby)
戳他了解更多↓↓↓