docker run 过程解析 - Go语言中文社区

docker run 过程解析


以运行 busybox容器为线索,跟踪docekr启动容器的过程,

vito@caas:~$ docker run -it busybox /bin/sh

这里写图片描述

1、docker 客户端解析

Docker client主要的工作是通过解析用户所提供的一系列参数后,docker的入口函数main,在入口函数中处理传入的参数,并把参数转化为cobra的command类型,最后通过cobra调用相应的方法。
/docker/api/client/container/run.go

// NewRunCommand create a new `docker run` command
func NewRunCommand(dockerCli *client.DockerCli) *cobra.Command {
    var opts runOptions
    var copts *runconfigopts.ContainerOptions

    cmd := &cobra.Command{
        Use:   "run [OPTIONS] IMAGE [COMMAND] [ARG...]",
        Short: "Run a command in a new container",
        Args:  cli.RequiresMinArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            copts.Image = args[0]
            if len(args) > 1 {
                copts.Args = args[1:]
            }
            //run 命令对应的客户端方法
            return runRun(dockerCli, cmd.Flags(), &opts, copts)
        },
    }

客户端中预准备的步骤:

func runRun(dockerCli *client.DockerCli, flags *pflag.FlagSet, opts *runOptions, copts *runconfigopts.ContainerOptions) error {
    stdout, stderr, stdin := dockerCli.Out(), dockerCli.Err(), dockerCli.In()
    //实例化一个客户端,用于发送run命令请求
    client := dockerCli.Client()

    // TODO: pass this as an argument
    cmdPath := "run"

    //定义错误信息
    var (
        flAttach                              *opttypes.ListOpts
        ErrConflictAttachDetach               = fmt.Errorf("Conflicting options: -a and -d")
        ErrConflictRestartPolicyAndAutoRemove = fmt.Errorf("Conflicting options: --restart and --rm")
        ErrConflictDetachAutoRemove           = fmt.Errorf("Conflicting options: --rm and -d")
    )

    //解析命令和参数
    //config :主要是与主机无关的配置数据,比如hostname,user;默认omitempty设置,如果为空置则忽略字段。
    //hostConfig : 与主机相关的配置。
    //networkingConfig :网络相关的配置。
    config, hostConfig, networkingConfig, err := runconfigopts.Parse(flags, copts)
    fmt.Println('-----------print config:/n',config, hostConfig, networkingConfig)

分析:
config文件的结构定义在container包中,具体路径如下:
docker/vendor/github.com/docker/engine-api/types/container/config.go
docker/vendor/github.com/docker/engine-api/types/container/host_config.go

  1. container.Config :包含着容器的配置数据,主要是与主机无关的配置数据,比如hostname,user;默认omitempty设置,如果为空置则忽略字段。
type Config struct {
    Hostname   string // Hostname 容器内的主机名
    Domainname string // Domainname 域名服务器名称
    //容器内用户名,用于运行CMD命令
    User string // User that will run the command(s) inside the container

    AttachStdin  bool                  // Attach the standard input, makes possible user interaction
    AttachStdout bool                  // Attach the standard output 是否附加标准输出
    AttachStderr bool                  // Attach the standard error
    ExposedPorts map[nat.Port]struct{} `json:",omitempty"` // List of exposed ports 容器内暴露的端口号
    Tty          bool                  // Attach standard streams to a tty, including stdin if it is not closed.是否分配一个伪终端
    OpenStdin    bool                  // Open stdin 在没有附加标准输入是,是否依然打开标准输入
    //如该为真,用户关闭标准输入,容器的标准输入关闭
    StdinOnce       bool                // If true, close stdin after the 1 attached client disconnects.
    Env             []string            // List of environment variable to set in the container 环境变量
    Cmd             strslice.StrSlice   // Command to run when starting the container 容器内运行的指令
    Healthcheck     *HealthConfig       `json:",omitempty"` // Healthcheck describes how to check the container is healthy
    ArgsEscaped     bool                `json:",omitempty"` // True if command is already escaped (Windows specific)
    Image           string              // Name of the image as it was passed by the operator (eg. could be symbolic) 镜像名称
    Volumes         map[string]struct{} // List of volumes (mounts) used for the container 挂载目录
    WorkingDir      string              // Current directory (PWD) in the command will be launched 容器内,进程指定的工作目录
    Entrypoint      strslice.StrSlice   // Entrypoint to run when starting the container 覆盖镜像中默认的entrypoint
    NetworkDisabled bool                `json:",omitempty"` // Is network disabled 是否关闭容器网络功能
    MacAddress      string              `json:",omitempty"` // Mac Address of the container MAC地址
    //在dockerfile中定义的,指定的命令在容器构建时不执行,而是在镜像构建完成之后被出发执行
    OnBuild     []string          // ONBUILD metadata that were defined on the image Dockerfile
    Labels      map[string]string // List of labels set to this container 容器中的labels
    StopSignal  string            `json:",omitempty"` // Signal to stop a container
    StopTimeout *int              `json:",omitempty"` // Timeout (in seconds) to stop a container
    Shell       strslice.StrSlice `json:",omitempty"` // Shell for shell-form of RUN, CMD, ENTRYPOINT
}
  1. container.HostConfig: 与主机相关的配置信息。
type HostConfig struct {
    // Applicable to all platforms
    //从宿主机上绑定到容器的volume
    Binds []string // List of volume bindings for this container
    //用于写入容器ID的文件名
    ContainerIDFile string // File (path) where the containerId is written
    //配置容器的日志
    LogConfig LogConfig // Configuration of the logs for this container
    //容器的网络模式
    NetworkMode NetworkMode // Network mode to use for the container
    //容器绑定到宿主及的端口
    PortBindings nat.PortMap // Port mapping between the exposed port (container) and the host
    //容器退出是采取的重启策略
    RestartPolicy RestartPolicy // Restart policy to be used for the container
    //容器退出时是否自动移除容器
    AutoRemove   bool     // Automatically remove container when it exits
    VolumeDriver string   // Name of the volume driver used to mount volumes
    VolumesFrom  []string // List of volumes to take from other container

    // Applicable to UNIX platforms
    CapAdd          strslice.StrSlice // List of kernel capabilities to add to the container
    CapDrop         strslice.StrSlice // List of kernel capabilities to remove from the container
    DNS             []string          `json:"Dns"`        // List of DNS server to lookup
    DNSOptions      []string          `json:"DnsOptions"` // List of DNSOption to look for
    DNSSearch       []string          `json:"DnsSearch"`  // List of DNSSearch to look for
    ExtraHosts      []string          // List of extra hosts
    GroupAdd        []string          // List of additional groups that the container process will run as
    IpcMode         IpcMode           // IPC namespace to use for the container
    Cgroup          CgroupSpec        // Cgroup to use for the container
    Links           []string          // List of links (in the name:alias form)
    OomScoreAdj     int               // Container preference for OOM-killing
    PidMode         PidMode           // PID namespace to use for the container
    Privileged      bool              // Is the container in privileged mode
    PublishAllPorts bool              // Should docker publish all exposed port for the container
    ReadonlyRootfs  bool              // Is the container root filesystem in read-only
    SecurityOpt     []string          // List of string values to customize labels for MLS systems, such as SELinux.
    StorageOpt      map[string]string `json:",omitempty"` // Storage driver options per container.
    Tmpfs           map[string]string `json:",omitempty"` // List of tmpfs (mounts) used for the container
    UTSMode         UTSMode           // UTS namespace to use for the container
    UsernsMode      UsernsMode        // The user namespace to use for the container
    ShmSize         int64             // Total shm memory usage
    Sysctls         map[string]string `json:",omitempty"` // List of Namespaced sysctls used for the container
    Runtime         string            `json:",omitempty"` // Runtime to use with this container

    // Applicable to Windows
    ConsoleSize [2]int    // Initial console size
    Isolation   Isolation // Isolation technology of the container (eg default, hyperv)

    // Contains container's resources (cgroups, ulimits)
    Resources
}
  1. networktypes.NetworkingConfig:网络相关的配置。
  2. 其他的一些配置校验
    其中最关键的两个步骤:create、start
    创建容器的函数:
    //创建容器
    createResponse, err := createContainer(ctx, dockerCli, config, hostConfig, networkingConfig, hostConfig.ContainerIDFile, opts.name)
    if err != nil {
        reportError(stderr, cmdPath, err.Error(), true)
        return runStartContainerErr(err)
    }

运行容器的函数:

    //启动容器
    if err := client.ContainerStart(ctx, createResponse.ID, types.ContainerStartOptions{}); err != nil {
        // If we have holdHijackedConnection, we should notify
        // holdHijackedConnection we are going to exit and wait
        // to avoid the terminal are not restored.
        if attach {
            cancelFun()
            <-errCh
        }

通过上面的分析我们可以看出,docker run 命令主要执行了两个操作,一个是docker create、另一个是docker start,
查看route,找到相应的方法对应的方法实现:
container 相关的 route地址:docker/api/server/router/container/container.go

func (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),
        router.NewGetRoute("/containers/{name:.*}/json", r.getContainersByName),
        router.NewGetRoute("/containers/{name:.*}/top", r.getContainersTop),
        router.Cancellable(router.NewGetRoute("/containers/{name:.*}/logs", r.getContainersLogs)),
        router.Cancellable(router.NewGetRoute("/containers/{name:.*}/stats", r.getContainersStats)),
        router.NewGetRoute("/containers/{name:.*}/attach/ws", r.wsContainersAttach),
        router.NewGetRoute("/exec/{id:.*}/json", r.getExecByID),
        router.NewGetRoute("/containers/{name:.*}/archive", r.getContainersArchive),
        // POST
        router.NewPostRoute("/containers/create", r.postContainersCreate),
        router.NewPostRoute("/containers/{name:.*}/kill", r.postContainersKill),
        router.NewPostRoute("/containers/{name:.*}/pause", r.postContainersPause),
        router.NewPostRoute("/containers/{name:.*}/unpause", r.postContainersUnpause),
        router.NewPostRoute("/containers/{name:.*}/restart", r.postContainersRestart),
        router.NewPostRoute("/containers/{name:.*}/start", r.postContainersStart),
        router.NewPostRoute("/containers/{name:.*}/stop", r.postContainersStop),
        router.NewPostRoute("/containers/{name:.*}/wait", r.postContainersWait),
        router.NewPostRoute("/containers/{name:.*}/resize", r.postContainersResize),
        router.NewPostRoute("/containers/{name:.*}/attach", r.postContainersAttach),
        router.NewPostRoute("/containers/{name:.*}/copy", r.postContainersCopy), // Deprecated since 1.8, Errors out since 1.12
        router.NewPostRoute("/containers/{name:.*}/exec", r.postContainerExecCreate),
        router.NewPostRoute("/exec/{name:.*}/start", r.postContainerExecStart),
        router.NewPostRoute("/exec/{name:.*}/resize", r.postContainerExecResize),
        router.NewPostRoute("/containers/{name:.*}/rename", r.postContainerRename),
        router.NewPostRoute("/containers/{name:.*}/update", r.postContainerUpdate),
        // PUT
        router.NewPutRoute("/containers/{name:.*}/archive", r.putContainersArchive),
        // DELETE
        router.NewDeleteRoute("/containers/{name:.*}", r.deleteContainers),
    }

其中
create对应的handler:postContainersCreate
start对应的handler:postContainersStart
下面对两个过程逐个分析:

2、docker create 分析

这阶段Docker daemon的主要工作是对client提交的POST表单进行分析整理,获得config配置和hostconfig配置。然后daemon会调用daemon.newContainer函数来创建一个基本的container对象,并将config和hostconfig中保存的信息填写到container对象中。当然此时的container对象并不是一个具体的物理容器,它其中保存着所有用户指定的参数和Docker生成的一些默认的配置信息。最后,Docker会将container对象进行JSON编码,然后保存到其对应的状态文件中。

首先我们分析postContainersCreate方法,如下:

func (s *containerRouter) postContainersCreate(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
    if err := httputils.ParseForm(r); err != nil {
        return err
    }
    if err := httputils.CheckForJSON(r); err != nil {
        return err
    }
    name := r.Form.Get("name")
    config, hostConfig, networkingConfig, err := s.decoder.DecodeConfig(r.Body)
    //服务器端接收到的配置信息
    fmt.Println("服务器端接收到的配置信息 config:n", config, "n---------------hostConfign", hostConfig, "n------------networkingConfig:n", networkingConfig)
    if err != nil {
        return err
    }
    version := httputils.VersionFromContext(ctx)
    adjustCPUShares := versions.LessThan(version, "1.19")
    validateHostname := versions.GreaterThanOrEqualTo(version, "1.24")

    //对应的接口ContainerCreate,在daemon文件夹中查找该接口的实现
    ccr, err := s.backend.ContainerCreate(types.ContainerCreateConfig{
        Name:             name,
        Config:           config,
        HostConfig:       hostConfig,
        NetworkingConfig: networkingConfig,
        AdjustCPUShares:  adjustCPUShares,
    }, validateHostname)
    if err != nil {
        return err
    }

    return httputils.WriteJSON(w, http.StatusCreated, ccr)
}

分析代码可知,每一类操作都是定义了接口,方法调用的时候直接使用的接口调用,然后在daemon包中再具体实现接口,container的接口定义文件:/docker/api/server/router/container/backend.go

type stateBackend interface {
    ContainerCreate(config types.ContainerCreateConfig, validateHostname bool) (types.ContainerCreateResponse, error)
    ContainerKill(name string, sig uint64) error
    ContainerPause(name string) error
    ContainerRename(oldName, newName string) error
    ContainerResize(name string, height, width int) error
    ContainerRestart(name string, seconds int) error
    ContainerRm(name string, config *types.ContainerRmConfig) error
    ContainerStart(name string, hostConfig *container.HostConfig, validateHostname bool) error
    ContainerStop(name string, seconds int) error
    ContainerUnpause(name string) error
    ContainerUpdate(name string, hostConfig *container.HostConfig, validateHostname bool) ([]string, error)
    ContainerWait(name string, timeout time.Duration) (int, error)
}

在daemon包中找到ContainerCreate该接口的实现:
源代码路径:/docker/daemon/create.go

func (daemon *Daemon) ContainerCreate(params types.ContainerCreateConfig, validateHostname bool) (types.ContainerCreateResponse, error) {
    return daemon.containerCreate(params, false, validateHostname)
}

我们分析containerCreate方法,经过几个配置确认工作,开始创建容器,创建容器方法中经过一系列的操作完成了容器的创建过程,总结下来,主要做的事情有以下几步:

  1. 获取镜像ID GetImage
  2. 合并容器配置
  3. 合并日志配置
  4. 创建容器对象 newContainer
  5. 设置安全选项
  6. 设置容器读写层
  7. 创建文件夹保存容器配置信息
  8. 容器网络配置
  9. 保存到硬盘
  10. 注册到daemon

首先是在创建容器以前的确认工作分别,对配置文件中定义的容器工作目录、容器端口、容器hostname、容器网络等信息进行了确认,如该没问题开始下面的创建工作:
下面逐个分析每一步做了那些工作:
1、 获取镜像ID GetImage
通过镜像的名字,获取完整的镜像ID,然后使用该镜像ID获得完整的镜像结构体,用于以后创建容器

    //通过image name 获取 image id
    if params.Config.Image != "" {
        img, err = daemon.GetImage(params.Config.Image)
        if err != nil {
            return nil, err
        }
        imgID = img.ID()
    }

2. 将用户指定的config参数与镜像中json文件中的config参数合并并验证
方法如下:

    if err := daemon.mergeAndVerifyConfig(params.Config, img); err != nil {
        return nil, err
    }

主要做的工作是,初始化容器的一些配置,比如环境变量,主机名称等,如果在启动的时候传入的参数包含这些配置,例如在启动的时候设置了环境变量,在这个方法中会合并启动命令中的参数和镜像里面的参数,把镜像中的默认配置写入到配置文件中,对比写入前后的数据,以下是用命令docker run -e aaa=bb -h hosttest busybox 命令的前后对比结果:

-----------------合并配置前params:
 &{hosttest   false false false map[] false false false [aaa=bbb] [] <nil> false busybox map[]  [] false  [] map[]  <nil> []} 
 &{[]  { map[]} default map[] {no 0} false  [] [] [] [] [] [] [] []   [] 0  false false false [] map[] map[]   67108864 map[] runc [0 0]  {0 0  0 [] [] [] [] [] 0 0   [] 0 0 0 0 0xc421096e60 0xc421096e6a 0 [] 0 0 0 0}} 
 &{map[]}
-----------------合并配置后params:
 &{hosttest   false false false map[] false false false [aaa=bbb PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin] [sh] <nil> true busybox map[]  [] false  [] map[]  <nil> []} 
 &{[]  {json-file map[]} default map[] {no 0} false  [] [] [] [] [] [] [] []   [] 0  false false false [] map[] map[]   67108864 map[] runc [0 0]  {0 0  0 [] [] [] [] [] 0 0   [] 0 0 0 0 0xc421096e60 0xc421096e6a 0 [] 0 0 0 0}} 
 &{map[]}

3. 将用户指定的log config参数与镜像中json文件中的config参数合并并验证 ??
如该容器的日志选项没有配置,该方法将会把daemon 中的默认日志配置合并到容器中,具体细节有待进一步深入分析

4. 创建新的container对象

初始化一个容器的结构体newContainer,包含基本的信息,比如容器名称、配置文件结构体、对应的镜像ID等:

    if container, err = daemon.newContainer(params.Name, params.Config, imgID, managed); err != nil {
        return nil, err
    }

同时设置一些安全选项和存储选项的配置到container结构体,setSecurityOptions、StorageOpt

5. 设置安全选项??

6. 创建容器的读写层:

    if err := daemon.setRWLayer(container); err != nil {
        return nil, err
    }

7. 创建文件夹,用于保存容器的配置信息
在/var/lib/docker/containers/id下:

获取rootUID, rootGID,并创建容器文件夹保存容器的配置信息:

    //创建文件夹,用于保存容器的配置信息,在/var/lib/docker/containers/id下,并赋予容器进程的读写权限
    if err := idtools.MkdirAs(container.Root, 0700, rootUID, rootGID); err != nil {
        return nil, err
    }

    //把配置文件保存到磁盘
    if err := daemon.setHostConfig(container, params.HostConfig); err != nil {
        return nil, err
    }

8. 更新容器网络配置,并保存到硬盘

    if err := daemon.updateContainerNetworkSettings(container, endpointsConfigs); err != nil {
        return nil, err
    }

9. 最后注册该容器到docker daemon
把容器的id加入到docker daemon 中,容器创建完成。

待研究问题:docker Security :/media/vito/code/golang/src/docker/daemon/daemon_unix.go

3、docker start 分析

还是从docker daemon 收到请求的route路由开始梳理:
/docker/api/server/router/container/container.go文件下,找到start容器的API路由,

router.NewPostRoute(“/exec/{name:.*}/start”, r.postContainerExecStart)

我们从postContainerExecStart开始追踪,我们从postContainerExecStart中能够找到start容器对应的接口是ContainerExecStart,下一步是在daemon中找到该接口的实现。

    if err := s.backend.ContainerStart(vars["name"], hostConfig, validateHostname); err != nil {
        return err
    }

找到ContainerStart接口的daemon包实现:/docker/daemon/start.go

func (daemon *Daemon) ContainerStart(name string, hostConfig *containertypes.HostConfig, validateHostname bool) error {
    container, err := daemon.GetContainer(name)
    if err != nil {
        return err
    }

然后检查一些配置和状态,例如是否运行、是否暂停等,确认hostconfig与当前系统配置是否一致。最后执行函数containerStart,开始启动容器,具体函数如下:

func (daemon *Daemon) containerStart(container *container.Container) (err error)

下面我们仔细分析容器启动过程,主要由两步来完成,第一步是创建container 实例;第二部是启动容器,我们逐步分析:
第一步,创建container实例过程:

1. 实例化容器对象
因为前面已经创建完成了container,所有这里使用查找方法查找container,查找方式可以是完整的container ID,完整的container 名称或者ID的前缀。返回一个container 对象。

    //1、通过container名称或者完整ID,或者ID前缀获取一个container实例
    container, err := daemon.GetContainer(name)
    if err != nil {
        return err
    }

2. 判断暂停状态

    //2、如果暂停的容器不能启动,先unpause再启动
    if container.IsPaused() {
        return fmt.Errorf("Cannot start a paused container, try unpause instead.")
    }

3. 判断是否运行

    //3、如果是运行的容器不用启动
    if container.IsRunning() {
        err := fmt.Errorf("Container already started")
        return errors.NewErrorWithStatusCode(err, http.StatusNotModified)
    }

4. 向后兼容设置
主要针对非windows系统

5. 确认hostconfig配置

    //5、确认hostconfig与当前系统是否一致
    if _, err = daemon.verifyContainerSettings(container.HostConfig, nil, false, validateHostname); err != nil {
        return err
    }
  1. 调整旧版容器设置
    //为老的容器调整的函数,老的容器在创建阶段没有机会调用的函数
    if err := daemon.adaptContainerSettings(container.HostConfig, false); err != nil {
        return err
    }
    linuxMinCPUShares = 2
    linuxMaxCPUShares = 262144
    platformSupported = true

主要是cpu、内存限制的校验和设置

7. 创建结束,返回container对象

8. 开始进入启动容器函数

  1. 容器对象加锁
    //1、container 对象加锁
    container.Lock()
    defer container.Unlock()
  1. 状态校验,如该已经运行,直接返回
    //2、容器状态判断
    if container.Running {
        return nil
    }
  1. 挂载读写层
    返回挂载的路径,一般是:
    设置container.BaseFS为该路径
    dir, err := container.RWLayer.Mount(container.GetMountLabel())
    if err != nil {
        return err
    }
  1. 初始化网络
    首先验证网络模型是否是正确的值,然后设置网络模式,保存到container对象中,最后设置hostname

  2. 创建RunC的spec对象

  3. containerd 调用runc

  4. 进入RunC启动容器

4、runc过程解析

这里写图片描述
看runc的源代码:

//初始化了一个APP,设置了APP都有那些command, 然后执行app.Run 看一下app.Run()函数:
func main() {
    app := cli.NewApp()
    app.Name = "runc"
    app.Usage = usage
...
...
    app.Commands = []cli.Command{
        checkpointCommand,
        createCommand,
        deleteCommand,
        eventsCommand,
        execCommand,
        initCommand,
        killCommand,
        listCommand,
        pauseCommand,
        psCommand,
        restoreCommand,
        resumeCommand,
        runCommand,
        specCommand,
        startCommand,
        stateCommand,
        updateCommand,
    }

接下来看一下app.Run()函数:
/runc/vendor/github.com/urfave/cli/app.go

func (a *App) Run(arguments []string) (err error) {
    a.Setup()

    // parse flags
    set := flagSet(a.Name, a.Flags)
    set.SetOutput(ioutil.Discard)
    err = set.Parse(arguments[1:])
    nerr := normalizeFlags(a.Flags, set)
    context := NewContext(a, set, nil)
    ...
    ...
    args := context.Args()
    if args.Present() {
        name := args.First()
        c := a.Command(name)
        if c != nil {
            return c.Run(context)
        }
    }

下面 name=args.First()获取到的就是create、restore、run等。接下来调用HandleAction(a.Action, context) 会调用到 create对应的cli.command的Action函数,我们先看一下cli.command 的createCommand函数的定义
/runc/utils_linux.go

//启动容器
func startContainer(context *cli.Context, spec *specs.Spec, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) {
    id := context.Args().First()
    if id == "" {
        return -1, errEmptyID
    }

    notifySocket := newNotifySocket(context, os.Getenv("NOTIFY_SOCKET"), id)
    if notifySocket != nil {
        notifySocket.setupSpec(context, spec)
    }
    //createContainer创建了一个container的对象内附参数
    container, err := createContainer(context, id, spec)
    if err != nil {
        return -1, err
    }

最终在以下函数中运行容器

func (r *runner) run(config *specs.Process) (int, error) {
    if err := r.checkTerminal(config); err != nil {
        r.destroy()
        return -1, err
    }
    process, err := newProcess(*config)
    if err != nil {
        r.destroy()
        return -1, err
    }
版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/warrior_0319/article/details/79931987
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。
  • 发表于 2020-03-01 23:13:53
  • 阅读 ( 1010 )
  • 分类:docker

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢