本文导航
前言
- 本文摘录自知乎文章(链接附在文末),对于我这个小白很适用。文章分为Docker和K8s的介绍,本文只有关于Docker的内容,后续会加入K8s部分。
- 在本篇文章可以搞懂了镜像和容器的区别,以及为什么迎来了容器化时代。
- ⭐NOTE部分是本人读文章的总结以及补充,便于理解。
1.什么是Docker
Docker 是一个开源的应用容器引擎,是一种资源虚拟化技术,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 机器上。虚拟化技术演历路径可分为三个时代:
- 物理机时代,多个应用程序可能跑在一台物理机器上
- 虚拟机时代,一台物理机器启动多个虚拟机实例,一个虚拟机跑多个应用程序
- 容器化时代,一台物理机上启动多个容器实例,一个容器跑多个应用程序
在没有 Docker 的时代,我们会使用硬件虚拟化(虚拟机)以提供隔离。这里,虚拟机通过在操作系统上建立了一个中间虚拟软件层 Hypervisor ,并利用物理机器的资源虚拟出多个虚拟硬件环境来共享宿主机的资源,其中的应用运行在虚拟机内核上。但是,虚拟机对硬件的利用率存在瓶颈,因为虚拟机很难根据当前业务量动态调整其占用的硬件资源,加之容器化技术蓬勃发展使其得以流行。
⭐NOTE-1
虚拟机技术共享物理机硬件资源,但是无法动态调整硬件资源。
Docker Vs 虚拟机
特性 | Docker | 虚拟机 |
---|---|---|
启动 | 秒级 | 分钟级 |
交付/部署 | 开发、测试、生产环境一致 | - |
性能 | 近似物理机 | 性能损耗大 |
体量 | 极小(MB) | 较大(GB) |
迁移/扩展 | 跨平台,可复制 | - |
另外开发人员在实际的工作中,经常会遇到测试环境或生产环境与本地开发环境不一致的问题,轻则修复保持环境一致,重则可能需要返工。但 Docker 恰好解决了这一问题,它将软件程序和运行的基础环境分开。开发人员编码完成后将程序整合环境通过 DockerFile 打包到一个容器镜像中,从根本上解决了环境不一致的问题。
⭐NOTE-2
Docker解决了开发环境与测试环境不一致的问题,将软件程序和运行的基础环境分开。
2.Docker的构成
Docker 由镜像、镜像仓库、容器三个部分组成
- 镜像: 跨平台、可移植的程序+环境包
- 镜像仓库: 镜像的存储位置,有云端仓库和本地仓库之分,官方镜像仓库地址
- 容器: 进行了资源隔离的镜像运行时环境
3.Docker的实现原理
到此读者们肯定很好奇 Docker 是如何进行资源虚拟化的,并且如何实现资源隔离的,其核心技术原理主要有(内容部分参考自Docker 核心技术与实现原理):
(1).Namespace
在日常使用 Linux 或者 macOS 时,我们并没有运行多个完全分离的服务器的需要,但是如果我们在服务器上启动了多个服务,这些服务其实会相互影响的,每一个服务都能看到其他服务的进程,也可以访问宿主机器上的任意文件,这是很多时候我们都不愿意看到的,我们更希望运行在同一台机器上的不同服务能做到完全隔离,就像运行在多台不同的机器上一样。
命名空间 (Namespaces) 是 Linux 为我们提供的用于分离进程树、网络接口、挂载点以及进程间通信等资源的方法。
⭐NOTE-3
命名空间提供了进程、网络、目录结构隔离
Linux 的命名空间机制提供了以下七种不同的命名空间,通过这七个选项我们能在创建新的进程时设置新进程应该在哪些资源上与宿主机器进行隔离。
- CLONE_NEWCGROUP
- CLONE_NEWIPC
- CLONE_NEWNET
- CLONE_NEWNS
- CLONE_NEWPID
- CLONE_NEWUSER
- CLONE_NEWUTS
在Linux系统中,有两个特殊的进程,一个是 pid 为 1 的 /sbin/init 进程,另一个是 pid 为 2 的 kthreadd 进程,这两个进程都是被 Linux 中的上帝进程 idle 创建出来的,其中前者负责执行内核的一部分初始化工作和系统配置,也会创建一些类似 getty 的注册进程,而后者负责管理和调度其他的内核进程。
当在宿主机运行 Docker,通过docker run
或docker start
创建新容器进程时,会传入 CLONE_NEWPID实现进程上的隔离。
接着,在方法createSpec
的setNamespaces
中,完成除进程命名空间之外与用户、网络、IPC 以及 UTS 相关的命名空间的设置。
func (daemon *Daemon) createSpec(c *container.Container) (*specs.Spec, error) {
s := oci.DefaultSpec()
// ...
if err := setNamespaces(daemon, &s, c); err != nil {
return nil, fmt.Errorf("linux spec namespaces: %v", err)
}
return &s, nil
}
func setNamespaces(daemon *Daemon, s *specs.Spec, c *container.Container) error {
// user
// network
// ipc
// uts
// pid
if c.HostConfig.PidMode.IsContainer() {
ns := specs.LinuxNamespace{Type: "pid"}
pc, err := daemon.getPidContainer(c)
if err != nil {
return err
}
ns.Path = fmt.Sprintf("/proc/%d/ns/pid", pc.State.GetPID())
setNamespace(s, ns)
} else if c.HostConfig.PidMode.IsHost() {
oci.RemoveNamespace(s, specs.LinuxNamespaceType("pid"))
} else {
ns := specs.LinuxNamespace{Type: "pid"}
setNamespace(s, ns)
}
return nil
}
网络
当 Docker 容器完成命名空间的设置,其网络也变成了独立的命名空间,与宿主机的网络互联便产生了限制,这就导致外部很难访问到容器内的应用程序服务。Docker提供了4种网络模式,通过--net
指定。
- host
- container
- none
- bridge
由于后续介绍 Kubernetes 利用了 Docker 的 bridge 网络模式,所以仅介绍该模式。Linux中为了方便各网络命名空间的网络互相访问,设置了Veth Pair和网桥来实现,Docker也是基于此方式实现了网络通信。
⭐NOTE-4
Veth Pair是一对的虚拟设备
Docker容器网络通信例子
下图中 `eth0` 与 `veth9953b75` 是一个 Veth Pair,`eth0` 与 `veth3e84d4f` 为另一个 Veth Pair。Veth Pair 在容器内一侧会被设置为 `eth0` 模拟网卡,另一侧连接 Docker0 网桥,这样就实现了不同容器间网络的互通。加之 Docker0 为每个容器配置的 iptables 规则,又实现了与宿主机外部网络的互通。挂载点
解决了进程和网络隔离的问题,但是 Docker 容器中的进程仍然能够访问或者修改宿主机器上的其他目录,这是我们不希望看到的。
在新的进程中创建隔离的挂载点命名空间需要在 clone 函数中传入 CLONE_NEWNS,这样子进程就能得到父进程挂载点的拷贝,如果不传入这个参数子进程对文件系统的读写都会同步回父进程以及整个主机的文件系统。
当一个容器需要启动时,它一定需要提供一个根文件系统(rootfs),容器需要使用这个文件系统来创建一个新的进程,所有二进制的执行都必须在这个根文件系统中,并建立一些符号链接来保证IO不会出现问题。
⭐NOTE-5
进一步解决目录隔离问题,将进程访问目录限制在单个目录下
另外,通过 Linux 的chroot
命令能够改变当前的系统根目录结构,通过改变当前系统的根目录,我们能够限制用户的权利,在新的根目录下并不能够访问旧系统根目录的结构个文件,也就建立了一个与原系统完全隔离的目录结构。
(2).Control Groups(CGroups)
Control Groups(CGroups) 提供了宿主机上物理资源的隔离,例如 CPU、内存、磁盘 I/O 和网络带宽。主要由这几个组件构成:
-
控制组(CGroup)一个 CGroup 包含一组进程,并可以在这个 CGroup 上增加 Linux Subsystem 的各种参数配置,将一组进程和一组 Subsystem 关联起来。
-
Subsystem 子系统 是一组资源控制模块,比如CPU子系统可以控制CPU时间分配,内存子系统可以限制 CGroup 内存使用量。可以通过
lssubsys -a
命令查看当前内核支持哪些 Subsystem。 -
Hierarchy 层级树 主要功能是把 CGroup 串成一个树型结构,使 CGruop 可以做到继承,每个 Hierarchy 通过绑定对应的 Subsystem 进行资源调度。
-
Task 在 CGroups 中,task 就是系统的一个进程。一个任务可以加入某个 CGroup,也可以从某个 CGroup 迁移到另外一个 CGroup。
⭐NOTE-6
CGroups提供了物理资源的隔离
在 Linux 的 Docker 安装目录下有一个 docker 目录,当启动一个容器时,就会创建一个与容器标识符相同的 CGroup,举例来说当前的主机就会有以下层级关系:
每一个 CGroup 下面都有一个 tasks 文件,其中存储着属于当前控制组的所有进程的 pid,作为负责 cpu 的子系统,cpu.cfs_quota_us 文件中的内容能够对 CPU 的使用作出限制,如果当前文件的内容为 50000,那么当前控制组中的全部进程的 CPU 占用率不能超过 50%。
当我们使用 Docker 关闭掉正在运行的容器时,Docker 的子控制组对应的文件夹也会被 Docker 进程移除。
(3).UnionFS
联合文件系统(Union File System),它可以把多个目录内容联合挂载到同一个目录下,而目录的物理位置是分开的。UnionFS可以把只读和可读写文件系统合并在一起,具有写时复制功能,允许只读文件系统的修改可以保存到可写文件系统当中。Docker之前使用的为AUFS(Advanced UnionFS),现为Overlay2。
Docker 中的每一个镜像都是由一系列只读的层组成的,Dockerfile 中的每一个命令都会在已有的只读层上创建一个新的层:
FROM ubuntu:15.04
COPY . /app
RUN make /app
CMD python /app/app.py
容器中的每一层都只对当前容器进行了非常小的修改,上述的 Dockerfile 文件会构建一个拥有四层 layer 的镜像
当镜像被 docker run
命令创建时就会在镜像的最上层添加一个可写的层,也就是容器层,所有对于运行时容器的修改其实都是对这个容器读写层的修改。容器和镜像的区别就在于,所有的镜像都是只读的,而每一个容器其实等于镜像加上一个可读写的层,也就是同一个镜像可以对应多个容器。
参考资料
https://zhuanlan.zhihu.com/p/437623321