[云原生] Docker核心技术
Docker 使用 Google 公司推出的 Go 语言 进行开发实现,基于 Linux 内核的 cgroup,namespace,以及 AUFS 类的 Union FS 等技术,对进程进行封装隔离,属于 操作系统层面的虚拟化技术。由于隔离的进程独立于宿主和其它的隔离的进程,因此也称其为容器。
系统架构
谈到Docker, 其核心问题是系统架构的演进。由传统分层架构到微服务,微服务将一个庞大系统分解成高内聚,松耦合的组件使得系统部署更快,更易理解和维护。
如何进行微服务改造呢?其分解原则基于Size, scope, capabilities. 通过审视并发现可以分离的业务逻辑业务逻辑,寻找天生隔离的代码模块,可以借助于静态代码分析工具。对于不同并发规模,不同内存需求的模块都可以分离出不同的微服务,此方法可提高资源利用率,节省成本
Docker
Docker 在容器的基础上,进行了进一步的封装,从文件系统、网络互联到进程隔离等等,极大的简化了容器的创建和维护,使得 Docker 技术比虚拟机技术更为轻便、快捷。
Docker优点在于:高效;快速启动;一致的运行环境;持续交付和部署;便于迁移,维护和扩展
与虚拟机相比,不需要虚拟化guest OS,秒级启动;
其主要特性:
- 隔离性 - 每个用户实例之间相互隔离, 互不影响。 硬件虚拟化方法给出的方法是VM, LXC给出的方法是container,更细一点是kernel namespace
- 可配额/可度量 - 每个用户实例可以按需提供其计算资源,所使用的资源可以被计量。硬件虚拟化方法因为虚拟了CPU, memory可以方便实现, LXC则主要是利用cgroups来控制资源
- 移动性/便携性 - 用户的实例可以很方便地复制、移动和重建。硬件虚拟化方法提供snapshot和image来实现,docker(主要)利用AUFS实现
- 安全性 - 这个话题比较大,这里强调是host主机的角度尽量保护container。硬件虚拟化的方法因为虚拟化的水平比较高,用户进程都是在KVM等虚拟机容器中翻译运行的, 然而对于LXC, 用户的进程是lxc-start进程的子进程, 只是在Kernel的namespace中隔离的, 因此需要一些kernel的patch来保证用户的运行环境不会受到来自host主机的恶意入侵, dotcloud(主要是)利用kernel grsec patch解决的.
OCI 容器标准 Open Container Initiative(OCI) 轻量级开放式管理组织(项目)
OCI 定义了运行时标准(Runtime Specification)、镜像标准(Image Specification)和分发标准(Distribution Specification)
- 运行时标准定义如何解压应用包并运行
- 镜像标准定义应用如何通过构建系统打包,生成镜像清单(Manifest),文件系统序列化文件,镜像配置。
- 分发标准定义如何分发容器镜像
Docker采用 C/S架构 Docker daemon 作为服务端接受来自客户的请求,并处理这些请求(创建、运行、分发容器)。 客户端和服务端既可以运行在一个机器上。
当使用 Docker 命令行工具执行命令时,Docker 客户端会将其转换为合适的 API 格式,并发送到正确的 API 端点。一旦 daemon 接收到创建新容器的命令,它就会向 containerd 发出调用。daemon 使用一种 CRUD 风格的 API,通过 gRPC 与 containerd 进行通信。虽然名叫 containerd,但是它并不负责创建容器,而是指挥 runc 去做。containerd 将 Docker 镜像转换为 OCI bundle,并让 runc 基于此创建一个新的容器。然后,runc 与操作系统内核接口进行通信,基于所有必要的工具(Namespace、CGroup等)来创建容器。容器进程作为 runc 的子进程启动,启动完毕后,runc 将会退出。
一旦容器进程的父进程 runc 退出,相关联的 containerd-shim 进程就会成为容器的父进程。当 daemon 重启的时候,容器不会终止,并且可以将容器的退出状态反馈给 daemon。
Docker vs Containerd
docker由 docker-client ,dockerd,containerd,docker-shim,runc组成,所以containerd是docker的基础组件之一
containerd可以作为一个底层容器运行时. 从k8s的角度看,可以选择 containerd 或 docker 作为运行时组件:Containerd 调用链更短,组件更少,更稳定,占用节点资源更少.
调用链
- Docker 作为 k8s 容器运行时,调用关系如下:
kubelet --> docker shim (在 kubelet 进程中) --> dockerd --> containerd - Containerd 作为 k8s 容器运行时,调用关系如下:
kubelet --> cri plugin(在 containerd 进程中) --> containerd
使用containerd不仅性能提高了(调用链变短了),而且资源占用也会变小(Docker不是一个纯粹的容器运行时,具有大量其他功能)。
安装Docker
参考 https://docs.docker.com/engine/install/ubuntu/
Docker 命令
docker run -it centos bash #run centos interactively
docker images
docker ps|grep centos
docker inspect <containerid>
Namespace
Linux Namespace是一种Linux Kernel提供的资源隔离方案:
- 系统可以为进程分配不同的Namespace
- 并保证不同的Namespace资源独立分配,进程彼此隔离,即不同Namespace下的进程互不干扰
对于一个进程,其内核代码中的数据结构,包含了uts;ipc;mnt;pid,net等namespace。
- pid namespace; 不同用户的进程就是通过 Pid namespace 隔离开的
- net namespace: 每个 net namespace 有独立的 network devices, IP addresses, IP routing tables, /proc/net 目录。Docker 默认采用 veth 的方式将 container 中的虚拟网卡同 host 上的一个 docker bridge: docker0 连接在一起.
- ipc namespace: Container 中进程交互还是采用 linux 常见的进程间交互方法 (interprocess communication – IPC), 包
括常见的信号量、消息队列和共享内存。container 的进程间交互实际上还是 host上 具有相同 Pid namespace 中的进程间交互,因此需要在 IPC资源申请时加入 namespace 信息-每个IPC资源有一个唯一的32位ID。 - mnt namespace: mnt namespace 允许不同 namespace 的进程看到的文件结构不同,这样每个 namespace 中的进程所看到的文件目录就被隔离开了。
- uts namespace: UTS(“UNIX Time-sharing System”) namespace允许每个 container 拥有独立的 hostname 和 domain name, 使其在网络上可以被视作一个独立的节点而非 Host 上的一个进程。
- user namespace: 每个 container 可以有不同的 user 和 group id, 也就是说可以在 container 内部用 container 内部的用户
执行程序而非 Host 上的用户。
关于 namespace 的常用操作:
lsns –t <type> # 查看当前系统的 namespace;
ls -la /proc/<pid>/ns/ # 查看某进程的 namespace
nsenter -t <pid> -n ip addr # 进入某 namespace 运行命令
linux启动进程是systemd,pid为1,分配一个默认的namespace;
对namespace常用操作:clone ; setns; unshare
Cgroups
Cgroups(Control Groups)是Linux下用于对一个或一组进程进行资源控制和控制的机制;可以对诸如CPU使用时间,内存,磁盘I/O等进程所需的资源进行限制。
不同资源的具体管理工作由相应的 Cgroup 子系统(Subsystem)来实现 ; 针对不同类型的资源限制,只要将限制策略在不同的的子系统上进行关联即可 ; Cgroups 在不同的系统资源管理子系统中以层级树(Hierarchy)的方式来组织管理,默认情况在/sys/fs/cgroup下面:每个 Cgroup 都可以包含其他的子 Cgroup,因此子Cgroup 能使用的资源除了受本Cgroup 配置的资源参数限制,还受到父Cgroup 设置的资源限制。
cpu子系统
这个子系统使用调度程序为 cgroup 任务提供 CPU 的访问。
cpu.shares: 可出让的能获得 CPU 使用时间的相对值。指分配cpu资源的权重,控制相对比例。如下图所示。
cpu.cfs_period_us:cfs_period_us 用来配置时间周期长度,单位为 us(微秒),默认10w。
cpu.cfs_quota_us:cfs_quota_us 用来配置当前 Cgroup 在 cfs_period_us 时间内最多能使用的 CPU 时间数,单
位为 us(微秒)。假如配了100w,则可以分配10个Cores。
cpu.stat : Cgroup 内的进程使用的 CPU 时间统计。
nr_periods : 经过 cpu.cfs_period_us 的时间周期数量。
nr_throttled : 在经过的周期内,有多少次因为进程在指定的时间周期内用光了配额时间而受到限制。
throttled_time : Cgroup 中的进程被限制使用 CPU 的总用时,单位是 ns(纳秒)。
Linux调度器
内核默认提供了5个调度器,Linux 内核使用 struct sched_class 来对调度器进行抽象:
- Stop 调度器,stop_sched_class:优先级最高的调度类,可以抢占其他所有进程,不能被其他进程抢占;
- Deadline 调度器,dl_sched_class:使用红黑树,把进程按照绝对截止期限进行排序,选择最小进程进行调度运行;
- RT 调度器, rt_sched_class:实时调度器,为每个优先级维护一个队列;
- CFS 调度器, cfs_sched_class:完全公平调度器,采用完全公平调度算法,引入虚拟运行时间概念;
- vruntime = 实际运行时间*1024/进程权重
- 维护了一个以虚拟运行时间为顺序的红黑树。
- IDLE-Task 调度器, idle_sched_class:空闲调度器,每个 CPU 都会有一个 idle 线程,当没有其他进程可以调度时,调度运行 idle 线程。
修改文件控制cpu占用
cd /sys/fs/cgroup/cpu
ls -la
cd cpudemo
cat cpu.cfs_quota_us #-1 means no control
cat cgroup.pros # no pid control
echo <pid> > cgroup.procs
each 10000 > cpu.cfs_quota_us # 10% cpu
cpuacct 子系统
用于统计 Cgroup 及其子 Cgroup 下进程的 CPU 的使用情况。
- cpuacct.usage
包含该 Cgroup 及其子 Cgroup 下进程使用 CPU 的时间,单位是 ns(纳秒)。 - cpuacct.stat
包含该 Cgroup 及其子 Cgroup 下进程使用的 CPU 时间,以及用户态和内核态的时间。
Memory 子系统
- memory.usage_in_bytes
cgroup 下进程使用的内存,包含 cgroup 及其子 cgroup 下的进程使用的内存 - memory.max_usage_in_bytes
cgroup 下进程使用内存的最大值,包含子 cgroup 的内存使用量。 - memory.limit_in_bytes
设置 Cgroup 下进程最多能使用的内存。如果设置为 -1,表示对该 cgroup 的内存使用不做限制。 - memory.soft_limit_in_bytes
这个限制并不会阻止进程使用超过限额的内存,只是在系统内存足够时,会优先回收超过限额的内存,使之向限定值靠拢。如通过swap交换内存,减少由操作系统core管控的部分内存。 - memory.oom_control
设置是否在 Cgroup 中使用 OOM(Out of Memory)Killer,默认为使用。当属于该 cgroup 的进程使用的内存超过最大的限定值时,会立刻被 OOM Killer 处理。
Cgroup driver
systemd:
• 当操作系统使用 systemd 作为 init system 时,初始化进程生成一个根 cgroup 目录结构并作为 cgroup
管理器。
• systemd 与 cgroup 紧密结合,并且为每个 systemd unit 分配 cgroup。 cgroupfs:
• docker 默认用 cgroupfs 作为 cgroup 驱动。
存在问题:
• 在 systemd 作为 init system 的系统中,默认并存着两套 groupdriver。 • 这会使得系统中 Docker 和 kubelet 管理的进程被 cgroupfs 驱动管,而 systemd 拉起的服务由
systemd 驱动管,让 cgroup 管理混乱且容易在资源紧张时引发问题。
因此 kubelet 会默认--cgroup-driver=systemd,若运行时 cgroup 不一致时,kubelet 会报错。
文件系统
Linux 的命名空间和控制组分别解决了不同资源隔离的问题,前者解决了进程、网络以及文件系统的隔离,后者实现了 CPU、内存等资源的隔离,但是在 Docker 中还有另一个非常重要的问题需要解决,也就是镜像的存储和分发问题。
Docker的创新点是Union FS。Docker 支持了不同的存储驱动,包括 aufs、devicemapper、overlay2、zfs 和 vfs 等等,在最新的 Docker 中,overlay2 取代了 aufs 成为了推荐的存储驱动,但是在没有 overlay2 驱动的机器上仍然会使用 aufs 作为 Docker 的默认驱动。
UnionFS(联合文件系统)其实是一种为 Linux 操作系统设计的用于把多个文件系统联合到同一个挂载点的文件系统服务。而 AUFS 即 Advanced UnionFS 其实就是 UnionFS 的升级版,它能够提供更优秀的性能和效率。
- 将不同目录挂载到同一个虚拟文件系统下 (unite several directories into a single virtual filesystem)
的文件系统 - 支持为每一个成员目录(类似Git Branch)设定 readonly、readwrite 和 whiteout-able 权限
- 文件系统分层, 对 readonly 权限的 branch 可以逻辑上进行修改(增量地, 不影响 readonly 部分的)。
- 通常 Union FS 有两个用途, 一方面可以将多个 disk 挂到同一个目录下, 另一个更常用的就是将一个readonly 的 branch 和一个 writeable 的 branch 联合在一起。
典型的 Linux 文件系统组成包含bootfs(boot file system)和rootfs (root file system)
- bootfs(boot file system)主要包含 bootloader和 Kernel,bootloader主要是引导加 kernel,Linux刚启动时会加bootfs文件系统,当boot加载完成之后整个内核就都在内存中了,此时内存的使用权已由 bootfs转交给内核,此时系统也会卸载bootfs。
- rootfs (root file system),在 bootfs之上。包含的就是典型 Linux系统中的/dev,/proc,/bin,/etc等标准目录和文件。 rootfs就是各种不同的操作系统发行版,比如 Ubuntu,Centos等等。
Linux 在启动后,首先将 rootfs 设置为 readonly, 进行一系列检查, 然后将其切换为 “readwrite”供用户使用。而Docker不存在Bootfs,初始化时也是将 rootfs 以 readonly 方式加载并检查,然而接下来利用 union mount 的方式将一个readwrite 文件系统挂载在 readonly 的 rootfs 之上;并且允许再次将下层的 FS(file system) 设定为 readonly 并且向上叠加。这样一组 readonly 和一个 writeable 的结构构成一个 container 的运行时态, 每一个 FS 被称作一个 FS 层。
由于镜像具有共享特性,所以对容器可写层的操作需要依赖存储驱动提供的写时复制和用时分配机制,以此来支持对容器可写层的修改,进而提高对存储和内存资源的利用率。
- 写时复制,即 Copy-on-Write。
- 一个镜像可以被多个容器使用,但是不需要在内存和磁盘上做多个拷贝。
- 在需要对镜像提供的文件进行修改时,该文件会从镜像的文件系统被复制到容器的可写层的文件系统进行修改,而镜像里面的文件不会改变。
- 不同容器对文件的修改都相互独立、互不影响。
- 用时分配
按需分配空间,而非提前分配,即当一个文件被创建出来后,才会分配空间。
镜像为什么要分层?
我们可以去下载一个镜像,注意观察下载的日志输出,可以看到是一层层的在下载 。 采用这种分层的结构,最大的好处,就是可以资源共享,节省磁盘空间,内存空间,加快下载速度,这种文件的组装方式提供了非常大的灵活性。通过容器镜像分层,每次检查checksum, 只更新需要更新的镜像层。
比如有多个镜像都从相同的Base镜像构建而来,那么宿主机只需在磁盘上保留一份base镜像,下载镜像的时候可以不用重复下载,同时内存中也只需要加载一份base镜像,这样就可以为所有的容器服务了,而且镜像的每一层都可以被共享。
Docker的overlayFS存储驱动利用了很多OverlayFS特性来构建和管理镜像与 容器 的磁盘结构。Overlay在主机上用到2个目录,这2个目录被看成是overlay的层。upperdir为容器层、lowerdir为镜像层使用联合挂载技术将它们挂载在同一目录(merged)下,提供统一视图。
网络
Docker网络的实现主要是依赖Linux网络有关的技术,这些技术有网络命名空间(Network Namespace)、Veth设备对、网桥、ipatables和路由。
(1)网络命名空间,实现网络隔离。
(2)Veth设备对,实现不同网络命名空间之间的通信。
(3)网桥,实现不同网络之间通信。
(4)ipatables,实现对数据包进行过滤和转发。
(5)路由,决定数据包到底发送到哪里。
运行容器时,你可以使用该-–net标志来指定容器应连接到哪些网络.
- Null(--net=None)
Null 模式是一个空实现,把容器放入独立的网络空间但不做任何网络配置;
用户需要通过运行 docker network 命令来完成网络配置。 - Host
使用主机网络名空间,复用主机网络。 - Container
重用其他容器的网络。 - Bridge(--net=bridge)
使用 Linux 网桥和 iptables 提供容器互联,Docker 在每台主机上创建一个名叫 docker0 的网桥,通过 veth pair 来连接该主机的每一个 EndPoint
实现原理
我们只要在宿主机上安装了docker,就会创建一个虚拟网桥docker0。
我们每启动一个docker容器,docker就会给容器分配一个docker0的子网的ip,同时会创建了一对 veth pair 接口,一端连接容器内部,一端连接docker0网桥。
通过这种方式,主机可以跟容器通信,容器之间也可以相互通信。
默认模式– 网桥和 NAT
Linux网桥是工作在 TCP/IP 二层协议的虚拟网络设备,与现实世界中的交换机功能相似。与其他虚拟网络设备一样,可以配置 IP、MAC。Bridge 的主要功能是在多个接入 Bridge 的网络接口间转发数据包。Bridge 有多个端口,数据可以从多个端口进,从多个端口出。Docker容器通过docker0 网桥实现同一主机间中,容器的ip地址分配和访问。
docker run -it -p 8888:80 nginx # 启动nginx容器,且将本机8888端口映射到容器80端口(-p,通过iptables修改包地址)
docker ps | grep nginx # 得到容器Id
docker inspect 883b033a06bd | grep -i pid # 得到容器的Pid
brctl show # 当前网桥配置,可以看到启动容器后,新增了网桥接口
curl 172.17.0.2 # 主机可以直接访问容器接口
nsenter -t 1487 -n ip a # 通过pid验证容器ip地址
nsenter -t 1487 -n ip r # 通过pid验证容器网络的路由信息
那么容器启动后,如何解决与主机的网络联通呢?参考setup-network, 我们可以通过手动创建一个网络链路来模拟Docker底层为我们做的事。
那么不同主机之间的容器是如何互通的呢?有两种网络模型
- Overlay网络模型。为避免各容器间的IP地址冲突,一个常见的解决方案是将每个宿主机分配到同一网络中的不同子网,各主机基于自有子网向其容器分配IP地址。Docker 提供了 overlay driver,使用户可以创建基于 VxLAN 的 overlay 网络。VxLAN 可将二层数据封装到 UDP 进行传输,VxLAN 提供与 VLAN 相同的以太网二层服务,但是拥有更强的扩展性和灵活性。
- Underlay网络模型。容器网络中的Underlay网络是指借助驱动程序将宿主机的底层网络接口直接暴露给容器使用的一种网络构建技术,较为常见的解决方案有MAC VLAN、IP VLAN和直接路由等。
DockerFile
Docker 镜像是一个特殊的文件系统,除了提供容器运行时所需的程序、库、资源、配置等文件外,还包含了一些为运行时准备的一些配置参数(如匿名卷、环境变量、用户等)。镜像不包含任何动态数据,其内容在构建之后也不会被改变。
镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提及的无法重复的问题、镜像构建透明性的问题、体积的问题就都会解决。这个脚本就是 Dockerfile。Dockerfile 是一个文本文件,其内包含了一条条的指令(Instruction),每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。有了 Dockerfile,当我们需要定制自己额外的需求时,只需在 Dockerfile 上添加或者修改指令,重新生成 image 即可,省去了敲命令的麻烦。
Docker 遵循12-Factor原则管理和构建应用。具体的说,
- 12-Factor 应用的进程必须无状态(Stateless)且无共享(Share nothing)。
- 任何需要持久化的数据都要存储在后端服务内,比如数据库。应在构建阶段将源代码编译成待执行应用。
- Session Sticky 是 12-Factor 极力反对的。Session 中的数据应该保存在诸如 Memcached 或 Redis 这样的带有过期时间
的缓存中。
当运行 docker build 命令时,当前工作目录被称为构建上下文。
- docker build 运行时,首先会把构建上下文传输给 docker daemon,把没用的文件包含在构建上下文时,会导致传输时间长,构建需要的资源多,构建出的镜像大等问题。
- 构建容器镜像时,Docker 依次读取 Dockerfile 中的指令,并按顺序依次执行构建指令。Docker 读取指令后,会先判断缓存中是否有可用的已存镜像(通过检查checksum),只有已存镜像不存在时才会重新构建
- Docker 17.05版本以后,官方就提供了一个新的特性:Multi-stage builds(多阶段构建)。使用多阶段构建,你可以在一个Dockerfile 中使用多个 FROM 语句。每个 FROM 指令都可以使用不同的基础镜像,并表示开始一个新的构建阶段。你可以很方便的将一个阶段的文件复制到另外一个阶段,在最终的镜像中保留下你需要的内容即可。
- 对于多进程的容器镜像,需要选择适当的 init 进程;用于1.需要捕获 SIGTERM 信号并完成子进程的优雅终止;2.负责清理退出的子进程以避免僵尸进程; 可参考 https://github.com/krallin/tini
Dockerfile 常用指令
- FROM:选择基础镜像,推荐 alpine
FROM [--platform=] <image>[@ ] [AS ] - LABELS:按标签组织项目
LABEL multi.label1="value1" multi.label2="value2" other="value3”
配合 label filter 可过滤镜像查询结果
docker images -f label=multi.label1="value1" - RUN:执行命令
最常见的用法是 RUN apt-get update && apt-get install,这两条命令应该永远用&&连接,如果分开执行,RUN
apt-get update 构建层被缓存,可能会导致新 package 无法安装 - CMD:容器镜像中包含应用的运行命令,需要带参数
CMD ["executable", "param1", "param2"…] - EXPOSE:发布端口
EXPOSE[<port>/<protocol>...]
是镜像创建者和使用者的约定; 在 docker run –P 时,docker 会自动映射 expose 的端口到主机大端口,如0.0.0.0:32768->80/tcp - ENV 设置环境变量
ENV= ...