Docker 核心技术

系统架构

传统分层架构 VS 微服务

image-20231229161244176

当业务逻辑简单,需求也比较简单的情况下(例如多年前的it互联网环境),只需要一台或者几台物理服务器即可满足需求,在物理机上部署 UI 服务、业务逻辑服务、存储服务等。

随着业务复杂度和体量增大,架构开始演进到分层架构(分为前端、后端、DBA),将复杂业务拆分成更小颗粒度的、独立的服务(单个部门负责单个服务)。但是多个服务部署到同一台物理服务器上之后,服务之间的隔离性就无法保证,当服务 A 出现异常,可能导致内存占用过多,影响物理机,结果影响其他服务。

服务之间相互隔离,再增加一些高可用、负载均衡的能力,也就逐步演进出微服务架构。

image-20231229162408338

单体架构和微服务架构,在不同的系统上各有优缺点。

微服务改造

当需要将服务应用(或者一个耦合度高的微服务)改造成一个微服务时,方法和建议:

  • 审视并发现可以分离的业务逻辑业务逻辑
  • 寻找天生隔离的代码模块,可以借助于静态代码分析工具
  • 不同并发规模,不同内存需求的模块都可以分离出不同的微服务,此方法可提高资源利用率,节省成本

一些常用的可微服务化的组件:

  • 用户和账户管理
  • 授权和会话管理
  • 系统配置
  • 通知和通讯服务
  • 照片,多媒体,元数据等

分解原则:基于 size(规模)、scope(范围) and capabilities(能力)

微服务间通讯

点对点

image-20231229163517004

  • 多用于系统内部多组件之间通讯
  • 有大量的重复模块如认证授权
  • 缺少统一规范,如监控,审计等功能
  • 后期维护成本高,服务和服务的依赖关系错综复杂难以管理

API 网关

image-20231229163628714

  • 基于一个轻量级的 message gateway
  • 新 API 通过注册至 Gateway 实现
  • 整合实现 Common function

一些内部功能和整合进 API 网关,例如认证、授权、统计。

理解 Docker

  • 基于 Linux 内核的 Cgroup(资源管控,用于限制、控制和隔离进程组的系统资源使用),Namespace(隔离进程,隔离网络栈,隔离文件系统挂载点,隔离进程间通信,隔离主机名和域名,隔离用户和用户组标识符),以及 Union FS 等技术,对进程进行封装隔离,属于操作系统层面的虚拟化技术,由于隔离的进程独立于宿主和其他的隔离的进程,因此也称其为容器。
  • 最初实现是基于 LXC,从 0.7 以后开始去除 LXC,转而使用自行开发的 Libcontainer,从 1.11 开始,则进一步演进为使用 runCContainerd
  • Docker 在容器的基础上,进行了进一步的封装,从文件系统、网络互连到进程隔离等等,极大的简化了容器的创建和维护,使得 Docker 技术比虚拟机技术更为轻便、快捷。

使用 Docker 的理由:

  • 更高效地利用系统资源:不需要额外内存使用内核
  • 更快速的启动时间:不需要启动过程中的硬件自检、加载内核等
  • 一致的运行环境:容器提供运行环境
  • 持续交付和部署:更新镜像即可
  • 更轻松地迁移:迁移镜像即可
  • 更轻松地维护和扩展

等。

虚拟机和容器运行态的对比

虚拟机

image-20231229164755530

在操作系统上,通过 Hypervisor 提供虚拟化能力,各个应用启动对应操作系统,应用在独立的操作系统中运行。

这个调用链比较长,而且有两层操作系统。

image-20231229173153401

在 Docker Engine 上,启动应用。

相比而言,调用链短,而且不需要启动额外的操作系统,节省资源。

性能对比

image-20231229173351025

一些虚拟机也在做 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
    3
    FROM ubuntu
    ENV MY_SERVICE_PORT=80
    ADD bin/amd64/httpserver /httpserver ENTRYPOINT /httpserver
  • Dockerfile 打包成镜像

    1
    2
    docker build -t xxx/httpserver:${tag} . 
    docker push xxx/httpserver:v1.0
  • 运行容器

    1
    docker run -d xxx/httpserver:v1.0

容器标准

Docker 发展过程中,由于一些历史性原因,由谷歌牵头定义的一些规范。

  • Open Container InitiativeOCI
    • 轻量级开放式管理组织(项目)
  • OCI 主要定义两个规范
    • Runtime Specification
      • 文件系统包如何解压至硬盘,共运行时运行
    • Image Specification
      • 如何通过构建系统打包,生成镜像清单(Manifest)、文件系统序列化文件、镜像配置。

直白来说,就是镜像如何打包,打包的镜像如何解压如何运行。目的是保障打包、部署过程中的效率。

容器主要特性

image-20231229175218442

隔离性:通过 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
    7
    struct 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 更新后,可能还会新增更多的操作方式。

隔离性

image-20240102105557872

fork 不同的进程后,进程之间可以通过 Namespace 隔离。

例如 PID,在不同的 Namespace 中看到的 PID 是不同的,但是它们是继承关系;网络也不一样,在不同的 Namespace 中,网卡配置没有任何关联。

image-20240102105609178

一个用户进程,拥有不同类型的 namespace

Pid namespace

  • 不同用户的进程就是通过 Pid namespace 隔离开的,且不同 namespace 中可以有相同 Pid
  • 有了 Pid namespace,每个 namespace 中的 Pid 能够相互隔离。

例如在不同的 Namespace 中使用 ps 只获取到本 Namespace 中的进程,方便管理。

net namespace

  • 网络隔离是通过 net namespace 实现的,每个 net namespace 有独立的 network devicesIP addressesIP routing tablesproc/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

  • UTSUNIX Time-sharing Systemnamespace 允许每个 container 拥有独立的 hostnamedomain name,使其在网络上可以被视作一个独立的节点,而非 Host 上的一个进程。

user namespace

  • 每个 container 可以有不同的 usergroup id,也就是说可以在 container 内部用 container 内部的用户执行程序而非 Host 上的用户。

namespace 常用操作

  • 查看当前系统的 namespace

    1
    lsns -t <type>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    # lsns
    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
    14
    # ls -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
    9
    # nsenter -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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 在新 network namespace 执行 sleep 指令:
# unshare -fn sleep 60

// 查看进程信息
# ps -ef | grep "sleep 60"
root 1327579 1146983 0 11:52 pts/3 00:00:00 unshare -fn sleep 60
root 1327580 1327579 0 11:52 pts/3 00:00:00 sleep 60
root 1332215 1329013 0 11:53 pts/4 00:00:00 grep --color=auto sleep 60

// 查看网络 namespace
# lsns -t net | grep unshare
4026543244 net 2 1327579 root unshare -fn sleep 60

// 进入该进程所在 namespace 查看网络配置
# nsenter -t 1332760 -n ip a
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

Cgroups

  • CgroupsControl Groups) 是 Linux 下用于对一个或一组进程进行资源控制和监控的机制;
  • 可以对诸如 CPU 使用时间、内存、磁盘 I/O 等进程所需的资源进行限制;
  • 不同资源的具体管理工作由相应的 Cgroup 子系统(Subsystem)来实现;
  • 针对不同类型的资源限制,只要将限制策略在不同的子系统上进行关联即可;
  • Cgroup 在不同的系统资源管理子系统中以层级树(Hierarchy)的方式来组织管理:每个 Cgroup 都可以包含其他的子 Cgroup,因此子 Cgroup 能使用的资源除了受本 Cgroup 配置的资源参数限制,还受到父 Cgroup 设置的资源限制。

cgroup 管理,例如删除 cgroup 需要安装包

1
2
# yum install libcgroup-tools // centos
# apt install cgroup-tools // ubuntu

Linux 内核代码中 Cgroup 的实现

  • 进程数据结构

    1
    2
    3
    4
    5
    6
    7
    struct task_struct 
    {
    #ifdef CONFIG_CGROUPS
    struct css_set_rcu *cgroups;
    struct list_head cg_list;
    #endif
    }
  • css_setcgroup_subsys_state 对象的集合数据结构

    1
    2
    3
    4
    5
    6
    7
    struct 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)

    image-20240102154000956

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# pwd
/sys/fs/cgroup
# ls -ltrah
total 0
drwxr-xr-x 7 root root 0 Nov 26 20:09 ..
dr-xr-xr-x 6 root root 0 Nov 26 20:09 systemd
dr-xr-xr-x 6 root root 0 Nov 26 20:09 pids
lrwxrwxrwx 1 root root 16 Nov 26 20:09 net_prio -> net_cls,net_prio
dr-xr-xr-x 4 root root 0 Nov 26 20:09 net_cls,net_prio
lrwxrwxrwx 1 root root 16 Nov 26 20:09 net_cls -> net_cls,net_prio
dr-xr-xr-x 6 root root 0 Nov 26 20:09 blkio
dr-xr-xr-x 2 root root 0 Nov 26 20:09 rdma
dr-xr-xr-x 2 root root 0 Nov 26 20:09 misc
dr-xr-xr-x 6 root root 0 Nov 26 20:09 memory
dr-xr-xr-x 6 root root 0 Nov 26 20:09 cpu,cpuacct
lrwxrwxrwx 1 root root 11 Nov 26 20:09 cpuacct -> cpu,cpuacct
lrwxrwxrwx 1 root root 11 Nov 26 20:09 cpu -> cpu,cpuacct
dr-xr-xr-x 4 root root 0 Nov 26 20:09 perf_event
dr-xr-xr-x 4 root root 0 Nov 26 20:09 hugetlb
dr-xr-xr-x 4 root root 0 Nov 26 20:09 freezer
dr-xr-xr-x 6 root root 0 Nov 26 20:09 devices
dr-xr-xr-x 4 root root 0 Nov 26 20:09 cpuset
drwxr-xr-x 15 root root 380 Nov 26 20:09 .

/sys/fs/cgroup/cpu 目录下创建子目录即可创建一个管理 CPUcgroupecho <pid> > cgroup.procs 即可将进程添加到 cgropu 进程配置组。

CPU 子系统

CPU 分配给进程的时间片有两种方式,一种是相对值,一种是绝对值。

image-20240102154933393

cpu.shares:可让出的能获得 CPU 使用时间的相对值。(例如上图,CGroupAshares512,实际占用 CPU 33% 的时间,如果修改成 1024,则占用 50% 的时间)

1
2
# cat /sys/fs/cgroup/cpu/cpu.shares
1024

image-20240102155230255

cpu.cfs_period_usfs_period_us 用来配置时间周期长度,单位为 us(微秒)。(默认 100000

1
2
# cat /sys/fs/cgroup/cpu/cpu.cfs_period_us
100000

cpu.cfs_quota_usfs_quota_us 用来配置当前 Cgroupcfs_period_us 时间内最多能使用的 CPU 时间数,单位为 us (微秒)。(如果配置 100000,代表可以拿到 1个 CPU

1
2
# cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us
-1

cpu.statCgroup 内的进程使用的 CPU 时间统计。

1
2
3
4
# cat /sys/fs/cgroup/cpu/cpu.stat
nr_periods 0
nr_throttled 0
throttled_time 0

nr_periods:经过 cpu.cfs_period_us 的时间周期数量。

nr_throttled:在经过的周期内,有多少次因为进程在指定的时间周期内用光了配额时间而受到限制。

throttled_timeCgroup 中的进程被限制使用 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 调度器

  • CFSCompletely Fair Scheduler 简称,即完全公平调度器。
  • CFS 实现的主要思想是维护为任务提供处理器时间方面的平衡,这意味着应给进程分配相当数量的处理器。
  • 分给某个任务的时间失去平衡时,应给失去平衡的任务分配时间,让其执行。
  • CFS 通过虚拟运行时间 (vruntime) 来实现平衡,维护提供给某个任务的时间量。
    • vruntime = 实际运行时间 * 1024 / 进程权重
  • 进程按照各自不同的速率在物理时钟节拍内前进,优先级高则权重大,其虚拟时钟比真实时钟跑得慢,但获得比较多的运行时间。

vruntime 红黑树

CFS 调度器没有将进程维护在运行队列中,而是维护了一个以虚拟运行时间为顺序的红黑树。

红黑树的主要特点:

  1. 自平衡,树上没有一条路径会比其他路径长出两倍。
  2. O(logn) 时间复杂度,能够在树上进行快速高效地插入或删除进程。

image-20240102161120245

CFS 进程调度

  • 在时钟周期开始时,调度器调用 _schdule() 函数来开始调度的运行。
  • _schdule() 函数调用 pick_next_task() 让进程调度器从就绪队列中选择一个最合适的进程 next,即红黑树最左边的节点。
  • 通过 context_switch() 切换到新的地址空间,从而保证 next 进程运行。
  • 在时钟周期结束时,调度器调用 entity_tick() 函数来更新进程负载、进程状态以及 vruntime(当前 vruntime + 该时钟周期内运行的时间)。
  • 最后,将该进程的虚拟时间与就绪队列红黑树中最左边的调度实体的虚拟时间做比较,如果小于左边的时间,则不用触发调度,继续调度当前调度实体。

image-20240102162500743

cpuacct 子系统

用于统计 Cgroup 及其子 Cgroup 下进程的 CPU 的使用情况。

  • cpuacct.usage

    包含该 Cgroup 及其子 Cgroup 下进程使用 CPU 的时间,单位是 ns(纳秒)

    1
    2
    # cat /sys/fs/cgroup/cpu/cpuacct.usage
    7873567244444726
  • cpuacct.stat

    包含该 Cgroup 及其子 Cgroup 下进程使用的 CPU 时间,以及用户态和内核态的时间。

    1
    2
    3
    # cat /sys/fs/cgroup/cpu/cpuacct.stat
    user 576051691
    system 210990765

Memory 内存子系统

负责管理 cgroup 中的内存限制。在 /sys/fs/cgroup/memory 目录下创建一个子目录即可创建对应可管理内存的 cgropuecho <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 中使用 OOMOut Of MemoryKiller,默认为使用。当属于该 cgroup 的进程使用的内存超过最大的限定值时,会立刻被 OOM Killer 处理。

Cgroup driver

systemd 作为 init system 的系统中,默认并存着两套 cgroup driver

systemd

  • 当操作系统使用 systemd 作为 init system 时,初始化进程生成一个根 cgroup 目录结构并作为 cgroup 管理器。
  • systemdcgroup 紧密结合,并且为每个 systemd unit 分配 cgroup

cgroupfs

  • docker 默认用 cgroupfs 作为 cgroup 驱动

由于 dockerkubelet 默认使用 cgroupfs,而 systemd 拉起的服务由 systemd 驱动管,让 cgroup 管理混乱且容易在资源紧张时引发问题。

因此 kubelet 会默认 --cgroup-driver=systemd,若运行时 cgroup 不一致时,kubelet 会报错。

文件系统

Union FS

  • 将不同目录挂载到同一个虚拟文件系统上(unite serveral directories into a single virtual filesystem) 的文件系统
  • 支持为每一个成员目录(类似 Git Branch)设定 readonlyreadwritewhiteout-able 权限
  • 文件系统分层,对 readonly 权限的 branch 可以逻辑上进行修改(增量地,不影响 readonly 部分的)。
  • 通常 Union FS 有两个用途,一方面可以将多个 disk 挂到同一个目录下,另一个更常用的就是将一个 readonlybranch 和一个 writeablebranch 联合在一起。

容器镜像

image-20240103100712447

例如使用 Dockfile 构建镜像。

通过每一条命令,创建一层镜像,在底层镜像之上,构建上层镜像。

好处是在 devops 时,需要启动一个容器,不需要每个节点上下载全部镜像内容,只下载新增部分或者差异部分即可。

Docker 的文件系统

典型的 Linux 文件系统组成:

  • Bootfsboot file system

    • Bootloader - 引导加载 kernel
    • Kernel - 当 kernel 被加载到内存中后 umount bootfs

    image-20240103101538997

  • rootfsroot file system

    • /dev/proc/bin/etc 等标准目录和文件
    • 对于不同的 linux 发行版,bootfs 基本是一致的,但 rootfs 会有差别。

    image-20240103101656721

Docker 启动

Linux

  • 在启动后,首先将 rootfs 设置为 readonly,进行一系列检查,然后将其切换为 readwrite 供用户使用。

Docker

  • 初始化时也是将 rootfsreadonly 方式加载并检查,然后接下来利用 union mount 的方式,将一个 readwrite 文件系统挂载在 readonlyrootfs 之上;
  • 并且允许再次将下层的 FSfile system)设定为 readonly 并且向上叠加
  • 这样一组 readonly 和一个 writeable 的结构构成一个 container 的运行时态,每一个 FS 被称作一个 FS 层。

image-20240103102104646

写操作

由于镜像具有共享特性,所以对容器可写层的操作需要依赖存储驱动提供的写时复制和用时分配机制,以此来支持对容器可写层的修改,进而提高对存储和内存资源的利用率。

  • 写时复制

    • 写时复制,即 Copy-on-Write
    • 一个镜像可以被多个容器使用,但是不需要在内存和磁盘上做多个拷贝
    • 在需要对镜像提供的文件进行修改时,该文件会从镜像的文件系统被复制到容器的可写层的文件系统进行修改,而镜像里面的文件不会改变。
    • 不同容器对文件的修改都是相互独立、互不影响。
  • 用时分配

    按需分配空间,而非提前分配,即当一个文件被创建出来后,才会分配空间。

容器存储驱动

image-20240103102911924

目前容器主流的存储驱动是 OverlayFS

image-20240103103004115

以 OverlayFS 为例

OverlayFS 也是一种与 AUFS 类似的联合文件系统,同样属于文件级的存储驱动,包含了最初的 Overlay 和更新更稳定的 overlay2.

Overlay 只有两层:upper 层和 lower 层,Lower 层代表镜像层,upper 层代表容器可写层。

image-20240103103156311

例如在 Dockerilfe 中,每一个指令都是从下层往上叠一层,在容器中的文件,要么直接从镜像层获取,要么是从容器层新创建的文件获取。

OverlayFS 使用案例

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 创建四个目录,用于做准备
// 上层 下层 目标目录 临时工作目录
$ mkdir upper lower merged work
// 构建下层
$ echo "from lower" > lower/in_lower.txt
// 构建上层
$ echo "from upper" > upper/in_upper.txt
// 上下层同一个文件,内容不同
$ echo "from lower" > lower/in_both.txt
// 上下层同一个文件,内容不同
$ echo "from upper" > upper/in_both.txt
// 挂载到 merged 目录
$ sudo mount -t overlay overlay -o lowerdir=`pwd`/lower,upperdir=`pwd`/upper,workdir=`pwd`/work `pwd`/merged
// 查看挂载之后的结果
$ ls merged/
in_lower.txt in_upper.txt in_both.txt
$ cat merged/in_both.txt
from upper

// 删除合并之后的 both 文件
$ delete merged/in_both.txt
$ ls merged/
in_lower.txt in_upper.txt

// 删除合并之后的下层文件
$ delete merged/in_lower.txt
$ ls -l upper/
总用量 4
c--------- 1 root root 0, 0 1月 4 12:27 in_both.txt
c--------- 1 root root 0, 0 1月 4 12:28 in_lower.txt
-rw-r--r-- 1 root root 11 1月 4 12:26 in_upper.txt

// 删除合并之后的上层文件
$ delete merged/in_upper.txt

// merged 里面是空的
$ ls merged/

$ ls -l upper/ // 删除合并后的上层文件,其实是删除了上层的文件,上层可写,下层是镜像层。
总用量 0
c--------- 1 root root 0, 0 1月 4 12:27 in_both.txt // 上层可以看到下层的文件
c--------- 1 root root 0, 0 1月 4 12:28 in_lower.txt

$ cat upper/in_both.txt
cat: upper/in_both.txt: 没有那个设备或地址

$ ls lower/
in_both.txt in_lower.txt

查看容器详情时,可以直接看到容器的overlayfs信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# docker inspect 8a77db202e9b
[
{
...
"GraphDriver": {
"Data": {
"LowerDir": "/var/lib/xxx/docker/overlay2/8c88839766cd1d90bebe283a9e2634c8e2fd4f2707375c479287eb5bb7980910-init/diff:/var/lib/xxx/docker/overlay2/53961e498c6657e9372f15861845d3c386642a3cb9ac00f882c49f5d4e01aab5/diff:/var/lib/xxx/docker/overlay2/22b9d9592d3329aeb7427dc015ead91d5830e8d7671182d4106d7fcf4c4ffdaa/diff:/var/lib/xxx/docker/overlay2/687713c2f236e08c11b4893106264e5a2ffe1ff8ff4c578f9941b01673a28df3/diff:/var/lib/xxx/docker/overlay2/52acc6a1047de39d8ddad126bc3f6a10f5d1ca13fec8557115f814e2e65f8a04/diff:/var/lib/xxx/docker/overlay2/f669fa0b144ef5dccce491bdd3ac6257a21463ad704d847dfabcd41437970805/diff:/var/lib/xxx/docker/overlay2/b4bb1ec0613fedf0c8995317a380ef9fbc6a8b7fe503233548ed2d44d3ceebd7/diff",
"MergedDir": "/var/lib/xxx/docker/overlay2/8c88839766cd1d90bebe283a9e2634c8e2fd4f2707375c479287eb5bb7980910/merged",
"UpperDir": "/var/lib/xxx/docker/overlay2/8c88839766cd1d90bebe283a9e2634c8e2fd4f2707375c479287eb5bb7980910/diff",
"WorkDir": "/var/lib/xxx/docker/overlay2/8c88839766cd1d90bebe283a9e2634c8e2fd4f2707375c479287eb5bb7980910/work"
},
"Name": "overlay2"
},
...
}
]

OCI 容器标准

Open Container Initiative

  • OCI 组织于 2015 年创建,是一个致力于定义容器镜像标准和运行时标准的开放式组织
  • OCI 定义了镜像标准(Runtime Specification)、运行时标准(Image Specification)和分发标准(Distribution Specification
    • 镜像标准定义应用如何打包
    • 运行时标准定义如何解压应用包并运行
    • 分发标准定义如何分发容器镜像

Docker 引擎架构

image-20240103104613595

使用 docker container run 运行一个容器时,是使用 containerd 引擎运行。而 docker 早期本身的架构上,主进程是 docker daemon,其他进程是由主进程 fork 出来的,当 docker 需要更新或者重启,需要重启 docker-daemon,就会导致子进程一并重启。

containerd 引擎,通过 shim进程 fork 出容器子进程 ,当 containerd 主进程重启时,不影响子进程。(shim 的父进程是 systemd,而不是 containerd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 查看一个容器的进程
# docker inspect efd96d555c73 | grep -i pid
"Pid": 4917,
"PidMode": "",
"PidsLimit": null,

// 查看这个进程的父进程
# ps -ef | grep 4917
systemd+ 4917 4823 0 2023 ? 00:30:30 mysqld
root 8750 7207 0 11:00 pts/13 00:00:00 grep --color=auto 4917

// 查看父进程 4823 的信息
# ps -ef | grep 4823
root 4823 1 0 2023 ? 00:03:47 /usr/bin/containerd-shim-runc-v2 -namespace moby -id efd96d555c73ec357acba410e75c602c40ce82b5ca641e3ce23dedaab0311aa4 -address /run/containerd/containerd.sock
systemd+ 4917 4823 0 2023 ? 00:30:30 mysqld

# ps -p 1
PID TTY TIME CMD
1 ? 01:06:16 systemd

可以看到 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

当扩展到多个机器上,需要主机之间通信,而且需要容器之间相互隔离和有独立的网络,就无法直接使用主机网络。

解决方式有两种:

  • Remotenetwork with remote drivers
    • Underlay
      • 使用现有底层网络,为每一个容器配置可路由的网络 IP
    • Overlaylibnetworklibkv):
      • 通过网络封包实现

Null 模式

  • Null 模式是一个空实现;
  • 可以通过 Null 模式启动容器并在宿主机上通过命令为容器配置网络
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// 创建 network 的 ns 的临时目录
mkdir -p /var/run/netns
find -L /var/run/netns -type l -delete

// 开启一个容器,例如nginx,获取到 pid
docker run --network=none -d nginx
docker ps | grep nginx
docker inspect <container_id> | grep -i pid

// 链接出来,操作更方便
ln -s /proc/$pid/ns/net /var/run/netns/$pid

// 创建一个 veth ,上面两个口,A 口和 B 口
ip link add A type veth peer name B
// 查看当前 docker0 网桥的状态
# brctl show
bridge name bridge id STP enabled interfaces
docker0 8000.0242838bdad8 no

// br0上插 A 口,并且打开 A 口
brctl addif docker0 A
ip link set A up
# brctl show docker0
bridge name bridge id STP enabled interfaces
docker0 8000.0242838bdad8 no A

// 获取主机上的 docker 网络配置
# ip a show docker0
4: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:83:8b:da:d8 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
valid_lft forever preferred_lft forever
inet6 fe80::42:83ff:fe8b:dad8/64 scope link
valid_lft forever preferred_lft forever


// 设置 B 口,命名、打开、配置 IP
ip link set B netns $pid
ip netns exec $pid ip link set dev B name eth0
ip netns exec $pid ip link set eth0 up
ip netns exec $pid ip addr add $SETIP/$SETMASK dev eth0
ip netns exec $pid ip route add default via $GATEWAY


// 例如
# ip netns exec 4854 ip addr add 172.17.0.100/16 dev eth0
# ip netns exec 4854 ip route add default via 172.17.0.1
// 此时,主机和docker之间就可以通过 veth 通信
# curl 172.17.0.100
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

默认模式 - Bridge 和 NAT

image-20240103115105833

例如,为主机 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

image-20240103170022413

借助主机网卡,通过虚拟网桥实现交换机功能,让容器直接从外部网络中获取网络信息;

  • 采用 Linux 网桥设备(sbrctl),通过物理网络连通容器;
  • 创建新的网桥设备 mydr0
  • 将主机网卡加入网桥;
  • 把主机网卡的地址配置到网桥,并把默认路由规则转移到网桥 mydr0
  • 启动容器;
  • 创建 veth 对,并且把一个 peer 添加到网桥 mydr0
  • 配置容器把 veth 的另一个 peer 分配给容器网卡;

Docker Libnetwork Overlay

  • Docker overlay 网络驱动原生支持多主机网络;
  • Libnetwork 是一个内置的基于 VXLAN 的网络驱动;

VXLAN

image-20240103170440937

在每一个主机上都有一个设备,例如 VTEP-1,在处理处理容器网络包时,在外层封装一层源地址和源MAC,通过网络进行转发,经过路由到另外一台主机上之后,通过解压缩获取到对端网络信息以及它的目标网络信息。这种方式封装和解封装数据包会影响性能。

Overlay network sample - Flannel

image-20240103170836063

  • 同一主机内的 Pod 可以使用网桥进行通信;
  • 不同主机上的 Pod 将通过 flanneld 将其流量封装在 UDP 数据包中。

需要在每个设备上安装 flannneld,跨主机的包都需要通过 flanneld 封装包。

Flannel Packet Sample

flanneld 抓包示例:

image-20240103171044864

可以看到,其实是一个 ICMP 协议的包,外层是 IPV4,但是 IPV4 外层还封装了一层 UDP,然后在 UDP 外层再封装 IPV4

创建 docker 镜像

定义 dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM ubuntu

# so apt-get doesn't complain
ENV DEBIAN_FRONTEND=noninteractive

RUN sed -i 's/^exit 101/exit 0/' /usr/sbin/policy-rc.d
RUN \
apt-get update && \
apt-get install -y ca-certificates && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*

ADD ./bin/eic eic
ENTRYPOINT ["/eic"]

定义之后 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
      2
      docker build -f /xxx/xxx/xxx/Dockerfile
      docker build /xxx/xxx/xxx/
    • 可以通过 .dockerignore 文件从编译上下文排除某些文件

  • 因此需要确保构建上下文清晰,比如创建一个专门的目录放置 Dockerfile,并在目录中运行 docker build

镜像构建日志

1
2
3
4
5
6
7
8
9
10
11
12
13
Sending build context to Docker daemon 14.57MB
Step 1/4 : FROM ubuntu
---> cf0f3ca922e0
Step 2/4 : ENV MY_SERVICE_PORT=80
---> Using cache
---> a7d824f74410
Step 3/4 : ADD bin/amd64/httpserver /httpserver
---> Using cache
---> 00bb47fce704
Step 4/4 : ENTRYPOINT /httpserver
---> Using cache
---> f77ee3366d08
Successfully built f77ee3366d08

Build Cache

构建容器镜像时,Docker 依次读取 Dockerfile 中的指令,并按顺序依次执行构建指令。

Docker 读取指令后,会先判断缓存中是否有可用的已存镜像,只有已存镜像不存在时才会重新构建。

  • 通常 Docker 简单判断 Dockerfile 中的指令与镜像
  • 针对 ADDCOPY 指令,Docker 判断该镜像层每一个文件的内容并生成一个 checksum,与现存镜像比较时,Docker 比较的是二者的 checksum
  • 其他指令,比如 RUN apt-get -y updateDocker 简单比较与现存镜像中的指令字串是否一致。
  • 当某一层 cache 失效以后,后续所有层级的 cache 均一并失效,后续指令都重新构建镜像。

因此,构建时,应该将变动不频繁的指令放在前面,将变动频繁的指令放在后面,尽量使用缓存,降低镜像拉取和存储的消耗的资源。

多段构建(Multi-stage build)

比如在构建比较复杂的应用时,需要有很多的依赖包,而只需要更换最后一个执行文件,此时就可以通过多段构建来将准备环境打包到一个基础镜像。

有效减少镜像层级的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 基础镜像,下载包
FROM golang:1.16-alpine AS build
// 准备环境
RUN apk add --no-cache git
RUN go get github.com/golang/dep/cmd/dep

COPY Gopkg.lock Gopkg.toml /go/src/project/
WORKDIR /go/src/project/
RUN dep ensure -vendor-only
COPY . /go/src/project/

// 基础包中,核心内容就是这一个二进制文件
RUN go build -o /bin/project (只有这个二进制文件是产线需要的,其他都是waste)

// 安装包,将二进制文件拷贝过来即可
FROM scratch
COPY --from=build /bin/project /bin/project
ENTRYPOINT ["/bin/project"]
CMD ["--help"]

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
    2
    ADD [--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
    2
    COPY [--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
    2
    ENTRYPOINT ["executable", "param1", "param2"] // docker run参数追加模式 
    ENTRYPOINT command param1 param2 // docker run 参数替换模式
    • docker run -entrypoint 可替换 Dockerfile 中定义的 ENTRYPOINT

    • ENTRYPOINT 的最佳实践是用 ENTRYPOINT 定义镜像主命令,并通过 CMD 定义主要参数,如下所示

      1
      2
      ENTRYPOINT ["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 只有 RUNCOPYADD 创建新层,其他指令创建临时层,不会增加镜像大小。
      • 比如 EXPOST 指令就不会生成新层
    • 多条 RUN 命令可通过连接符连接成一条指令集以减少层数
    • 通过多段构建减少镜像层数
  • 把多行参数按字母排序,可以减少可能出现的重复参数,并且提高可读性
  • 编写 dockerfile 的时候,应该把变更频率低的编译指令优先构建,以便放在镜像底层以有效利用 build cache
  • 复制文件时,每个文件应独立复制,这确保某个文件变更时,只影响该文件对应的缓存。

目标是:易管理、少漏洞、镜像小、层级少、利用缓存

多进程的容器镜像

  • 选择适当的 init 进程
    • 需要捕获 SIGTERM 信号并完成子进程的优雅终止
    • 负责清理退出的子进程以避免僵尸进程

开源项目:https://github.com/krallin/tini,通过 tini 作为初始化进程管理子进程。

Docker 镜像管理

1
2
3
4
5
6
// save 可以将镜像保存为 tar 包,load可以将 tar 包解压成镜像
docker save/load
// tag 可以给镜像打标签
docker tag
// push将镜像推送到远端镜像仓,pull从远端镜像仓拉取镜像到本地
docker push/pull

基于 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
  • 在并星期,所有的变更同时放进 masterrelease branch
  • 版本发布
    • release branch 为基础构建镜像,并为镜像标记版本信息:docker tag 0e5574283393 k8s.io/kubernetes/apiserver:v1.21
  • github 中保存 release 代码快照
    • git tag v1.21

当内部使用临时测试镜像时,还可以使用 githubgit 号和提交次数,作为版本号标记。

镜像仓库

Docker hubhttps://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 (下)