關於如何用100行如何實現docker

愛布偶的zzy發表於2018-11-27

最近逛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對,你可以再簡單的理解為一跟網線連線,圖解一下。


關於如何用100行如何實現docker


那麼這根網線的兩端這裡一端是namespace中的裝置另外一端則是宿主機,這裡結構圖解析一下,可以看到docker有個eth0,主機有個veth,他們就是一個veth對。


關於如何用100行如何實現docker


這樣就能讓容器裡邊的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對應的資料都是存檔案,儲存在目錄中的。


關於如何用100行如何實現docker


最後使用 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,相信也不會很難。


相關文章