容器docker虚拟化的底层实现原理
GO环境搭建
安装GO环境
go官网https://golang.google.cn/dl/
下载go1.7.1.linux-amd64.tar.gz
,
解压到/usr/local
目录,suso tar -C /usr/local -xzf go1.7.1.linux-amd64.tar.gz
,
编辑$HOME/.profile
,将export PATH=$PATH:/usr/local/go/bin
添加进去,
执行source $HOME/.profile
让设置生效,
给root用户的环境变量添加go,su切换到root,
cd
然后vim .profile
,添加上面的内容,执行生效命令。
打开一个终端,执行go version
查看go环境是否配置成功。
tar -C
: 解压时修改解压到的目录
.bashrc
和.profile
都是sehll的启动设置文件。.bashrc
在系统启动后会自动执行,.profile
在用户登录后才运行。
配置GOPATH信息
编辑~/.profile
,添加命令export GOPATH=/go
到文件,即在根目录设置一个文件作为工作目录。
执行source ~/.profile
让设置生效,
执行go env
查看go语言的环境是否配置成功。
然后自己手动创建/go
和其下面的三个文件夹。
go存放代码的路径时
$GOPATH
,并且go也是根据这个变量来寻找依赖的包(命令行通过go get就会下载到这个目录下)。老版本GOPATH目录下规定放三个子目录,分别是存放源码的src
、存放编译后的生成文件的pkg
、存放编译后的可执行文件的bin
。
Namespace
上一篇文章中记录了linux实现的六种Namespace,使用这些namespace主要使用三个系统调用:使用clone()
创建进程时设置进程的namespace属性;使用unshare()
将一个进程移出指定的namespace、使用setns()
将进程加到一个指定的namespace里面。这篇笔记里重新用go进行实验。
UTS Namespace
可以用这个命名空间来隔离hostname,只需要设置标志为CLONE_NEWUTS
即可。
现在src目录下创建main文件夹作为一会编写go代码所属的包。
package main
import (
"os/exec"
"syscall"
"os"
"log"
)
func main(){
cmd := exec.Command("sh") //指定被fork出来的子进程的初始命令
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS, //设置进程属性为CLONE_NEWUTS
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err:= cmd.Run(); err!=nil{ //Run函数等待子进程结束才退出
log.Fatal(err)
}
}
编译go文件go build test.go
,运行./test
,在新开启的进程空间里修改hostname生效,对重新打开的一个命令行窗口的hostname不生效,证明成功隔离。
IPC Namespace
IPC是进程间通信的意思,以此来隔离进程通信空间。在上面代码基础上增加syscall.CLONE_NEWIPC
即可。
package main
import (
"os/exec"
"syscall"
"os"
"log"
)
func main(){
cmd := exec.Command("sh") //指定被fork出来的子进程的初始命令
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err:= cmd.Run(); err!=nil{ //Run函数等待子进程结束才退出
log.Fatal(err)
}
}
PID Namespace
隔离pid可以做到像docker里一样进程号从1开始。在上面的代码基础上添加syscall.CLONE_NEWPID
package main
import (
"os/exec"
"syscall"
"os"
"log"
)
func main(){
cmd := exec.Command("sh") //指定被fork出来的子进程的初始命令
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS|syscall.CLONE_NEWIPC|syscall.CLONE_NEWPID,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err:= cmd.Run(); err!=nil{ //Run函数等待子进程结束才退出
log.Fatal(err)
}
}
运行后命令echo $$
查看当前shell的id为1,验证成功。
Mount Namespace
被隔离开的两个进程能看到的文件系统是不同的视图,通过mount()
和umount()
只会影响当前mount命名空间的文件系统。mount namespace比chroot更安全。但是在mount()
之前是可以看到宿主机文件系统的,并且在根目录下创建文件可以影响到宿主机。
在上面代码基础上添加syscall.CLONE_NEWNS
package main
import (
"os/exec"
"syscall"
"os"
"log"
)
func main(){
cmd := exec.Command("sh") //指定被fork出来的子进程的初始命令
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err:= cmd.Run(); err!=nil{ //Run函数等待子进程结束才退出
log.Fatal(err)
}
}
运行后执行命令mount -t proc proc /proc
,把虚拟文件系统proc挂载到宿主机的/proc目录下,这样以后再查看/proc目录就看到的是子进程被隔离的proc文件系统。
mount -t type 设备名 dir
,但是对于proc虚拟文件系统没有设备名,所以设备名设为什么都没有影响。
再命令ps -ef
可以看到此时的文件系统已经被隔离开,看不到宿主机的进程信息。
ps -ef就是查看proc虚拟文件系统中的信息来返回结果的。
User Nmaespace
隔离用户组,用syscall.CLONE_NEWUSER
可以在子进程自己命名空间中映射为root用户,但是在宿主机看这个进程只是一个普通进程。
package main
import (
"os/exec"
"syscall"
"os"
"log"
)
func main(){
cmd := exec.Command("sh") //指定被fork出来的子进程的初始命令
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err:= cmd.Run(); err!=nil{ //Run函数等待子进程结束才退出
log.Fatal(err)
}
}
运行后输入id
,发现此时id已经不是root的0了,证明被隔开。
Network Namespace
被隔离的容器有自己独立的网络设备,clone的时候设置标志CLONE_NEWNET
package main
import (
"os/exec"
"syscall"
"os"
"log"
)
func main(){
cmd := exec.Command("sh") //指定被fork出来的子进程的初始命令
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWIPC | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUSER | syscall.CLONE_NEWNET,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err:= cmd.Run(); err!=nil{ //Run函数等待子进程结束才退出
log.Fatal(err)
}
}
执行后使用命令ifconfig
查看不到网卡信息,打开另一个终端命令ifconfig
可以看到当前网卡信息,证明network namespace起作用。
CGroup
CGroup可以对进程做资源上的限制,包括cpu、内存、存储等。
CGroup主要包含三大组件,cgroup(注意是小写)提供进程分组管理的机制,subsystem资源控制模块,hierarchy(层次体系)提供cgroup的树状结构。
CGroup实验
首先创建一个cgroup树,查看其下的内容。
mkdir cgroup-lab
sudo mount -t cgroup -o none,name=cgroup-lab cgroup-lab ./cgroup-lab
ls ./cgroup-lab/
-t cgroup
指定挂载的类型
-o none,name=cgroup-lab
指定选项
cgroup-lab
即设备名称的位置这个字符串可以是任意值
./cgroup-lab
是挂载点
cgroup.clone_child,cpuset的subsystem会读取这个配置文件,默认值为0;是1的情况下子cgroup会继承父cgroup的cpuset的配置。
cgroup.procs,树中当前节点(cgroup)中的进程组id,根节点的cgroup.procs值包含所有进程组的id。
release_agent是一个路径,用作进程退出后自动清理掉不再使用的cgroup,notify_on_release标识这个cgroup标识的进程组中最后一个进程退出后是否执行release_agent。
tasks,标识cgroup节点包含的进程的id,就是设置这个值来将进程加入一个group中。
在树的根节点创建两个子cgroup,此时会自动装填cgroup节点的配置文件值。
接下来将当前终端shell的进程加到cgroup-1
中,先cd到这个目录下,然后命令sudo sh -c "echo $$ >> tasks"
,查看cat /proc/$$/cgroup
,可以看到被加入到cgroup-1中。
通过subsystem限制cgroup中进程的cpu占用资源,这里利用系统为memory默认创建的hierachy进行实验。
系统为每个subsystem创建一个默认的hierarchy。
使用stress工具模拟高cpu占用进程stress --cpu 1 &
,
接着将这个进程添加到cpu目录下的cpu-lab这个cgroup中,并做出限制,代码如下
cd /sys/fs/cgroup/cpu
mkdir cpu-lab
cd cpu-lab
sudo sh -c "echo 4551 > cgroup.procs"
sudo sh -c "echo 10000 > cpu.cfs_quota_us"
之后可以看到这个进程的cpu占用率从100%降到了10%(10000/10^6=10%)。
docker使用CGroup限制容器
docker运行一个内存做出限制的容器
sudo docker run -itd -m 128m ubuntu
然后去cgroup查看这个容器的信息
可以看到docker对于内存的限制措施是在系统的memory下创建一个子cgroup为docker,然后再在docker这个节点下创建每个容器的节点,在这个容器节点下做出限制。
go实现通过CGroup限制容器资源
package main
import (
"os"
"fmt"
"os/exec"
"path"
"io/ioutil"
"syscall"
"strconv"
)
const cgroupMemoryHierarchyMount = "/sys/fs/cgroup/memory"
func main(){
if os.Args[0] == "/proc/self/exe" {
cmd := exec.Command("sh", "-c", `stress --vm-bytes 200m --vm-keep -m 1`)
cmd.SysProcAttr = &syscall.SysProcAttr{
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
//只有创建的子进程才会进入这个大的if,子进程执行cmd.Run是执行stress命令,不会再执行下面
if err:= cmd.Run(); err!=nil {
fmt.Println(err)
os.Exit(1)
}
}
cmd := exec.Command("/proc/self/exe") ///proc/self/exe 它代表当前程序,即这个程序创建子进程调用自己
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err:= cmd.Start(); err!=nil {
fmt.Println("oh~ error", err)
os.Exit(1)
}else{
fmt.Printf("child pid:%v\n", cmd.Process.Pid) //fork的子进程在宿主机的pid
//在/sys/fs/cgroup/memory下创建一个cgroup
os.Mkdir(path.Join(cgroupMemoryHierarchyMount, "memory-limit-lab"), 0775) //0775是权限
//把进程id加入到cgroup中
ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "memory-limit-lab", "tasks"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644)
//限制cgroup进程的内存占用
ioutil.WriteFile(path.Join(cgroupMemoryHierarchyMount, "memory-limit-lab", "memory.limit_in_bytes"), []byte("100m"), 0644)
cmd.Process.Wait()
}
}
docker镜像分层—AUFS
Union File System使用写时复制的机制使得对文件修改操作不会改变原来文件的内容。之后的更稳定可靠的版本称为AUFS。
写时复制:对于一个没有修改的资源,多个实例共享;如果有一个实例修改了这个资源,才会对该资源复制一份并进行修改。
docker镜像使用AUFS原理
老板版本docker中每个镜像都是由很多只读层read-only layer
组成,这些镜像都被存储在/var/lib/docker/aufs/diff/
下;另外一个目录/var/lib/docker/aufs/layers
存储着每个镜像依赖的只读层信息。在pull一个镜像后会下载相关的组成这个镜像的文件镜像,如果在这个镜像上稍作修改,那么会对于修改的部分制作为一个新的文件镜像,并关联一些个没有被修改的文件镜像,而由于只是修改了很小一部分,所以这个利用写时复制机制创建的新镜像的组成部分的文件镜像体积很小。
docker容器使用AUFS原理
启动一个容器时,docker为其创建一个``read-only的init层,和
read-write`层来保存容器信息的修改。
容器的read-write
层也存储在/var/lib/docker/aufs/diff
目录下,因为容器可以转成镜像,镜像可以开启容器,所以这两个东西在某种意义上是等价的。这也解释了关闭容器后不会丢失数据。
如果要删除一个文件file
,则aufs机制会在只读层生成一个.wh.file
文件来隐藏所有只读层的file
文件。
AUFS实验
自己创建一个aufs目录,在其下面创建一下内容
把container-layer和image-layer用aufs方式挂载到mnt目录下,注意mount aufs如果没有指定挂载文件的权限,默认第一个目录是read-write权限,后面都是read-only权限。
sudo mount -t aufs -o dirs=./container-layer:./image-layer none ./mnt
效果如下
可以查看挂载信息sudo cat /sys/fs/aufs/si_xxxxx/*
往只读的image-layer.txt文件中写内容echo -e "\nhello world" >> ./mnt/image-layer.txt
,查看mnt下的image-layer被改动,但是image-layer目录下的image-layer.txt没有被改动,并且container-layer目录下多了一个image-layer.txt,其中的内容就是改动后的文件内容。
但是对于读写层的container-layer的内容修改,mnt目录和container-layer目录下内容保持一致。
相关概念
proc文件系统
proc文件系统是由内核提供的,并不真实存在于磁盘上。很多命令就是通过读取这个虚拟的文件系统来获取文件的信息。
/proc/n pid为n的进程信息
/proc/n/cmdline n号进程的启动命令
/proc/n/cwd n号进程当前的工作目录
/proc/n/environ 进程环境变量列表
/proc/n/exe 进程的执行命令文件
/proc/n/fd 进程相关的所有文件描述符
/proc/n/maps 与进程相关的内存映射信息
/proc/n/mem 进程持有的内存
/proc/n/root 进程能看到的根目录
/proc/n/stat 进程的状态
/proc/n/statm 进程占用的内存状态
/proc/n/status 详细进程状态
/proc/self 连接到当前正在运行的进程
chroot、pivot_root
chroot的作用是改变进程的根目录视图,使它不能访问该目录之外的其他文件。
chroot <newroot> [<command><arg>...]
newroot: 要切换到新的root目录
command: 切换root目录后要执行的命令
示例: sudo chroot /home/jgc/lab/rootfs_lab /bin/bash
但是chroot只是改变当前进程的根目录视图。
pivot_root的功能和chroot类似,但是pivot_root把整个系统切换到一个新的根目录,这样就可以去掉对之前rootfs的依赖,即可以umount卸载挂载点了。
容器与虚拟机比较
容器是共享内核的,其底层原理是多个进程同时运行在一个内核上,利用Namespace把它们隔离开,然后用CGroup限制可用资源。
而虚拟机是共享“硬件”的,每个虚拟机都有自己独立的操作系统。所以,虚拟机是可引导的、绝对安全的隔离技术;而容器是相对脆弱的,不安全的隔离技术。
容器的网络配置
linux是通过网络设备操作和使用网卡,系统安装一个网卡后为其生成一个网络设备,例如eth0;也可以虚拟出一个网络设备,例如veth,bridge。
veth
veth是成对出现的虚拟网络设备,在一端发送数据,在另一端会获取到数据;通常使用veth连接不同的Net Namespace。
下面进行实验,创建两个网络和一对veth,并把veth的两端分别绑定到这两个网络中,并且为两个网络设置ip,最后用一个网络namespace去ping另一个网络namespace。
sudo ip netns add ns1
sudo ip netns add ns2
sudo ip link add veth1 type veth peer name veth2
sudo ip link set veth1 netns ns1
sudo ip link set veth2 netns ns2
查看ns1这个namespace的网络设备
sudo ip netns exec ns1 ip link
配置每个veth的网络地址和路由
sudo ip netns exec ns1 ifconfig veth1 192.168.0.2/24 up
sudo ip netns exec ns2 ifconfig veth2 192.168.0.3/24 up
sudo ip netns exec ns1 route add default dev veth1
sudo ip netns exec ns2 route add default dev veth2
# 用veth一段去ping另一端
sudo ip netns exec ns1 ping -c 1 192.168.0.3
注意ns1是ping不通宿主机的,因为两个ip不在同一网段,并且设备连接两个网段,网络示意图如下。
bridge与路由表
虚拟bridge相当于交换机,连接不同的网络设备;网络数据包到达bridge时,通过报文中的mac进行广播或者转发。
可以通过定义路由表来决定某个网络的namespace中的数据包的流向。
下面进行实验,创建一个ns1,和一个veth1-veth2,把一端veth1绑定到ns1,再创建一个网桥,网桥一段挂载到veth1,另一端挂载到eth0。
sudo ip netns add ns1
sudo ip link add veth1 type veth peer name veth2
sudo ip link set veth1 netns ns1
sudo brctl addbr br0
sudo brctl addif br0 eth0
sudo brctl addif br0 veth2
sudo ip link set veth2 up
sudo ip link set br0 up
sudo ip netns exec ns1 ifconfig veth1 192.168.0.2/24 up
分别设置ns1的路由和宿主机上的路由
ns1中的所有流量都经过veth1的网络设备流出
sudo ip netns exec ns1 route add default dev veth1
宿主机将192.168.0.0/24网段的数据路由到br0
sudo route add -net 192.168.0.0/24 dev br0
从ns1中ping宿主机地址
sudo ip netns exec ns1 ping -c 1 192.168.200.129
此时ns1可以ping通宿主机,网络示意图如下
iptables
容器虚拟化常用的两种策略MASQUERADE
和DNAT
来实现容器和宿主机外部的网络通信。
MASQUERADE策略可以将请求包中的源地址转换成一个网络设备的地址。
下面进行实验,先打开ip转发功能,再把源地址为192.168.0.0/24网段来的数据修改为eth0的ip的源地址,这样就可以在ns1中访问宿主机外部的网络。但是这里没有成功,不知原因。
sudo sysctl -w net.ipv4.conf.all.forwarding=1
sudo iptables -t nat -A POSTROUTING -s 192.168.0.0/24 -o eth0 -j MASQUERADE
注意还要关闭防火墙
apt-get install ufw
ufw disable
DNAT也是做网络地址转换,用于把内部网络的端口映射出去。
实验,把宿主机80端口的tcp请求转发到namespace的80端口
sudo iptables -t nat -A PREROUTING -p tcp -m tcp --dport 80 -j DNAT --to-destination 192.168.0.2:80
go实现获取一个未分配的ip
package main
import(
"fmt"
"os"
"path"
"json"
"net"
)
const ipamDefaultAllocatorPath = "C:/subnet.json"
//IP Address Management
type IPAM struct{
//分配ip的文件存放位置
SubnetAllocatorPath string
//网段和位图算法的数组map,key是网段,value是分配的位图数组
Subnets *map[string]string
}
//初始化一个IPAM对象
var ipAllocator = &IPAM{
SubnetAllocatorPath: ipamDefaultAllocatorPath
}
//json文件转ip分配信息
func (ipam *IPAM) load() error{
if _, err := os.Stat(ipam.SubnetAllocatorPath); err!=nil{
return err
}
//打开文件
subnetConfigFile, err := os.Open(ipam.SubnetAllocatorPath)
defer subnetConfigFile.Close()
if err!= nil{
return err
}
//读取json格式信息
subnetJson := make([]byte, 2000)
n, err := subnetConfigFile.Read(subnetJson)
if err!=nil{
return err
}
//json格式转换
err = json.Unmarsha1(subnetJson[:n], ipam.Subnets)
if err!=nil{
return err
}
return nil
}
//ip分配信息转json文件
func (ipam *IPAM) dump() err{
ipamConfigFileDir, _ := path.Split(ipam.SubnetAllocatorPath)
//检查目录是否存在
if _, err := os.Stat(ipamConfigFileDir); err !=nil {
if os.IsNotExist(err){
os.MkdirAll(ipamConfigFileDir, 0644)
}else{
return err
}
}
//打开文件
subnetConfigFile, err := os.OpenFile(ipam.SubnetAllocatorPath, os.O_TRUNC | os.O_WRONLY | os.O_CREATE, 0644)
defer subnetConfigFile.Close()
if err!=nil{
return err
}
//存储
ipamConfigJson, err := json.Marsha1(ipam.Subnets)
if err !=nil{
return err
}
_, err = subnetConfigFile.Write(ipamConfigJson)
if err !=nil{
return err
}
return nil
}
//在网段中获取一个可用的ip
func (ipam *IPAM) Allocate(subnet *net.IPNet) (ip net.IP, err error){
ipam.Subnets = &map[string]string{}
err = ipam.load()
if err!=nil{
fmt.Println(err)
}
one, size := subnet.Mask.Size() //返回子网掩码长度和ip总长度
//如果没分配过这个网段,则初始化网段
if _, exist := (*ipam.Subnets)[subnet.String()]; !exist {
//2^(size-one)个可分配ip,每一位0表示可用ip,1表示不可用(已经分配掉)
(*ipam.Subnets)[subnet.String()] = strings.Repeat("0", 1<<uint8(size-one))
}
//遍历网段的位图数组
for c:= range((*ipam.Subnets)[subnet.String()]){
if (*ipam.Subnets)[subnet.String()][c] =='0'{
ipalloc := []byte((*ipam.Subnets)[subnet.String()])
ipalloc[c] = '1'
(*ipam.Subnets)[subnet.String()] = string(ipalloc)
ip = subnet.Ip //初始ip,比如网段192.168.0.0/24, 这里ip为192.168.0.0
//处理ip的值,在网段的基础上加上修正
for t:=uint(4); t>0; t-=1 {
[]byte(ip)[4-t] += uint8(c>>((t-1)*8))
}
ip[3] += 1 //ip从1开始
break
}
}
ipam.dump()
return
}
//释放ip地址
func (ipam *IPAM) Release(subnet *net.IPNet, ipaddr *net.IP) error{
ipam.Subnets = &map[string]string{}
if err := ipam.load(); err !=nil {
fmt.Println(err)
}
c := 0
releaseIP := ipaddr.To4() //将ip地址转成4个字节的表示方式
releaseIP[3] -=1
for t:=uint(4) ; t>0; t-=1 {
c += int(releaseIP[t-1]-subnet.IP[t-1]) << ((4-t)*8)
}
ipalloc := []byte((*ipam.Subnets))[subnet.String()]
ipalloc[c] = '0'
(*ipam.Subnets)[subnet.String()] = String(ipalloc)
ipam.dump()
return nil
}
//测试分配ip
func TestAllocate(t *testing.T){
_, ipnet, _ := net.ParseCIDR("192.168.0.0/24")
ip, _ := ipAllocator.Allocate(ipnet)
fmt.Println(ip)
}
//测试释放ip
func TestRelease(t *testing.T){
ip, ipnet, _ := net.ParseCIDR("192.168.0.1/24")
ipAllocator.Release(ipnet, &ip)
}
- 点赞
- 收藏
- 关注作者
评论(0)