MichaelFu

好记性不如烂笔头

容器网络 - 跨主机容器通信中,我们使用 flannel 实现了容器的跨主机通信,在使用 kubeadm 创建多借点集群时,在集群初始化之后,首先安装了 kube-flannel CNI插件,用于 k8s 集群 pod 之间互通,这是集群节点 Ready 的必要条件,因为 k8s 自身并不能实现 pod 之间互通,需要借助 CNI 完成此功能。

单机容器通信是将主上的容器通过连接在 docker0 网桥实现,然后跨主机容器通信是通过 vxlan 中的 flannel.x 设备实现跨主机之间的容器通信,k8sflannel-cni 插件处理不同 pod 之间互通的方式就和跨主机容器通信的方式一样,只不过在 k8s 集群中将用于单机上容器互通的 docker0 网桥换成了 cni0

k8s 之所以要创建一个与 docker0 功能相同的网桥,是因为 k8s 并没有使用 Docker 的网络模型,它并不希望和 Docker 之间有强依赖,所以不具备配置这样一个网桥的能力。

所以在使用 flannel-cni 插件的模式下,k8s 之间不同 pod 互通的模式下如下图所示,和容器网络 - 跨主机容器通信唯一区别是网桥名称的变化:

阅读全文 »

iptablesLinux 上重要的防火墙程序,它既是一个用户态的程序,也是一个内核的模块,通过向 Linux 内核 netfilter 框架注入钩子函数,以及自定义的规则,实现包过滤,修改,地址转换,日志记录等功能。在 k8s 生态中,作为 kube-proxy 的默认后端,实现流量在集群之内的的路由和转发,写这篇文章的最初原有也是想了解 k8s 是如何将访问到节点上的流量,路由到自定义的 Service 以及最终的 pod 内部。

netfilter

在了解 iptables 之前,先认识下 netfilter,它是 Linux 内核子系统,允许实现各种与网络相关的操作,它是网络相关操作领域的基础设施,基于此可以实现任何大多数网络包的诉求:

  • 包过滤,这可能是大多数场景下的诉求,也是 iptables 最多的使用场景,可以用来限制某些特征的包进入到本机,例如,指定 ip 范围,某类协议的;
  • NAT,负责转换网络数据包的源 IP 和目的 IP;
  • 数据包修改,地址转换只是数据包修改的一种,还可以修改数据包的 TOS(Type Of Service,服务类型)、TTL 指以及为数据包设置 Mark 标记等;

Netfilter 框架在 Linux 内核中提供了一堆钩子,当网络数据包通过内核中的协议栈时,它会遍历这些钩子。Netfilter 允许使用这些钩子编写模块并注册回调函数,当钩子被触发时,回调函数将被调用。这些钩子被用在包处理的以下 5 个阶段:

  • NF_INET_PRE_ROUTING:当数据包从网卡上收到还有路由之前,这类钩子函数就会被触发,然后内核判断这个数据包是否是发往当前主机的,根据条件,将触发以下两个钩子;
  • NF_INET_LOCAL_IN:当数据包决定路由到本机上的时候,就会触发这类钩子;
  • NF_INET_FORWARD:当数据包决定要继续转发的时候,这类钩子会被触发;
  • NF_INET_LOCAL_OUT:这类钩子函数会在本机生成数据包,发出去之前被调用;
  • NF_INET_POST_ROUTING:这类钩子函数主要用于从本机发出去的数据包,但是在发到网卡之前;
阅读全文 »

容器其实是一种沙盒技术。顾名思义,沙盒就是能够像一个集装箱一样,把你的应用装起来的技术。这样,应用与应用之间,就因为有了边界而不至于相互干扰。对于应用来说,它的静态表现就是程序,平常都安安静静地待在磁盘上;而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现。容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个边界。对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace 技术则是用来修改进程视图的主要方法。本篇文章的主要目标就是手动利用 Linux 提供的 CgroupNamespace 技术创建出一个容器。

容器镜像

首先我们从容器镜像开始,从我们对容器的认识来说,进入到容器之后,看到了一个独立的文件系统,和宿主机完全隔离,包含了应用程序所需要的数据、文件以及所有依赖,而容器镜像就是用来构建应用程序所需的文件系统,容器镜像有一个更为专业的名字,叫做 rootfs 根文件系统,当我们启动一个进程时,为进程启用 Linux Namespace 配置,设置 Cgroup 参数用于资源限制,切换进程的根目录,这样它看起来就像在一个独立的系统中运行。但是需要明确的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。因此同一台机器上的所有容器,都共享宿主机操作系统的内核。

这就意味着,如果我们的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个 “全局变量”,牵一发而动全身。这也是容器相比于虚拟机的主要缺陷之一:毕竟后者不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的 Guest OS 给应用随便折腾。

所以,容器启动快是因为本质上就是宿主机上的一个进程而已,启动一个进程的速度当然比启动一个虚拟机的速度快。不过,正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性,rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

阅读全文 »

在使用 k8s 的过程中,始终绕不开容器运行时这个关键组件,当通过 kubectl 创建一个应用时,节点上的 kubelet 组件接收到这个事件,然后调用容器运行时实现的 CRI 接口创建容器。当我们开始关注这个容器运行时的实现和生态的时候,发现存在很多关键词,例如:dockercontainerdruncOCI 以及 CRI 等等,本篇文章主要记录厘清这些关键词所代表的概念及其出现的背景。

从公众的视野来看,DockerK8S 要早得多,2013 年,Docker 就凭借着 Build,Ship and Run Any App, Anywhere 这句名满世界的广告语,迅速进入了开发者的视线中,方便、快速使它得到空前的发展,一时间内,容器化、微服务化成了各大公司技术团队主要的技术方向。由于 Docker 大火,有人比较眼红,CoreOS 实现了自己的容器引擎 rkt,为了避免容器技术领域分裂和一家独大的场面出现,在 2015 年,Docker 公司联合 Linux 基金会联合推动发起了 OCI(Open Container Initiative)倡议,其内容主要包括 OCI Runtime Spec(容器运行时规范)OCI Image Spec(镜像格式规范)OCI Distribution Spec(镜像分发规范)。同时,Docker 公司将 libcontainer 模块捐给社区,作为 OCI 标准的实现,并改名为 runc,这就是我们常说的 runc 的由来,后面交由在 2015 年成立的 CNCF 基金会管理,为其他玩家不依赖于 Docker 构建自家平台提供了可能性,所以说 OCI 是大家为了避免命脉被别人把住的协商结果。

k8s 和 Docker 的竞争主要是围绕容器编排领域展开,Docker 除了自身的容器引擎,后续还逐步发展出了 docker swarm 容器集群管理管理系统,以及配套的 docker machinedocker compose 等工具,但由于 Docker 公司始终在 Docekr 的规划中占据着话语权,让社区以及其他玩家不服,所以开始主推 k8s,由于 k8s 先进的 podsidecar 设计理念以及在社区的民主化架构,从 API 到容器运行时的每一层,Kubernetes 项目都为开发者暴露出了可以扩展的插件机制,鼓励用户通过代码的方式介入 Kubernetes 项目的每一个阶段。Kubernetes 项目的这个变革的效果立竿见影,很快在整个容器社区中催生出了大量的、基于 Kubernetes API 和扩展接口的创新工作,涌现了一大批优秀的项目,比如:IstioRookDocekr 发现在和 k8s 竞争出现劣势的时候,强行将自家的容器编排系统 docker swarm 内置到 docker 中,这种内置容器编排、集群管理和负载均衡能力,固然可以使得 Docker 项目的边界直接扩大到一个完整的 PaaS 项目的范畴,但这种变更带来的技术复杂度和维护难度,长远来看对 Docker 项目是不利的,从外界来看就是一条道走到黑,要保持霸权地位,不开放。

由于 k8s 的茁壮成长,Docker 发现竞争无望,将自己的容器运行时 containerd 从架构上独立出来,并且捐赠给社区维护,放弃和 k8s 的竞争,Docker 项目改名 mobyDocker 全面升级成 PaaS 平台,从此 k8s 一统江湖。

阅读全文 »

本篇文章介绍使用 kubeadm 创建一个多节点的 K8S,使用 containerd 作为容器运行时,第一步,首先是准备 3 个虚拟机节点,使用 multipass 创建 3 台虚拟机,该镜像中自带 docker,无需再安装,使用如下命令创建:

multipass launch --name ctrlnode -d 40G docke
multipass launch --name node1 -d 40G docker
multipass launch --name node2 -d 40G docker

每个节点至少 2GB 内存,2 个 CPU,具体要求请看这里。创建成功之后,如下所示:

1
2
3
4
5
6
7
8
$ multipass list
Name State IPv4 Image
ctrlnode Running 192.168.67.8 Ubuntu 22.04 LTS
172.17.0.1
node1 Running 192.168.67.10 Ubuntu 22.04 LTS
172.17.0.1
node2 Running 192.168.67.9 Ubuntu 22.04 LTS
172.17.0.1

VM 版本如下:

1
2
3
4
5
6
ubuntu@node2:~$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 22.04.3 LTS
Release: 22.04
Codename: jammy
阅读全文 »

单机场景下,相同主机上的容器会通过 Docker 默认创建的 docker0 网桥以及在启动容器时创建的 veth pair 设备实现互通。而对于跨主机容器通信,社区提供了很多种不同的方案,例如 weaveflannel,本篇文章将以 flannel 为例,实现跨主机容器通信,flannel 有多种后端实现,本文以 VXLAN 为例,动手实践,最终达到的效果如下图所示:

阅读全文 »

Docker 容器通过 Linux 提供的各种 namespace 技术,将运行中的容器封闭在一个沙箱中,看起来很像一个虚拟机,都拥有独立的网络栈,有独立的 IP 地址,但是这些同主机上的独立容器貌似天生互通,能通过各自的 IP 相互访问,这是如何做到的的?

如果我们想要实现两台独立主机之间互通,最简单的办法就是拿一根网线把它们连在一起;想要实现多台主机互通,这个时候就需要一台交换机了。

现在在不同的容器之间,想要实现互通,我们也可以借鉴交换机这种技术,毕竟容器看起来很像独立的主机。在 Linux 中,可以通过网桥(Bridge)模拟交换机,网桥工作是一个二层网络设备,工作在数据链路层,主要功能是能够根据 MAC 地址将数据包发送到网桥的不同端口上

二层网络和三层网络的主要区别是,二层网络中可以仅靠 MAC 地址就实现互通,但是三层网络需要通过 IP 路由实现跨网络互通,这也能看出,二层网络的组网能力非常有限,一般只是小局域网,三层网络可以组建大型网络。

Docker 项目为了实现这种在相同主机上创建容器之间互通的目的,在主机上会创建一个名叫 docker0 的网桥,凡是连接在 docker0 上的容器,就可以通过它进行通信。要把一个容器插在网桥上,需要依赖 Veth Pair 这个虚拟设备了,它的特点是,它被创建出来之后,总是以两张虚拟网卡成对出现,并且从一张网卡发出的数据包,可以直接出现在与它对应的另一张网卡上,即使两张网卡在不同的 namespace 中。一旦一张虚拟网卡被插在了网桥设备上,它就会被降级成网桥的端口,丢失了处理数据包的能力,数据包会全部交由网桥进行处理。

如下是宿主机上 docker0 设备信息,172.17.0.1/16 是 Docker 默认的子网:

1
2
3
4
5
6
7
8
root@michael-host:/home/michael# ip addr show docker0
7: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ad:c7:75:98 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:adff:fec7:7598/64 scope link
valid_lft forever preferred_lft forever
root@michael-host:/home/michael#
阅读全文 »

在学习 Linux 网络相关的知识时或者在定位网络相关的问题中,经常需要使用 route 命令查看路由表,本节主要记录该命令的输出及其含义。Linux 系统上一般有 3 张路由表,可以通过 ip rule 命令查看:

1
2
3
4
# ip rule list
0: from all lookup local
32766: from all lookup main
32767: from all lookup default

路由表的配置可以通过 ip route list table {name} 输出,如果是查看 main 表,可以直接使用 route -n,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
root:/mnt/e/github/proto# ip route list table main
default via 172.23.32.1 dev eth0
10.42.0.0/24 dev cni0 proto kernel scope link src 10.42.0.1
172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown
172.23.32.0/20 dev eth0 proto kernel scope link src 172.23.45.94
root:/mnt/e/github/proto#
root:/mnt/e/github/proto#
root:/mnt/e/github/proto# route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 172.23.32.1 0.0.0.0 UG 0 0 0 eth0
10.42.0.0 0.0.0.0 255.255.255.0 U 0 0 0 cni0
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 docker0
172.23.32.0 0.0.0.0 255.255.240.0 U 0 0 0 eth0
10.244.186.193 0.0.0.0 255.255.255.255 UH 0 0 0 cali687d9beb32a

各字段主要说明如下:

  • Destination:目标网络或目标主机;
  • Gateway:网关,连接两个不同网络的设备;
  • Genmask:目的地址的子网掩码。255.255.255.255 表示目的主机,0.0.0.0 表示默认路由,其他情况 GenmaskDestination 组成目标网络;
  • Flags:标识 U 表示路由生效,G 表示网关,H 表示目标地址是一个主机;
  • Metric:到目标地址的跳数;
  • Ref:路由被引用数;
  • Use: 路由被查询次数;
  • Iface:接口,去往目的地址所经出口;

对于第一条路由,目标地址 0.0.0.0,表示没有明确指定的意思,既默认路由。路由匹配是按照掩码长短从长到端开始匹配,默认路由掩码也是 0.0.0.0,表示最后匹配;

对于中间三条路由,Gateway 都是 0.0.0.0,表示本条路由不经网关,网关是从一个网络进入另一个网络的边缘设备。换句话说,命中网关是 0.0.0.0 的报文,它的目标是可能是同一网络下的其它目标地址。这时候走的是二层直连,需要发起 ARP 请求换取 MAC 地址进行发送。这条路由通常是在网卡上配置 IP 时候自动生成的。在网卡上每绑定一个 IP,就相应地生成一条这样的记录。可以看到本条路由的 Flags 并没有 G 标志。

第五条路由,标志为 H,掩码是 255.255.255.255,表示目标地址是 10.244.186.193,直接发往 cali687d9beb32a,而这个设备的另一端是容器内的 eth0。这种情况也不需要网关,网关为 0.0.0.0

阅读全文 »

Protobuf 是 Google 出品的消息编码工具,相比常用的 json 等编码方式,以牺牲可读性,而提高编码效率,减少编码之后消息体占用的字节大小,以提升传输效率。本篇文章主要分享如何生成 Go 语言 pb 版本,对于 Go 语言而言,protoc 不能直接生成 Go 代码,需要额外的插件。对于这个插件,官方有自己的实现,也有第三方的 gogo/protobuf,本节主要是用来厘清他们之间的区别以及用法。在开始之前,我们先澄清一些基本的概念:

  1. golang/protobuf 是官方早期的插件实现;
  2. google.golang.org/protobuf 是上面的继承者,有更新和更简化的 API,以及其他许多改进,是官方当前的实现;
  3. gogo/protobuf 社区实现,该实现目前被废弃,但是在历史中依然后很多著名的软件在使用,例如 etcd
  4. protocprotobuf 的编译器,用于将 .proto 文件编译成各自语言的实现;
  5. protobuf 是一般用于指这门编码语言,该语言目前有两个版本,proto2proto3

关于 protobuf 编码是如何优化编码效率,可以查看这篇文章:Protocol Buffers 编码

阅读全文 »

本篇文章介绍常用的二进制减小方案,某些场景下,对二进制文件的大小有比较严格的要求,尤其是某些便携嵌入式设备上。

代码优化

编码阶段,我们可以从以下几点入手:

  1. 减少使用泛型,考虑使用动态类型替换;但是动态调用相比静态展开有性能损失,需要做权衡;
  2. 合理使用宏;有些宏展开后会生成很多代码,如果不合理使用,例如,某些通用的 log 宏,助手宏,会展开生成很多代码导致二进制文件体积增加;
  3. 合理使用内联函数;一般我们使用内联函数加快代码执行的速度,但过多的内联函数也会导致二进制体积增加;

例如对于泛型和动态类型,这两种方式实现的代码编译之后二进制大小是有差异的,print1 会根据不同类型的参数展开成不同的版本:

1
2
3
4
5
6
7
8
9

fn print1<T: Display>(param: T) {
println!("{:}", param);
}

fn print2(param: &dyn Display) {
println!("{:}", param);
}

阅读全文 »
0%