从系统架构谈起
传统分层架构 vs 微服务
微服务改造
分离微服务的方法建议:
- 审视并发现可以分离的业务逻辑业务逻辑
- 寻找天生隔离的代码模块,可以借助于静态代码分析工具
- 不同并发规模,不同内存需求的模块都可以分离出不同的微服务,此方法可提高资源利用率,节省成本
一些常用的可微服务化的组件:
- 用户和账户管理
- 授权和会话管理
- 系统配置
- 通知和通讯服务
- 照片,多媒体,元数据等
分解原则:基于 size, scope and capabilities
微服务间通讯
点对点
- 多用于系统内部多组件之间通讯;
- 有大量的重复模块如认证授权;
- 缺少统一规范,如监控,审计等功能;
- 后期维护成本高,服务和服务的依赖关系错综复杂难以管理。
API 网关
- 基于一个轻量级的 message gateway
- 新 API 通过注册至 Gateway 实现
- 整合实现 Common function
理解 Docker
Docker
- 基于 Linux 内核的 Cgroup,Namespace,以及 Union FS 等技术,对进程进行封装隔离,属于操作系统层面的虚拟化技术,由于隔离的进程独立于宿主和其它的隔离的进程,因此也称其为容器。
- 最初实现是基于 LXC,从 0.7 以后开始去除 LXC,转而使用自行开发的 Libcontainer,从 1.11 开始,则进一步演进为使用 runC 和 Containerd。
- Docker 在容器的基础上,进行了进一步的封装,从文件系统、网络互联到进程隔离等等,极大的简化了容器的创建和维护,使得 Docker 技术比虚拟机技术更为轻便、快捷。
为什么要用 Docker
- 更高效地利用系统资源
- 更快速的启动时间
- 一致的运行环境
- 持续交付和部署
- 更轻松地迁移
- 更轻松地维护和扩展
虚拟机和容器运行态的对比
性能对比
特性 | 容器 | 虚拟机 |
---|---|---|
启动 | 秒级 | 分钟级 |
硬盘使用 | MB | GB |
性能 | 接近原生 | 弱于 |
系统支持量 | 单机支持上千个容器 | 一般几十个 |
安装 Docker
在 ubuntu 上安装 Docker 运行时,参考 https://docs.docker.com/engine/install/ubuntu/
1 | $ sudo apt-get update |
容器操作
- 启动:
1 | docker run |
- 启动已终止容器:
docker start
- 停止容器:
docker stop
- 查看容器进程:
docker ps
- 查看容器细节:
docker inspect <containerid>
- 进入容器:
1 | Docker attach: |
- 拷贝文件至容器内:
docker cp file1 <containerid>:/file-to-path
初识容器
- cat Dockerfile
1 | FROM ubuntu |
- 将 Dockerfile 打包成镜像
1 | docker build -t cncamp/httpserver:${tag} . |
- 运行容器
1 | docker run -d cncamp/httpserver:v1.0 |
容器标准
Open Container Initiative(OCI)
轻量级开放式管理组织(项目)OCI 主要定义两个规范
Runtime Specification:文件系统包如何解压至硬盘,共运行时运行。
Image Specification:如何通过构建系统打包,生成镜像清单(Manifest)、文件系统序列化文件、镜像配置。
容器主要特性
- 安全性
- 隔离性
- 便携性
- 可配额
Namespace
Linux Namespace 是一种 Linux Kernel 提供的资源隔离方案:
- 系统可以为进程分配不同的 Namespace;
- 并保证不同的 Namespace 资源独立分配、进程彼此隔离,即不同的 Namespace 下的进程互不干扰 。
Linux 内核代码中 Namespace 的实现
- 进程数据结构
1 | struct task_struct { |
- Namespace 数据结构
1 | struct nsproxy { |
Linux 对 Namespace 操作方法
clone
在创建新进程的系统调用时,可以通过 flags 参数指定需要新建的 Namespace 类型:
1 | // CLONE_NEWCGROUP / CLONE_NEWIPC / CLONE_NEWNET / CLONE_NEWNS / CLONE_NEWPID / CLONE_NEWUSER / CLONE_NEWUTS |
setns
该系统调用可以让调用进程加入某个已经存在的 Namespace 中:
1 | Int setns(int fd, int nstype) |
unshare
该系统调用可以将调用进程移动到新的 Namespace 下:
1 | int unshare(int flags) |
隔离性 – Linux Namespace
Namespace 类型 隔离资源 Kernel 版本
IPC System V IPC 和 POSIX 消息队列 2.6.19
Network 网络设备、网络协议栈、网络端口等 2.6.29
PID 进程 2.6.14
Mount 挂载点 2.4.19
UTS 主机名和域名 2.6.19
USR 用户和用户组 3.8
Pid namespace
- 不同用户的进程就是通过 Pid namespace 隔离开的,且不同 namespace 中可以有相同 Pid。
- 有了 Pid namespace, 每个 namespace 中的 Pid 能够相互隔离。
net 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 的常用操作
- 查看当前系统的 namespace:
lsns –t <type>
- 查看某进程的 namespace:
ls -la /proc/<pid>/ns/
- 进入某 namespace 运行命令:
nsenter -t <pid> -n ip addr
Cgroups
- Cgroups (Control Groups)是 Linux 下用于对一个或一组进程进行资源控制和监控的机制;
- 可以对诸如 CPU 使用时间、内存、磁盘 I/O 等进程所需的资源进行限制;
- 不同资源的具体管理工作由相应的 Cgroup 子系统(Subsystem)来实现 ;
- 针对不同类型的资源限制,只要将限制策略在不同的的子系统上进行关联即可 ;
- Cgroups 在不同的系统资源管理子系统中以层级树(Hierarchy)的方式来组织管理:每个 Cgroup 都可以包含其他的子 Cgroup,因此子 Cgroup 能使用的资源除了受本 Cgroup 配置的资源参数限制,还受到父Cgroup 设置的资源限制。
Linux 内核代码中 Cgroups 的实现
进程数据结构
1
2
3
4
5
6struct task_struct{
struct css_set__rcu *cgroups;
struct list_head cg_list;
}css_set 是 cgroup_subsys_state 对象的集合数据结构
1
2
3
4
5
6
7
8
9
10struct 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 的访问。
- cpuacct:产生 cgroup 任务的 CPU 资源报告。
- cpuset:如果是多核心的 CPU,这个子系统会为 cgroup 任务分配单独的 CPU 和内存。
- devices:允许或拒绝 cgroup 任务对设备的访问。
- freezer:暂停和恢复 cgroup 任务。
- memory:设置每个 cgroup 的内存限制以及产生内存资源报告。
- net_cls:标记每个网络包以供 cgroup 方便使用。
- ns:名称空间子系统。
- pid:进程标识子系统。
CPU 子系统
cpu.shares:可出让的能获得 CPU 使用时间的相对值。
cpu.cfs_period_us:cfs_period_us 用来配置时间周期长度,单位为 us(微秒)。
cpu.cfs_quota_us:cfs_quota_us 用来配置当前 Cgroup 在 cfs_period_us 时间内最多能使用的 CPU 时间数,单位为 us(微秒)。
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:完全公平调度器,采用完全公平调度算法,引入虚拟运行时间概念;
- IDLE-Task 调度器, idle_sched_class:空闲调度器,每个 CPU 都会有一个 idle 线程,当没有其他进程可以调度时,调度运行 idle 线程。
CFS 调度器
- CFS 是 Completely Fair Scheduler 简称,即完全公平调度器。
- CFS 实现的主要思想是维护为任务提供处理器时间方面的平衡,这意味着应给进程分配相当数量的处理器。
- 分给某个任务的时间失去平衡时,应给失去平衡的任务分配时间,让其执行。
- CFS 通过虚拟运行时间(vruntime)来实现平衡,维护提供给某个任务的时间量。vruntime = 实际运行时间*1024 / 进程权重。
- 进程按照各自不同的速率在物理时钟节拍内前进,优先级高则权重大,其虚拟时钟比真实时钟跑得慢,但获得比较多的运行时间。
vruntime 红黑树
CFS 调度器没有将进程维护在运行队列中,而是维护了一个以虚拟运行时间为顺序的红黑树。 红黑树的主要特点有:
- 自平衡,树上没有一条路径会比其他路径长出俩倍。
- O(log n) 时间复杂度,能够在树上进行快速高效地插入或删除进程。
CFS进程调度
- 在时钟周期开始时,调度器调用 __schedule() 函数来开始调度的运行。
- __schedule() 函数调用 pick_next_task() 让进程调度器从就绪队列中选择一个最合适的进程 next,即红黑树最左边的节点。
- 通过 context_switch() 切换到新的地址空间,从而保证 next 进程运行。
- 在时钟周期结束时,调度器调用 entity_tick() 函数来更新进程负载、进程状态以及 vruntime(当前vruntime + 该时钟周期内运行的时间)。
- 最后,将该进程的虚拟时间与就绪队列红黑树中最左边的调度实体的虚拟时间做比较,如果小于坐左边的时间,则不用触发调度,继续调度当前调度实体。
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:这个限制并不会阻止进程使用超过限额的内存,只是在系统内存足够时,会优先回收超过限额的内存,使之向限定值靠拢。
- 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 会报错。
文件系统
Union FS
- 将不同目录挂载到同一个虚拟文件系统下 (unite several directories into a single virtual filesystem)的文件系统
- 支持为每一个成员目录(类似Git Branch)设定 readonly、readwrite 和 whiteout-able 权限
- 文件系统分层, 对 readonly 权限的 branch 可以逻辑上进行修改(增量地, 不影响 readonly 部分的)。
- 通常 Union FS 有两个用途, 一方面可以将多个 disk 挂到同一个目录下, 另一个更常用的就是将一个readonly 的 branch 和一个 writeable 的 branch 联合在一起。
容器镜像
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层。
Docker 引擎架构
网络
Null(–net=None)
- 把容器放入独立的网络空间但不做任何网络配置;
- 用户需要通过运行 docker network 命令来完成网络配置。
Host
- 使用主机网络名空间,复用主机网络。
Container
- 重用其他容器的网络。
Bridge(–net=bridge)
- 使用 Linux 网桥和 iptables 提供容器互联,Docker 在每台主机上创建一个名叫 docker0的网桥,通过 veth pair 来连接该主机的每一个 EndPoint。
Overlay(libnetwork, libkv)
- 通过网络封包实现。
Remote(work with remote drivers)
- Underlay:使用现有底层网络,为每一个容器配置可路由的网络 IP。
- Overlay:通过网络封包实现。
Null 模式
- Null 模式是一个空实现;
- 可以通过 Null 模式启动容器并在宿主机上通过命令为容器配置网络。
1 | mkdir -p /var/run/netns |
VXLAN
Overlay network sample – Flannel
- 同一主机内的 Pod 可以使用网桥进行通信。
- 不同主机上的 Pod 将通过flanneld 将其流量封装在 UDP数据包中
Flannel packet sample
创建 docker 镜像
1,定义 dockerfile
1 | FROM ubuntu |
2,docker build
Dockerfile 的最佳实践
理解构建上下文(Build Context)
- 当运行 docker build 命令时,当前工作目录被称为构建上下文。
- docker build 默认查找当前目录的 Dockerfile 作为构建输入,也可以通过 –f 指定 Dockerfile。
docker build –f ./Dockerfile
- 当 docker build 运行时,首先会把构建上下文传输给 docker daemon,把没用的文件包含在构建上下文时,会导致传输时间长,构建需要的资源多,构建出的镜像大等问题。
试着到一个包含文件很多的目录运行下面的命令,会感受到差异;
docker build -f $GOPATH/src/github.com/cncamp/golang/httpserver/Dockerfile ;
docker build $GOPATH/src/github.com/cncamp/golang/httpserver/;
可以通过.dockerignore文件从编译上下文排除某些文件。
- 因此需要确保构建上下文清晰,比如创建一个专门的目录放置 Dockerfile,并在目录中运行 docker build。
镜像构建日志
1 | docker build $GOPATH/src/github.com/cncamp/golang/httpserver/ |
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 | FROM golang:1.16-alpine AS build |
Dockerfile 常用指令
FROM:选择基础镜像,推荐 alpine
FROM [--platform=<platform>] <image>[@<digest>] [AS <name>]
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> [<port>/<protocol>...]
是镜像创建者和使用者的约定
在 docker run –P 时,docker 会自动映射 expose 的端口到主机大端口,如0.0.0.0:32768->80/tcp
ENV 设置环境变量
ENV <key>=<value> ...
ADD:从源地址(文件,目录或者 URL)复制文件到目标路径
ADD [--chown=<user>:<group>] <src>... <dest>
ADD [--chown=<user>:<group>] [“<src>”,... “<dest>”] (路径中有空格时使用)
ADD 支持 Go 风格的通配符,如 ADD check* /testdir/
src 如果是文件,则必须包含在编译上下文中,ADD 指令无法添加编译上下文之外的文件
src 如果是 URL,如果 dest 结尾没有/,那么 dest 是目标文件名,如果 dest 结尾有/,那么 dest 是目标目录名
如果 src 是一个目录,则所有文件都会被复制至 dest
如果 src 是一个本地压缩文件,则在 ADD 的同时完整解压操作
如果 dest 不存在,则 ADD 指令会创建目标目录
应尽量减少通过 ADD URL 添加 remote 文件,建议使用 curl 或者 wget && untar
COPY:从源地址(文件,目录或者URL)复制文件到目标路径
COPY [--chown=<user>:<group>] <src>... <dest>
COPY [--chown=<user>:<group>] ["<src>",... "<dest>"] // 路径中有空格时使用
COPY 的使用与 ADD 类似,但有如下区别
COPY 只支持本地文件的复制,不支持 URL
COPY 不解压文件
COPY 可以用于多阶段编译场景,可以用前一个临时镜像中拷贝文件
COPY --from=build /bin/project /bin/project
ENTRYPOINT:定义可以执行的容器镜像入口命令
ENTRYPOINT ["executable", "param1", "param2"] // docker run参数追加模式
ENTRYPOINT command param1 param2 // docker run 参数替换模式
docker run –entrypoint 可替换 Dockerfile 中定义的 ENTRYPOINT
ENTRYPOINT 的最佳实践是用 ENTRYPOINT 定义镜像主命令,并通过 CMD 定义主要参数,如下所示
ENTRYPOINT [“s3cmd”]
CMD [“–help”]VOLUME: 将指定目录定义为外挂存储卷,Dockerfile 中在该指令之后所有对同一目录的修改都无效
VOLUME [“/data”]等价于 docker run –v /data,可通过 docker inspect 查看主机的 mount point,/var/lib/docker/volumes//_data USER:切换运行镜像的用户和用户组,因安全性要求,越来越多的场景要求容器应用要以 non-root 身份运行
USER <user>[:<group>]
WORKDIR:等价于 cd,切换工作目录
WORKDIR /path/to/workdir
练习
Namespace 练习
CPU 子系统练习
课后练习3.1
OverlayFS 文件系统练习
思考并讨论容器的劣势
课后练习 3.2
参考资料
https://www.cnblogs.com/XiaoliBoy/p/10410686.html
http://www.wowotech.net/process_management/451.html