像虚拟机一样运行容器

背景

平时看过文章标题比较多的都是说“像容器一样运行虚拟机”,大家都想要有虚拟机的隔离性,又想要容器的便捷性,也有一些开源项目比如 Firecracker 或 KataContainer 在做。今天反过来,来看看如何“像虚拟机一样运行容器”。

为啥要把容器搞得像虚拟机一样呢?我平时用到容器比较多的地方就是在 CI 集成部分,通过 docker 快速搭建环境,进行单元测试或集成测试,测试完成后清理镜像,简单方便。但是在CD 部分,就有一点比较头疼的问题,就是调试。zouquan 同学之前在知乎上提了一个问题:容器化环境里如何方便的进行debug和测试?,回答中的一个总结很好的描述了这个问题的关键: 虽然我在本地开发,但我的应用就像在 k8s 里一样。

那怎么在容器中开发像是在本地一样呢?肯定不能每次改了代码都走一遍 build,push,deploy 的流程,上面问题的回答中给出的是借助各种工具来达成这样的效果,我不像要用那些奇奇怪怪的工作(学不动了),那么只能想办法把容器搞的跟虚拟机一样了。

最近看到了 weaveworks/footloose 项目,这个项目的简介就是我的最原始的需求:Containers that look like Virtual Machines。先来看看这个项目的示例(开源项目中examples 写的好真是上手快)。

功能示例

Ansible 远程控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[root@yiran ansible]# footloose config create --replicas 1 # 指定 machine 副本数为 1
[root@yiran ansible]# footloose create # 创建目标资源
INFO[0000] Creating SSH key: cluster-key ...
INFO[0000] Docker Image: quay.io/footloose/centos7:0.6.1 present locally
INFO[0000] Creating machine: cluster-node0 ...
INFO[0001] Machine cluster-node0 is already created...
[root@yiran ansible]# cat ansible.cfg # 在 ansible 配置文件中指定 inventory 及连接参数
[defaults]
inventory=inventory.txt
remote_user=root
debug=no

[privilege_escalation]
become=no

[root@yiran ansible]# cat inventory.txt
[all]
cluster-node0 ansible_connection=docker # 编写对应 machine 连接方式

[root@yiran ansible]# ansible -m ping all # 验证 ansible 通信
cluster-node0 | SUCCESS => {
"changed": false,
"ping": "pong"
}

可以看到,通过 footloose 创建一个 machine(容器),可以支持我们远程连接,通过 Ansible 来控制,那么我们来试试 Ansible Playbook 的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
---
- name: Install nginx
hosts: cluster-node0

tasks:
- name: Add epel-release repo
yum:
name: epel-release
state: latest

- name: Install nginx
yum:
name: nginx
state: latest

- name: Insert Index Page
copy:
content: "welcome to footloose nginx ansible example"
dest: /usr/share/nginx/html/index.html

- name: Start NGiNX
service:
name: nginx
state: started

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
[root@yiran ansible]# ansible-playbook  example1.yml

PLAY [Install nginx] ********************************

TASK [Gathering Facts] *********************************
ok: [cluster-node0]

TASK [Add epel-release repo] *******************************
changed: [cluster-node0]

TASK [Install nginx] ********************************
changed: [cluster-node0]

TASK [Insert Index Page] ***********************************
changed: [cluster-node0]

TASK [Start NGiNX] **********************************
changed: [cluster-node0]

PLAY RECAP ************************
cluster-node0 : ok=5 changed=4 unreachable=0 failed=0

[root@yiran ansible]# ansible all -m raw -a 'systemctl status nginx'
cluster-node0 | SUCCESS | rc=0 >>
● nginx.service - The nginx HTTP and reverse proxy server
Loaded: loaded (/usr/lib/systemd/system/nginx.service; disabled; vendor preset: disabled)
Active: active (running) since Thu 2019-09-19 08:51:26 UTC; 10s ago
Process: 437 ExecStart=/usr/sbin/nginx (code=exited, status=0/SUCCESS)
Process: 436 ExecStartPre=/usr/sbin/nginx -t (code=exited, status=0/SUCCESS)
Process: 435 ExecStartPre=/usr/bin/rm -f /run/nginx.pid (code=exited, status=0/SUCCESS)
Main PID: 438 (nginx)
CGroup: /docker/6b8bd7e41a6a303d5cc023e2c2e576773649e4a5188f4ef15b0ad3079e148b49/system.slice/nginx.service
├─438 nginx: master process /usr/sbin/ngin
├─439 nginx: worker proces
├─440 nginx: worker proces
├─441 nginx: worker proces
└─442 nginx: worker proces

Sep 19 08:51:26 node0 systemd[1]: Starting The nginx HTTP and reverse proxy server...
Sep 19 08:51:26 node0 nginx[436]: nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
Sep 19 08:51:26 node0 nginx[436]: nginx: configuration file /etc/nginx/nginx.conf test is successful
Sep 19 08:51:26 node0 systemd[1]: Started The nginx HTTP and reverse proxy server.

可以执行 Ansible Playbook,那几乎意味着我们可以执行任何操作,我们可以通过 ansible rsync 模块直接将代码同步到容器中,也可以通过 Playbook 在容器中执行一些配置来达到我们对环境的修改,可以说是很方便了。

SSH 连接

既然可以通过 Ansible 进行控制,那么我们肯定也可以通过 ssh 进行连接,可以通过 footloose 提供的默认命令 footloose ssh

1
2
3
4
5
6
[root@yiran ansible]# footloose ssh root@node0
Last login: Thu Sep 19 08:57:17 2019 from gateway
[root@node0 ~]# hostname
node0
[root@node0 ~]# logout
Connection to localhost closed.

Host 端口映射

在容器使用的过程中,我们通常需要跑一些对外提供端口的服务,这时候就需要进行 Host 端口映射,先来看下 footloose 的配置文件,这里我们指定了 machine的数量是 2,并且指定了容器的 22 端口映射到 host 的 2222端口,依次递增:

1
2
3
4
5
6
7
8
9
10
11
cluster:
name: cluster
privateKey: cluster-key
machines:
- count: 2
spec:
image: quay.io/footloose/centos7
name: node%d
portMappings:
- containerPort: 22
hostPort: 2222

创建对应 machine 资源:

1
2
3
4
5
[root@yiran simple-hostPort]# footloose create
INFO[0000] Creating SSH key: cluster-key ...
INFO[0000] Pulling image: quay.io/footloose/centos7 ...
INFO[0013] Creating machine: cluster-node0 ...
INFO[0014] Creating machine: cluster-node1 ...

通过 netstat 查看 Host 端口情况,这里可以看到 footloose 使用的是 docker 作为容器管理入口:

1
2
3
[root@yiran simple-hostPort]# netstat -antp |grep 222
tcp6 0 0 :::2222 :::* LISTEN 42227/docker-proxy
tcp6 0 0 :::2223 :::* LISTEN 42540/docker-proxy

这时候就可以使用普通的 ssh 命令连接到容器中了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[root@yiran simple-hostPort]# ssh root@127.0.0.1 -p 2222 -i cluster-key hostname
The authenticity of host '[127.0.0.1]:2222 ([127.0.0.1]:2222)' can't be established.
ECDSA key fingerprint is SHA256:a6w9oFXMxjPCIXV42C44ogH9uaOILQiAdo/nlGdOnoc.
ECDSA key fingerprint is MD5:6b:a8:78:08:78:63:d4:26:b8:11:9e:3c:31:24:ad:6e.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '[127.0.0.1]:2222' (ECDSA) to the list of known hosts.
node0
[root@yiran simple-hostPort]# ssh root@127.0.0.1 -p 2223 -i cluster-key hostname
The authenticity of host '[127.0.0.1]:2223 ([127.0.0.1]:2223)' can't be established.
ECDSA key fingerprint is SHA256:o5cVIJ1MBlw/J/OcNcjZxjqogiIVe03HhU0ZYZEuyPM.
ECDSA key fingerprint is MD5:06:a6:4f:09:4c:23:1e:17:ee:f6:fe:f1:fd:35:e1:ba.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '[127.0.0.1]:2223' (ECDSA) to the list of known hosts.
node1
[root@yiran simple-hostPort]# footloose show
NAME HOSTNAME PORTS IP IMAGE CMD STATE BACKEND
cluster-node0 node0 2222->22 172.17.0.2 quay.io/footloose/centos7 /sbin/init Running
cluster-node1 node1 2223->22 172.17.0.3 quay.io/footloose/centos7 /sbin/init Running

写了三个使用场景,那么我们来看看 footloose 是怎么实现的。

代码实现

machine 创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// CreateMachine creates and starts a new machine in the cluster.
func (c *Cluster) CreateMachine(machine *Machine, i int) error {
name := machine.ContainerName()

publicKey, err := c.publicKey(machine) # 获取当前主机的公钥
...

cmd := "/sbin/init" # 指定容器运行命令
if machine.spec.Cmd != "" {
cmd = machine.spec.Cmd
}

if machine.IsIgnite() { # 判断 backend
...
} else {
runArgs := c.createMachineRunArgs(machine, name, i)
_, err := docker.Create(machine.spec.Image,
runArgs,
[]string{cmd},
)
if err != nil {
return err
}

if len(machine.spec.Networks) > 1 { # 当容器有多个网络配置时,依次进行 bridge 连接
for _, network := range machine.spec.Networks[1:] {
log.Infof("Connecting %s to the %s network...", name, network)
if network == "bridge" {
if err := docker.ConnectNetwork(name, network); err != nil {
return err
}
} else {
if err := docker.ConnectNetworkWithAlias(name, network, machine.Hostname()); err != nil {
return err
}
}
}
}

if err := docker.Start(name); err != nil {
return err
}

// Initial provisioning.
if err := containerRunShell(name, initScript); err != nil {
return err
}
if err := copy(name, publicKey, "/root/.ssh/authorized_keys"); err != nil {
return err
}
}

return nil
}

解这看下 createMachineRunArgs 里面的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func (c *Cluster) createMachineRunArgs(machine *Machine, name string, i int) []string {
runArgs := []string{ # 根据已有参数,进行 docker 的命令行拼接
"-it",
"--label", "works.weave.owner=footloose",
"--label", "works.weave.cluster=" + c.spec.Cluster.Name,
"--name", name,
"--hostname", machine.Hostname(),
"--tmpfs", "/run", # 注意这里传入的参数部分
"--tmpfs", "/run/lock",
"--tmpfs", "/tmp:exec,mode=777",
"-v", "/sys/fs/cgroup:/sys/fs/cgroup:ro",
}

for _, volume := range machine.spec.Volumes { # 卷挂载
...
}

for _, mapping := range machine.spec.PortMappings { # 端口映射
...
}

if machine.spec.Privileged {
runArgs = append(runArgs, "--privileged")
}

if len(machine.spec.Networks) > 0 { # 网络连接
...
}

return runArgs
}

这里需要注意的是,在 docker 命令行最终执行时,添加了 --tmpfs /run --tmpfs /run/lock --tmpfs /tmp:exec,mode=777 参数,并且将 Host 的 cgroup 配置路径通过只读权限传递给了容器,后面有用到。

其他的启动,停止,删除等操作也都是拼接为 docker 的命令行然后执行处理的,这里不过多描述。

那么有个问题,在容器内部,pid 为1 的进程应该是我们运行容器时传递的参数,也就时说,当我们执行的进程结束时,容器也就退出了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[root@yiran ~]# docker run centos sleep 6000
Unable to find image 'centos:latest' locally
latest: Pulling from library/centos
d8d02d457314: Already exists
Digest: sha256:307835c385f656ec2e2fec602cf093224173c51119bbebd602c53c3653a3d6eb
Status: Downloaded newer image for centos:latest

# 新开 termimal
[root@yiran ~]# docker ps |grep -i centos
6337dc1ad054 centos "sleep 6000" 13 minutes ago Up 13 minutes heuristic_haibt
[root@yiran ~]# docker exec -it 6337dc1ad054 bash
[root@6337dc1ad054 /]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:29 ? 00:00:00 sleep 6000
root 23 0 3 10:43 pts/0 00:00:00 bash
root 36 23 0 10:43 pts/0 00:00:00 ps -ef

来看下 footloose 创建的 machine 是如何保证容器持久运行的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[root@yiran ansible]# 
[root@yiran ansible]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
[root@yiran ansible]# footloose create
INFO[0000] Docker Image: quay.io/footloose/centos7:0.6.1 present locally
INFO[0000] Creating machine: cluster-node0 ...
[root@yiran ansible]# footloose show
NAME HOSTNAME PORTS IP IMAGE CMD STATE BACKEND
cluster-node0 node0 32773->22 172.17.0.2 quay.io/footloose/centos7:0.6.1 /sbin/init Running docker
[root@yiran ansible]# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8ba9af085e53 quay.io/footloose/centos7:0.6.1 "/sbin/init" 8 seconds ago Up 7 seconds 0.0.0.0:32773->22/tcp cluster-node0
[root@yiran ansible]# footloose ssh node0
[root@node0 ~]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:46 ? 00:00:00 /sbin/init
root 17 1 0 10:46 ? 00:00:00 /usr/lib/systemd/systemd-journald
root 50 1 0 10:46 ? 00:00:00 /usr/sbin/sshd -D
root 58 50 0 10:46 ? 00:00:00 sshd: root@pts/1
dbus 60 1 0 10:46 ? 00:00:00 /usr/bin/dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation
root 61 1 0 10:46 ? 00:00:00 /usr/lib/systemd/systemd-logind
root 62 58 0 10:46 pts/1 00:00:00 -bash
root 75 62 0 10:46 pts/1 00:00:00 ps -ef

可以看到在 machine 中, pid 为1 的进程是 init,这个初始化参数是写死在代码里面的,因为 machine 中存在 init 进程,也就保证了我们之后的进程都是在 init 进程树下的,我们可以通过 systemd 对服务进行管理,直到我们的从容器外部将容器杀死。

前面使用过程中,一直忽略了一点,就是我们的容器镜像内部有什么不同么?看下 Dockerfile 里面的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
master ✗ $ cat Dockerfile          
FROM centos:7 # base 镜像是 centos7

ENV container docker

RUN yum -y install sudo procps-ng net-tools iproute iputils wget && yum clean all # 安装必要的debug 工具

# 在 centos7 中,init 切换为 systemd 管理,针对容器中删除部分 systemd 配置
RUN (cd /lib/systemd/system/sysinit.target.wants/; for i in *; do [ $i == \
systemd-tmpfiles-setup.service ] || rm -f $i; done); \
rm -f /lib/systemd/system/multi-user.target.wants/*;\
rm -f /etc/systemd/system/*.wants/*;\
rm -f /lib/systemd/system/local-fs.target.wants/*; \
rm -f /lib/systemd/system/sockets.target.wants/*udev*; \
rm -f /lib/systemd/system/sockets.target.wants/*initctl*; \
rm -f /lib/systemd/system/basic.target.wants/*;\
rm -f /lib/systemd/system/anaconda.target.wants/*;\
rm -f /lib/systemd/system/*.wants/*update-utmp*;

# 为了支持 ssh 连接,安装 openssh
RUN yum -y install openssh-server && yum clean all

# 暴露 22 端口
EXPOSE 22

# https://www.freedesktop.org/wiki/Software/systemd/ContainerInterface/
STOPSIGNAL SIGRTMIN+3

CMD ["/bin/bash"]

可以看到 footloose 支持的镜像在官方的 CentOS7 的基础上进行了部分配置,比如 systemd、openssh、端口暴露等,来让容器更像是一台虚拟机。

总结

为了方便的进行持续集成,我们引入了容器;为了更方便的进行调试/测试,我们让容器装作虚拟机的样子,也是无奈。

参考链接