专栏名称: 爱布偶的zzy
后端研发
今天看啥  ›  专栏  ›  爱布偶的zzy

关于如何用100行如何实现docker

爱布偶的zzy  · 掘金  ·  · 2018-11-28 03:20
阅读 72

关于如何用100行如何实现docker

最近逛github无意发现了一个很好地项目bocker, 用上百行的代码就实现了一个简易的docker,然后我看了一下,觉得挺有趣的,简单的玩了一下,也做一些更改(项目很久不更新了,有不支持的地方),简单分析了一下分享出来。

前言

我当时一看100行写docker, 肯定是不可能,以前看像最简化的python加上依赖也得几百行代码如moker,还有go实现的完善一点的也有上千行mydocker,可是这个项目看了一下,还真是只有100多行,不过看使用的是shell, 不过想起来100多行应该也只能用shell完成了吧,不熟悉shell的可以去看一些shell的基本知识就可以了。

目前这个项目主要实现里镜像拉取,镜像查看,容器启动,容器删除,容器查看,容器资源限制,镜像删除,功能都是一些最基本的,也有很多不完善的,我这里大致分析一下他们是的实现原理,分析各个流程,按照操作的顺序正常分析,首先这里讨论的情况是linux环境,推荐使用centos7和ubuntu14以上的系统,流程其实比较简单,底层实现依赖于linux的一些基础组件iptables,cgroup和linux namespace完成网络,资源限制,资源隔离,利用shell去管理这些资源。

开始操作!!

配置环境

最好是vagrant (如果是mac和windows建议使用该环境,如果linux,系统内核较高则可直接操作), vagrant可以帮我们实现轻量级的开发环境,个人非常喜欢,它操作和管理vm,处理更重环境会比较方便,这里需要提前配置好环境,我在链接中附上了官方地址,按照教程配置即可。

官方Vagrantfile的epel数据源有问题,而且网络依赖,整个过程是自动化的,不过不方便调试,这里为了方便个人调试,我将流程写为一步一步的了,操作起来也会比较方便。

加载虚拟环境(vagrant配置文件)

生成Vagrant配置文件

Vagrant配置启动

$script = <<SCRIPT
(
echo "echo start---config"
) 2>&1
SCRIPT
Vagrant.configure(2) do |config|
config.vm.box = 'puppetlabs/centos-7.0-64-nocm'
config.ssh.username = 'root'
config.ssh.password = 'puppet'
config.ssh.insert_key = 'true'
config.vm.provision 'shell', inline: $script
end

拷贝上边的文件Vim为保存到一个文件中Vagrantfile中
vagrant up (直接启动,这里会去源拉去centos的镜像,时长主要根据个人网络)

vagrant ssh (直接进入)
复制代码

安装依赖

  • 安装rpm源:
wget https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
rpm -ivh epel-release-latest-7.noarch.rpm(官方用的eprl源不存在了)
复制代码
  • 然后对应依赖:

核心是cgourp, btrfs-progs


yum install -y -q autoconf automake btrfs-progs docker gettext-devel git libcgroup-tools libtool python-pip
jq
复制代码
  • 创建挂载文件系统:(docker镜像支持的一种文件结构) 具体细节可以看链接btrfs wiki


fallocate -l 10G ~/btrfs.img
mkdir /var/bocker
mkfs.btrfs ~/btrfs.img
mount -o loop ~/btrfs.img /var/bocker
复制代码
  • 安装base:
pip install git+https://github.com/larsks/undocker
systemctl start docker.service
docker pull centos
docker save centos | undocker -o base-image
复制代码
  • 安装linux-utils 一个linux的工具
git clone https://github.com/karelzak/util-linux.git
cd util-linux
git checkout tags/v2.25.2
./autogen.sh
./configure --without-ncurses --without-python
make
mv unshare /usr/bin/unshare
复制代码
  • 配置网卡和网络转发
echo 1 > /proc/sys/net/ipv4/ip_forward
iptables --flush
iptables -t nat -A POSTROUTING -o bridge0 -j MASQUERADE
iptables -t nat -A POSTROUTING -o enp0s3 -j MASQUERADE
ip link add bridge0 type bridge
ip addr add 10.0.0.1/24 dev bridge0
ip link set bridge0 up
复制代码


我简单解释一下上边的流程,由于docker底层网络会利用iptables和linux namespace实现,这里是为了让容器网络正常工作,主要分为2部分。

1 首先需要创建一块虚拟网卡bridge0,然后配置bridge0网卡的nat地址转换,这里bridge相当于docker中的docker0,bridge0相当于在网络中的交换机二层设备,他可以连接不同的网络设备,当请求到达Bridge设备时,可以通过报文的mac地址进行广播和转发,所以所有的容器虚拟网卡需要在bridge下,这也是连接namespace中的网络设备和宿主机网络的方式,这里下变会有讲解。(如果需要实现overlay等,需要换用更高级的转换工具,如用ovs来做类vxlan,gre协议转换)

2 开启开启内核转发和配置iptables MASQUERADE,这是为了用MASQUERADE规则将容器的ip转换为宿主机出口网卡的ip,在linux namespace中,请求宿主机外部地址时,将namespace中的原地址换成宿主机作为原地址,这样就可以在namespace中进行地址正常转换了。

环境准备完成,可以分析下具体实现了

首先想一下,对docker来讲最重要的就是几部分,一个是镜像,第二个是独立的环境,ip,网络,第三个是资源限制

这里我在代码中增加了一些中文注释方便理解,这个项目叫bocker,我也叫bocker吧

  • 程序入库口
[[ -z "${1-}" ]] && bocker_help "$0"
    # @1 执行与help
case $1 in
    pull|init|rm|images|ps|run|exec|logs|commit|cleanup) bocker_"$1" "${@:2}" ;;
    *) bocker_help "$0" ;;
esac
复制代码

help比较简单,程序入口,逻辑相当于是我们程序里面的main函数,根据传入的参数执行不同的函数。

  • 运行环境? 镜像拉去 bocker pull ()
function bocker_pull() { #HELP Pull an image from Docker Hub:\nBOCKER pull <name> <tag>


    # @1 获取对应镜像进行拉去, 源代码老版本是v1的docker registry是无效的, 我更新为了v2版本
    token=$(curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/$1:pull"  | jq '.token'| sed 's/\"//g')
    registry_base='https://registry-1.docker.io/v2'
    tmp_uuid="$(uuidgen)" && mkdir /tmp/"$tmp_uuid"

    # @2 获取docker镜像每一层的layter,保存到数组中
    manifest=$(curl -sL -H "Authorization: Bearer $token" "$registry_base/library/$1/manifests/$2" | jq -r '.fsLayers' | jq -r '.[].blobSum' )
    [[ "${#manifest[@]}" -lt 1 ]] && echo "No image named '$1:$2' exists" && exit 1

    # @3 依次获取镜像每一层, 然后init
    for id in ${manifest[@]}; do
        curl -#L -H "Authorization: Bearer $token" "$registry_base/library/$1/blobs/$id" -o /tmp/"$tmp_uuid"/layer.tar
        tar xf /tmp/"$tmp_uuid"/layer.tar -C /tmp/"$tmp_uuid"
    done
    echo "$1:$2" > /tmp/"$tmp_uuid"/img.source
    bocker_init /tmp/"$tmp_uuid" && rm -rf /tmp/"$tmp_uuid"
}
复制代码

这个项目简易的实现了docker,所以docker镜像仓库肯定是没有实现的,镜像仓库还是使用官方源,这里如果需要使用自己私有源,需要对镜像源和代码都做变更,这里其实逻辑是下载对应镜像每个分层,然后转存到自己的文件镜像存储中,这里我更改了他的逻辑,使用了docker registry api v2版本,(因为作者源v1版本代码已经失效,从官方不能获取正确数据,作者其实已经三年未提交了,docker发展速度太快,也可以理解),流程是首先是auth,获取对应镜像对应权限的进行一个token,然后利用token获取到镜像的每一个layer,这里我用了jq json解析插件,会比较方便的操作Jason,转为shell相关变量,然后下载所有的layer转存到自己的唯一镜像目录中,同时保存一个镜像名为一个文件。

  • bocker保存镜像


function bocker_init() { #HELP Create an image from a directory:\nBOCKER init 
# @1 生成随机数镜像,就像生成docker images 唯一id
uuid="img_$(shuf -i 42002-42254 -n 1)"
if [[ -d "$1" ]]; then
    [[ "$(bocker_check "$uuid")" == 0 ]] && bocker_run "$@"

# @2 创建对应image文件 btrfs volume
    btrfs subvolume create "$btrfs_path/$uuid" > /dev/null
    cp -rf --reflink=auto "$1"/* "$btrfs_path/$uuid" > /dev/null
    [[ ! -f "$btrfs_path/$uuid"/img.source ]] && echo "$1" > "$btrfs_path/$uuid"/img.source
    echo "Created: $uuid"
else
    echo "No directory named '$1' exists"
fi
}
复制代码

这里其实就是保存从镜像仓库拉取下来的layer,然后创建目录,这里需要强调的是docker使用的镜像目录在这里必须是btrfs的文件结构,然后保存对应的镜像名到img.source文件中 ,这里环境准备的时候通过btrfs命令创建了10g的文件系统,docker是支持多种存储系统的,具体详情可以到这里看

Docker storage drivers​

docs.docker.com图标

  • 有了镜像就可以进行重要的bocker run 了(第一部分)
function bocker_run() { #HELP Create a container:\nBOCKER run <image_id> <command>

    # @1 环境准备,生成唯一id,检查相关镜像,ip, mac地址
    uuid="ps_$(shuf -i 42002-42254 -n 1)"
    [[ "$(bocker_check "$1")" == 1 ]] && echo "No image named '$1' exists" && exit 1
    [[ "$(bocker_check "$uuid")" == 0 ]] && echo "UUID conflict, retrying..." && bocker_run "$@" && return
    cmd="${@:2}" && ip="$(echo "${uuid: -3}" | sed 's/0//g')" && mac="${uuid: -3:1}:${uuid: -2}"

    # @2 通过ip link && ip netns 实现隔离的网络namespace与网络通信
    ip link add dev veth0_"$uuid" type veth peer name veth1_"$uuid"
    ip link set dev veth0_"$uuid" up
    ip link set veth0_"$uuid" master bridge0
    ip netns add netns_"$uuid"
    ip link set veth1_"$uuid" netns netns_"$uuid"
    ip netns exec netns_"$uuid" ip link set dev lo up
    ip netns exec netns_"$uuid" ip link set veth1_"$uuid" address 02:42:ac:11:00"$mac"
    ip netns exec netns_"$uuid" ip addr add 10.0.0."$ip"/24 dev veth1_"$uuid"
    ip netns exec netns_"$uuid" ip link set dev veth1_"$uuid" up
    ip netns exec netns_"$uuid" ip route add default via 10.0.0.1
    btrfs subvolume snapshot "$btrfs_path/$1" "$btrfs_path/$uuid" > /dev/null
复制代码

解析:

在运行bocker run时会进行一些列配置,我在也加了也进行了注释,第一部先生成相关配置,首先会通过shuf函数生成每个bocker唯一的Id,进行相关合法性检验,然后根据生成的截取生成的随机数id,截取部分字段组成ip地址和mac地址(注意这里可能会有概率ip冲突,后期应该需要优化)

第二部分,生成Linux veth对(Veth是成对的出现在虚拟网络设备,发送动Veth虚拟设备的请求会从另一端的虚拟设备发出,在容器的虚拟化场景中,经常会使用Veth连接不同的namespace) , 利用ip命令创建veth对 veth0_xx, veth1_xx,创建唯一uuid namespace, 绑定veth1到namespace中, 对其绑定ip,mac地址,然后绑定路由,启动网卡,网络接口,这里用到的veth对,你可以再简单的理解为一跟网线连接,图解一下。



那么这根网线的两端这里一端是namespace中的设备另外一端则是宿主机,这里结构图解析一下,可以看到docker有个eth0,主机有个veth,他们就是一个veth对。



这样就能让容器里边的bocker正常上网了。

  • bocker run 资源限制(第二部分)
# @3 更改nameserver, 保存cmd
    echo 'nameserver 8.8.8.8' > "$btrfs_path/$uuid"/etc/resolv.conf
    echo "$cmd" > "$btrfs_path/$uuid/$uuid.cmd"

    # @4 通过cgroup-tools工具配置cgroup资源组与调整资源限制
    cgcreate -g "$cgroups:/$uuid"
    : "${BOCKER_CPU_SHARE:=512}" && cgset -r cpu.shares="$BOCKER_CPU_SHARE" "$uuid"
    : "${BOCKER_MEM_LIMIT:=512}" && cgset -r memory.limit_in_bytes="$((BOCKER_MEM_LIMIT * 1000000))" "$uuid"

    # @5 执行
    cgexec -g "$cgroups:$uuid" \
        ip netns exec netns_"$uuid" \
        unshare -fmuip --mount-proc \
        chroot "$btrfs_path/$uuid" \
        /bin/sh -c "/bin/mount -t proc proc /proc && $cmd" \
        2>&1 | tee "$btrfs_path/$uuid/$uuid.log" || true
    ip link del dev veth0_"$uuid"
    ip netns del netns_"$uuid"
复制代码


这里为了简便操作,使用了cgroup工具进行资源限制,cgroup是linux 自带的进程资源限制工具,链接中有对应详情。这里利用了cgroup-tools工具操作cgroup会比较简便,在这里利用cgcreate增加了CPU,set, mem进行限制,通过随机创建的id创建cgroup组,cgset默认增加了CPU, mem的参数限制(如果是程序开发的话会对应的依赖封装库)

下图可以看到其实cgroup对应的数据都是存文件,保存在目录中的。



最后使用 cgroup exec执行启动执行程序,将输出通过tee输出到日志目录。

当程序执行结束,删除对应的网络接口和命名空间,清楚网络接口是为了方便将绑定在主机上的虚拟网卡删除

这里一个bocker run就可以实现了,下边的是一些细节了

  • 清除网络接口
function bocker_cleanup() { #HELP Delete leftovers of improperly shutdown containers:\nBOCKER cleanup
    # @1 清楚所有的相关网络接口
    for ns in $(ip netns show | grep netns_ps_); do [[ ! -d "$btrfs_path/${ns#netns_}" ]] && ip netns del "$ns"; done
    for iface in $(ifconfig | grep veth0_ps_ | awk '{ print $1 }'); do [[ ! -d "$btrfs_path/${iface#veth0_}" ]] && ip link del dev "$iface"; done
}
复制代码

ps出相应网卡删除对应的网络接口即可

  • 查看容器日志 bocker logs
function bocker_logs() { #HELP View logs from a container:\nBOCKER logs <container_id>

    # @1 查看日志
    [[ "$(bocker_check "$1")" == 1 ]] && echo "No container named '$1' exists" && exit 1
    cat "$btrfs_path/$1/$1.log"
}
复制代码

所有的日志在都是保存在btrfs文件系统对应的子目录中$btrfs_path/$uuid中,这里对应到btrfs_path,所以只需要获取到正确的目录,cat出文件即可

还有几个简单命令我就不分析了,比较简单,可以自己去看开头给的链接,下载源码对应我文中的代码更改。

总结:

整体来说,这个项目利用了shell的优势,实现了一小部分docker的主要功能,框架是有了,还有99%的功能没有实现,比如跨主机通信,端口转发,端口映射,异常处理等等,不过作为学习的项目来说,可以让人眼前一亮,大家也可以根据这个项目的思路去实现一个简单的docker,相信也不会很难。





原文地址:访问原文地址
快照地址: 访问文章快照