动手实现一个docker引擎-4-进阶:实现mydocker的ps/exec/logs/envs等命令

学习自《自己动手写Docker》

作者:陈显鹭(花名:遥鹭)-阿里云高级研发工程师

京东购买链接:https://item.jd.com/10033552355433.html

其他链接:

  • https://zhuanlan.zhihu.com/p/101096040
  • https://www.chaochaogege.com/2019/09/11/2/

往期:

一、构造容器进阶

1. Version8-实现容器的后台运行

我们除了直接run的容器还需要能够让容器在后台运行,也就是detach类型的容器。

本节代码获取:

$ git clone https://github.com/xwjahahahaha/myDocker.git
$ git checkout 5a679

在docker的早期版本,所有的容器的init进程都是docker daemon(守护进程)fork出来的,如果守护进程挂了,那么所有容器就都会宕机。后来docker使用了containerd,也就是现在的runC就可以实现即时daemon挂掉,容器依然健在的功能了。其结构如下:

image-20211113192942202

​ 我们并不想实现daemon,因为这和容器的关联不是很大。而且runC也提供一种detach的功能,可以保证在runC退出的情况下容器可以运行。因此,我们将会使用detach功能去实现创建完成容器后,mydocker就会退出,但是容器依然运行的功能。

扫描二维码关注公众号,回复: 13214207 查看本文章

​ 容器在操作系统看来就是一个进程,当前运行mydocker命令的是主进程,容器是当前主进程复制的一个子进程。子进程与父进程是一个异步的过程,所以父进程永远不会知道子进程什么时候结束,如果创建子进程的父进程结束,那么这个子进程就成了没人管的孤儿进程。为了避免孤儿进程退出的时候无法释放资源而僵住,1号进程会接受这些孤儿进程。所以我们的原理就是将容器进程交给1号进程管理,这样就实现了主进程的退出,容器不宕机的功能

1. 实现

所以非常的简单,我们先增加一个flag,-d.

cmd/init.go

runDocker.Flags().BoolVarP(&Detach, "detach", "d", false, "Run container in background and print container ID")

cmd/commands.go

后台运行与交互式不能同时设置,所以我们加入一个判断:

var runDocker = &cobra.Command{
    
    
	Use:   "run [command]",
	Short: runUsage,
	Long:  runUsage,
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
    
    
		if tty && Detach {
    
    
			// 两个标志不运行同时设置
			return fmt.Errorf(" tty and detach can't both provided.")
		}
		// 获取交互flag值与command, 启动容器
		container.Run(tty, strings.Split(args[0], " "), ResourceLimitCfg, CgroupName, Volume)
		return nil
	},
}

最后,在Run函数中做一个小修改:

func Run(tty bool, cmdArray []string, res *subsystems.ResourceConfig, cgroupName string, volume string){
    
    
	....
	// 将容器进程加入到各个子系统中
	cgroupManager.Apply(parent.Process.Pid)
	// 等待结束
	if tty {
    
    
		// 如果是detach模式的话就父进程不需要等待子进程结束,而是启动子进程后自行结束就可以了
		if err := parent.Wait(); err != nil {
    
    
			log.Log.Error(err)
		}
		cgroupManager.Destroy()
		// 删除设置的AUFS工作目录
		rootUrl := "./"
		mntUrl := "./mnt"
		DeleteWorkSpace(rootUrl, mntUrl, volume)
		os.Exit(1)
	}
}

只有设置了tty交互模式才让父进程等待子进程/容器进程的结束,这样我们就实现了后台运行

2. 测试

$  ./mydocker run -d top
$ ps -ef | grep top

6fvmhR

可以看到我们的容器进程在后台跑着,其父进程变为了1

2. Version9-实现查看运行中的容器

这一节我们实现mydocker ps的命令,查看运行中的所有容器,包括其PID、创建时间、运行命令等信息

本节代码获取:

$ git clone https://github.com/xwjahahahaha/myDocker.git
$ git checkout 0babf

1. 实现

首先加入一个flag,-n可以指定容器的名称

cmd/init.go

runDocker.Flags().StringVarP(&Name, "container-name", "n", "", "set a container nickname")

然后在调用Run方法的时候将设置的容器名称传递

cmd/commands.go

var runDocker = &cobra.Command{
    
    
	Use:   "run [command]",
	Short: runUsage,
	Long:  runUsage,
	Args:  cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
    
    
		if tty && Detach {
    
    
			// 两个标志不运行同时设置
			return fmt.Errorf(" tty and detach can't both provided.")
		}
		// 获取交互flag值与command, 启动容器
		container.Run(tty, strings.Split(args[0], " "), ResourceLimitCfg, CgroupName, Volume, Name)
		return nil
	},
}

新增一个container/record.go文件,负责记录容器的基本信息

package container

import (
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
	"xwj/mydocker/log"
)

const (
	IDLen = 10
)

type ContainerInfo struct {
    
    
	Pid         string `json:"pid"`
	Id          string `json:"id"`
	Name        string `json:"name"`
	Command     string `json:"command"`
	CreatedTime string `json:"created_time"`
	Status      string `json:"status"`
}

var (
	RUNNING             = "running"
	STOP                = "stopped"
	EXIT                = "exited"
	DefaultInfoLocation = "/var/run/mydocker/"
	ConfigName          = "containerInfo.json"
)

// randStringContainerID
// @Description: 容器ID随机生成器
// @param n
// @return string
func randStringContainerID(n int) string {
    
    
	if n < 0 || n > 32 {
    
    
		n = 32
	}
	// 这里就采用对时间戳取hash的方法实现容器的随机ID生成
	hashBytes := sha256.Sum256([]byte(strconv.Itoa(int(time.Now().UnixNano()))))
	return fmt.Sprintf("%x", hashBytes[:n])
}

func recordContainerInfo(cPID int, commandArray []string, cName string) (string, error) {
    
    
	// 首先生成容器ID
	id := randStringContainerID(IDLen)
	// 以当前时间为容器的创建时间
	createTime := time.Now().Format("2006-01-02 15:04:05")
	// 如果用户没有指定容器名就用容器ID做为容器名
	if cName == "" {
    
    
		cName = id
	}
	containerInfo := ContainerInfo{
    
    
		Pid:         strconv.Itoa(cPID),
		Id:          id,
		Name:        cName,
		Command:     strings.Join(commandArray, ""),
		CreatedTime: createTime,
		Status:      RUNNING,
	}
	// 序列为json
	jsonBytes, err := json.Marshal(containerInfo)
	if err != nil {
    
    
		log.LogErrorFrom("recordContainerInfo", "Marshal", err)
		return "", err
	}
	// 创建容器信息对应的文件夹
	dirUrl := filepath.Join(DefaultInfoLocation, id)
	if err := os.MkdirAll(dirUrl, 0622); err != nil {
    
    
		log.LogErrorFrom("recordContainerInfo", "MkdirAll", err)
		return "", err
	}
	// 创建json文件
	fileName := filepath.Join(dirUrl, ConfigName)
	configFile, err := os.OpenFile(fileName, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0644)
	if err != nil {
    
    
		log.LogErrorFrom("recordContainerInfo", "OpenFile", err)
		return "", err
	}
	defer configFile.Close()
	// 写入到文件
	if _, err := configFile.WriteString(string(jsonBytes)); err != nil {
    
    
		log.LogErrorFrom("recordContainerInfo", "WriteString", err)
		return "", err
	}
	return id, nil
}

func DeleteContainerInfo(containerID string) {
    
    
	dirUrl := filepath.Join(DefaultInfoLocation, containerID)
	if err := os.RemoveAll(dirUrl); err != nil {
    
    
		log.LogErrorFrom("DeleteContainerInfo", "RemoveAll", err)
	}
}

randStringContainerID负责生成随机ID(使用时间戳作为随机源)

然后在recordContainerInfo函数中构造ContainerInfo结构体,json序列化后保存到/var/run/mydocker/<容器ID>/containerInfo.json

DeleteContainerInfo函数用于当容器结束时,删除掉容器的信息文件

2. 使用流程

image-20211113223517609

3. 测试

启动容器并设置名字

$ ./mydocker run -t -n xwj sh 

保持容器运行,然后在另一终端查看/var/run/mydocker文件夹

EmPMl7

当容器退出的时候也会删除掉这个文件夹

3. Version10-实现mydocker ps

代码获取地址:

本节代码获取:

$ git clone https://github.com/xwjahahahaha/myDocker.git
$ git checkout 5d1f8

1. 实现

在实现mydocker ps之前我们需要先解决一个问题:多个容器同时运行。

我们之前的实现没有考虑到多个容器的同时运行,当一个容器运行后会在当前目录生成mnt writerLayer等文件,如果在当前目录启动多个的话就会发生冲突。所以我们要向docker的AUFS那样,用唯一ID标识。

我们重新设置了根目录:/var/lib/mydocker/aufs/, 在其下创建两个文件夹mntdiff

  • diff: 存放对镜像压缩包解压的镜像层文件夹(也是容器的只读层)以及容器的读写层
  • mnt: 每个容器运行的目录, 统一挂载的目录

实现后,容器的效果如下:

image-20211114155949850

三个容器分别用其ID区别,共享镜像文件busybox。对应的删除也是删除其对应的文件夹即可。

同样的,cgroup也需要进行ID区分,做法其实也很简单,在之前cgroupName的目录名后加一个ID来唯一标识即可。(对应原来的代码中Mkdir要改成MkdirAll

效果如下:

image-20211114160316876

上面这些操作都是简单的文件夹创建与删除

添加一个-ps的flag,实现也很简单,遍历刚刚上一节的文件夹即可。

本节代码变动:

run multiple containers

(https://github.com/xwjahahahaha/myDocker/commit/c89c49dbe35ccf16a13fec54602b258a81da04a5)

2. 使用流程

image-20211114161819458

3. 测试

$ ./mydocker run -d top
$ ./mydocker run -d top -n viper
$ ./mydocker run -d top -n scount

ahtGt1

我们多开启几个并重命名, 然后查看./mydocker ps

AW9jPB

看一下目录结构:

Y6cHkb

4. Version11-实现查看容器后台日志

本节代码获取:

$ git clone https://github.com/xwjahahahaha/myDocker.git
$ git checkout 2a943

1. 实现

实现的思路就是将容器中的标准输出保存到一个日志文件,当需要的时候就访问这个文件即可。以此思路实现mydocker logs

我们将日志文件放到与容器信息containerInfo.json一起即/var/run/mydocker/<容器ID>/container.log文件下,然后创建一个新的命令logs来指定查看一个容器的运行日志,实现的思路就是读取容器的日志文件将其内容输出到宿主机的控制台上。

所有代码的改动如下:

add container logs

(https://github.com/xwjahahahaha/myDocker/commit/2a943b38c93ecd95bf00bbeb5ab80f0555bdf7c8)

2. 测试

后台启动一个容器后,查看其日志文件:

$ ./mydocker run -d top -n showmaker
[e45054b64c5225eba324]    
$ ./mydocker logs e45054b64c5225eba324

zsWqhJ

rlZuxv

5. Version12-实现进入容器Namespace

本节就是实现mydocker exec命令,重新进入容器

本节代码获取:

$ git clone https://github.com/xwjahahahaha/myDocker.git
$ git checkout 68376

1. 前置知识

setns

setns是一个系统调用。可以根据提供的PID再次进入到指定的Namespace中。它需要先打开/proc/[pid]/ns/文件夹下的对应文件,然后使当前进程进入到指定的Namespace中。

但是有一点是很麻烦的,对于进入指定的Mount Namespace,一个具有多线程的进程是不允许的,而Go每次启动一个程序就会进入多线程的状态,所以无法简单的使用go直接系统调用进入Mount Namespace。所以这里的思路就是使用Cgo调用C来实现这个功能。

Cgo

cgo运行go语言调用C的函数与标准库。只需要以一种特殊的方式在Go源码里写出需要调用的C的代码,Cgo就会把你的C源码文件和Go文件整合成一个包。例如一个简单的例子:

package main

/*
#include <stdlib.h>
*/
import "C"
import (
	"fmt"
	"time"
)

func Random() int {
    
    
	return int(C.random())
}

func Seed(i int)  {
    
    
	C.srandom(C.uint(i))
}

func main()  {
    
    
	Seed(int(time.Now().Unix()))
	fmt.Println(Random())
}
$ go run cgo.go
1657929052

需要注意的是:

/*
#include <stdlib.h>
*/
import "C"

之间不能够有空行,否则会报错could not determine kind of name for C.random

2. 实现

新创建namespace文件夹并创建setns.go文件,在其中实现如下的C代码:

//+build linux
package namespace

/*
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

// __attribute__((constructor))指的是一旦这个包被调用那么这个函数就会自动被执行。类似于构造函数,会在程序一启动的时候运行
__attribute__((constructor)) void enter_namespace(void) {
	char *mydocker_pid;
	// 从环境变量中获取需要进入的PID
	mydocker_pid = getenv("mydocker_pid");
	if (mydocker_pid) {
		fprintf(stdout, "C : mydocker_pid = %s\n", mydocker_pid);
	}else {
		//这里如果没有指定pid,那么就不需要继续向下执行了
		return;
	}
	char *mydocker_cmd;
	// 从环境变量中获取需要执行的命令
	mydocker_cmd = getenv("mydocker_cmd");
	if (mydocker_cmd) {
		fprintf(stdout, "C : mydocker_cmd = %s\n", mydocker_cmd);
	}else {
		// 同理
		return;
	}
	int i;
	char nspath[1024];
	char *namespaces[] = { "ipc", "uts", "net", "pid", "mnt"};
	// 循环每一个Namespace,让进程进入
	for (i=0; i<5; i++) {
		// 拼接对应的路径/proc/pid/ns/ipc
		sprintf(nspath, "/proc/%s/ns/%s", mydocker_pid, namespaces[i]);
		int fd = open(nspath, O_RDONLY);
		// 调用setns系统调用实现进入对应的Namespace, -1是成功的返回值
		if (setns(fd, 0) == -1) {
			return;
		}
		close(fd);
	}
	// 进入所有Namespace后执行指定的命令
	int res = system(mydocker_cmd);
	exit(0);
	return;
}
*/
import "C"

func EnterNamespace()  {
    
    }

以上的代码只要这个包(namespace)被导入,那么就会在所有的go代码之前执行。我们通过EnterNamespace这个空函数的调用实现对这个包的调用,这样执行这个空函数的时候就会自动执行C的代码:获取目标容器的PID,进入容器执行指定的命令。

注意:__attribute__((constructor))前面是双下划线

注意:这一节的代码需要在Linux上编译,否则会出一些问题,例如我在M1 arm64上编译会提示build constraints exclude all Go files in…并且setns也不满足C99标准等问题。如果非要交叉编译的话要设置CGO_ENABLED=1 CC=..., 可见Golang交叉编译中的那些坑

下面新创建一个子命令exec

var execCommand = &cobra.Command{
    
    
	Use:   "exec [container_id] [command]",
	Short: "exec a command into container",
	Long:  "print logs of a container",
	Run: func(cmd *cobra.Command, args []string) {
    
    
		if os.Getenv(ENV_EXEC_PID) != "" {
    
    
			// 第二次调用的时候执行
			log.Log.Infof("pid callback pid %s", os.Getenv(ENV_EXEC_PID))
			// 调用namespace包自动调用C代码setns进入容器空间
			namespace.EnterNamespace()
			return
		}
		if len(args) < 2 {
    
    
			log.Log.Errorf("Missing container name or command.")
			return
		}
		cid, commandAry := args[0], strings.Split(args[1], " ")
		// 设置环境变量并fork一个子进程重新调用自己
		container.ExecContainer(cid, commandAry)
	},
}

整体的思路就是判断当前是否有环境变量,如果没有那么就设置环境变量然后在重新调用自己

通过getContainerPidByID函数实现获取对应容器的PID

func getContainerPidByID(containerID string) (string, error)  {
    
    
	// 读取容器信息文件
	containerInfoPath := filepath.Join(DefaultInfoLocation, containerID, ConfigName)
	content, err := ioutil.ReadFile(containerInfoPath)
	if err != nil {
    
    
		log.LogErrorFrom("getContainerPidByID", "ReadFile", err)
		return "", err
	}
	var containerInfo ContainerInfo
	if err := json.Unmarshal(content, &containerInfo); err != nil {
    
    
		log.LogErrorFrom("getContainerPidByID", "Unmarshal", err)
		return "", err
	}
	return containerInfo.Pid, nil
}

然后设置环境变量,并创建子进程重新执行自己ExecContainer

func ExecContainer(containerID string, commandAry []string)  {
    
    
	pid, err := getContainerPidByID(containerID)
	if err != nil {
    
    
		return
	}
	cmdStr := strings.Join(commandAry, " ")
	log.Log.Infof("container pid %s", pid)
	log.Log.Infof("command %s", cmdStr)

	cmd := exec.Command("/proc/self/exe", "exec")
	cmd.Stdin = os.Stdin
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	// 设置环境变量:进程号与执行命令
	if err := os.Setenv(ENV_EXEC_PID, pid); err != nil {
    
    
		log.LogErrorFrom("ExecContainer", "Setenv_ENV_EXEC_PID", err)
		return
	}
	if err := os.Setenv(ENV_EXEC_CMD, cmdStr); err != nil {
    
    
		log.LogErrorFrom("ExecContainer", "Setenv_ENV_EXEC_CMD", err)
		return
	}

	if err := cmd.Run(); err != nil {
    
    
		log.LogErrorFrom("ExecContainer", "Run", err)
		return
	}
}

exec.Command("/proc/self/exe", "exec")熟悉的自调用方法,我们在运行容器的时候调用的是exec.Command("/proc/self/exe", "init")然后将这个init进程变为了设置命令的进程。而这里我们是重新的调用自己即exec命令,此时已经设置了环境变量,所以第一个判断就会进入,执行C的代码实现setns,当前线程切到fd对应的namespace即对应容器的ns并执行命令。

代码改动如下:

exec command compeleted

(https://github.com/xwjahahahaha/myDocker/commit/68376d0293c7c8114132a67fb9ca899d2c8083ca)

3. 使用流程

image-20211115095138263

4. 测试

找一个之前存在的容器

$ ./mydocker ps
$ ./mydocker exec 45b63b0d88ff7f6b36a7 sh

EJ2XZM

可以发现容器中进程为1的是之前后台运行的top命令,我们运行的是一个新的命令sh

6. Version13-实现停止容器

本节代码获取:

$ git clone https://github.com/xwjahahahaha/myDocker.git
$ git checkout 1fc5d

1. 实现

本节实现mydocker stop命令,实现的原理很简单,主要就是查找容器主进程的PID然后发送SIGTERM信号, 等到进程结束就好.

实现的步骤如下:

  1. 获取容器PID
  2. 对该PID发送Kill信号
  3. 修改容器信息
  4. 重新写入存储容器信息的文件

代码详细修改如下:

add stop command

(https://github.com/xwjahahahaha/myDocker/commit/1fc5d35024f1d7daab0ff2682cd95c801e433307)

2. 使用流程

image-20211115102742213

3. 测试

首先在宿主机上查看一下各个容器运行情况:

$ ps -ef | grep top

选择一个容器,然后关闭它

$ ./mydocker ps
$ ./mydocker stop e45054b64c5225eba324 
$ ./mydocker ps

再次在宿主机上查看

$ ps -ef | grep top

LXp91L

XHsEBd

可以看到宿主机上的对应进程已经被退出了。

7. Version14-实现删除容器

本节代码获取:

$ git clone https://github.com/xwjahahahaha/myDocker.git
$ git checkout ecb22

1. 实现

本节实现的思路很简单就是删除掉一些文件:

  • /var/run/mydocker/下的容器信息以及日志文件
  • /var/lib/mydocker/aufs/目录下的对应容器的读写层目录以及mnt挂载目录(之前已经实现了函数,调用即可)

步骤:

  1. 获取对应容器的信息,判断是否处于stopping状态
  2. 删除容器信息、日志文件
  3. 删除容器读写层、挂载目录

需要注意的是,一个容器可能设置了Volume, 所以当我们删除的时候也会将其删除。在containerInfo结构体中增加volume字段,以用来记录后台运行的容器的数据卷情况,在删除时一起删除。

add remove command

(https://github.com/xwjahahahaha/myDocker/commit/ecb22a3209fb32cbadd579504b33f952de480edc)

2. 测试

体验一整套流程

$ ./mydocker run -d top -v ./volume/:./cVolume
# f80cdde49c724ed9c927
$ ./mydocker ps
$ ./mydocker exec f80cdde49c724ed9c927
$ ls /cVolume
$ ./mydocker rm f80cdde49c724ed9c927 
$ ./mydocker stop f80cdde49c724ed9c927
$ ./mydocker rm f80cdde49c724ed9c927
$ ./mydocker ps

sxt9S9

738YKj

也可以在上面两个目录中检查是否所有文件都已经删除。

8. Version15-实现通过容器制作镜像

本节代码获取:

$ git clone https://github.com/xwjahahahaha/myDocker.git
$ git checkout f08cc

1.实现

之前通过run命令实现了一个同时运行多个容器的功能。但是对于我们的commit命令并没有修改,让其变成单独为每个容器打包成镜像

modify commit command

(https://github.com/xwjahahahaha/myDocker/commit/f08cc3e26c5e94b6bb33c77bc144d178e61dc94b)

2.测试

创建一个容器包含数据卷,然后将其打包(与busybox不同的是容器中有一个c1文件夹)

$ ./mydocker run -d -n test1 -v ./from1:/c1 top
$ ./mydocker ps
$ ./mydocker commit 7b572aad3c21d4ab06d2 busybox_test1

可以看到当前目录下会新创建一个压缩包文件:

loe2Sw

下面我们使用这个压缩文件创建一个镜像

$ ./mydocker run -t sh -i ./busybox_test1.tar 

image-20211115141800130

可以看到这个文件,我们再观察容器diff层的变化

$ cd /var/lib/mydocker/aufs/diff
$ ls -l

image-20211115141940328

可以看到我们打包的镜像又作为了新的容器的只读层进行了创建,观察新容器的读写层:

image-20211115142058571

并没有c1文件夹,所以已经不能修改这个打包好的镜像了。

9. Version16-实现容器指定环境变量运行

本节代码获取:

$ git clone https://github.com/xwjahahahaha/myDocker.git
$ git checkout 1099b

1. 实现

之前的实现中,文件类型的资源可以通过volume挂载到容器中访问,命令也可以传递。但是环境变量缺不可以,所以这一节我们实现在启动容器的时候指定环境变量,让容器内可以运行外部传递的环境变量。

在原来的基础上增加-e选项,允许用户指定环境变量,用户可以使用多次使用-e选项传递多个环境变量

然后传递这个环境变量,在创建子命令的时候设置:

// os.Environ()就是系统默认的配置(宿主机的环境变量),默认新启动进程都是默认继承父进程的环境变量
cmd.Env = append(os.Environ(), EnvSlice...)

对于执行exec命令时,我们设置的环境变量就无法生效了,因为exec生成的进程是宿主机父进程生成的,并不是容器内的初始进程。我们只是在初始进程这里设置了传递环境变量。需要指出的是,对于容器内的初始进程其随后创建的子进程都是保留这个环境变量的,所以不必担心这个问题。

下一步我们在exec命令中实现直接使用env命令查看容器内环境变量的功能

首先实现一个功能,通过指定的PID获取其环境变量:

func getEnvsByPid(pid string) []string {
    
    
	// 进程的环境变量存放在/proc/self/environ
	path := fmt.Sprintf("/proc/%s/environ", pid)
	contentBytes, err := ioutil.ReadFile(path)
	if err != nil {
    
    
		log.LogErrorFrom("getEnvsByPid", "ReadFile", err)
		return nil
	}
	// 多个环境变量中的分隔符是\u0000
	envs := strings.Split(string(contentBytes), "\u0000")
	return envs
}

同样的加入即可:

// 将容器进程的环境变量都放到exec进程内
cmd.Env = append(os.Environ(), getEnvsByPid(pid)...)

add env suppose

(https://github.com/xwjahahahaha/myDocker/commit/1099be357aaad64d6d330d0f0e9af869bc9e8f03)

2. 测试

交互模式检查环境变量:

$ ./mydocker run -t -n xwj111 -e bird=123 -e luck=bird sh
$ env | grep bird

luck=bird
bird=123

后台运行模式检查环境变量:

$ ./mydocker run -d -n hahah -e bird=123 -e luck=bird top
$ env | grep bird

luck=bird
bird=123

觉得不错的话,请点赞关注呦~~你的关注就是博主的动力
关注公众号,查看更多go开发、密码学和区块链科研内容:
2DrbmZ

猜你喜欢

转载自blog.csdn.net/weixin_43988498/article/details/121336392