A Simplified OCI(Open Containers Initiative) Implementation, just like runC

Travis-CI
GoDoc
codecov
Report card

https://github.com/songxinjianqwe/capsule

Project Structure

Capsule是一个CLI工具,提供了对容器的CRUD操作。
CLI与C-S架构主要的区别是CLI仅支持创建在本机中运行的容器,而C-S架构可以创建远程容器。
Docker是一个C-S架构的软件,而Docker底层依赖于runC,runC是实现了OCI标准的CLI软件,Docker以可执行程序的方式来调用runC管理容器。
Capsule实现了部分OCI标准,主体与runC类似,架构与实现方面部分参考了runC,尽可能简化其逻辑,只保留了容器核心技术的运用(如namespaces, cgroups, pivot root, network, image等)。
除了OCI标准外,Capsule也实现了容器网络与镜像管理的功能,这部分其实应该放在另一个软件中实现,但由于时间有限,暂时放到本项目中实现。

Features

Capsule创建的容器可以提供一下功能:

  • namespace 支持, 包括 uts, pid, mount, network,暂不支持user ns
  • control group(linux cgroups) 支持,目前仅支持cpu与memory的控制
  • 支持运行在用户提供的root fs上
  • 容器网络, 包括容器间网络、容器与宿主机间网络、容器与外部网络
  • 丰富的容器CLI命令支持, 包括 list, state, create, run, start, kill, delete, exec, ps, log and spec.
  • 镜像管理,包括镜像导入(由Docker导出的镜像),以类似于Docker CLI的方式运行容器(即不需要提供OCI标准的config.json)

Install

Step0 go get “github.com/songxinjianqwe/capsule

Step1 开启宿主机的ip forward

所谓转发即当主机拥有多于一块的网卡时,其中一块收到数据包,根据数据包的目的ip地址将包发往本机另一网卡,该网卡根据路由表继续发送数据包。这通常就是路由器所要实现的功能。

bridge收到来自容器的请求时,根据数据包的目的IP(比如目的IP为公网IP,则匹配到默认路由default,默认路由到eth0),将数据包转发到eth0,bridge和eth0不需要直连。

  1. vi /usr/lib/sysctl.d/50-default.conf #命令(编辑配置文件)
  2. net.ipv4.ip_forward=1 # 设置转发
  3. sysctl p

Step2 安装iptables

CentOS7默认的防火墙不是iptables,而是firewalle.

  1. #先检查是否安装了iptables
  2. service iptables status
  3. #安装iptables
  4. yum install -y iptables
  5. #安装iptables-services
  6. yum install -y iptables-services
  7. #停止firewalld服务
  8. systemctl stop firewalld
  9. #禁用firewalld服务
  10. systemctl mask firewalld
  11. #启用iptables
  12. systemctl enable iptables.service
  13. systemctl start iptables.service
  14. systemctl status iptables.service
  1. #查看iptables现有规则
  2. iptables -L -n
  3. # 注意删掉REJCECT规则,否则在ping的时候会出现Destination Host Prohibited
  4. # 比如说刚装好之后可能是这样的,注意把INPUT的第5条和FORWARD的第1条删掉
  5. [root@localhost mycontainer]# iptables -L -n --line-number
  6. Chain INPUT (policy ACCEPT)
  7. num target prot opt source destination
  8. 1 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHED
  9. 2 ACCEPT icmp -- 0.0.0.0/0 0.0.0.0/0
  10. 3 ACCEPT all -- 0.0.0.0/0 0.0.0.0/0
  11. 4 ACCEPT tcp -- 0.0.0.0/0 0.0.0.0/0 state NEW tcp dpt:22
  12. 5 REJECT all -- 0.0.0.0/0 0.0.0.0/0 reject-with icmp-host-prohibited
  13. Chain FORWARD (policy ACCEPT)
  14. num target prot opt source destination
  15. 1 REJECT all -- 0.0.0.0/0 0.0.0.0/0 reject-with icmp-host-prohibited
  16. Chain OUTPUT (policy ACCEPT)
  17. num target prot opt source destination

QuickStart

以符合OCI规范的方式运行容器

首先需要了解一下OCI规范,此处可以参考runC的README
简单来说运行一个容器分为三步:

  1. 准备一个rootfs,可以使用docker export导出
  2. 准备一个config.json,以配置文件的方式来配置容器运行参数
  3. 使用命令行工具

Step0 准备镜像

我们需要一个具有一些工具(如ifconfig, stress, gcc, iptables等)的镜像,这里提供一个示例Dockerfile来打镜像:

  1. FROM centos
  2. ADD stress-1.0.4.tar.gz /tmp/
  3. RUN yum install -y gcc automake autoconf libtool make net-tools.x86_64 iptables-services nmap-ncat.x86_64 && cd /tmp/stress-1.0.4 && ./configure && make && make install

使用docker build来构造镜像。
使用docker export $(docker create $image_name) -o centos_with_utilities.tar来导出镜像。
这样我们就得到了一个tar文件,这个文件解压后就是rootfs。

  • 创建一个目录,作为容器的bundle目录。
  • 将tar文件解压到该目录下的rootfs目录,tar -xvf centos_with_utilities.tar -C $container_dir/rootfs/

    Step1 准备config.json

  • 进入该目录,然后运行capsule spec,可以生成一个示例的config.json。

这里提供一个示例config.json,其中args就是容器运行的命令,比如这里就是sh:

  1. {
  2. "ociVersion": "1.0.1-dev",
  3. "process": {
  4. "user": {
  5. "uid": 0,
  6. "gid": 0
  7. },
  8. "args": [
  9. "sh"
  10. ],
  11. "env": [
  12. "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  13. "TERM=xterm"
  14. ],
  15. "cwd": "/"
  16. },
  17. "root": {
  18. "path": "rootfs",
  19. "readonly": true
  20. },
  21. "hostname": "capsule",
  22. "mounts": [
  23. {
  24. "destination": "/proc",
  25. "type": "proc",
  26. "source": "proc"
  27. },
  28. {
  29. "destination": "/dev",
  30. "type": "tmpfs",
  31. "source": "tmpfs",
  32. "options": [
  33. "nosuid",
  34. "strictatime",
  35. "mode=755",
  36. "size=65536k"
  37. ]
  38. },
  39. {
  40. "destination": "/dev/pts",
  41. "type": "devpts",
  42. "source": "devpts",
  43. "options": [
  44. "nosuid",
  45. "noexec",
  46. "newinstance",
  47. "ptmxmode=0666",
  48. "mode=0620",
  49. "gid=5"
  50. ]
  51. },
  52. {
  53. "destination": "/dev/shm",
  54. "type": "tmpfs",
  55. "source": "shm",
  56. "options": [
  57. "nosuid",
  58. "noexec",
  59. "nodev",
  60. "mode=1777",
  61. "size=65536k"
  62. ]
  63. },
  64. {
  65. "destination": "/dev/mqueue",
  66. "type": "mqueue",
  67. "source": "mqueue",
  68. "options": [
  69. "nosuid",
  70. "noexec",
  71. "nodev"
  72. ]
  73. },
  74. {
  75. "destination": "/sys",
  76. "type": "sysfs",
  77. "source": "sysfs",
  78. "options": [
  79. "nosuid",
  80. "noexec",
  81. "nodev",
  82. "ro"
  83. ]
  84. }
  85. ],
  86. "linux": {
  87. "resources": {
  88. "devices": [
  89. {
  90. "allow": false,
  91. "access": "rwm"
  92. }
  93. ],
  94. "memory": {
  95. "limit": 104857600
  96. },
  97. "cpu": {
  98. "shares": 512
  99. }
  100. },
  101. "namespaces": [
  102. {
  103. "type": "pid"
  104. },
  105. {
  106. "type": "uts"
  107. },
  108. {
  109. "type": "ipc"
  110. },
  111. {
  112. "type": "network"
  113. },
  114. {
  115. "type": "mount"
  116. }
  117. ]
  118. }
  119. }

Step2 运行容器

保证当前目录下有一个config.json和一个rootfs目录(当然rootfs目录也可以放在别的地方,注意修改config.json中的root.path的值)。
在当前目录下运行capsule run $container_name,这样就可以运行起一个容器了。注意$container_name是一个唯一的id。

capsule list可以查看所有容器;
capsule state $container_name可以查看该容器的详细信息。

以镜像的方式运行容器

Step0 准备镜像

这一步和上面一致,也是导出一个tar文件。

Step1 导入镜像

capsule image create $image_name $tar_path
将该镜像纳入到capsule管理,注意image_name也是一个唯一的id。
capsule image list可以查看所有镜像。

Step2 运行容器

capsule image run $image_name $args --name $container_name
比如说capsule image run centos sh --name centos_container

Usage

全局参数

capsule —root $root_dir
可以指定运行时文件的根目录,可选参数,默认值为 /var/run/capsule

create

将有容器的config.json所在的目录称为bundle。
可以在bundle下使用capsule create $container_name来创建一个容器,容器会进入Created状态,也可以在任意目录,但要加入bundle参数,指明config.json的所在目录。
容器目前有三种状态,分别是:

  • Created:在create命令执行后会进入的状态,容器的init process会阻塞在执行用户指定命令之前,等待start命令唤醒自己。
  • Running:在start命令唤醒后会进入的状态,容器会执行用户指定命令。
  • Stopped:容器启动失败或用户指定的命令执行完毕或被容器init process被kill后会进入的状态。

参数:

Name Short Name Type Usage Default Value
bundle b string path to the root of the bundle directory, defaults to the current directory $cwd
network net string network connected by container capsule_bridge0(类似于docker0)
port p string array port mappings, example: host port:container port []

start

capsule start $container_name可以启动一个Created状态的容器。
无参数,注意start的话默认情况下是前台运行的。

run

run = create + start + destroy(对于前台运行的容器来说)
可以在bundle下使用capsule run $container_name来创建一个容器,容器会进入Created状态,也可以在任意目录,但要加入bundle参数,指明config.json的所在目录。
不指定-d或者-d false时容器为前台运行,当退出时容器随之退出并将自己销毁;指定-d时会以后台方式运行,可以使用capsule list或者capsule state来查看该容器状态。
参数:

Name Short Name Type Usage Default Value
bundle b string path to the root of the bundle directory, defaults to the current directory $cwd
network net string network connected by container capsule_bridge0(类似于docker0)
port p string array port mappings, example: host port:container port []
detach d bool detach from the container’s process false

list

列出所有容器,已经被销毁的容器不会被显示。(Docker可以用docker ps -a来展示已经被销毁的容器,这里做了简化,已经被销毁的不再记录)。
示例:capsule list
ID PID STATUS IP BUNDLE CREATED
capsule-demo-container 6995 Running 192.168.1.4 /var/run/capsule/images/containers/capsule-demo-container 2019-04-22T14:15:27.69530294-04:00
mysql 6877 Running 192.168.1.3 /var/run/capsule/images/containers/mysql 2019-04-22T14:07:31.989435108-04:00
redis 2689 Running 192.168.1.2 /var/run/capsule/images/containers/redis 2019-04-22T13:38:46.534200797-04:00

kill

可以对一个Created或Running状态的容器执行kill命令。
capsule kill $container_name [$signal]
这里$signal可以不填,默认是SIGTERM,也可以使用其他信号,如SIGKILL等。
其实就是对容器init process发送一个信号。

log

可以查看一个容器的stdout和stderr日志。
capsule log $container_name
也可以查看某一次后台运行的exec的日志:capsule log $container_name -exec $exec_id
$exec_id是在exec -d执行后控制台打印出来的UUID。

ps

可以查看一个容器的进程信息,等同于capsule exec $container_name ps
capsule ps $container_name

delete

删除一个容器,如果想删除一个Created或Running的容器,需要加-f参数。
capsule delete [-f] $container_name
加-f相当于 capsule kill $container_name SIGKILLcapsule delete $container_name

spec

在当前目录下生成一个示例spec,类似于下面的样子:
一般情况下只需要关心:

  • args:目录
  • env:环境变量
  • hostname:主机名
  • mounts:挂载
  • cpu:linux.cpu.shares是容器所占用cpu的比例,默认为1024,即全部占用。
  • memory:linux.memory.limit是容器最多使用的内存大小,单位是byte。
    1. {
    2. "ociVersion": "1.0.1-dev",
    3. "process": {
    4. "user": {
    5. "uid": 0,
    6. "gid": 0
    7. },
    8. "args": [
    9. "sleep", "24h"
    10. ],
    11. "env": [
    12. "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
    13. "TERM=xterm"
    14. ],
    15. "cwd": "/"
    16. },
    17. "root": {
    18. "path": "rootfs",
    19. "readonly": true
    20. },
    21. "hostname": "capsule",
    22. "mounts": [
    23. {
    24. "destination": "/proc",
    25. "type": "proc",
    26. "source": "proc"
    27. },
    28. {
    29. "destination": "/dev",
    30. "type": "tmpfs",
    31. "source": "tmpfs",
    32. "options": [
    33. "nosuid",
    34. "strictatime",
    35. "mode=755",
    36. "size=65536k"
    37. ]
    38. },
    39. {
    40. "destination": "/dev/pts",
    41. "type": "devpts",
    42. "source": "devpts",
    43. "options": [
    44. "nosuid",
    45. "noexec",
    46. "newinstance",
    47. "ptmxmode=0666",
    48. "mode=0620",
    49. "gid=5"
    50. ]
    51. },
    52. {
    53. "destination": "/dev/shm",
    54. "type": "tmpfs",
    55. "source": "shm",
    56. "options": [
    57. "nosuid",
    58. "noexec",
    59. "nodev",
    60. "mode=1777",
    61. "size=65536k"
    62. ]
    63. },
    64. {
    65. "destination": "/dev/mqueue",
    66. "type": "mqueue",
    67. "source": "mqueue",
    68. "options": [
    69. "nosuid",
    70. "noexec",
    71. "nodev"
    72. ]
    73. },
    74. {
    75. "destination": "/sys",
    76. "type": "sysfs",
    77. "source": "sysfs",
    78. "options": [
    79. "nosuid",
    80. "noexec",
    81. "nodev",
    82. "ro"
    83. ]
    84. }
    85. ],
    86. "linux": {
    87. "resources": {
    88. "devices": [
    89. {
    90. "allow": false,
    91. "access": "rwm"
    92. }
    93. ],
    94. "memory": {
    95. "limit": 104857600
    96. },
    97. "cpu": {
    98. "shares": 512
    99. }
    100. },
    101. "namespaces": [
    102. {
    103. "type": "pid"
    104. },
    105. {
    106. "type": "uts"
    107. },
    108. {
    109. "type": "ipc"
    110. },
    111. {
    112. "type": "network"
    113. },
    114. {
    115. "type": "mount"
    116. }
    117. ]
    118. }
    119. }

state

查看某个容器的信息
capsule state $container_name [-d]
如果希望查看详细信息,可以加-d参数,可以查看更为详细的信息。

exec

进入一个Created或Running的容器中执行命令。
capsule exec $container_name $args [-e $env] [-cwd $cwd] [-d]
指定-d可以以后台方式来运行此进程。

network

network是一个二级命令,下面包含create, delete, list, show四个子命令。
网络通常会有一个driver参数,指定网络的驱动类型,理论上可以支持多种驱动,目前仅支持网桥,即bridge。

create

创建一个网络,一般情况是创建一个指定网段的网桥。
capsule network create $network_name -driver bridge -subnet $subnet
subnet是一个网段,比如说192.168.1.0/24,在创建容器时可以使用-network $network_name来将该容器的IP地址的分配范围指定为该网络的网段。

delete

删除一个网络。
capsule network delete $network_name -driver bridge

list

列出所有的网络,注意,如果没有创建任何网段,当第一次创建容器时会自动创建一个名为capsule_bridge0,网段为192.168.1.0/24的网桥,类似于Docker的docker0。
capsule network list

show

显示一个网络的详细信息
capsule network show $container_name

image

image同样是个二级目录,下面包含create, delete, list, get, runc, destroyc6个子命令。

create

创建一个镜像
capsule image create $image_name $tar_path

delete

删除一个镜像
capsule image delete $image_name

list

列出所有镜像
capsule image list

get

显示一个镜像的信息。
capsule image get $image_name

runc

以镜像方式来启动一个容器,类似于Docker。
capsule image run $image_name command
-id $container_name
[-d]
[-workdir $workdir]
[-hostname $hostname]
[-env $k=$v]
[-cpushare $cpushare]
[-memory $memory_limit]
下面是spec里没有的,由capsule负责做的配置信息
[-link $container_name:$container_alias]
[-volume $host_dir/$container_dir:$host_dir]
[-network $network_name]
[-port $host_port:$container_host]
[-label $k=$v]

Name Short Name Type Usage Default Value
detach d bool 是否以后台方式启动 false
id string 容器名称,必填,唯一
cwd string 容器启动后所处的工作目录 /
env e string array 环境变量 []
hostname h string 主机名 $container_name
cpushare c int64 cpu比例 1024
memory m uint64 最大内存 0,即无限制
network net string 网络名称 capsule_bridge0
port p string array 端口映射,host_port:container_port []
label l string array 容器标签 []
volume v string array 数据卷,container_dir或者host_dir:container_dir []
link string array 容器间的连接,container_id:alias []

destroyc

类似于capsule delete,实际上是capsule delete + 清理容器与镜像间的关联数据。
capsule image destroyc $container_name [-f]

使用capsule来运行capsule-demo-app SpringBoot应用+MySQL+Redis

这里提到的capsule-demo-app参见这个github仓库

Step0 准备镜像

首先我们需要在Docker中pull下mysql和redis镜像,然后使用docker export命令导出镜像为tar包。
capsule-demo-app的Dockerfile为:

  1. FROM java:8
  2. VOLUME /tmp
  3. ADD capsule-demo-app.jar app.jar
  4. EXPOSE 8080
  5. ENTRYPOINT [ "sh", "-c", "java -jar /app.jar"]

同样也要导出tar包,此时我们会有三个tar包。

Step1 导入镜像

capsule image create $image_name $tar_path
image.png

Step2 启动Redis

首先我们需要知道Dockerfile中有CMD或者ENTRYPOINT这样的语句用来指定启动时的命令,capsule为了简化没有做这一步,对capsule来说镜像==rootfs。启动命令需要自己输入。
通过阅读Redis的Dockerfile,可以拿到启动命令,大概就是运行一个脚本,在同目录下可以读到这个docker-entrypoint.sh脚本代码。
因为capsule没有实现user namespace,容器中只能用root权限,所以我们需要手动修改脚本内容,将下图中红框部分的代码去掉,否则运行时会报错。
image.png

这里需要手动修改脚本,通过capsule image list 命令可以看到每个镜像对应的layer id。
在/var/run/capsule/images/layers/$layer_id下可以看到rootfs,然后修改该脚本文件:
image.png

然后使用capsule image runc redis /usr/local/bin/docker-entrypoint.sh redis-server --id redis -p 6379:6379 -d来启动redis容器。
我们分析一下这条命令:

  • capsule image runc是根据镜像来启动容器的命令
  • redis是镜像名
  • /usr/local/bin/docker-entrypoint.sh redis-server是启动命令
  • id即容器名,需要唯一,这里是redis
  • p是port的缩写,指定端口映射,即将容器内的6379端口映射到宿主机的6379端口
  • d是detach的缩写,指定后台运行

启动之后如果没有报错,则使用capsule image list命令来查看已经启动的容器。
如果STATUS是Running,则说明容器启动成功。
可以进入容器来使用redis-cli来检测是否真正OK。
capsule exec redis bash
redis-cli
127.0.0.1:6379> keys
(empty list or set)
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> get k1
“v1”
127.0.0.1:6379> keys

1) “k1”
127.0.0.1:6379> del k1
(integer) 1
127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379> exit
exit

Step3 启动MySQL

类似于Redis,同样需要修改脚本文件。
将红框部分的代码删掉,否则启动时会报错error: exec: “/usr/local/bin/docker-entrypoint.sh”: stat /usr/local/bin/docker-entrypoint.sh: permission denied。
image.png
使用这条命令来启动mysql容器:capsule image runc mysql "/usr/local/bin/docker-entrypoint.sh mysqld --user=root" -id=mysql -v /root/mysql/logs:/logs -v /root/mysql/data:/var/lib/mysql -p 3306:3306 -d

我们分析一下这条命令:

  • capsule image runc是根据镜像来启动容器的命令
  • mysql是镜像名
  • “/usr/local/bin/docker-entrypoint.sh mysqld —user=root”是启动命令,因为命令中也包含参数,所以用引号引起来,capsule中对于args数组长度为1的进行了特殊处理,如果包含空格则split后再赋值给args
  • id即容器名,需要唯一,这里是mysql
  • v是volume的缩写,指定volume可以使得容器在销毁后仍然在宿主机上保存部分文件,对于mysql这种需要持久化存储的应用来说volume是必要的,当然宿主机上的目录需要我们先行创建好。
  • p是port的缩写,指定端口映射,即将容器内的6379端口映射到宿主机的6379端口
  • d是detach的缩写,指定后台运行

启动之后我们需要进入容器中,创建一个名为demo的数据库schema,并且将外部访问权限由仅本机修改为任意host。
capsule exec mysql bash
mysql -uroot -p
密码为空,直接回车即可
> show databases;
> create database demo;
> use mysql;
> update user set host=’%’ where user=’root’;
> flush privileges;
> exit

Step4 启动Web应用

capsule image runc capsule-demo-app "java -jar /app.jar" -id capsule-demo-container -e "SPRING_PROFILES_ACTIVE=prod" -p 8080:8080 -d -link mysql:mysql-container -link redis:redis-container
这里使用link来指定连接的mysql和redis服务器。

如果遇到问题可以使用capsule log $container_name的方式来打印容器的stdout日志。

这个Web应用对外暴露了三个HTTP接口:

HTTP Method Path Body Description
GET /users 获得所有用户的信息
GET /users/$userId 获得该用户的信息,会使用Redis缓存
POST /users {
“id”: “tom”,
“nickName”: “tom”
}
添加一个用户