Docker 核心技术
系统架构
传统分层架构 VS 微服务
当业务逻辑简单,需求也比较简单的情况下(例如多年前的it互联网环境),只需要一台或者几台物理服务器即可满足需求,在物理机上部署 UI 服务、业务逻辑服务、存储服务等。
随着业务复杂度和体量增大,架构开始演进到分层架构(分为前端、后端、DBA),将复杂业务拆分成更小颗粒度的、独立的服务(单个部门负责单个服务)。但是多个服务部署到同一台物理服务器上之后,服务之间的隔离性就无法保证,当服务 A 出现异常,可能导致内存占用过多,影响物理机,结果影响其他服务。
服务之间相互隔离,再增加一些高可用、负载均衡的能力,也就逐步演进出微服务架构。
单体架构和微服务架构,在不同的系统上各有优缺点。
微服务改造
当需要将服务应用(或者一个耦合度高的微服务)改造成一个微服务时,方法和建议:
- 审视并发现可以分离的业务逻辑业务逻辑
- 寻找天生隔离的代码模块,可以借助于静态代码分析工具
- 不同并发规模,不同内存需求的模块都可以分离出不同的微服务,此方法可提高资源利用率,节省成本
一些常用的可微服务化的组件:
- 用户和账户管理
- 授权和会话管理
- 系统配置
- 通知和通讯服务
- 照片,多媒体,元数据等
分解原则:基于 size(规模)、scope(范围) and capabilities(能力)
微服务间通讯
点对点
- 多用于系统内部多组件之间通讯
- 有大量的重复模块如认证授权
- 缺少统一规范,如监控,审计等功能
- 后期维护成本高,服务和服务的依赖关系错综复杂难以管理
API 网关
- 基于一个轻量级的
message gateway
- 新 API 通过注册至
Gateway
实现 - 整合实现
Common function
一些内部功能和整合进 API 网关,例如认证、授权、统计。
理解 Docker
- 基于
Linux
内核的Cgroup
(资源管控,用于限制、控制和隔离进程组的系统资源使用),Namespace
(隔离进程,隔离网络栈,隔离文件系统挂载点,隔离进程间通信,隔离主机名和域名,隔离用户和用户组标识符),以及Union FS
等技术,对进程进行封装隔离,属于操作系统层面的虚拟化技术,由于隔离的进程独立于宿主和其他的隔离的进程,因此也称其为容器。 - 最初实现是基于
LXC
,从 0.7 以后开始去除LXC
,转而使用自行开发的Libcontainer
,从 1.11 开始,则进一步演进为使用runC
和Containerd
。 Docker
在容器的基础上,进行了进一步的封装,从文件系统、网络互连到进程隔离等等,极大的简化了容器的创建和维护,使得Docker
技术比虚拟机技术更为轻便、快捷。
使用 Docker 的理由:
- 更高效地利用系统资源:不需要额外内存使用内核
- 更快速的启动时间:不需要启动过程中的硬件自检、加载内核等
- 一致的运行环境:容器提供运行环境
- 持续交付和部署:更新镜像即可
- 更轻松地迁移:迁移镜像即可
- 更轻松地维护和扩展
等。
虚拟机和容器运行态的对比
虚拟机
在操作系统上,通过 Hypervisor
提供虚拟化能力,各个应用启动对应操作系统,应用在独立的操作系统中运行。
这个调用链比较长,而且有两层操作系统。
在 Docker Engine 上,启动应用。
相比而言,调用链短,而且不需要启动额外的操作系统,节省资源。
性能对比
一些虚拟机也在做 miniOS
,通过瘦身解决启动慢和资源问题,但是依然需要加载额外的操作系统。
Docker 操作
官方文档:https://docs.docker.com/engine/
一些常用操作:
启动
docker run
-it
交互-d
后台运行-p
端口映射-v
磁盘挂载
启动已终止容器
docker start
停止容器
docker stop
查看容器进程
docker ps
查看容器细节:
docker inspect <containerid>
进入容器;
Docker attach
通过
nsenter
PID=$(docker inspect --format "{{ .State.Pid }}" <container>)
$ nsenter --target $PID --mount --uts --ipc --net --pid
拷贝文件至容器内:
docker cp file1 <containerid>:/file-to-path
初识容器
创建镜像:
cat Dockerfile
1
2
3FROM ubuntu
ENV MY_SERVICE_PORT=80
ADD bin/amd64/httpserver /httpserver ENTRYPOINT /httpserver将
Dockerfile
打包成镜像1
2docker build -t xxx/httpserver:${tag} .
docker push xxx/httpserver:v1.0运行容器
1
docker run -d xxx/httpserver:v1.0
容器标准
在 Docker
发展过程中,由于一些历史性原因,由谷歌牵头定义的一些规范。
Open Container Initiative
(OCI
)- 轻量级开放式管理组织(项目)
OCI
主要定义两个规范Runtime Specification
- 文件系统包如何解压至硬盘,共运行时运行
Image Specification
- 如何通过构建系统打包,生成镜像清单(
Manifest
)、文件系统序列化文件、镜像配置。
- 如何通过构建系统打包,生成镜像清单(
直白来说,就是镜像如何打包,打包的镜像如何解压如何运行。目的是保障打包、部署过程中的效率。
容器主要特性
隔离性:通过 namespace
实现
可配额:通过 cgroup
实现
便携性: 通过容器镜像
安全性:通过隔离和配额实现
Namespace
Linux Namespace 是一种 Linux Kernel 提供的资源隔离方案:
- 系统可以为进程分配不同的
Namespace
; - 并保证不同的
Namespace
资源独立分配、进程彼此隔离,即不同的Namespace
下的进程互不干扰。
Linux 内核代码中 Namespace 的实现
进程数据结构
在
Linux
内核中提供了多个namespace
,其中包括fs (mount)
,uts
,network
,sysvipc
, 等。一个进程可以属于多个
namesapce
,既然namespace
和进程相关,那么在task_struct
结构体中就会包含和namespace
相关联的变量。在
task_struct
结构中有一个指向namespace
结构体的指针nsproxy
。1
2
3
4
5
6
7struct task_struct
{
……..
/* namespaces */
struct nsproxy *nsproxy;
…….
}Namespace
数据结构再看一下
nsproxy
是如何定义的,在include/linux/nsproxy.h
文件中,这里一共定义了6个各自的命名空间结构体,在该结构体中定义了5个指向各个类型namespace
的指针,由于多个进程可以使用同一个namespace
,所以nsproxy
可以共享使用,count
字段是该结构的引用计数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25/*
* A structure to contain pointers to all per-process
* namespaces - fs (mount), uts, network, sysvipc, etc.
*
* The pid namespace is an exception -- it's accessed using
* task_active_pid_ns. The pid namespace here is the
* namespace that children will use.
*
* 'count' is the number of tasks holding a reference.
* The count for each namespace, then, will be the number
* of nsproxies pointing to it, not the number of tasks.
*
* The nsproxy is shared by tasks which share all namespaces.
* As soon as a single namespace is cloned or unshared, the
* nsproxy is copied.
*/
struct nsproxy {
atomic_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns_for_children;
struct net *net_ns;
struct cgroup_namespace *cgroup_ns;
};
Linux 对 Namespace 操作方法
当 Linux 启动时,会通过init
启动 pid
为 1 的进程,并且分配给进程默认的 Namespace
。
当要启动其他进程时,可以通过以下几种方法:
clone
在创建新进程的系统调用时,可以通过
flags
参数指定需要新建的Namespace
类型:1
2
3
4// CLONE_NEWCGROUP / CLONE_NEWIPC / CLONE_NEWNET / CLONE_NEWNS
// CLONE_NEWPID / CLONE_NEWUSER / CLONE_NEWUTS
// 通过 flags 来指定新的 NS
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg)setns
该系统调用可以让调用进程加入某个已经存在的
Namespace
中:1
Int setns(int fd,int nstype)
unshare
该系统调用可以将调用进程移动到新的
Namespace
下:1
int unshare(int flags)
Linux
更新后,可能还会新增更多的操作方式。
隔离性
在 fork
不同的进程后,进程之间可以通过 Namespace
隔离。
例如 PID
,在不同的 Namespace
中看到的 PID
是不同的,但是它们是继承关系;网络也不一样,在不同的 Namespace
中,网卡配置没有任何关联。
一个用户进程,拥有不同类型的 namespace
。
Pid namespace
- 不同用户的进程就是通过
Pid namespace
隔离开的,且不同namespace
中可以有相同Pid
。 - 有了
Pid namespace
,每个namespace
中的Pid
能够相互隔离。
例如在不同的 Namespace
中使用 ps
只获取到本 Namespace
中的进程,方便管理。
net namespace
- 网络隔离是通过
net namespace
实现的,每个net namespace
有独立的network devices
,IP addresses
,IP routing tables
,proc/net
目录 Docker
默认采用veth
的方式将container
中的虚拟网卡同host
上的一个docker bridge: docker0
连接在一起
不同的 namespace
有不同的网络配置、路由表
ipc namespace
Container
中进程交互采用Linux
常见的进程间交互方法(interprocess communication
-IPC
),包括常见的信号量、消息队列和共享内存Container
的进程间交互实际上还是hosst
上具有相同Pid namespace
中的进程间交互,因此需要在IPC
资源申请时加入namespace
信息 - 每个IPC
资源有一个唯一的 32 位 ID。
mnt namespace
mnt namespace
允许不同namespace
的进程看到的文件结构不同,这样每个namepace
中的进程所看到的文件目录就被隔离开了。
uts namespace
UTS
(UNIX Time-sharing System
)namespace
允许每个container
拥有独立的hostname
和domain name
,使其在网络上可以被视作一个独立的节点,而非Host
上的一个进程。
user namespace
- 每个
container
可以有不同的user
和group id
,也就是说可以在container
内部用container
内部的用户执行程序而非Host
上的用户。
namespace 常用操作
查看当前系统的
namespace
1
lsns -t <type>
1
2
3
4
5
6
7
8
9
10
11
12lsns
NS TYPE NPROCS PID USER COMMAND
4026531836 pid 826 1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 22
4026531837 user 4343 1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 22
4026531838 uts 829 1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 22
4026531839 ipc 824 1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 22
4026531840 mnt 819 1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 22
4026531860 mnt 1 293 root kdevtmpfs
4026531992 net 835 1 root /usr/lib/systemd/systemd --switched-root --system --deserialize 22
4026532751 mnt 1 1520 chrony /usr/sbin/chronyd
4026532752 mnt 1 1526 root /usr/sbin/NetworkManager --no-daemon
4026532753 mnt 1 11183 root /pause可以看到不同的
PID
下有不同的类型的namespace
。查看某进程的
namespace
1
ls -la /proc/<PID>/ns/
1
2
3
4
5
6
7
8
9
10
11
12
13
14ls -la /proc/1/ns/
total 0
dr-x--x--x 2 root root 0 Jan 2 00:01 .
dr-xr-xr-x 9 root root 0 Nov 26 20:09 ..
lrwxrwxrwx 1 root root 0 Jan 2 11:17 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Jan 2 11:13 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Jan 2 11:13 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 Jan 2 11:13 net -> net:[4026531992]
lrwxrwxrwx 1 root root 0 Jan 2 00:01 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Jan 2 11:17 pid_for_children -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Jan 2 11:17 time -> time:[4026531834]
lrwxrwxrwx 1 root root 0 Jan 2 11:17 time_for_children -> time:[4026531834]
lrwxrwxrwx 1 root root 0 Jan 2 11:13 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Jan 2 11:13 uts -> uts:[4026531838]进入某
namespace
运行命令:1
nsenter -t <pid> -n ip addr
1
2
3
4
5
6
7
8
9nsenter -t 4176346 -n ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
3: eth0@if931: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether d6:6b:62:44:63:bb brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 10.0.0.32/24 brd 10.0.0.255 scope global eth0
valid_lft forever preferred_lft forever例如 docker 中没有需要的命令,可以通过获取这个
docker
的进程,然后使用nsenter
命令,进入到对应namespace
中操作。
例如创建一个 namespace
,并且执行一些操作:
1 | // 在新 network namespace 执行 sleep 指令: |
Cgroups
Cgroups
(Control Groups
) 是 Linux 下用于对一个或一组进程进行资源控制和监控的机制;- 可以对诸如 CPU 使用时间、内存、磁盘
I/O
等进程所需的资源进行限制; - 不同资源的具体管理工作由相应的
Cgroup
子系统(Subsystem
)来实现; - 针对不同类型的资源限制,只要将限制策略在不同的子系统上进行关联即可;
Cgroup
在不同的系统资源管理子系统中以层级树(Hierarchy
)的方式来组织管理:每个Cgroup
都可以包含其他的子Cgroup
,因此子Cgroup
能使用的资源除了受本Cgroup
配置的资源参数限制,还受到父Cgroup
设置的资源限制。
cgroup
管理,例如删除 cgroup
需要安装包
1 | yum install libcgroup-tools // centos |
Linux 内核代码中 Cgroup 的实现
进程数据结构
1
2
3
4
5
6
7struct task_struct
{
struct css_set_rcu *cgroups;
struct list_head cg_list;
}css_set
是cgroup_subsys_state
对象的集合数据结构1
2
3
4
5
6
7struct css_set {
/*
* Set of subsystem states, one for each subsystem. This array is * immutable after creation apart from the init_css_set during
* subsystem registration (at boot time).
*/
struct cgroup_subsys_state *subsys[CGROUP_SUBSYS_COUNT];
};可配额/可度量 - Control Groups(cgroups)
cgroups
实现了对资源的配额和度量
blkio
:这个子系统设置限制了每个块设备的输入输出控制。例如:磁盘,光盘以及USB
等等。CPU
:这个子系统使用调度程序为cgroup
任务提供CPU
的访问。(例如在Concurrency
过程中,多个进程同时抢占一个CPU
,实际上是将CPU
分为多个时间片,分配给不同的进程执行。)cpuacct
:产生cgroup
任务的CPU
资源报告cpuset
:如果是多核心的CPU
,这个子系统会为cgroup
任务分配单独的CPU
和内存。devices
:允许或拒绝cgroup
任务对设备的访问。freezer
:暂停和恢复cgroup
任务。memory
:设置每个cgroup
的内存限制以及产生内存资源报告。net_cls
:标记每个网络包以供cgroup
方便使用。ns
:名称空间子系统。pid
:进程标识子系统。
1 | pwd |
在 /sys/fs/cgroup/cpu
目录下创建子目录即可创建一个管理 CPU
的 cgroup
,echo <pid> > cgroup.procs
即可将进程添加到 cgropu
进程配置组。
CPU 子系统
CPU
分配给进程的时间片有两种方式,一种是相对值,一种是绝对值。
cpu.shares
:可让出的能获得 CPU
使用时间的相对值。(例如上图,CGroupA
的 shares
为 512
,实际占用 CPU
33%
的时间,如果修改成 1024
,则占用 50%
的时间)
1 | cat /sys/fs/cgroup/cpu/cpu.shares |
cpu.cfs_period_us
:fs_period_us
用来配置时间周期长度,单位为 us
(微秒)。(默认 100000
)
1 | cat /sys/fs/cgroup/cpu/cpu.cfs_period_us |
cpu.cfs_quota_us
:fs_quota_us
用来配置当前 Cgroup
在 cfs_period_us
时间内最多能使用的 CPU
时间数,单位为 us
(微秒)。(如果配置 100000
,代表可以拿到 1个 CPU
)
1 | cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us |
cpu.stat
:Cgroup
内的进程使用的 CPU
时间统计。
1 | cat /sys/fs/cgroup/cpu/cpu.stat |
nr_periods
:经过 cpu.cfs_period_us
的时间周期数量。
nr_throttled
:在经过的周期内,有多少次因为进程在指定的时间周期内用光了配额时间而受到限制。
throttled_time
:Cgroup
中的进程被限制使用 CPU
的总用时,单位是 ns
(纳秒)。
在 /sys/fs/cgroup/cpu
创建对应目录,即可创建对应名称的 Cgroup
,将需要管理的 PID
写入到目录下的 cgroup.procs
目录下即可管理对应进程。
通过 echo 10000 > cpu.cfs_quota_us
,即可让对应进程的 CPU 消耗卡在 10%
以内。
Linux 调度器
内核默认提供了 5 个调度器,Linux 内核使用 struct sched_class
来对调度器进行抽象:
Stop
调度器,stop_sched_class
:优先级最高的调度类,可以抢占其他所有进程,不能被其他进程抢占;Deadline
调度器,dl_sched_class
:使用红黑树,把进程按照绝对截止期限进行排序,选择最小进程进行调度运行;RT
调度器,rt_sched_class
:实时调度器,为每个优先级维护一个队列;(公平调度)CFS
调度器,cfs_sched_class
:完全公平调度器,采用完全公平调度算法,引入虚拟运行时间概念;IDLE-Task
调度器,idle_sched_class
:空闲调度器,每个CPU
都会有一个idle
线程,当没有其他进程可以调度时,调度运行idle
线程。
CFS 调度器
CFS
是Completely Fair Scheduler
简称,即完全公平调度器。CFS
实现的主要思想是维护为任务提供处理器时间方面的平衡,这意味着应给进程分配相当数量的处理器。- 分给某个任务的时间失去平衡时,应给失去平衡的任务分配时间,让其执行。
CFS
通过虚拟运行时间 (vruntime
) 来实现平衡,维护提供给某个任务的时间量。vruntime
= 实际运行时间 * 1024 / 进程权重
- 进程按照各自不同的速率在物理时钟节拍内前进,优先级高则权重大,其虚拟时钟比真实时钟跑得慢,但获得比较多的运行时间。
vruntime
红黑树
CFS
调度器没有将进程维护在运行队列中,而是维护了一个以虚拟运行时间为顺序的红黑树。
红黑树的主要特点:
- 自平衡,树上没有一条路径会比其他路径长出两倍。
O(logn)
时间复杂度,能够在树上进行快速高效地插入或删除进程。
CFS 进程调度
- 在时钟周期开始时,调度器调用
_schdule()
函数来开始调度的运行。 _schdule()
函数调用pick_next_task()
让进程调度器从就绪队列中选择一个最合适的进程next
,即红黑树最左边的节点。- 通过
context_switch()
切换到新的地址空间,从而保证next
进程运行。 - 在时钟周期结束时,调度器调用
entity_tick()
函数来更新进程负载、进程状态以及vruntime
(当前vruntime
+ 该时钟周期内运行的时间)。 - 最后,将该进程的虚拟时间与就绪队列红黑树中最左边的调度实体的虚拟时间做比较,如果小于左边的时间,则不用触发调度,继续调度当前调度实体。
cpuacct 子系统
用于统计 Cgroup
及其子 Cgroup
下进程的 CPU
的使用情况。
cpuacct.usage
包含该
Cgroup
及其子Cgroup
下进程使用CPU
的时间,单位是ns
(纳秒)1
2cat /sys/fs/cgroup/cpu/cpuacct.usage
7873567244444726cpuacct.stat
包含该
Cgroup
及其子Cgroup
下进程使用的CPU
时间,以及用户态和内核态的时间。1
2
3cat /sys/fs/cgroup/cpu/cpuacct.stat
user 576051691
system 210990765
Memory 内存子系统
负责管理 cgroup
中的内存限制。在 /sys/fs/cgroup/memory
目录下创建一个子目录即可创建对应可管理内存的 cgropu
,echo <pid> > cgroup.procs
即可将进程添加到 cgropu
进程配置组。
memory.usage_in_bytes
cgroup
下进程使用的内存,包含cgroup
及其子cgroup
下的进程使用的内存memory.max_usage_in_bytes
cgroup
下进程使用内存的最大值,包含cgroup
的内存使用量。memory.limit_in_bytes
设置
cgroup
下进程最多能使用的内存。如果设置为-1
,表示对该cgroup
的内存使用不做限制。echo 104960000 > memory.limit_in_bytes
即可配置。memory.soft_limit_in_bytes
这个限制并不会阻止进程使用超过配额的内存,只是在系统内存足够时,会优先回收超过限额的内存,使之向限定值靠拢。(例如当使用
swap
分区或者一些页内存超过配额,则可以将这部分内存回收回来,或者转储到硬盘上。)memory.oom_control
设置是否在
cgroup
中使用OOM
(Out Of Memory
)Killer
,默认为使用。当属于该cgroup
的进程使用的内存超过最大的限定值时,会立刻被OOM Killer
处理。
Cgroup driver
在 systemd
作为 init system
的系统中,默认并存着两套 cgroup driver
:
systemd
:
- 当操作系统使用
systemd
作为init system
时,初始化进程生成一个根cgroup
目录结构并作为cgroup
管理器。 systemd
与cgroup
紧密结合,并且为每个systemd unit
分配cgroup
cgroupfs
:
docker
默认用cgroupfs
作为cgroup
驱动
由于 docker
和 kubelet
默认使用 cgroupfs
,而 systemd
拉起的服务由 systemd
驱动管,让 cgroup
管理混乱且容易在资源紧张时引发问题。
因此 kubelet
会默认 --cgroup-driver=systemd
,若运行时 cgroup
不一致时,kubelet
会报错。
文件系统
Union FS
- 将不同目录挂载到同一个虚拟文件系统上(
unite serveral directories into a single virtual filesystem
) 的文件系统 - 支持为每一个成员目录(类似
Git Branch
)设定readonly
,readwrite
和whiteout-able
权限 - 文件系统分层,对
readonly
权限的branch
可以逻辑上进行修改(增量地,不影响readonly
部分的)。 - 通常
Union FS
有两个用途,一方面可以将多个disk
挂到同一个目录下,另一个更常用的就是将一个readonly
的branch
和一个writeable
的branch
联合在一起。
容器镜像
例如使用 Dockfile
构建镜像。
通过每一条命令,创建一层镜像,在底层镜像之上,构建上层镜像。
好处是在 devops
时,需要启动一个容器,不需要每个节点上下载全部镜像内容,只下载新增部分或者差异部分即可。
Docker 的文件系统
典型的 Linux
文件系统组成:
Bootfs
(boot file system
)Bootloader
- 引导加载kernel
Kernel
- 当kernel
被加载到内存中后umount bootfs
rootfs
(root file system
)/dev
、/proc
、/bin
、/etc
等标准目录和文件- 对于不同的
linux
发行版,bootfs
基本是一致的,但rootfs
会有差别。
Docker 启动
Linux
- 在启动后,首先将
rootfs
设置为readonly
,进行一系列检查,然后将其切换为readwrite
供用户使用。
Docker
- 初始化时也是将
rootfs
以readonly
方式加载并检查,然后接下来利用union mount
的方式,将一个readwrite
文件系统挂载在readonly
的rootfs
之上; - 并且允许再次将下层的
FS
(file system
)设定为readonly
并且向上叠加 - 这样一组
readonly
和一个writeable
的结构构成一个container
的运行时态,每一个FS
被称作一个FS
层。
写操作
由于镜像具有共享特性,所以对容器可写层的操作需要依赖存储驱动提供的写时复制和用时分配机制,以此来支持对容器可写层的修改,进而提高对存储和内存资源的利用率。
写时复制
- 写时复制,即
Copy-on-Write
- 一个镜像可以被多个容器使用,但是不需要在内存和磁盘上做多个拷贝
- 在需要对镜像提供的文件进行修改时,该文件会从镜像的文件系统被复制到容器的可写层的文件系统进行修改,而镜像里面的文件不会改变。
- 不同容器对文件的修改都是相互独立、互不影响。
- 写时复制,即
用时分配
按需分配空间,而非提前分配,即当一个文件被创建出来后,才会分配空间。
容器存储驱动
目前容器主流的存储驱动是 OverlayFS
以 OverlayFS 为例
OverlayFS
也是一种与 AUFS
类似的联合文件系统,同样属于文件级的存储驱动,包含了最初的 Overlay
和更新更稳定的 overlay2
.
Overlay
只有两层:upper
层和 lower
层,Lower
层代表镜像层,upper
层代表容器可写层。
例如在 Dockerilfe
中,每一个指令都是从下层往上叠一层,在容器中的文件,要么直接从镜像层获取,要么是从容器层新创建的文件获取。
OverlayFS 使用案例
1 | // 创建四个目录,用于做准备 |
查看容器详情时,可以直接看到容器的overlayfs
信息。
1 | docker inspect 8a77db202e9b |
OCI 容器标准
Open Container Initiative
OCI
组织于 2015 年创建,是一个致力于定义容器镜像标准和运行时标准的开放式组织OCI
定义了镜像标准(Runtime Specification
)、运行时标准(Image Specification
)和分发标准(Distribution Specification
)- 镜像标准定义应用如何打包
- 运行时标准定义如何解压应用包并运行
- 分发标准定义如何分发容器镜像
Docker 引擎架构
使用 docker container run
运行一个容器时,是使用 containerd
引擎运行。而 docker
早期本身的架构上,主进程是 docker daemon
,其他进程是由主进程 fork
出来的,当 docker
需要更新或者重启,需要重启 docker-daemon
,就会导致子进程一并重启。
containerd
引擎,通过 shim
进程 fork
出容器子进程 ,当 containerd
主进程重启时,不影响子进程。(shim
的父进程是 systemd
,而不是 containerd
)
1 | // 查看一个容器的进程 |
可以看到 4823
进程是 containerd-shim-runc-v2
,它的父进程是 PID
为1,也就是 systemd
。
网络
进程在独立的网络 namespace
中,可以有独立的网卡、网络配置等。
在 docker
中,提供多种网络模式:
Null
(–net=None)- 把容器放入独立的网络空间但不做任何网络配置;例如
k8s
中 - 用户需要通过运行
docker network
命令来完成网络配置。
- 把容器放入独立的网络空间但不做任何网络配置;例如
Host
- 使用主机网络名空间,复用主机网络
Container
- 重用其他容器的网络
Bridge
(--net=bridge
)(默认)- 使用
Linux
网桥和iptables
提供容器互联,Docker
在每台主机上创建一个名叫docker0
的网桥,通过veth pair
来连接该主机的每一个EndPoint
- 使用
当扩展到多个机器上,需要主机之间通信,而且需要容器之间相互隔离和有独立的网络,就无法直接使用主机网络。
解决方式有两种:
Remote
(network with remote drivers
)Underlay
:- 使用现有底层网络,为每一个容器配置可路由的网络
IP
- 使用现有底层网络,为每一个容器配置可路由的网络
Overlay
(libnetwork
,libkv
):- 通过网络封包实现
Null 模式
Null
模式是一个空实现;- 可以通过
Null
模式启动容器并在宿主机上通过命令为容器配置网络
1 | // 创建 network 的 ns 的临时目录 |
默认模式 - Bridge 和 NAT
例如,为主机 eth0
分配IP 192.168.0.101
;
启动 docker daemon
,查看主机 iptables
;
1 | POSTROUTING -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE |
在主机启动容器:
docker run -d --name ssh -p 2333:22 centos-ssh
Docker
会以标准模式配置网络:- 创建
veth pair
; - 将
veth pair
的一端连接到docker0
网桥; veth pair
的另一端设置为容器名空间的eth0
;- 为容器名空间的
eth0
分配ip
; - 主机上的
iptables
规则:PREROUTING -A DOCKER ! -i docker0 -p tcp -m tcp --dport 2333 -j DNAT --to- destination 172.17.0.2:22
- 创建
Underlay
借助主机网卡,通过虚拟网桥实现交换机功能,让容器直接从外部网络中获取网络信息;
- 采用
Linux
网桥设备(sbrctl
),通过物理网络连通容器; - 创建新的网桥设备
mydr0
; - 将主机网卡加入网桥;
- 把主机网卡的地址配置到网桥,并把默认路由规则转移到网桥
mydr0
; - 启动容器;
- 创建
veth
对,并且把一个peer
添加到网桥mydr0
; - 配置容器把
veth
的另一个peer
分配给容器网卡;
Docker Libnetwork Overlay
Docker overlay
网络驱动原生支持多主机网络;Libnetwork
是一个内置的基于VXLAN
的网络驱动;
VXLAN
在每一个主机上都有一个设备,例如 VTEP-1
,在处理处理容器网络包时,在外层封装一层源地址和源MAC,通过网络进行转发,经过路由到另外一台主机上之后,通过解压缩获取到对端网络信息以及它的目标网络信息。这种方式封装和解封装数据包会影响性能。
Overlay network sample - Flannel
- 同一主机内的
Pod
可以使用网桥进行通信; - 不同主机上的
Pod
将通过flanneld
将其流量封装在UDP
数据包中。
需要在每个设备上安装 flannneld
,跨主机的包都需要通过 flanneld
封装包。
Flannel Packet Sample
flanneld
抓包示例:
可以看到,其实是一个 ICMP
协议的包,外层是 IPV4
,但是 IPV4
外层还封装了一层 UDP
,然后在 UDP
外层再封装 IPV4
。
创建 docker 镜像
定义 dockerfile
1 | FROM ubuntu |
定义之后 docker build .
Dockerfile 的最佳实践
Docker
遵循 12-Factor
的原则管理和构建应用;
核心是进程无状态和无共享。
https://12factor.net/
https://zhuanlan.zhihu.com/p/286100357
构建上下文(Build Context)
当运行
docker build
命令时,当前工作目录被称为构建上下文;docker build
默认查找当前目录的Dockerfile
作为构建输入,也可以通过-f
指定Dockerfile
。docker build -f ./Dockerfile
当
docker build
运行时,首先会把构建上下文传输给docker daemon
,把没用的文件包含在构建上下文时,会导致传输时间长,构建需要的资源多,构建出的镜像大等问题。例如可以尝试在一个包含很多目录的目录下,运行下面的命令,会感受到差异
1
2docker build -f /xxx/xxx/xxx/Dockerfile
docker build /xxx/xxx/xxx/可以通过
.dockerignore
文件从编译上下文排除某些文件
因此需要确保构建上下文清晰,比如创建一个专门的目录放置
Dockerfile
,并在目录中运行docker build
镜像构建日志
1 | Sending build context to Docker daemon 14.57MB |
Build Cache
构建容器镜像时,Docker
依次读取 Dockerfile
中的指令,并按顺序依次执行构建指令。
Docker
读取指令后,会先判断缓存中是否有可用的已存镜像,只有已存镜像不存在时才会重新构建。
- 通常
Docker
简单判断Dockerfile
中的指令与镜像 - 针对
ADD
和COPY
指令,Docker
判断该镜像层每一个文件的内容并生成一个checksum
,与现存镜像比较时,Docker
比较的是二者的checksum
- 其他指令,比如
RUN apt-get -y update
,Docker
简单比较与现存镜像中的指令字串是否一致。 - 当某一层
cache
失效以后,后续所有层级的cache
均一并失效,后续指令都重新构建镜像。
因此,构建时,应该将变动不频繁的指令放在前面,将变动频繁的指令放在后面,尽量使用缓存,降低镜像拉取和存储的消耗的资源。
多段构建(Multi-stage build)
比如在构建比较复杂的应用时,需要有很多的依赖包,而只需要更换最后一个执行文件,此时就可以通过多段构建来将准备环境打包到一个基础镜像。
有效减少镜像层级的方式。
1 | // 基础镜像,下载包 |
Dockerfile 常用指令
FROM
:选择基础镜像,推荐alpine
1
FROM [--platform=<platform>] <image>[@<digest>] [AS <name>]
LABELS
:按标签组织项目1
LABEL multi.label1="value1" multi.label2="value2" other="value3”
配合
label filter
可过滤镜像查询结果1
docker images -f label=multi.label1="value1"
例如
auth
之类也可以通过LABELS
RUN
:执行命令最常见的用法是
RUN apt-get update && apt-get install
,这两条命令应该永远用&&
连接,如果分开执行,RUN apt-get update
构建层被缓存,可能会导致新package
无法安装。另外,连接起来也可以有效的减少镜像overlay
层级。CMD
:容器镜像中包含应用的运行命令,需要带参数1
CMD ["executable", "param1", "param2"...]
EXPOSE
:发布端口1
EXPOSE <port> [<port>/<protocol>...]
- 是镜像创建者和使用者的约定,约定需要暴露哪些端口
- 在
docker run -P
时(使用-p
时可以指定端口和映射端口),docker
会自动映射expose
的端口到主机大端口,大端口会随机选择,如0.0.0.0:32768->80/tcp
ENV
:设置环境变量1
ENV <key>=<value>...
ADD
:从源地址(文件,目录或者URL
)复制文件到目标路径1
2ADD [--chown=<user>:<group>] <src>... <dest>
ADD [--chown=<user>:<group>] ["<src>",... "<dest>"] (路径中有空格时使用)ADD
支持 Go 风格的通配符,如ADD check* /testdir/
scr
如果是文件,则必须包含在编译上下文中,ADD
指令无法添加编译上下文之外的文件src
如果是RUL
:- 如果
dest
结尾没有/
,那么dest
是目标文件名 - 如果
dest
结尾有/
,那么dest
是目标目录名
- 如果
src
如果是一个目录,则所有文件都会被复制至dest
src
如果是一个本地压缩文件,则在ADD
的同时完整解压操作- 如果
dest
不存在,则ADD
指令会创建目标目录 - 应尽量减少通过
ADD URL
添加remote
文件,建议使用curl
或者wget && untar
(主要是这个命令理解困难,而且可控力度少)
COPY
:从源地址(文件,目录)复制文件到目标路径1
2COPY [--chown=<user>:<group>] <src>... <dest>
COPY [--chown=<user>:<group>] ["<src>",... "<dest>"] // 路径中有空格时使用COPY
的使用与ADD
类似,但有如下区别:COPY
只支持本地文件的复制,不支持URL
COPY
不解压文件COPY
可以用于多阶段编译场景,可以用前一个临时镜像中拷贝文件1
COPY --from=build /bin/project /bin/project
COPY
语义上更加直白,复制本地文件时,优先使用COPY
。ENTRYPOINT
:定义可以执行的容器镜像入口命令1
2ENTRYPOINT ["executable", "param1", "param2"] // docker run参数追加模式
ENTRYPOINT command param1 param2 // docker run 参数替换模式docker run -entrypoint
可替换Dockerfile
中定义的ENTRYPOINT
ENTRYPOINT
的最佳实践是用ENTRYPOINT
定义镜像主命令,并通过CMD
定义主要参数,如下所示1
2ENTRYPOINT ["s3cmd"]
CMD ["--help"]
VOLUME
:将指定目录定义为外挂存储卷,Dockfile
中在该指令之后所有对同一目录的修改都无效1
VOLUME ["/data"]
等价于
docker run -v /data
,可通过docker inspect
查看主机的mount point
。一般用来保护某个目录
1
/var/lib/docker/volumes/<containerid>/_data
USER
:切换运行镜像的用户和用户组,因安全性要求,越来越多的场景要求容器应用要以non-root
身份运行1
USER <user>[:<group>]
WORKDIR
:等价于cd
,切换工作目录1
WORKDIR /path/to/workdir
其他非常用指令:
ARG
:构建参数,没有指定则使用默认,可以在构建过程中传递;ONBUILD
STOPSIGNAL
HEALTHCHECK
SHELL
最佳实践
- 不要安装无效软件包;会徒增镜像容量
- 应简化镜像中同时运行的进程数,理想状况下,每个镜像应该只有一个进程;这样易于管理,易于监控状态
- 当无法避免同一镜像运行多进程时,应选择合理的初始化进程(
init process
) - 最小化层级数
- 最新的
docker
只有RUN
、COPY
、ADD
创建新层,其他指令创建临时层,不会增加镜像大小。- 比如
EXPOST
指令就不会生成新层
- 比如
- 多条
RUN
命令可通过连接符连接成一条指令集以减少层数 - 通过多段构建减少镜像层数
- 最新的
- 把多行参数按字母排序,可以减少可能出现的重复参数,并且提高可读性
- 编写
dockerfile
的时候,应该把变更频率低的编译指令优先构建,以便放在镜像底层以有效利用build cache
- 复制文件时,每个文件应独立复制,这确保某个文件变更时,只影响该文件对应的缓存。
目标是:易管理、少漏洞、镜像小、层级少、利用缓存
多进程的容器镜像
- 选择适当的
init
进程- 需要捕获
SIGTERM
信号并完成子进程的优雅终止 - 负责清理退出的子进程以避免僵尸进程
- 需要捕获
开源项目:https://github.com/krallin/tini,通过 tini
作为初始化进程管理子进程。
Docker 镜像管理
1 | // save 可以将镜像保存为 tar 包,load可以将 tar 包解压成镜像 |
基于 Docker 镜像的版本管理
docker tag
命令可以为容器镜像添加标签
1
docker tag 0e5574283393 xxx.xxx.com/bbb/httpserver:v1.0
xxx.xxx.com
:镜像仓库地址,如果不填,则默认为hub.docker.com
bbb
:代表仓库地址的repositry
httpserver
:镜像名v1.0
:tag,常用来记录版本信息
Docker tag 与 github 的版本管理合力
以 Kubernetes 为例
- 开发分支
git checkout master
- Release 分支
git checkout -b release-1.21
- 在并星期,所有的变更同时放进
master
和release branch
- 版本发布
- 以
release branch
为基础构建镜像,并为镜像标记版本信息:docker tag 0e5574283393 k8s.io/kubernetes/apiserver:v1.21
- 以
- 在
github
中保存release
代码快照git tag v1.21
当内部使用临时测试镜像时,还可以使用 github
的 git
号和提交次数,作为版本号标记。
镜像仓库
Docker hub
:https://hub.docker.com/
创建私有镜像仓库:docker run -d -p 5000:5000 registry
Docker 优势
- 封装性
- 不需要再启动内核,所以应用扩缩容时可以秒速启动
- 资源利用率高,直接使用宿主机内核调度资源,性能损失小
- 方便的
CPU
、内存资源调整 - 能实现秒级快速回滚
- 一键启动所有依赖服务,测试不用为搭建环境犯愁,PE 也不用为建站复杂担心
- 镜像一次编译,随处使用
- 测试、生产环境高度一致(数据除外)
- 镜像增量分发
- 由于采用了
Union FS
,简单来说,就是支持将不同的目录挂载到同一个虚拟文件系统下,并实现一种layer
的概念,每次发布只传输变化的部分,节约带宽
- 由于采用了
- 隔离性
- 应用的运行环境和宿主机环境无关,完全由镜像控制,一台物理机上部署多种环境的镜像测试
- 多个应用版本可以并存在机器上
- 社区活跃
- Docker 命令简单、易用,社区十分活跃,且周边组件丰富
References
Linux内核CFS调度器
CFS调度器(5)-带宽控制
docker 容器基础技术:linux namespace 简介
搞懂容器技术的基石: namespace (上)
搞懂容器技术的基石: namespace (下)