目录
chroot
rootfs
Volume(数据卷)
打包一个go镜像
总结
chroot
当一个容器被创建的时候,我们希望容器中进程看到的文件是一个独立的隔离环境,我们可以在容器进程重启之前挂载整个根目录 /
,由于 Mount Namespace
的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。
在 Linux 中可以使用 chroot 来改变某进程的根目录。
来看下 chroot
chroot 主要是用来改换根目录的,在新设定的虚拟根目录中运行指定的命令或交互 Shell。一个运行在这个环境下,经由 chroot 设置根目录的程序,它不能够对这个指定根目录之外的文件进行访问动作,不能读取,也不能更改它的内容。
rootfs
为了让容器这个根目录看起来更'真实',一般会在容器的根目录下面挂载一个完整的操作系统的文件系统,比如 Ubuntu16.04
的 ISO。这样,在容器启动之后,我们在容器里通过执行 ls /
查看根目录下的内容,就是 Ubuntu 16.04
的所有目录和文件。
这个挂载到容器根目录,用来给容器提供隔离后的执行环境的文件系统,称为为'容器镜像',或者 rootfs(根文件系统)。
对于 Docker 来讲,最核心的原理就是为待创建的用户进程执行下面三个操作:
1、启用 Linux Namespace 配置;
2、设置指定的 Cgroups 参数;
3、切换进程的根目录(Change Root)。
第三步,进程根目录的切换,Docker 会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。
rootfs 是一个操作系统包含的所有的文件、配置和目录,并不包括操作系统内核。同一宿主机中的容器都共享主机操作系统的内核。
正是由于 rootfs 的存在,容器中的一个很重要的特性才能实现,一致性。
因为 rootfs 中打包的不止是应用,还包括整个操作系统的文件和目录,应用和应用运行的所有依赖都会被封装在一起。这样无在任何一台机器中,只需要解压打包好的镜像,直接运行即可,因为镜像里面已经包含了应用运行的所有环境。
对于基础 rootfs 的制作,如果后续有更改的需求,一个很简单的操作就是,新 fork 一个然后修改,这样的缺点就是有很多碎片化的版本。
rootfs 的制作,也是支持增量的方式进行操作的,Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。
上面的读写层也称为容器层,下面的只读称为镜像层,所有的增删查改都只会作用在容器层,相同的文件上层会覆盖下层。
上面的读写层,在没有文件写入之前里面是空的,如果在容器里面做了修改,修改的内容就会以增量的方式出现在这个层中。
例如进行文件的修改,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,修改,对容器来讲可以看到容器层中的这个文件,看不到镜像层中的这个文件。
进行删除的时候,也是在读写层做个标记,当这两个层被联合挂载之后,读写层的文件删除标记,会把容器中对应的文件“遮挡”起,对外面展示的效果就是该文件找不到了,被删除了。
最上面的可读可写层,就是专门存放修改后 rootfs 后产生的增量,修改,新增,删除产生的文件都会被记录到这里。这就是 rootfs 制作能支持增量模式的最主要实现。
这些增量的 rootfs,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上。同时,原先只读层中的内容不会发生任何变化。当然这些读写层的增量 rootfs 在 commit 之后就会变成一个新的只读层了。
Volume(数据卷)
Volume 机制,允许将宿主机中指定的目录或者文件,挂载到容器中进行取和修改操作。
Volume 有两种挂载方式
$ docker run -v /test ...
$ docker run -v /home:/test ...
两种挂载方式实质上是一样的,第一种,没有指定挂载的宿主机的目录,docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data
,然后把它挂载到容器的 /test 目录上。
第二种,指定了宿主机中的目录,docker 就会把指定的宿主机中的 /home
目录挂载到容器的 /test
目录上。
docker 中使用了 rootfs 机制和 Mount Namespace
,构建出了一个同宿主机完全隔离开的文件系统环境。对于 Volume 挂载又是如何实现的呢?这里来具体的分析下。
当容器进程被创建之后,尽管开启了 Mount Namespace
,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。
所以只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录挂载到容器中的目录上即可,这样 Volume 挂载工作就完成了。
在执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace
已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。
这里用到了 Linux 的绑定挂载(bind mount)机制,它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。
绑定挂载实际上是一个 inode 替换的过程,在 Linux 操作系统中,inode 可以理解为存放文件内容的"对象",dentry 也叫目录项,就是访问 inode 所有的指针。
上面图片的栗子
mount --bind /home /test
,会将 /home
挂载到 /test
上。其实相当于将 /test 的 dentry,重定向到了 /home
的 inode。这样当我们修改 /test
目录时,实际修改的是 /home
目录的 inode。
如果执行 umount 命令,解除绑定,/test
文件中的内容就会恢复,因为修改发生的目录是在 /home
中。
同样如果对这个镜像执行 commit 操作,docker 容器 Volume 里的信息也是不会被提交的,但是这个挂载点的 /test
空目录会被提交。
打包一个go镜像
了解了 docker 的基本原理,这里来构建一个简单的 docker 镜像
首先一个简单的 go 服务
package main
import (
"encoding/json"
"log"
"net/http"
)
func main() {
http.HandleFunc("/hello", sayHello)
log.Println("【默认项目】服务启动成功 监听端口 80")
er := http.ListenAndServe("0.0.0.0:80", nil)
if er != nil {
log.Fatal("ListenAndServe: ", er)
}
}
func sayHello(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
log.Println("request hello")
data := map[string]interface{}{
"status": "ok",
"message": "hello",
}
json.NewEncoder(w).Encode(&data)
}
交叉编译
export CGO_ENABLED=0
export GOOS=linux
export GOARCH=amd64
go build -o go-server .
编写 Dockerfile 文件
# 基础镜像
FROM alpine
# Dockerfile 后面的操作都以这一句指定的 /app 目录作为当前目录
WORKDIR /app
# 将编译好的go程序,复制到 app 目录下
COPY ./go-server ./app
# 允许外接访问的端口
EXPOSE 80
CMD ["/app/go-server"]
Dockerfile 中的命令都是按照顺序执行的。
最后使用 CMD 来启动 go 应用,在 Dockerfile 中除了 CMD 还可以使用 ENTRYPOINT 来执行一些容器中的命令操作。
默认情况下,Docker 会为你提供一个隐含的 ENTRYPOINT,即:/bin/sh -c
。所以,在不指定 ENTRYPOINT 时,比如在我们这个例子里,实际上运行在容器里的完整进程是:/bin/sh -c “/app/go-server”
,即 CMD 的内容就是 ENTRYPOINT 的参数。
总结
1、对于 Docker 来讲,最核心的原理就是为待创建的用户进程执行下面三个操作:
-
1、启用 Linux Namespace 配置;
-
2、设置指定的 Cgroups 参数;
-
3、切换进程的根目录(Change Root)。
2、Docker 容器启动的进程还是在宿主机中运行的,和宿主机中其他运行的进程是没有区别的,只是 docker 容器会给这些进程,添加各种各样的 Namespace 参数,使这些进程和宿主机中的其它进程隔离开来,感知不到有其它进程的存在;
3、Docker 通过 Namespace 可以这些进程只能看到自己 Namespace 的相关资源,这样和其它 Namespace 的进程起到了隔离的作用,使得这些在容器中运行的进程像是运行在一个独立的环境中一样;
4、Docker 使用 Linux cgroups 来限制容器中的进程允许使用的系统资源,防止这些进程可能会占用很多的系统资源,影响到其他的进程;
5、Mount namespace
为进程提供独立的文件系统视图。简单点说就是,mount namespace
用来隔离文件系统的挂载点,这样进程就只能看到自己的 mount namespace 中的文件系统挂载点;
6、当一个容器被创建的时候,我们希望容器中进程看到的文件是一个独立的隔离环境,为了让容器这个根目录看起来更'真实',一般会在容器的根目录下面挂载一个完整的操作系统的文件系统,比如 Ubuntu16.04 的 ISO。这样,在容器启动之后,我们在容器里通过执行 ls / 查看根目录下的内容,就是 Ubuntu 16.04 的所有目录和文件;
7、rootfs 是一个操作系统包含的所有的文件、配置和目录,并不包括操作系统内核。同一宿主机中的容器都共享主机操作系统的内核;
8、正是由于 rootfs 的存在,容器中的一个很重要的特性才能实现,一致性;
9、对于基础 rootfs 的制作,如果后续有更改的需求,一个很简单的操作就是,新 fork 一个然后修改,这样的缺点就是有很多碎片化的版本。rootfs 的制作,也是支持增量的方式进行操作的,Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。