1

docker 源码分析(3) -- daemon 启动流程

 2 years ago
source link: https://yanhang.me/post/2015-02-05-docker-source-code-part3-daemon-start/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

本篇的主要内容是关于docker daemon的启动流程。其主要内容均包含在 github.com/docker/docker/docker/daemon.go文件中的mainDaemon函数中,本文即按 其执行流程分析源码。因为所涉源码较多,所以所涉部分多是点到为止,详细分析会在后续 分专篇讲述。

Engine

mainDaemon的开始处,在确认参数解析无误后,首先便生成了一个Engine的实例:

eng := engine.New()
signal.Trap(eng.Shutdown)

Engine可以说是docker的核心。它用来执行docker的各种操作(统一为job的形式), 管理container的存储。其结构定义为 :

type Engine struct {
	handlers   map[string]Handler
	catchall   Handler
	hack       Hack // data for temporary hackery (see hack.go)
	id         string
	Stdout     io.Writer
	Stderr     io.Writer
	Stdin      io.Reader
	Logging    bool
	tasks      sync.WaitGroup
	l          sync.RWMutex // lock for shutdown
	shutdown   bool
	onShutdown []func() // shutdown handlers
}

大部分均可见名知意,下面对部分字段进行详细解析。

Handler

Engine结构体中中最关键的便是handlers映射表,docker daemon启动时会向其中注册各种功 能的handler,比如关于网络设置的、web server、版本等等,然后就可以通过名字调用进行 初始化:

type Handler func(*Job) Status

各个模块在初始化时只要设置好相应环境变量并注册一个job即可。统一的函数接口能够 让docker内部各组件在代码结构和执行流程上更加清晰一致。

jobEngine最为基本的执行单元。所有的docker操作,比如启动一个 container,在container内部执行一个程序,从网络pull一个镜像等等,都可以用 job来表示。

type Job struct {
	Eng     *Engine
	Name    string
	Args    []string
	env     *Env
	Stdout  *Output
	Stderr  *Output
	Stdin   *Input
	handler Handler
	status  Status
	end     time.Time
	closeIO bool
}
const (
	StatusOK       Status = 0
	StatusErr      Status = 1
	StatusNotFound Status = 127
)

从结构体的定义来看,jobunix上的进程的结构表示非常类似:名字、参数、环境变 量、标准输入输出、退出状态(0 表示成功,其他表示错误)……我们完全可以将其当作像进程一样的概念 来看待。

Initializes

New函数用来初始化一个Engine,基本上只是对各变量进行简单的初始化:

func New() *Engine {
    eng := &Engine{
        handlers: make(map[string]Handler),
        id:       utils.RandomString(),
        Stdout:   os.Stdout,
        Stderr:   os.Stderr,
        Stdin:    os.Stdin,
        Logging:  true,
    }
    eng.Register("commands", func(job *Job) Status {
        for _, name := range eng.commands() {
            job.Printf("%s\n", name)
        }
        return StatusOK
    })
    // Copy existing global handlers
    for k, v := range globalHandlers {
        eng.handlers[k] = v
    }
    return eng
}
  1. Engine id是一个随机字符串
  2. 注册了一个commandshandler,用来返回Engine所支持的commands(handlers表中的key)列表。
  3. 如果已经有预定义好的globalHandlers,也添加到Enginehandlers表中.

Shutdown

Engine关闭的流程大概如下:

  1. 不再接受新的执行job的请求
  2. 等待所有正在执行中的job结束
  3. 并发调用已经注册的各个shutdown handlers
  4. 所有handlers结束或者等待 15 秒后返回

具体可参考github.com/docker/docker/engine/engine.go#Shutdown()

上面mainDaemonTrap的设置可以让Engine像大多数unix程序一样在接收到信号时做 一些指定的操作:

  • SIGINT 或者 SIGTERM, 直接调用eng.Shutdown,然后程序结束
  • SIGINT 或者 SIGTERMeng.Shutdown执行完成之前重复了 3 次,那么就直接停止执行并且直接结束程序
  • 如果DEBUG环境变量被设置,SIGQUIT会直接让程序退出而不调用eng.Shutdown

Builtins

if err := builtins.Register(eng); err != nil {
    log.Fatal(err)
}

builtins包主要用来给Engine注册一些内部使用的handlers : 网络设置、 apiserverEvents设置和version信息 :

func Register(eng *engine.Engine) error {
    if err := daemon(eng); err != nil {
        return err
    }
    if err := remote(eng); err != nil {
        return err
    }
    if err := events.New().Install(eng); err != nil {
        return err
    }
    if err := eng.Register("version", dockerVersion); err != nil {
        return err
    }

    return nil
}

因为只是注册,具体的执行还在后面,所以这里暂时不深入探讨各个handler的详细内容, 等分析到实际执行的时候再结合运行时信息详细探讨,理解起来应该更容易一些。这里只列 出注册的handlers映射信息:

Name Handler

init_networkdriver bridge.InitDriver

serveapi apiserver.ServeApi

acceptconnections apiserver.AcceptConnections

version dockerVersion

Version

因为dockerVersion的实现比较简单,所以就直接写在了 github.com/docker/docker/builtins/builtins.go里面:

func dockerVersion(job *engine.Job) engine.Status {
    v := &engine.Env{}
    v.SetJson("Version", dockerversion.VERSION)
    v.SetJson("ApiVersion", api.APIVERSION)
    v.SetJson("GitCommit", dockerversion.GITCOMMIT)
    v.Set("GoVersion", runtime.Version())
    v.Set("Os", runtime.GOOS)
    v.Set("Arch", runtime.GOARCH)
    if kernelVersion, err := kernel.GetKernelVersion(); err == nil {
        v.Set("KernelVersion", kernelVersion.String())
    }
    if _, err := v.WriteTo(job.Stdout); err != nil {
        return job.Error(err)
    }
    return engine.StatusOK
}

我们可以直接通过执行docker version命令来查看其大概效果:

Events

我们可以先通过docker events命令来看看dockerEvents是干嘛用的。如图,启动一个 container

在另一个窗口的docker events命令显示结果:

可见Events是类似于 log 的一种东西,不过是一种结构化的记录方式,而且只记录特定的 运行时信息。

const eventsLimit = 64

type listener chan<- *utils.JSONMessage

type Events struct {
    mu          sync.RWMutex
    events      []*utils.JSONMessage
    subscribers []listener
}

func New() *Events {
    return &Events{
        events: make([]*utils.JSONMessage, 0, eventsLimit),
    }
}

而前面提到的events.New().Install(eng)也是向Engine注册了一些handlers:

func (e *Events) Install(eng *engine.Engine) error {
    // Here you should describe public interface
    jobs := map[string]engine.Handler{
        "events":            e.Get,
        "log":               e.Log,
        "subscribers_count": e.SubscribersCount,
    }
    for name, job := range jobs {
        if err := eng.Register(name, job); err != nil {
            return err
        }
    }
    return nil
}

具体的函数实现则不再赘述。

Registry

if err := registry.NewService(daemonCfg.InsecureRegistries).Install(eng); err != nil {
    log.Fatal(err)
}

registry主要是给Engine提供认证和搜索官方(dockerhub)镜像的能力:

func (s *Service) Install(eng *engine.Engine) error {
    eng.Register("auth", s.Auth)
    eng.Register("search", s.Search)
    return nil
}

如代码所示,Registry注册了authsearch两个handler

Daemon

经过前面的那么多设置,Engine算是配置的差不多了,下面就是对daemon进行各项配置 的时候了:

go func() {
    d, err := daemon.NewDaemon(daemonCfg, eng)
    if err != nil {
        log.Fatal(err)
    }
    
    log.Infof("docker daemon: %s %s; execdriver: %s; graphdriver: %s",
        dockerversion.VERSION,
        dockerversion.GITCOMMIT,
        d.ExecutionDriver().Name(),
        d.GraphDriver().String(),
    )
    
    if err := d.Install(eng); err != nil {
        log.Fatal(err)
    }
    
    b := &builder.BuilderJob{eng, d}
    b.Install()
    
    if err := eng.Job("acceptconnections").Run(); err != nil {
        log.Fatal(err)
    }
}()

主要内容如下:

  1. daemon各个模块的设置,创建daemon。这部分内容非常长,下面将详述。

  2. 打印一些关键日志信息。如下图所示:

  3. Engine注册daemon所提供的各种handlers,主要就是docker client各种命令 的后台实现:

  4. docker build的后台handler实现。因为这个命令实现比较复杂,所以单列。

  5. daemon设置完成后即启动api server准备接受请求。

Config

Config定义了docker daemon的各项配置:

type Config struct {
	Pidfile                     string
	Root                        string
	AutoRestart                 bool
	Dns                         []string
	DnsSearch                   []string
	Mirrors                     []string
	EnableIptables              bool
	EnableIpForward             bool
	EnableIpMasq                bool
	DefaultIp                   net.IP
	BridgeIface                 string
	BridgeIP                    string
	FixedCIDR                   string
	InsecureRegistries          []string
	InterContainerCommunication bool
	GraphDriver                 string
	GraphOptions                []string
	ExecDriver                  string
	Mtu                         int
	DisableNetwork              bool
	EnableSelinuxSupport        bool
	Context                     map[string][]string
	TrustKeyPath                string
	Labels                      []string
}
type Daemon struct {
	ID             string
	repository     string
	sysInitPath    string
	containers     *contStore
	execCommands   *execStore
	graph          *graph.Graph
	repositories   *graph.TagStore
	idIndex        *truncindex.TruncIndex
	sysInfo        *sysinfo.SysInfo
	volumes        *volumes.Repository
	eng            *engine.Engine
	config         *Config
	containerGraph *graphdb.Database
	driver         graphdriver.Driver
	execDriver     execdriver.Driver
	trustStore     *trust.TrustStore
}

从这些配置项也可以看出,很多都是与docker启动时的参数一一对应的。NewDaemon函 数即通过这些参数来进行daemon的各项设置:

Settings

从上面ConfigDaemon的定义也可以看出,二者包含了docker运行时需要关注 的绝大部分内容及组件。而具体的设置由 github.com/docker/docker/daemon/daemon.go#NewDaemonFromDirectory完成,因为比较 琐碎,所以将其归为以下几类介绍:

network args

因为网络参数比较多,有的还有冲突,所有还要进行一定的检测。关于网络的设置主要由以 下几项:

  1. MTU,容器网络的最大传输单元。未指定则使用默认值: 1500。如果网络环境的自定义程 度较高,则MTU需要小心设置,不然可能因为额外的封包解包过程导致包大小超过MTU而 被丢弃。

  2. --bridge--bip 参数不能同时指定。因为bridge是用来创建自定义的 bridge网络,而--bip是用来给默认的docker0指定其他地址和掩码的。

  3. --iptables=false--icc=false不能同时指定。因为ICC依赖于iptables

system

  • pidfile的创建和管理
if config.Pidfile != "" {
  if err := utils.CreatePidFile(config.Pidfile); err != nil {
    return nil, err
  }
  eng.OnShutdown(func() {
    // Always release the pidfile last, just in case
    utils.RemovePidFile(config.Pidfile)
  })
}

使用pid文件可以说是linux上大多数daemon服务的一种通用模式了: 没有则创建, 并且在程序退出时删除(通过shutdown handler来处理)。

  • 操作系统及内核版本检测,要求 linux 3.8 以上的 kernel.
if runtime.GOOS != "linux" {
  return nil, fmt.Errorf("The Docker daemon is only supported on linux")
}

if err := checkKernelAndArch(); err != nil {
  return nil, err
}
  • 权限检测,docker需要root权限运行
if os.Geteuid() != 0 {
  return nil, fmt.Errorf("The Docker daemon needs to be run as root")
}
  • TempDir设置

这里的TempDir是相对于docker的目录而言的,并不是指系统的/tmp目录。从参数设置 上可以看到默认的根目录为/var/lib/docker:

flag.StringVar(&config.Root, []string{"g", "-graph"}, "/var/lib/docker", "Path to use as the root of the Docker runtime")

如果使用默认值,则TempDir/var/lib/docker/tmp

func TempDir(rootDir string) (string, error) {
    var tmpDir string
    if tmpDir = os.Getenv("DOCKER_TMPDIR"); tmpDir == "" {
        tmpDir = filepath.Join(rootDir, "tmp")
    }
    err := os.MkdirAll(tmpDir, 0700)
    return tmpDir, err
}
  • SELinux

检测是否开启SELinux支持。SELinuxApparmor是 docker 支持的两种安全机制,SELinux功能强大,架构也比较复 杂,AppArmor则相反。

if !config.EnableSelinuxSupport {
	selinuxSetDisabled()
}
  • Docker root directory

Docker所有文件存储的根目录,默认为/var/lib/docker

graphdriver

graph driver是主要用来管理容器文件系统及镜像存储的组件,与宿主机对各文件系统的支持 相关。比如ubuntu上默认使用的是AUFS,Centos上是devicemapper,Coreos上则是btrfsgraph driver定义了一个统一的、抽象的接口,以一种可扩展的方式对各文件系统提供了支持。


// Set the default driver
graphdriver.DefaultDriver = config.GraphDriver

// Load storage driver
driver, err := graphdriver.New(config.Root, config.GraphOptions)
if err != nil {
  return nil, err
}
log.Debugf("Using graph driver %s", driver)

因为config.GraphDriver并没有设置(没有供用户指定的参数选项),所以graphDriver会从其支持的文件系统列表中 一个一个检测系统是否支持,找到一个支持的即设为要用的 driver :

for _, name := range priority {
  driver, err = GetDriver(name, root, options)
  if err != nil {
    if err == ErrNotSupported || err == ErrPrerequisites || err == ErrIncompatibleFS {
        continue
	}
    return nil, err
  }
  return driver, nil
}

priority列表为:

priority = []string{
	"aufs",
	"btrfs",
	"devicemapper",
	"vfs",
	// experimental, has to be enabled manually for now
	"overlay",
}

如果使用的是btrfs,因为其与SELinux的不兼容,所以还要进行一些检测:

if selinuxEnabled() && config.EnableSelinuxSupport && driver.String() == "btrfs" {
    return nil, fmt.Errorf("SELinux is not supported with the BTRFS graph driver!")
}

之后检测/var/lib/docker/containers目录是否存在,不存在则创建。我们来看看 containers目录下的内容:

每个container创建的时候,与网络有关的配置文件 (/etc/hosts,/etc/resolv.conf等)与其他文件的处理是不同的,他们是通过挂载的方式 供container使用的,有点类似于docker container本身的存储方式: 一个只读的层, 加上一些可写的层。containers目录就是用来存储这些信息的。

graph

g, err := graph.NewGraph(path.Join(config.Root, "graph"), driver)
if err != nil {
  return nil, err
}

Graph是用来存储标记的文件系统镜像以及他们之间的关系的组件:

type Graph struct {
	Root    string
	idIndex *truncindex.TruncIndex
	driver  graphdriver.Driver
}

其中idIndex的作用是使我们可以使用长 id 的前缀来检索镜像,RootGraph的根目录,一般为/var/lib/docker/graphNewGraph即是用此目 录下的文件来重建镜像索引。我们可以查看此目录下的目录的结构:

每一个镜像一个目录,下面包含一个描述镜像信息的 json 文件,也包含了记录镜像大小的 layersize文件。我们用一些实例来对比查看一下,下图是docker images --tree的部分 结果:

我们选取487e08镜像来对照,json 文件记录了其parent image的 id、创建时间、大小等 等信息。这个大小与 layersize 文件中的相一致。

volumes

Volumes是一种特殊的目录,其数据可以被一个或多个container共享,它和创建它的 container的生命周期分离开来,在container被删去之后能继续存在。在实现上,使用 的依然是只读层和读写层结合(union file system)的方式。

volumesDriver, err := graphdriver.GetDriver("vfs", config.Root, config.GraphOptions)
if err != nil {
    return nil, err
}

volumes, err := volumes.NewRepository(path.Join(config.Root, "volumes"), volumesDriver)
if err != nil {
    return nil, err
}

VFS是一个中间层,下面是个各种文件系统实现,对外提供的则是统一的访问接口,这非常 类似我们之前提到的GraphDriver的机制。刚开始看这段代码很难知道它是干嘛用的,但我们还可以 仿照之前Graph部分先对/var/lib/docker/volumes目录进行一番探究。

我们先用官方的例子创建一个包含Volumescontainer

然后通过docker inspect查看与Volumes相关的信息:

到获取到的目录去看下:

里面什么也没有。我们进到 container 内部在/webapp目录下创建一个文件看看:

可以确定,/var/lib/docker/vfs目录下的目录是用来存储Volumes中实际数据的。我们 再来看看/var/lib/docker/volumes目录下的内容:

可以看到,这个目录只用来存储关于Volumes的关键信息的。

明白了这些之后,就会发现上面的代码和之前的与Graph有关的代码是非常类似的 : 初始 化driver,然后从相应目录里读取原有的关于image或者container的信息并加载。

repository

repositories, err := graph.NewTagStore(path.Join(config.Root, "repositories-"+driver.String()), g, config.Mirrors, config.InsecureRegistries)
if err != nil {
    return nil, fmt.Errorf("Couldn't create Tag store: %s", err)
}

我们依然先来查看下相关的文件: /var/lib/docker/repositories-aufs :

整个 json 文件记录了所有的镜像的不同的 tag 及其对应的 id.从函数名NewTagStore上也可 以看出,tag信息的记录是其主要功能之一。

type TagStore struct {
	path               string
	graph              *Graph
	mirrors            []string
	insecureRegistries []string
	Repositories       map[string]Repository
	sync.Mutex
	// FIXME: move push/pull-related fields
	// to a helper type
	pullingPool map[string]chan struct{}
	pushingPool map[string]chan struct{}
}

pullingPool记录有哪些镜像正在被下载,若某一个镜像正在被下载,则驳回其他Docker Client发起下载该镜像的请求。pushingPool记录有哪些镜像正在被上传,若某一个镜像 正在被上传,则驳回其他Docker Client发起上传该镜像的请求;

trust

trustDir := path.Join(config.Root, "trust")
if err := os.MkdirAll(trustDir, 0700); err != nil && !os.IsExist(err) {
	return nil, err
}
t, err := trust.NewTrustStore(trustDir)
if err != nil {
	return nil, fmt.Errorf("could not create trust store: %s", err)
}

还是先看/var/lib/docker/trust下的内容:

跟认证签名有关的一些信息。这个文件是从下面这个地方获取到的:

var baseEndpoints = map[string]string{"official": "https://dvjy3tqbc323p.cloudfront.net/trust/official.json"}

然后用其中的内容来初始化TrustStore:

type TrustStore struct {
	path          string
	caPool        *x509.CertPool
	graph         trustgraph.TrustGraph
	expiration    time.Time
	fetcher       *time.Timer
	fetchTime     time.Duration
	autofetch     bool
	httpClient    *http.Client
	baseEndpoints map[string]*url.URL

	sync.RWMutex
}

init_networkdriver

if !config.DisableNetwork {
    job := eng.Job("init_networkdriver")

    job.SetenvBool("EnableIptables", config.EnableIptables)
    job.SetenvBool("InterContainerCommunication", config.InterContainerCommunication)
    job.SetenvBool("EnableIpForward", config.EnableIpForward)
    job.SetenvBool("EnableIpMasq", config.EnableIpMasq)
    job.Setenv("BridgeIface", config.BridgeIface)
    job.Setenv("BridgeIP", config.BridgeIP)
    job.Setenv("FixedCIDR", config.FixedCIDR)
    job.Setenv("DefaultBindingIP", config.DefaultIp.String())

    if err := job.Run(); err != nil {
        return nil, err
    }
}

前面提到在Builtins里注册了这个handler,这里就利用启动参数进行了相关环境变 量的设置并真正开始启动这个 Job。主要内容如下:

  1. bridge及其 ip 设置,一般都是使用默认的docker0
  1. iptablesipforward设置。

  2. fixed cidr 设置。这个可以用来限制contaienrdocker0获取到的ip地址的范 围。

  3. 注册了一些供以后进行各个容器的网络设置的handlers:

for name, f := range map[string]engine.Handler{
    "allocate_interface": Allocate,
    "release_interface":  Release,
    "allocate_port":      AllocatePort,
    "link":               LinkContainers,
} {
    if err := job.Eng.Register(name, f); err != nil {
        return job.Error(err)
    }
}

linkgraph.db

graphdbPath := path.Join(config.Root, "linkgraph.db")
graph, err := graphdb.NewSqliteConn(graphdbPath)
if err != nil {
    return nil, err
}

/var/lib/docker/linkgraph.db是一个 SQLITE3 的数据库文件。里面有两个表: edgeentity(两个图理论中常用的概念)。查看其内容:

edge里存储了容器的名字和 id,entity只存储了容器的 id。daemon通过这个数据库来重 建容器名称与 id 的关联。

execdriver

sysInfo := sysinfo.New(false)
ed, err := execdrivers.NewDriver(config.ExecDriver, config.Root, sysInitPath, sysInfo)
if err != nil {
	return nil, err
}

docker最开始使用的是linuxlxc作为其底层的容器执行引擎,后来自己开发了 libcontainer,用来替代lxc,所以我们现在看到docker info里显示的Excution Drivernative:

sysinfocgroup相关的一些系统信息,lxc exec driver初始化时需要从其中获取关于系统中apparmor的一些信息,但native exec driver不需要。

type SysInfo struct {
	MemoryLimit            bool
	SwapLimit              bool
	IPv4ForwardingDisabled bool
	AppArmor               bool
}
func NewDriver(name, root, initPath string, sysInfo *sysinfo.SysInfo) (execdriver.Driver, error) {
    switch name {
    case "lxc":
        return lxc.NewDriver(root, initPath, sysInfo.AppArmor)
    case "native":
        return native.NewDriver(path.Join(root, "execdriver", "native"), initPath)
    }
    return nil, fmt.Errorf("unknown exec driver %s", name)
}

看看代码中提到的目录/var/lib/docker/execdriver/native:

又是一堆container或者镜像的id,既然是执行引擎了,多半是关于container的一些 运行时信息,挑一个进去查看一下:

state.json主要描述了此container所在cgroup的相关目录,网络状态,以及主进程的 pid及启动时间。container.json包含信息较多,部分截图如下:

  1. 各个设备的访问权限,主要是/dev下面那些
  2. 一些特殊文件的信息。比如/etc/hosts,Volumes,/etc/resolv.conf等等
  3. 网络详细信息
  4. capabilites
  5. namespaces

Restore

经过前面各个组件的设置及初始化,终于到了daemon的创建了:

daemon := &Daemon{
    ID:             trustKey.PublicKey().KeyID(),
    repository:     daemonRepo,
    containers:     &contStore{s: make(map[string]*Container)},
    execCommands:   newExecStore(),
    graph:          g,
    repositories:   repositories,
    idIndex:        truncindex.NewTruncIndex([]string{}),
    sysInfo:        sysInfo,
    volumes:        volumes,
    config:         config,
    containerGraph: graph,
    driver:         driver,
    sysInitPath:    sysInitPath,
    execDriver:     ed,
    eng:            eng,
    trustStore:     t,
}
if err := daemon.restore(); err != nil {
    return nil, err
}

基本上用到了我们前面设置好的各个组件。之后的restore便开始加载原有的container, 将设为自启动的container启动。

Shutdown

前面提到过Engine在关闭时会调用各个注册好的handlers,这里便是一个:

eng.OnShutdown(func() {
    if err := daemon.shutdown(); err != nil {
        log.Errorf("daemon.shutdown(): %s", err)
    }
    if err := portallocator.ReleaseAll(); err != nil {
        log.Errorf("portallocator.ReleaseAll(): %s", err)
    }
    if err := daemon.driver.Cleanup(); err != nil {
        log.Errorf("daemon.driver.Cleanup(): %s", err.Error())
    }
    if err := daemon.containerGraph.Close(); err != nil {
        log.Errorf("daemon.containerGraph.Close(): %s", err.Error())
    }
})

主要进行daemon自身的清理工作,端口的释放,挂载点的卸载,与graphdb连接的关闭。

ServeApi

job := eng.Job("serveapi", flHosts...)
job.SetenvBool("Logging", true)
job.SetenvBool("EnableCors", *flEnableCors)
job.Setenv("Version", dockerversion.VERSION)
job.Setenv("SocketGroup", *flSocketGroup)

job.SetenvBool("Tls", *flTls)
job.SetenvBool("TlsVerify", *flTlsVerify)
job.Setenv("TlsCa", *flCa)
job.Setenv("TlsCert", *flCert)
job.Setenv("TlsKey", *flKey)
job.SetenvBool("BufferRequests", true)
if err := job.Run(); err != nil {
    log.Fatal(err)
}

查看之前在Builtins中注册的handlers表,可知serveapi对应的是 apiserver.ServeApi函数。ServeApi即开始监听参数中指定的各种协议和端口,并准备 开始处理http请求了(docker clientdaemon 的交互都是通过REST API来进行 的)。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK