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%