分类 工具 下的文章

实战 Docker 容器网络

我们知道,容器技术出现的初衷是对容器之间以及容器和宿主机之间的进程、用户、网络、存储等进行隔离,提供一种类似沙盒的虚拟环境,容器网络是这个虚拟环境的一部分,它能让应用从宿主机操作系统的网络环境中独立出来,形成容器自有的网络设备、IP 协议栈、端口套接字、IP 路由表、防火墙等模块。但是网络作为一种特殊的通信机制,我们有时候又希望容器之间、容器和宿主机之间甚至容器和远程主机之间能够互相通信,既要保证容器网络的隔离性,又要实现容器网络的连通性,这使得在容器环境下,网络的问题变得异常复杂。

Docker 是目前最流行的容器技术之一,它提供了一套完整的网络解决方案,不仅可以解决单机网络问题,也可以实现跨主机容器之间的通信。

容器网络模型

在学习各种不同的容器网络解决方案之前,我们首先来了解下 CNM 的概念。CNM(Container Network Model) 是 Docker 提出并主推的一种容器网络架构,这是一套抽象的设计规范,主要包含三个主要概念:

  • Sandbox - 提供了容器的虚拟网络栈,即端口套接字、IP 路由表、防火墙、DNS 配置等内容,主要用于隔离容器网络与宿主机网络,形成了完全独立的容器网络环境,一般通过 Linux 中的 Network Namespace 或类似的技术实现。一个 Sandbox 中可以包含多个 Endpoint。
  • Endpoint - 就是虚拟网络的接口,就像普通网络接口一样,它的主要职责是创建 Sandbox 到 Network 之间的连接,一般使用 veth pair 之类的技术实现。一个已连接的 Endpoint 只能归属于一个 Sandbox 和 一个 Network。
  • Network - 提供了一个 Docker 内部的虚拟子网,一个 Network 可以包含多个 Endpoint,同一个 Network 内的 Endpoint 之间可以互相通讯,一般使用 Linux bridge 或 VLAN 来实现。

这三个概念之间的关系如下图所示:

cnm.jpeg

libnetwork 是一个 Go 语言编写的开源库,它是 CNM 规范的标准实现,Docker 就是通过 libnetwork 库来实现 CNM 规范中的三大概念,此外它还实现了本地服务发现、基于 Ingress 的容器负载均衡、以及网络控制层和管理层功能。

除了控制层和管理层,我们还需要实现网络的数据层,这部分 Docker 是通过 驱动(Driver) 来实现的。驱动负责处理网络的连通性和隔离性,通过不同的驱动,我们可以扩展 Docker 的网络栈,实现不同的网络类型。

Docker 内置如下这些驱动,通常被称作 原生驱动 或者 本地驱动

第三方也可以通过 Network plugins 实现自己的网络驱动,这些驱动也被称作 远程驱动,比如 calicoflannelweave 等。

network-drivers.png

基于 Docker 网络的这种可插拔的设计,我们通过切换不同的网络驱动,就可以实现不同的容器网络解决方案。

单机容器网络方案

让我们从最简单的单机网络方案开始。Docker 在安装时,默认会在系统上创建三个网络,可以通过 docker network ls 命令查看:

$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
04eb1fccf2a8   bridge    bridge    local
189dfa3e3e64   host      host      local
1e63120a4e7a   none      null      local

这三个网络分别是 bridgehostnone,可以看到这三个网络都是 local 类型的。

Bridge 网络

Bridge 网络是目前使用最广泛的 Docker 网络方案,当我们使用 docker run 启动容器时,默认使用的就是 Bridge 网络,我们也可以通过命令行参数 --network=bridge 显式地指定使用 Bridge 网络:

$ docker run --rm -it --network=bridge busybox
/ # ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:AC:11:00:02
          inet addr:172.17.0.2  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:9 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:782 (782.0 B)  TX bytes:0 (0.0 B)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

可以看到,使用 Bridge 网络的容器里除了 lo 这个本地回环网卡外,还有一个 eth0 以太网卡,那么这个 eth0 网卡是连接到哪里呢?我们可以从 /sys/class/net/eth0 目录下的 ifindexiflink 文件中一探究竟:

/ # cat /sys/class/net/eth0/ifindex
74
/ # cat /sys/class/net/eth0/iflink
75

其中 ifindex 表示网络设备的全局唯一 ID,而 iflink 主要被隧道设备使用,用于标识隧道另一头的设备 ID。也可以直接使用 ip link 命令查看:

/ # ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
74: eth0@if75: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff

如果 ifindexiflink 相同,表示这是一个真实的设备,ip link 中直接显示设备名,比如上面的 lo 网卡;如果 ifindexiflink 不相同,表示这是一个隧道设备,ip link 中显示格式为 ifindex: eth0@iflink,比如上面的 eth0 网卡。

所以容器中的 eth0 是一个隧道设备,它的 ID 为 74,连接它的另一头设备 ID 为 75,那么这个 ID 为 75 的设备又在哪里呢?答案在宿主机上。

我们在宿主机上运行 ip link 命令,很快就可以找到这个 ID 为 75 的设备:

# ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
    link/ether 08:00:27:c1:96:99 brd ff:ff:ff:ff:ff:ff
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default 
    link/ether 02:42:43:d2:12:6f brd ff:ff:ff:ff:ff:ff
75: vethe118873@if74: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default 
    link/ether 96:36:59:73:43:cc brd ff:ff:ff:ff:ff:ff link-netnsid 0

设备的名称为 vethe118873@if74,也暗示着另一头连接着容器里的 ID 为 74 的设备。像上面这样成对出现的设备,被称为 veth pairveth 表示虚拟以太网(Virtual Ethernet),而 veth pair 就是一个虚拟的以太网隧道(Ethernet tunnel),它可以实现隧道一头接收网络数据后,隧道另一头也能立即接收到,可以把它想象成一根网线,一头插在容器里,另一头插在宿主机上。那么它又插在宿主机的什么位置呢?答案是 docker0 网桥。

使用 brctl show 查看宿主机的网桥:

# brctl show
bridge name    bridge id        STP enabled    interfaces
docker0        8000.024243d2126f    no        vethe118873

可以看到一个名为 docker0 的网桥设备,并且能看到 vethe118873 接口就插在这个网桥上。这个 docker0 网桥是 Docker 在安装时默认创建的,当我们不指定 --network 参数时,创建的容器默认都挂在这个网桥上,它也是实现 Bridge 网络的核心部分。

使用下面的命令可以更直观的看出该网桥的详细信息:

# docker network inspect bridge
[
    {
        "Name": "bridge",
        "Id": "ea473e613c26e31fb6d51edb0fb541be0d11a73f24f81d069e3104eef97b5cfc",
        "Created": "2022-06-03T11:56:33.258310522+08:00",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {
            "33c8609c8cbe8e99f63fd430c926bd05167c2c92b64f97e4a06cfd0892f7e74c": {
                "Name": "suspicious_ganguly",
                "EndpointID": "3bf4b3d642826ab500f50eec4d5c381d20c75393ab1ed62a8425f8094964c122",
                "MacAddress": "02:42:ac:11:00:02",
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]

其中 IPAM 部分表明这个网桥的网段为 172.17.0.0/16,所以上面看容器的 IP 地址为 172.17.0.2;另外网关的地址为 172.17.0.1,这个就是 docker0 的地址:

# ip addr show type bridge
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:43:d2:12:6f 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:43ff:fed2:126f/64 scope link 
       valid_lft forever preferred_lft forever

docker0 网段地址可以在 Docker Daemon 的配置文件 /etc/docker/daemon.json 中进行修改:

$ vi /etc/docker/daemon.json
{
  "bip": "172.100.0.1/24"
}

修改之后重启 Docker Daemon:

$ systemctl restart docker

验证配置是否生效:

$ ip addr show docker0
3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default 
    link/ether 02:42:43:d2:12:6f brd ff:ff:ff:ff:ff:ff
    inet 172.100.0.1/24 brd 172.100.0.255 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:43ff:fed2:126f/64 scope link 
       valid_lft forever preferred_lft forever

下图是 Bridge 网络大致的网络拓扑结构:

bridge-network.png

自定义 Bridge 网络

从 Bridge 的网络拓扑结构中我们可以看到,所有的容器都连接到同一个 docker0 网桥,它们之间的网络是互通的,这存在着一定的安全风险。为了保证我们的容器和其他的容器之间是隔离的,我们可以创建自定义的网络:

$ docker network create --driver=bridge test
5266130b7e5da2e655a68d00b280bc274ff4acd9b76f46c9992bcf0cd6df7f6a

创建之后,再次查看系统中的网桥列表:

$ brctl show
bridge name    bridge id        STP enabled    interfaces
br-5266130b7e5d        8000.0242474f9085    no        
docker0        8000.024243d2126f    no    

brctl show 的运行结果中可以看出多了一个新的网桥,网桥的名字正是我们创建的网络 ID,Docker 会自动为网桥分配子网和网关,我们也可以在创建网络时通过 --subnet--gateway 参数手工设置:

$ docker network create --driver=bridge --subnet=172.200.0.0/24 --gateway=172.200.0.1 test2
18f549f753e382c5054ef36af28b6b6a2208e4a89ee710396eb2b729ee8b1281

创建好自定义网络之后,就可以在启动容器时通过 --network 指定:

$ docker run --rm -it --network=test2 busybox
/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue 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
80: eth0@if81: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:c8:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.200.0.2/24 brd 172.200.0.255 scope global eth0
       valid_lft forever preferred_lft forever

由于 test2 的网段为 172.200.0.0/24,所以 Docker 为我们的容器分配的 IP 地址是 172.200.0.2,这个网段和 docker0 是隔离的,我们 ping 之前的容器 IP 是 ping 不同的:

/ # ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
^C
--- 172.17.0.2 ping statistics ---
4 packets transmitted, 0 packets received, 100% packet loss

自定义 Bridge 网络的拓扑结构如下图所示:

custom-network.png

虽然通过上面的实验我们发现不同网桥之间的网络是隔离的,不过仔细思考后会发现这里有一个奇怪的地方,自定义网桥和 docker0 网桥实际上都连接着宿主机上的 eth0 网卡,如果有对应的路由规则它们之间按理应该是可以通信的,为什么会网络不通呢?这就要从 iptables 中寻找答案了。

Docker 在创建网络时会自动添加一些 iptables 规则,用于隔离不同的 Docker 网络,可以通过下面的命令查看 iptables 规则:

$ iptables-save | grep ISOLATION
:DOCKER-ISOLATION-STAGE-1 - [0:0]
:DOCKER-ISOLATION-STAGE-2 - [0:0]
-A FORWARD -j DOCKER-ISOLATION-STAGE-1
-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i br-5266130b7e5d ! -o br-5266130b7e5d -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i br-18f549f753e3 ! -o br-18f549f753e3 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN
-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o br-5266130b7e5d -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o br-18f549f753e3 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN

前两行以 : 开头,表示创建两条空的 规则链(Rule Chain),在 iptables 中,规则链是由一系列规则(Rule)组成的列表,每个规则可以根据指定的条件来匹配或过滤数据包,并执行特定的动作。规则链可以包含其他规则链,从而形成一个规则链树。iptables 中有五个默认的规则链,分别是:

  • INPUT:用于处理进入本地主机的数据包;
  • OUTPUT:用于处理从本地主机发出的数据包;
  • FORWARD:用于处理转发到其他主机的数据包,这个规则链一般用于 NAT;
  • PREROUTING:用于处理数据包在路由之前的处理,如 DNAT;
  • POSTROUTING:用于处理数据包在路由之后的处理,如 SNAT。

第三行 -A FORWARD -j DOCKER-ISOLATION-STAGE-1 表示将在 FORWARD 规则链中添加一个新的规则链 DOCKER-ISOLATION-STAGE-1,而这个新的规则链包含下面定义的几条规则:

-A DOCKER-ISOLATION-STAGE-1 -i docker0 ! -o docker0 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i br-5266130b7e5d ! -o br-5266130b7e5d -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -i br-18f549f753e3 ! -o br-18f549f753e3 -j DOCKER-ISOLATION-STAGE-2
-A DOCKER-ISOLATION-STAGE-1 -j RETURN

第一条规则中的 -i docker0 ! -o docker0 表示数据包想进入 docker0 接口并且不是从 docker0 接口发出的,-i 表示 输入接口(input interface)-o 表示 输出接口(output interface),后面的 -j DOCKER-ISOLATION-STAGE-2 表示当匹配该规则时,跳转到 DOCKER-ISOLATION-STAGE-2 规则链继续处理。后面的规则都非常类似,每一条对应一个 Docker 创建的网桥的名字。最后一条表示当所有的规则都不满足时,那么这个数据包将被直接返回到原始的调用链,也就是被防火墙规则调用链中的下一条规则处理。

在上面的实验中,我们的容器位于 test2 网络中(对应的网桥是 br-18f549f753e3),要 ping 的 IP 地址属于 docker0 网络中的容器,很显然,数据包是发往 docker0 接口的(输入接口),并且是来自 br-18f549f753e3 接口的(输出接口),所以数据包满足 DOCKER-ISOLATION-STAGE-1 规则链中的第一条规则,将进入 DOCKER-ISOLATION-STAGE-2 规则链:

-A DOCKER-ISOLATION-STAGE-2 -o docker0 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o br-5266130b7e5d -j DROP
-A DOCKER-ISOLATION-STAGE-2 -o br-18f549f753e3 -j DROP
-A DOCKER-ISOLATION-STAGE-2 -j RETURN

DOCKER-ISOLATION-STAGE-2 规则链的第三条规则是,如果数据包从 br-18f549f753e3 接口发出则被直接丢弃。这就是我们为什么在自定义网络中 ping 不通 docker0 网络的原因。

我们可以通过 iptables -R 命令,将规则链中的规则修改为 ACCEPT:

$ iptables -R DOCKER-ISOLATION-STAGE-2 1 -o docker0 -j ACCEPT
$ iptables -R DOCKER-ISOLATION-STAGE-2 2 -o br-5266130b7e5d -j ACCEPT
$ iptables -R DOCKER-ISOLATION-STAGE-2 3 -o br-18f549f753e3 -j ACCEPT

此时再测试网络的连通性发现可以正常 ping 通了:

/ # ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
64 bytes from 172.17.0.2: seq=0 ttl=63 time=0.123 ms
64 bytes from 172.17.0.2: seq=1 ttl=63 time=0.258 ms
64 bytes from 172.17.0.2: seq=2 ttl=63 time=0.318 ms

测试完记得将规则还原回来:

$ iptables -R DOCKER-ISOLATION-STAGE-2 1 -o docker0 -j DROP
$ iptables -R DOCKER-ISOLATION-STAGE-2 2 -o br-5266130b7e5d -j DROP
$ iptables -R DOCKER-ISOLATION-STAGE-2 3 -o br-18f549f753e3 -j DROP

docker network connect

上面介绍了 Docker 如何通过 iptables 隔离不同网桥之间的通信,但是在一些现实场景中,我们可能需要它们之间互相通信,这可以通过 docker network connect 命令来实现:

$ docker network connect test2 8f358828cec2

这条命令的意思是将 8f358828cec2 容器连接到 test2 网络,相当于在容器里添加了一张网卡,并使用一根网线连接到 test2 网络上。我们在容器里可以看到多了一张新网卡 eth1

/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue 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
8: eth0@if9: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:c8:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.2/24 brd 172.17.0.255 scope global eth0
       valid_lft forever preferred_lft forever
30: eth1@if31: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:c8:00:03 brd ff:ff:ff:ff:ff:ff
    inet 172.200.0.3/24 brd 172.200.0.255 scope global eth1
       valid_lft forever preferred_lft forever

eth1@if31 这个名字可以看出,它也是一个 veth pair 隧道设备,设备的一头插在容器里的 eth1 网卡上,另一头插在自定义网桥上:

docker-network-connect.png

此时,容器之间就可以互相通信了,不过要注意的是,连接在 docker0 的容器现在有两个 IP,我们只能 ping 通 172.200.0.3172.17.0.2 这个 IP 仍然是 ping 不通的:

/ # ping 172.200.0.3
PING 172.200.0.3 (172.200.0.3): 56 data bytes
64 bytes from 172.200.0.3: seq=0 ttl=64 time=0.109 ms
64 bytes from 172.200.0.3: seq=1 ttl=64 time=0.106 ms
^C
--- 172.200.0.3 ping statistics ---
2 packets transmitted, 2 packets received, 0% packet loss
round-trip min/avg/max = 0.106/0.107/0.109 ms
/ #
/ # ping 172.17.0.2
PING 172.17.0.2 (172.17.0.2): 56 data bytes
^C
--- 172.17.0.2 ping statistics ---
3 packets transmitted, 0 packets received, 100% packet loss

Container 网络

上面学习了两种容器间通信的方式,一种是通过 --network 参数在创建容器时指定自定义网络,另一种是 docker network connect 命令将已有容器加入指定网络,这两种方式都可以理解为:在容器内加一张网卡,并用网线连接到指定的网桥设备。

Docker 还提供了另一种容器间通信的方式:container 网络(也被称为 joined 网络),它可以让两个容器共享一个网络栈。我们通过 --network=container:xx 参数创建一个容器:

$ docker run --rm -it --network=container:9bd busybox

这个新容器将共享 9bd 这个容器的网络栈,容器的网络信息如下:

/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue 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
6: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

我们进入 9bd 容器并查看该容器的网络信息:

# docker exec -it 9bd sh
/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue 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
6: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

可以发现新容器和 9bd 两个容器具有完全相同的网络信息,不仅 IP 地址一样,连 MAC 地址也一样,这两个容器之间可以通过 127.0.0.1 来通信。这种网络模式的拓扑图如下所示:

container-network.png

Container 网络一般用于多个服务的联合部署,比如有些应用需要运行多个服务,使用 Container 网络可以将它们放在同一个网络中,从而让它们可以互相访问,相比于自定义网桥的方式,这种方式通过 loopback 通信更高效。此外 Container 网络也非常适合边车模式,比如对已有的容器进行测试或监控等。

Host 网络

通过 --network=host 参数可以指定容器使用 Host 网络,使用 Host 网络的容器和宿主机共用同一个网络栈,也就是说,在容器里面,端口套接字、IP 路由表、防火墙、DNS 配置都和宿主机完全一样:

> docker run --rm -it --network=host busybox
/ # ifconfig
docker0   Link encap:Ethernet  HWaddr 02:42:07:3E:52:7E
          inet addr:172.17.0.1  Bcast:172.17.255.255  Mask:255.255.0.0
          UP BROADCAST MULTICAST  MTU:1500  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

eth0      Link encap:Ethernet  HWaddr 0A:3E:D1:F7:58:33
          inet addr:192.168.65.4  Bcast:0.0.0.0  Mask:255.255.255.255
          inet6 addr: fe80::83e:d1ff:fef7:5833/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:1280 errors:0 dropped:0 overruns:0 frame:0
          TX packets:1271 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:2752699 (2.6 MiB)  TX bytes:107715 (105.1 KiB)

lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:709688 errors:0 dropped:0 overruns:0 frame:0
          TX packets:709688 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:149025485 (142.1 MiB)  TX bytes:149025485 (142.1 MiB)

veth2fbdd331 Link encap:Ethernet  HWaddr 46:B7:0B:D6:16:C2
          inet6 addr: fe80::44b7:bff:fed6:16c2/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:3876 errors:0 dropped:0 overruns:0 frame:0
          TX packets:3830 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:370360 (361.6 KiB)  TX bytes:413700 (404.0 KiB)

veth4d3538f6 Link encap:Ethernet  HWaddr 56:C0:72:ED:10:21
          inet6 addr: fe80::54c0:72ff:feed:1021/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:10934 errors:0 dropped:0 overruns:0 frame:0
          TX packets:12400 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:2197702 (2.0 MiB)  TX bytes:3897932 (3.7 MiB)

在容器里使用 ifconfig 命令,输出的结果其实是我们宿主机的网卡,容器里的 hostname 也和宿主机一样,所以我们可以在容器里使用 localhost 访问宿主机。

由于直接使用宿主机的网络,少了一层网络转发,所以 Host 网络具有非常好的性能,如果你的应用对网络传输效率有较高的要求,则可以选择使用 Host 网络。另外,使用 Host 网络还可以在容器里直接对宿主机网络进行配置,比如管理 iptables 或 DNS 配置等。

需要注意的是,Host 网络的主要优点是提高了网络性能,但代价是容器与主机共享网络命名空间,这可能会带来安全隐患。因此,需要仔细考虑使用 Host 网络的场景,并采取适当的安全措施,以确保容器和主机的安全性。

下图是 Host 网络大致的网络拓扑结构:

host-network.png

None 网络

None 网络是一种特殊类型的网络,顾名思义,它表示容器不连接任何网络。None 网络的容器是一个完全隔绝的环境,它无法访问任何其他容器或宿主机。我们在创建容器时通过 --network=none 参数使用 None 网络:

$ docker run --rm -it --network=none busybox
/ # ifconfig
lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

在使用 None 网络的容器里,除了 lo 设备之外,没有任何其他的网卡设备。这种与世隔离的环境非常适合一些安全性工作或测试工作,比如,对恶意软件进行逆向分析时,我们不希望恶意软件访问外部网络,使用 None 网络可以避免它对宿主机或其他服务造成影响;或者在单元测试时,通过 None 网络可以模拟网络隔离的效果,这样我们可以测试和验证没有网络情况下的程序表现。

下图是 None 网络大致的网络拓扑结构:

none-network.png

跨主机容器网络方案

在前一节中我们学习了 Docker 的几种网络模式,它们都是用于解决单个主机上的容器通信问题,那么如何实现不同主机上的容器通信呢?实际上,Docker 提供了几种原生的跨主机网络解决方案,包括:

这些跨主机网络方案需要具备一定的网络知识,比如 overlay 网络是基于 VxLAN (Virtual eXtensible LAN) 技术实现的,这是一种 隧道技术,将二层数据封装到 UDP 进行传输,这种网络技术我们将其称为 overlay 网络,指的是建立在其他网络上的网络;macvlan 是一种网卡虚拟化技术,它本身是 linux kernel 的一个模块,可以为同一个物理网卡配置多个 MAC 地址,每个 MAC 地址对应一个虚拟接口,由于它直接使用真实网卡通信,所以性能非常好,不过每个虚拟接口都有自己的 MAC 地址,而网络接口和交换机支持的 MAC 地址个数是有限的,MAC 地址过多时会造成严重的性能损失;ipvlan 解决了 MAC 地址过多的问题,它和 macvlan 类似,也是 linux kernel 的一个模块,但是它和 macvlan 不一样的是,macvlan 是为同一个网卡虚拟出多个 MAC 地址,而 ipvlan 是为同一个 MAC 地址虚拟多个 IP 地址;macvlan 和 ipvlan 不需要对包进行封装,这种网络技术又被称为 underlay 网络

除此之外,还有很多第三方网络解决方案,它们通过 Docker 网络的插件机制实现,包括:

参考

  1. Networking overview | Docker Documentation
  2. 每天5分钟玩转Docker容器技术
  3. Docker 网络模式详解及容器间网络通信
  4. 网络 - Docker — 从入门到实践
  5. 花了三天时间终于搞懂 Docker 网络了
  6. docker的网络-Container network interface(CNI)与Container network model(CNM)
  7. Docker容器网络互联
  8. linux 网络虚拟化: network namespace 简介
  9. Linux 虚拟网络设备 veth-pair 详解,看这一篇就够了
  10. 从宿主机直接进入docker容器的网络空间
  11. Deep dive into Linux Networking and Docker — Bridge, vETH and IPTables
  12. Container Networking: What You Should Know

更多

Kubernetes 网络

  1. 数据包在 Kubernetes 中的一生(1)
  2. 从零开始入门 K8s:理解 CNI 和 CNI 插件
  3. IPVS从入门到精通kube-proxy实现原理
  4. Kubernetes(k8s)kube-proxy、Service详解
扫描二维码,在手机上阅读!

使用 Helm 部署 Kubernetes 应用

Kubernetes 使用小记 中我们学习了如何通过 Deployment 部署一个简单的应用服务并通过 Service 来暴露它,在真实的场景中,一套完整的应用服务可能还会包含很多其他的 Kubernetes 资源,比如 DaemonSetIngressConfigMapSecret 等,当用户部署这样一套完整的服务时,他就不得不关注这些底层的 Kubernetes 概念,这对用户非常不友好。

我们知道,几乎每个操作系统都内置了一套软件包管理器,用来方便用户安装、配置、卸载或升级各种系统软件和应用软件,比如 Debian 和 Ubuntu 使用的是 DEB 包管理器 dpkgapt,CentOS 使用的是 RPM 包管理器 yum,Mac OS 有 Homebrew,Windows 有 WinGetChocolatey 等,另外很多系统还内置了图形界面的应用市场方便用户安装应用,比如 Windows 应用商店、Apple Store、安卓应用市场等,这些都可以算作是包管理器。

使用包管理器安装应用大大降低了用户的使用门槛,他只需要执行一句命令或点击一个安装按钮即可,而不用去关心这个应用需要哪些依赖和哪些配置。那么在 Kubernetes 下能不能也通过这种方式来部署应用服务呢?Helm 作为 Kubernetes 的包管理器,解决了这个问题。

Helm 简介

Helm 是 Deis 团队 于 2015 年发布的一个 Kubernetes 包管理工具,当时 Deis 团队还没有被微软收购,他们在一家名为 Engine Yard 的公司里从事 PaaS 产品 Deis Workflow 的开发,在开发过程中,他们需要将众多的微服务部署到 Kubernetes 集群中,由于在 Kubernetes 里部署服务需要开发者手动编写和维护数量繁多的 Yaml 文件,并且服务的分发和安装也比较繁琐,Matt Butcher 和另外几个同事于是在一次 Hackthon 团建中发起了 Helm 项目。

Helm 的取名非常有意思,Kubernetes 是希腊语 “舵手” 的意思,而 Helm 是舵手操作的 “船舵”,用来控制船的航行方向。

Helm 引入了 Chart 的概念,它也是 Helm 所使用的包格式,可以把它理解成一个描述 Kubernetes 相关资源的文件集合。开发者将自己的应用配置文件打包成 Helm chart 格式,然后发布到 ArtifactHub,这和你使用 docker push 将镜像推送到 DockerHub 镜像仓库一样,之后用户安装你的 Kubernetes 应用只需要一条简单的 Helm 命令就能搞定,极大程度上解决了 Kubernetes 应用维护、分发、安装等问题。

Helm 在 2018 年 6 月加入 CNCF,并在 2020 年 4 月正式毕业,目前已经是 Kubernetes 生态里面不可或缺的明星级项目。

快速开始

这一节我们将通过官方的入门示例快速掌握 Helm 的基本用法。

安装 Helm

我们首先从 Helm 的 Github Release 页面找到最新版本,然后通过 curl 将安装包下载下来:

$ curl -LO https://get.helm.sh/helm-v3.11.1-linux-amd64.tar.gz

然后解压安装包,并将 helm 安装到 /usr/local/bin 目录:

$ tar -zxvf helm-v3.11.1-linux-amd64.tar.gz
$ sudo mv linux-amd64/helm /usr/local/bin/helm

这样 Helm 就安装好了,通过 helm version 检查是否安装成功:

$ helm version
version.BuildInfo{Version:"v3.11.1", GitCommit:"293b50c65d4d56187cd4e2f390f0ada46b4c4737", GitTreeState:"clean", GoVersion:"go1.18.10"}

使用 helm help 查看 Helm 支持的其他命令和参数。

一般来说,直接下载 Helm 二进制文件就可以完成安装,不过官方也提供了一些其他方法来安装 Helm,比如通过 get_helm.sh 脚本来自动安装,或者通过 yumapt 这些操作系统的包管理器来安装,具体内容可参考官方的 安装文档

使用 Helm

Helm 安装完成之后,我们就可以使用 Helm 在 Kubernetes 中安装应用了。对于新手来说,最简单的方法是在 ArtifactHub 上搜索要安装的应用,然后按照文档中的安装步骤来操作即可。比如我们想要部署 Nginx,首先在 ArtifactHub 上进行搜索:

search-nginx.png

注意左侧的 KIND 勾选上 Helm charts,搜索出来的结果会有很多条,这些都是由不同的组织或个人发布的,可以在列表中看出发布的组织或个人名称,以及该 Charts 所在的仓库。Bitnami 是 Helm 中最常用的仓库之一,它内置了很多常用的 Kubernetes 应用,于是我们选择进入 第一条搜索结果

bitnami-nginx.png

这里可以查看关于 Nginx 应用的安装步骤、使用说明、以及支持的配置参数等信息,我们可以点击 INSTALL 按钮,会弹出一个对话框,并显示该应用的安装步骤:

nginx-install.png

我们按照它的提示,首先使用 helm repo add 将 Bitnami 仓库添加到我们的电脑:

$ helm repo add bitnami https://charts.bitnami.com/bitnami
"bitnami" has been added to your repositories

然后使用 helm install 安装 Nginx 应用:

$ helm install my-nginx bitnami/nginx --version 13.2.23
NAME: my-nginx
LAST DEPLOYED: Sat Feb 11 08:58:10 2023
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
CHART NAME: nginx
CHART VERSION: 13.2.23
APP VERSION: 1.23.3

** Please be patient while the chart is being deployed **
NGINX can be accessed through the following DNS name from within your cluster:

    my-nginx.default.svc.cluster.local (port 80)

To access NGINX from outside the cluster, follow the steps below:

1. Get the NGINX URL by running these commands:

  NOTE: It may take a few minutes for the LoadBalancer IP to be available.
        Watch the status with: 'kubectl get svc --namespace default -w my-nginx'

    export SERVICE_PORT=$(kubectl get --namespace default -o jsonpath="{.spec.ports[0].port}" services my-nginx)
    export SERVICE_IP=$(kubectl get svc --namespace default my-nginx -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
    echo "http://${SERVICE_IP}:${SERVICE_PORT}"

稍等片刻,Nginx 就安装好了,我们可以使用 kubectl 来验证:

$ kubectl get deployments
NAME       READY   UP-TO-DATE   AVAILABLE   AGE
my-nginx   1/1     1            1           12m
$ kubectl get svc
NAME             TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
kubernetes       ClusterIP      10.96.0.1        <none>        443/TCP          75d
my-nginx         LoadBalancer   10.111.151.137   localhost     80:31705/TCP     12m

访问 localhost:80 可以看到 Nginx 已成功启动:

nginx.png

卸载和安装一样也很简单,使用 helm delete 命令即可:

$ helm delete my-nginx
release "my-nginx" uninstalled

我们还可以通过 --set 选项来改写 Nginx 的一些参数,比如默认情况下创建的 Service 端口是 80,使用下面的命令将端口改为 8080:

$ helm install my-nginx bitnami/nginx --version 13.2.23 \
    --set service.ports.http=8080

更多的参数列表可以参考安装文档中的 Parameters 部分。

另外,helm 命令和它的子命令还支持一些其他选项,比如上面的 --version--set 都是 helm install 子命令的选项,我们可以使用 helm 命令的 --namespace 选项将应用部署到指定的命名空间中:

$ helm install my-nginx bitnami/nginx --version 13.2.23 \
    --set service.ports.http=8080 \
    --namespace nginx --create-namespace

常用的 Helm 命令

通过上一节的学习,我们大致了解了 Helm 中三个非常重要的概念:

  • Repository
  • Chart
  • Release

Repository 比较好理解,就是存放安装包的仓库,可以使用 helm env 查看 HELM_REPOSITORY_CACHE 环境变量的值,这就是仓库的本地地址,用于缓存仓库信息以及已下载的安装包:

$ helm env | grep HELM_REPOSITORY_CACHE
HELM_REPOSITORY_CACHE="/home/aneasystone/.cache/helm/repository"

当我们执行 helm repo add 命令时,会将仓库信息缓存到该目录;当我们执行 helm install 命令时,也会将安装包下载并缓存到该目录。查看该目录,可以看到我们已经添加的 bitnami 仓库信息,还有已下载的 nginx 安装包:

$ ls /home/aneasystone/.cache/helm/repository
bitnami-charts.txt  bitnami-index.yaml  nginx-13.2.23.tgz

这个安装包就被称为 Chart,是 Helm 特有的安装包格式,这个安装包中包含了一个 Kubernetes 应用的所有资源文件。而 Release 就是安装到 Kubernetes 中的 Chart 实例,每个 Chart 可以在集群中安装多次,每安装一次,就会产生一个 Release。

明白了这三个基本概念,我们就可以这样理解 Helm 的用途:它先从 Repository 中下载 Chart,然后将 Chart 实例化后以 Release 的形式部署到 Kubernetes 集群中,如下图所示(图片来源):

helm-overview.png

而绝大多数的 Helm 命令,都是围绕着这三大概念进行的。

Repository 相关命令

helm repo

该命令用于添加、删除或更新仓库等操作。

将远程仓库添加到本地:

$ helm repo add bitnami https://charts.bitnami.com/bitnami

查看本地已安装仓库列表:

$ helm repo list
NAME    URL
bitnami https://charts.bitnami.com/bitnami

更新本地仓库:

$ helm repo update bitnami
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "bitnami" chart repository
Update Complete. ⎈Happy Helming!⎈

读取某个仓库目录,根据找到的 Chart 生成索引文件:

$ helm repo index SOME_REPO_DIR
# cat SOME_REPO_DIR/index.yaml

删除本地仓库:

$ helm repo remove bitnami
"bitnami" has been removed from your repositories

helm search

该命令用于在 ArtifactHub 或本地仓库中搜索 Chart。

在 ArtifactHub 中搜索 Chart:

$ helm search hub nginx
URL                                                     CHART VERSION   APP VERSION          DESCRIPTION
https://artifacthub.io/packages/helm/cloudnativ...      3.2.0           1.16.0               Chart for the nginx server
https://artifacthub.io/packages/helm/bitnami/nginx      13.2.23         1.23.3               NGINX Open Source is a web server that can be a...

要注意搜索出来的 Chart 链接可能不完整,不能直接使用,根据 stackoverflow 这里的解答,我们可以使用 -o json 选项将结果以 JSON 格式输出:

$ helm search hub nginx -o json | jq .
[
  {
    "url": "https://artifacthub.io/packages/helm/cloudnativeapp/nginx",
    "version": "3.2.0",
    "app_version": "1.16.0",
    "description": "Chart for the nginx server",
    "repository": {
      "url": "https://cloudnativeapp.github.io/charts/curated/",
      "name": "cloudnativeapp"
    }
  },
  {
    "url": "https://artifacthub.io/packages/helm/bitnami/nginx",
    "version": "13.2.23",
    "app_version": "1.23.3",
    "description": "NGINX Open Source is a web server that can be also used as a reverse proxy, load balancer, and HTTP cache. Recommended for high-demanding sites due to its ability to provide faster content.",
    "repository": {
      "url": "https://charts.bitnami.com/bitnami",
      "name": "bitnami"
    }
  }
}

在本地配置的所有仓库中搜索 Chart:

$ helm search repo nginx
NAME                                    CHART VERSION   APP VERSION     DESCRIPTION
bitnami/nginx                           13.2.23         1.23.3          NGINX Open Source is a web server that can be a...
bitnami/nginx-ingress-controller        9.3.28          1.6.2           NGINX Ingress Controller is an Ingress controll...
bitnami/nginx-intel                     2.1.15          0.4.9           DEPRECATED NGINX Open Source for Intel is a lig...

Chart 相关命令

helm create

该命令用于创建一个新的 Chart 目录:

$ helm create demo
Creating demo

创建的 Chart 目录是下面这样的结构:

$ tree demo
demo
├── Chart.yaml
├── charts
├── templates
│   ├── NOTES.txt
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── hpa.yaml
│   ├── ingress.yaml
│   ├── service.yaml
│   ├── serviceaccount.yaml
│   └── tests
│       └── test-connection.yaml
└── values.yaml

3 directories, 10 files

helm package

将 Chart 目录(必须包含 Chart.yaml 文件)打包成 Chart 归档文件:

$ helm package nginx
Successfully packaged chart and saved it to: /home/aneasystone/helm/nginx-13.2.23.tgz

helm lint

验证 Chart 是否存在问题:

$ helm lint ./nginx
==> Linting ./nginx

1 chart(s) linted, 0 chart(s) failed

helm show

该命令用于显示 Chart 的基本信息,包括:

  • helm show chart - 显示 Chart 定义,实际上就是 Chart.yaml 文件的内容
  • helm show crds - 显示 Chart 的 CRD
  • helm show readme - 显示 Chart 的 README.md 文件中的内容
  • helm show values - 显示 Chart 的 values.yaml 文件中的内容
  • helm show all - 显示 Chart 的所有信息

helm pull

从仓库中将 Chart 安装包下载到本地:

$ helm pull bitnami/nginx
$ ls
nginx-13.2.23.tgz

helm push

将 Chart 安装包推送到远程仓库:

$ helm push [chart] [remote]

Release 相关命令

helm install

将 Chart 安装到 Kubernetes 集群:

$ helm install my-nginx bitnami/nginx --version 13.2.23

安装时可以通过 --set 选项修改配置参数:

$ helm install my-nginx bitnami/nginx --version 13.2.23 \
    --set service.ports.http=8080

其中 bitnami/nginx 是要安装的 Chart,这种写法是最常用的格式,被称为 Chart 引用,一共有六种不同的 Chart 写法:

  • 通过 Chart 引用:helm install my-nginx bitnami/nginx
  • 通过 Chart 包:helm install my-nginx ./nginx-13.2.23.tgz
  • 通过未打包的 Chart 目录:helm install my-nginx ./nginx
  • 通过 Chart URL:helm install my-nginx https://example.com/charts/nginx-13.2.23.tgz
  • 通过仓库 URL 和 Chart 引用:helm install --repo https://example.com/charts/ my-nginx nginx
  • 通过 OCI 注册中心:helm install my-nginx --version 13.2.23 oci://example.com/charts/nginx

helm list

显示某个命名空间下的所有 Release:

$ helm list --namespace default
NAME            NAMESPACE       REVISION        UPDATED                                 STATUS          CHART           APP VERSION
my-nginx        default         1               2023-02-11 09:35:09.4058393 +0800 CST   deployed        nginx-13.2.23   1.23.3

helm status

查询某个 Release 的状态信息:

$ helm status my-nginx

helm get

获取某个 Release 的扩展信息,包括:

  • helm get hooks - 获取 Release 关联的钩子信息
  • helm get manifest - 获取 Release 的清单信息
  • helm get notes - 获取 Release 的注释
  • helm get values - 获取 Release 的 values 文件
  • helm get all - 获取 Release 的所有信息

helm upgrade

将 Release 升级到新版本的 Chart:

$ helm upgrade my-nginx ./nginx
Release "my-nginx" has been upgraded. Happy Helming!

升级时可以通过 --set 选项修改配置参数:

$ helm upgrade my-nginx ./nginx \
    --set service.ports.http=8080

helm history

查看某个 Release 的版本记录:

$ helm history my-nginx
REVISION        UPDATED                         STATUS          CHART           APP VERSION     DESCRIPTION
1               Sun Feb 12 11:14:18 2023        superseded      nginx-13.2.23   1.23.3          Install complete
2               Sun Feb 12 11:15:53 2023        deployed        nginx-13.2.23   1.23.3          Upgrade complete

helm rollback

将 Release 回滚到某个版本:

$ helm rollback my-nginx 1
Rollback was a success! Happy Helming!

再查看版本记录可以看到多了一条记录:

$ helm history my-nginx
REVISION        UPDATED                         STATUS          CHART           APP VERSION     DESCRIPTION
1               Sun Feb 12 11:14:18 2023        superseded      nginx-13.2.23   1.23.3          Install complete
2               Sun Feb 12 11:15:53 2023        superseded      nginx-13.2.23   1.23.3          Upgrade complete
3               Sun Feb 12 11:20:27 2023        deployed        nginx-13.2.23   1.23.3          Rollback to 1

helm uninstall

卸载 Release:

$ helm uninstall my-nginx
release "my-nginx" uninstalled

参考

  1. Helm | 快速入门指南
  2. Helm | 使用Helm
  3. Helm | 项目历史
  4. 微软 Deis Labs 的传奇故事
  5. 使用Helm管理kubernetes应用

更多

其他 Helm 命令

除了本文介绍的 Helm 三大概念以及围绕这三大概念的常用命令,也还有一些其他的命令:

制作自己的 Helm Chart

可视化管理工具

扫描二维码,在手机上阅读!

APISIX 使用小记

Apache APISIX 是基于 Nginx/OpenResty + Lua 方案打造的一款 动态实时高性能云原生 API 网关,提供了负载均衡、动态上游、灰度发布、服务熔断、身份认证、可观测性等丰富的流量管理功能。APISIX 由国内初创公司 支流科技 于 2019 年 6 月开源,并于 7 月纳入 CNCF 全景图,10 月进入 Apache 孵化器,次年 7 月 毕业,成为国内唯一一个由初创公司贡献的项目,也是中国最快毕业的 Apache 顶级项目。

入门示例初体验

学习一门技术最好的方法就是使用它。这一节,我们将通过官方的入门示例,对 APISIX 的概念和用法有个基本了解。

首先,我们下载 apisix-docker 仓库:

git clone https://github.com/apache/apisix-docker.git

这个仓库主要是用来指导用户如何使用 Docker 部署 APISIX 的,其中有一个 example 目录,是官方提供的入门示例,我们可以直接使用 docker-compose 运行它:

$ cd apisix-docker/example
$ docker-compose up -d
[+] Running 8/8
 - Network example_apisix                Created                         0.9s
 - Container example-web2-1              Started                         5.1s
 - Container example-web1-1              Started                         4.0s
 - Container example-prometheus-1        Started                         4.4s
 - Container example-grafana-1           Started                         5.8s
 - Container example-apisix-dashboard-1  Started                         6.0s
 - Container example-etcd-1              Started                         5.1s
 - Container example-apisix-1            Started                         7.5s

可以看到创建了一个名为 example_apisix 的网络,并在这个网络里启动了 7 个容器:

  • etcd - APISIX 使用 etcd 作为配置中心,它通过监听 etcd 的变化来实时更新路由
  • apisix - APISIX 网关
  • apisix-dashboard - APISIX 管理控制台,可以在这里对 APISIX 的 Route、Upstream、Service、Consumer、Plugin、SSL 等进行管理
  • prometheus - 这个例子使用了 APISIX 的 prometheus 插件,用于暴露 APISIX 的指标,Prometheus 服务用于采集这些指标
  • grafana - Grafana 面板以图形化的方式展示 Prometheus 指标
  • web1 - 测试服务
  • web2 - 测试服务

部署之后可以使用 APISIX 的 Admin API 检查其是否启动成功:

$ curl http://127.0.0.1:9180/apisix/admin/routes \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'
{"list":[],"total":0}

目前我们还没有创建任何路由,所以 /apisix/admin/routes 接口返回的结果为空。我们可以使用 Admin API 和 Dashboard 两种方式来创建路由。

使用 Admin API 创建路由

路由( Route ) 是 APISIX 中最基础和最核心的资源对象,APISIX 通过路由定义规则来匹配客户端请求,根据匹配结果加载并执行相应的插件,最后将请求转发到指定的上游服务。一条路由主要包含三部分信息:

  • 匹配规则:比如 methodsurihost 等,也可以根据需要自定义匹配规则,当请求满足匹配规则时,才会执行后续的插件,并转发到指定的上游服务;
  • 插件配置:这是可选的,但也是 APISIX 最强大的功能之一,APISIX 提供了非常丰富的插件来实现各种不同的访问策略,比如认证授权、安全、限流限速、可观测性等;
  • 上游信息:路由会根据配置的负载均衡信息,将请求按照规则转发到相应的上游。

所有的 Admin API 都采用了 Restful 风格,路由资源的请求地址为 /apisix/admin/routes/{id},我们可以通过不同的 HTTP 方法来查询、新增、编辑或删除路由资源(官方示例):

  • GET /apisix/admin/routes - 获取资源列表;
  • GET /apisix/admin/routes/{id} - 获取资源;
  • PUT /apisix/admin/routes/{id} - 根据 id 创建资源;
  • POST /apisix/admin/routes - 创建资源,id 将会自动生成;
  • DELETE /apisix/admin/routes/{id} - 删除指定资源;
  • PATCH /apisix/admin/routes/{id} - 标准 PATCH,修改指定 Route 的部分属性,其他不涉及的属性会原样保留;
  • PATCH /apisix/admin/routes/{id}/{path} - SubPath PATCH,通过 {path} 指定 Route 要更新的属性,全量更新该属性的数据,其他不涉及的属性会原样保留。

下面的例子将入门示例中的 web1 服务添加到路由中:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web1",
    "upstream": {
        "type": "roundrobin",
        "nodes": {
            "web1:80": 1
        }
    }
}'

其中 X-API-KEY: edd1c9f034335f136f87ad84b625c8f1 是 Admin API 的访问 Token,可以在 APISIX 的配置文件 apisix_conf/config.yaml 中找到:

deployment:
  admin:
    allow_admin:
      - 0.0.0.0/0
    admin_key:
      - name: "admin"
        key: edd1c9f034335f136f87ad84b625c8f1
        role: admin
      - name: "viewer"
        key: 4054f7cf07e344346cd3f287985e76a2
        role: viewer

如果路由创建成功,将返回下面的 201 Created 信息:

HTTP/1.1 201 Created
Connection: close
Transfer-Encoding: chunked
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: *
Access-Control-Max-Age: 3600
Content-Type: application/json
Date: Tue, 31 Jan 2023 00:19:03 GMT
Server: APISIX/3.1.0
X-Api-Version: v3

{"key":"\/apisix\/routes\/1","value":{"create_time":1675124057,"uri":"\/web1","status":1,"upstream":{"pass_host":"pass","scheme":"http","nodes":{"web1:80":1},"hash_on":"vars","type":"roundrobin"},"priority":0,"update_time":1675124057,"id":"1"}}

这个路由的含义是当请求的方法是 GET 且请求的路径是 /web1 时,APISIX 就将请求转发到上游服务 web1:80。我们可以通过这个路径来访问 web1 服务:

$ curl http://127.0.0.1:9080/web1
hello web1

如果上游信息需要在不同的路由中复用,我们可以先创建一个 上游(Upstream)

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/upstreams/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "type": "roundrobin",
    "nodes": {
        "web1:80": 1
    }
}'

然后在创建路由时直接使用 upstream_id 即可:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web1",
    "upstream_id": "1"
}'

另外,你可以使用下面的命令删除一条路由:

$ curl -X DELETE http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1'

使用 Dashboard 创建路由

APISIX 提供了一套图形化 Dashboard 用来对网关的路由、插件、上游等进行管理,在入门示例中已经自带部署了 Dashboard,通过浏览器 http://localhost:9000 即可访问:

dashboard-login.png

默认的用户名和密码可以在 dashboard_conf/conf.yaml 文件中进行配置:

authentication:
  secret:
    secret     
  expire_time: 3600
  users:
    - username: admin
      password: admin
    - username: user
      password: user

登录成功后进入路由页面:

dashboard-routes.png

然后点击 “创建” 按钮创建一个路由:

dashboard-create-route.png

看上去这里的路由信息非常复杂,但是实际上我们只需要填写 名称路径HTTP 方法 即可,其他的维持默认值,当我们对 APISIX 的路由理解更深刻的时候可以再回过头来看看这些参数。

点击 “下一步” 设置上游信息:

dashboard-create-route-2.png

同样的,我们只关心目标节点的 主机名端口 两个参数即可。

然后再点击 “下一步” 进入插件配置,这里暂时先跳过,直接 “下一步” 完成路由的创建。路由创建完成后,访问 /web2 来验证路由是否生效:

$ curl http://127.0.0.1:9080/web2
hello web2

使用 APISIX 插件

通过上面的示例,我们了解了 APISIX 的基本用法,学习了如何通过 Admin API 或 Dashboard 来创建路由,实现了网关最基础的路由转发功能。APISIX 不仅具有高性能且低延迟的特性,而且它强大的插件机制为其高扩展性提供了无限可能。我们可以在 APISIX 插件中心 查看所有官方已经支持的插件,也可以 使用 lua 语言开发自己的插件,如果你对 lua 不熟悉,还可以使用其他语言 开发 External Plugin,APISIX 支持通过 Plugin Runner 以 sidecar 的形式来运行你的插件,APISIX 和 sidecar 之间通过 RPC 通信,不过这种方式对性能有一定的影响,如果你比较关注性能问题,那么可以使用你熟悉的语言开发 WebAssembly 程序,APISIX 也支持 运行 wasm 插件

external-plugin.png

这一节我们将通过几个官方插件来实现一些常见的网关需求。

在上面的学习中我们知道,一个路由是由匹配规则、插件配置和上游信息三个部分组成的,但是为了学习的递进性,我们有意地避免了插件配置部分。现在我们可以重新创建一个路由,并为其加上插件信息:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/3 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web3",
    "plugins": {
        "limit-count": {
            "count": 2,
            "time_window": 60,
            "rejected_code": 503,
            "key": "remote_addr"
        },
        "prometheus": {}
    },
    "upstream": {
        "type": "roundrobin",
        "nodes": {
            "web1:80": 1
        }
    }
}'

上面的命令创建了一个 /web3 路由,并配置了两个插件:

  • limit-count - 该插件使用 固定窗口算法(Fixed Window algorithm) 对该路由进行限流,每分钟仅允许 2 次请求,超出时返回 503 错误码;
  • prometheus - 该插件将路由请求相关的指标暴露到 Prometheus 端点;

我们连续访问 3 次 /web3 路由:

$ curl http://127.0.0.1:9080/web3
hello web1
$ curl http://127.0.0.1:9080/web3
hello web1
$ curl http://127.0.0.1:9080/web3
<html>
<head><title>503 Service Temporarily Unavailable</title></head>
<body>
<center><h1>503 Service Temporarily Unavailable</h1></center>
<hr><center>openresty</center>
<p><em>Powered by <a href="https://apisix.apache.org/">APISIX</a>.</em></p></body>
</html>

可以看到 limit-count 插件的限流功能生效了,第 3 次请求被拒绝,返回了 503 错误码。另外,可以使用下面的命令查看 Prometheus 指标:

$ curl -i http://127.0.0.1:9091/apisix/prometheus/metrics

这个 Prometheus 指标地址可以在 apisix_conf/config.yaml 文件的 plugin_attr 中配置:

plugin_attr:
  prometheus:
    export_uri: /apisix/prometheus/metrics
    export_addr:
      ip: "0.0.0.0"
      port: 9091

APISIX 的插件可以动态的启用和禁用、自定义错误响应、自定义优先级、根据条件动态执行,具体内容可以参考 官方的 Plugin 文档。此外,如果一个插件需要在多个地方复用,我们也可以创建一个 Plugin Config

$ curl http://127.0.0.1:9180/apisix/admin/plugin_configs/1 \
  -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d '
{
    "desc": "enable limit-count plugin",
    "plugins": {
        "limit-count": {
            "count": 2,
            "time_window": 60,
            "rejected_code": 503
        }
    }
}'

然后在创建路由时,通过 plugin_config_id 关联:

$ curl -X PUT http://127.0.0.1:9180/apisix/admin/routes/1 \
    -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -i -d '
{
    "methods": ["GET"],
    "uri": "/web1",
    "upstream_id": "1",
    "plugin_config_id": "1"
}'

在 APISIX 的插件中心,我们可以看到 APISIX 将插件分成了下面几个大类:

  • General - 通用功能,比如 gzip 压缩配置、重定向配置等;
  • Transformation - 这类插件会对请求做一些转换操作,比如重写请求响应、gRPC 协议转换等;
  • Authentication - 提供一些常见的认证授权相关的功能,比如 API Key 认证、JWT 认证、Basic 认证、CAS 认证、LDAP 认证等;
  • Security - 安全相关的插件,比如开启 IP 黑白名单、开启 CORS、开启 CSRF 等;
  • Traffic - 这些插件对流量进行管理,比如限流、限速、流量镜像等;
  • Observability - 可观测性插件,支持常见的日志(比如 File-Logger、Http-Logger、Kafka-Logger、Rocketmq-Logger 等)、指标(比如 Prometheus、Datadog 等)和链路跟踪(比如 Skywalking、Zipkin、Opentelemetry 等)系统;
  • Serverless - 对接常见的 Serverless 平台,实现函数计算功能,比如 AWS Lambda、Apache OpenWhisk、CNCF Function 等;
  • Other Protocols - 这些插件用于支持 Dubbo、MQTT 等其他类型的协议;

参考

  1. 快速入门指南 | Apache APISIX® -- Cloud-Native API Gateway
  2. API 网关策略的二三事
  3. 从 Apache APISIX 来看 API 网关的演进
  4. 云原生时代的中外 API 网关之争

更多

APISIX 的部署模式

APISIX 支持多种不同的 部署模式,上面的示例中使用的是最常用的一种部署模式:traditional 模式,在这个模式下 APISIX 的控制平台和数据平面在一起:

deployment-traditional.png

我们也可以将 APISIX 部署两个实例,一个作为数据平面,一个作为控制平面,这被称为 decoupled 模式,这样可以提高 APISIX 的稳定性:

deployment-decoupled.png

上面两种模式都依赖于从 etcd 中监听和获取配置信息,如果我们不想使用 etcd,我们还可以将 APISIX 部署成 standalone 模式,这个模式使用 conf/apisix.yaml 作为配置文件,并且每间隔一段时间自动检测文件内容是否有更新,如果有更新则重新加载配置。不过这个模式只能作为数据平面,无法使用 Admin API 等管理功能(这是因为 Admin API 是基于 etcd 实现的):

deployment:
  role: data_plane
  role_data_plane:
    config_provider: yaml

将 APISIX 扩展为服务网格的边车

集成服务发现注册中心

扫描二维码,在手机上阅读!

构建多架构容器镜像实战

最近在一个国产化项目中遇到了这样一个场景,在同一个 Kubernetes 集群中的节点是混合架构的,也就是说,其中某些节点的 CPU 架构是 x86 的,而另一些节点是 ARM 的。为了让我们的镜像在这样的环境下运行,一种最简单的做法是根据节点类型为其打上相应的标签,然后针对不同的架构构建不同的镜像,比如 demo:v1-amd64demo:v1-arm64,然后还需要写两套 YAML:一套使用 demo:v1-amd64 镜像,并通过 nodeSelector 选择 x86 的节点,另一套使用 demo:v1-arm64 镜像,并通过 nodeSelector 选择 ARM 的节点。很显然,这种做法不仅非常繁琐,而且管理起来也相当麻烦,如果集群中还有其他架构的节点,那么维护成本将成倍增加。

你可能知道,每个 Docker 镜像都是通过一个 manifest 来描述的,manifest 中包含了这个镜像的基本信息,包括它的 mediaType、大小、摘要以及每一层的分层信息等。可以使用 docker manifest inspect 查看某个镜像的 manifest 信息:

$ docker manifest inspect aneasystone/hello-actuator:v1
{
        "schemaVersion": 2,
        "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
        "config": {
                "mediaType": "application/vnd.docker.container.image.v1+json",
                "size": 3061,
                "digest": "sha256:d6d5f18d524ce43346098c5d5775de4572773146ce9c0c65485d60b8755c0014"
        },
        "layers": [
                {
                        "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
                        "size": 2811478,
                        "digest": "sha256:5843afab387455b37944e709ee8c78d7520df80f8d01cf7f861aae63beeddb6b"
                },
                {
                        "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
                        "size": 928436,
                        "digest": "sha256:53c9466125e464fed5626bde7b7a0f91aab09905f0a07e9ad4e930ae72e0fc63"
                },
                {
                        "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
                        "size": 186798299,
                        "digest": "sha256:d8d715783b80cab158f5bf9726bcada5265c1624b64ca2bb46f42f94998d4662"
                },
                {
                        "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
                        "size": 19609795,
                        "digest": "sha256:112ce4ba7a4e8c2b5bcf3f898ae40a61b416101eba468397bb426186ee435281"
                }
        ]
}

可以加上 --verbose 查看更详细的信息,包括该 manifest 引用的镜像标签和架构信息:

$ docker manifest inspect --verbose aneasystone/hello-actuator:v1
{
        "Ref": "docker.io/aneasystone/hello-actuator:v1",
        "Descriptor": {
                "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
                "digest": "sha256:f16a1fcd331a6d196574a0c0721688360bf53906ce0569bda529ba09335316a2",
                "size": 1163,
                "platform": {
                        "architecture": "amd64",
                        "os": "linux"
                }
        },
        "SchemaV2Manifest": {
                ...
        }
}

我们一般不会直接使用 manifest,而是通过标签来关联它,方便人们使用。从上面的输出结果可以看出,该 manifest 通过 docker.io/aneasystone/hello-actuator:v1 这个镜像标签来关联,支持的平台是 linux/amd64,该镜像有四个分层,另外注意这里的 mediaType 字段,它的值是 application/vnd.docker.distribution.manifest.v2+json,表示这是 Docker 镜像格式(如果是 application/vnd.oci.image.manifest.v1+json 表示 OCI 镜像)。

可以看出这个镜像标签只关联了一个 manifest ,而一个 manifest 只对应一种架构;如果同一个镜像标签能关联多个 manifest ,不同的 manifest 对应不同的架构,那么当我们通过这个镜像标签启动容器时,容器引擎就可以自动根据当前系统的架构找到对应的 manifest 并下载对应的镜像。实际上这就是 多架构镜像( multi-arch images ) 的基本原理,我们把这里的多个 manifest 合称为 manifest list( 在 OCI 规范中被称为 image index ),镜像标签不仅可以关联 manifest,也可以关联 manifest list。

可以使用 docker manifest inspect 查看某个多架构镜像的 manifest list 信息:

$ docker manifest inspect alpine:3.17
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
   "manifests": [
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:c0d488a800e4127c334ad20d61d7bc21b4097540327217dfab52262adc02380c",
         "platform": {
            "architecture": "amd64",
            "os": "linux"
         }
      },
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:ecc4c9eff5b0c4de6be6b4b90b5ab2c2c1558374852c2f5854d66f76514231bf",
         "platform": {
            "architecture": "arm",
            "os": "linux",
            "variant": "v6"
         }
      },
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:4c679bd1e6b6516faf8466986fc2a9f52496e61cada7c29ec746621a954a80ac",
         "platform": {
            "architecture": "arm",
            "os": "linux",
            "variant": "v7"
         }
      },
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:af06af3514c44a964d3b905b498cf6493db8f1cde7c10e078213a89c87308ba0",
         "platform": {
            "architecture": "arm64", 
            "os": "linux",
         }
      },
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:af6a986619d570c975f9a85b463f4aa866da44c70427e1ead1fd1efdf6150d38",
         "platform": {
            "architecture": "386", 
            "os": "linux"
         }
      },
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:a7a53c2331d0c5fedeaaba8d716eb2b06f7a9c8d780407d487fd0fbc1244f7e6",
         "platform": {
            "architecture": "ppc64le",
            "os": "linux"
         }
      },
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:07afab708df2326e8503aff2f860584f2bfe7a95aee839c8806897e808508e12",
         "platform": {
            "architecture": "s390x",
            "os": "linux"
         }
      }
   ]
}

这里的 alpine:3.17 就是一个多架构镜像,从输出结果可以看到 mediaTypeapplication/vnd.docker.distribution.manifest.list.v2+json,说明这个镜像标签关联的是一个 manifest list,它包含了多个 manifest,支持 amd64、arm/v6、arm/v7、arm64、i386、ppc64le、s390x 多个架构。我们也可以直接在 Docker Hub 上看到这些信息:

alpine-image.png

很显然,在我们这个混合架构的 Kubernetes 集群中,这个镜像是可以直接运行的。我们也可以将我们的应用构建成这样的多架构镜像,那么在这个 Kubernetes 集群中就可以自由地运行我们自己的应用了,这种方法比上面那种为每个架构构建一个镜像的方法要优雅得多。

那么,我们要如何构建这样的多架构镜像呢?一般来说,如果你使用 Docker 作为你的构建工具,通常有两种方法:docker manifestdocker buildx

使用 docker manifest 创建多架构镜像

docker build 是最常用的镜像构建命令,首先,我们创建一个 Dockerfile 文件,内容如下:

FROM alpine:3.17
CMD ["echo", "Hello"]

然后使用 docker build 构建镜像:

$ docker build -f Dockerfile -t aneasystone/demo:v1 .

这样一个简单的镜像就构建好了,使用 docker run 对其进行测试:

$ docker run --rm -it aneasystone/demo:v1
Hello

非常顺利,镜像能正常运行。不过这样构建的镜像有一个问题,Docker Engine 是根据当前我们的系统自动拉取基础镜像的,我的系统是 x86 的,所以拉取的 alpine:3.17 镜像架构是 linux/amd64 的:

$ docker image inspect alpine:3.17 | grep Architecture

        "Architecture": "amd64",

如果要构建其他架构的镜像,可以有三种办法。第一种是最原始的方法,Docker 官方为每种 不同的架构创建了不同的独立账号,比如下面是一些常用的账号:

所以我们就可以通过 amd64/alpinearm64v8/alpine 来拉取相应架构的镜像,我们对 Dockerfile 文件稍微修改一下:

ARG ARCH=amd64
FROM ${ARCH}/alpine:3.17
CMD ["echo", "Hello"]

然后使用 --build-arg 参数来构建不同架构的镜像:

$ docker build --build-arg ARCH=amd64 -f Dockerfile-arg -t aneasystone/demo:v1-amd64 .
$ docker build --build-arg ARCH=arm64v8 -f Dockerfile-arg -t aneasystone/demo:v1-arm64 .

不过从 2017 年 9 月开始,一个镜像可以支持多个架构了,这种方法就渐渐不用了。第二种办法就是直接使用 alpine:3.17 这个基础镜像,通过 FROM 指令的 --platform 参数,让 Docker Engine 自动拉取特定架构的镜像。我们新建两个文件 Dockerfile-amd64Dockerfile-arm64Dockerfile-amd64 文件内容如下:

FROM --platform=linux/amd64 alpine:3.17
CMD ["echo", "Hello"]

Dockerfile-arm64 文件内容如下:

FROM --platform=linux/arm64 alpine:3.17
CMD ["echo", "Hello"]

然后使用 docker build 再次构建镜像即可:

$ docker build --pull -f Dockerfile-amd64 -t aneasystone/demo:v1-amd64 .
$ docker build --pull -f Dockerfile-arm64 -t aneasystone/demo:v1-arm64 .

注意这里的 --pull 参数,强制要求 Docker Engine 拉取基础镜像,要不然第二次构建时会使用第一次的缓存,这样基础镜像就不对了。

第三种方法不用修改 Dockerfile 文件,因为 docker build 也支持 --platform 参数,我们只需要像下面这样构建镜像即可:

$ docker build --pull --platform=linux/amd64 -f Dockerfile -t aneasystone/demo:v1-amd64 .
$ docker build --pull --platform=linux/arm64 -f Dockerfile -t aneasystone/demo:v1-arm64 .

在执行 docker build 命令时,可能会遇到下面这样的报错信息:

$ docker build -f Dockerfile-arm64 -t aneasystone/demo:v1-arm64 .
[+] Building 1.2s (3/3) FINISHED
 => [internal] load build definition from > Dockerfile-arm64                   0.0s
 => => transferring dockerfile: > 37B                                          0.0s
 => [internal] load .> dockerignore                                            0.0s
 => => transferring context: > 2B                                              0.0s
 => ERROR [internal] load metadata for docker.io/library/alpine:3.> 17         1.1s
------
 > [internal] load metadata for docker.io/library/alpine:3.17:
------
failed to solve with frontend dockerfile.v0: failed to create LLB > definition: unexpected status code [manifests 3.17]: 403 Forbidden

根据 这里 的信息,修改 Docker Daemon 的配置文件,将 buildkit 设置为 false 即可:

  "features": {
    "buildkit": false
  },

构建完不同架构的镜像后,我们就可以使用 docker manifest 命令创建 manifest list,生成自己的多架构镜像了。由于目前创建 manifest list 必须引用远程仓库中的镜像,所以在这之前,我们需要先将刚刚生成的两个镜像推送到镜像仓库中:

$ docker push aneasystone/demo:v1-amd64
$ docker push aneasystone/demo:v1-arm64

然后使用 docker manifest create 创建一个 manifest list,包含我们的两个镜像:

$ docker manifest create aneasystone/demo:v1 \
    --amend aneasystone/demo:v1-amd64 \
    --amend aneasystone/demo:v1-arm64

最后将该 manifest list 也推送到镜像仓库中就大功告成了:

$ docker manifest push aneasystone/demo:v1

可以使用 docker manifest inspect 查看这个镜像的 manifest list 信息:

$ docker manifest inspect aneasystone/demo:v1
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
   "manifests": [
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:170c4a5295f928a248dc58ce500fdb5a51e46f17866369fdcf4cbab9f7e4a1ab",
         "platform": {
            "architecture": "amd64",
            "os": "linux"
         }
      },
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 528,
         "digest": "sha256:3bb9c02263447e63c193c1196d92a25a1a7171fdacf6a29156f01c56989cf88b",
         "platform": {
            "architecture": "arm64",
            "os": "linux",
            "variant": "v8"
         }
      }
   ]
}

也可以在 Docker Hub 上看到这个镜像的架构信息:

demo-image.png

使用 docker buildx 创建多架构镜像

从上一节可以看出,使用 docker manifest 来构建多架构镜像的步骤大致分为以下四步:

  1. 使用 docker build 依次构建每个架构的镜像;
  2. 使用 docker push 将镜像推送到镜像仓库;
  3. 使用 docker manifest create 创建一个 manifest list,包含上面的每个镜像;
  4. 使用 docker manifest push 将 manifest list 推送到镜像仓库;

每次构建多架构镜像都要经历这么多步骤还是非常麻烦的,这一节将介绍一种更方便的方式,使用 docker buildx 来创建多架构镜像。

buildx 是一款 Docker CLI 插件,它对 Moby BuildKit 的构建功能进行了大量的扩展,同时在使用体验上还保持和 docker build 一样,用户可以很快上手。如果你的系统是 Windows 或 MacOS,buildx 已经内置在 Docker Desktop 里了,无需额外安装;如果你的系统是 Linux,可以使用 DEB 或 RPM 包的形式安装,也可以手工安装,具体安装步骤参考 官方文档

使用 docker buildx 创建多架构镜像只需简单一行命令即可:

$ docker buildx build --platform=linux/amd64,linux/arm64 -t aneasystone/demo:v2 .

不过第一次执行这行命令时会报下面这样的错:

ERROR: multiple platforms feature is currently not supported for docker driver. Please switch to a different driver (eg. "docker buildx create --use")

这是因为 buildx 默认使用的 构建器( builder ) 驱动是 docker driver,它不支持同时构建多个 platform 的镜像,我们可以使用 docker buildx create 创建其他驱动的构建器( 关于 buildx 的四种驱动以及它们支持的特性可以 参考这里 ):

$ docker buildx create --use
nice_cartwright

这样创建的构建器驱动是 docker-container driver,它目前还没有启动:

$ docker buildx ls
NAME/NODE          DRIVER/ENDPOINT                STATUS   BUILDKIT PLATFORMS
nice_cartwright *  docker-container
  nice_cartwright0 npipe:////./pipe/docker_engine inactive
default            docker
  default          default                        running  20.10.17 linux/amd64, linux/arm64, ...

当执行 docker buildx build 时会自动启动构建器:

$ docker buildx build --platform=linux/amd64,linux/arm64 -t aneasystone/demo:v2 .
[+] Building 14.1s (7/7) FINISHED
 => [internal] booting buildkit                                                                                                            1.2s 
 => => starting container buildx_buildkit_nice_cartwright0                                                                                 1.2s 
 => [internal] load build definition from Dockerfile                                                                                       0.1s 
 => => transferring dockerfile: 78B                                                                                                        0.0s 
 => [internal] load .dockerignore                                                                                                          0.0s 
 => => transferring context: 2B                                                                                                            0.0s 
 => [linux/amd64 internal] load metadata for docker.io/library/alpine:3.17                                                                12.3s 
 => [linux/arm64 internal] load metadata for docker.io/library/alpine:3.17                                                                12.2s 
 => [linux/arm64 1/1] FROM docker.io/library/alpine:3.17@sha256:f271e74b17ced29b915d351685fd4644785c6d1559dd1f2d4189a5e851ef753a           0.2s 
 => => resolve docker.io/library/alpine:3.17@sha256:f271e74b17ced29b915d351685fd4644785c6d1559dd1f2d4189a5e851ef753a                       0.1s 
 => [linux/amd64 1/1] FROM docker.io/library/alpine:3.17@sha256:f271e74b17ced29b915d351685fd4644785c6d1559dd1f2d4189a5e851ef753a           0.2s 
 => => resolve docker.io/library/alpine:3.17@sha256:f271e74b17ced29b915d351685fd4644785c6d1559dd1f2d4189a5e851ef753a                       0.1s 
WARNING: No output specified with docker-container driver. Build result will only remain in the build cache. To push result image into registry use --push or to load image into docker use --load

使用 docker ps 可以看到正在运行的构建器,实际上就是 buildkitd 服务docker buildx build 为我们自动下载了 moby/buildkit:buildx-stable-1 镜像并运行:

$ docker ps
CONTAINER ID   IMAGE                           COMMAND       CREATED         STATUS         PORTS     NAMES
e776505153c0   moby/buildkit:buildx-stable-1   "buildkitd"   7 minutes ago   Up 7 minutes             buildx_buildkit_nice_cartwright0

上面的构建结果中有一行 WARNING 信息,意思是我们没有指定 output 参数,所以构建的结果只存在于构建缓存中,如果要将构建的镜像推送到镜像仓库,可以加上一个 --push 参数:

$ docker buildx build --push --platform=linux/amd64,linux/arm64 -t aneasystone/demo:v2 .
[+] Building 14.4s (10/10) FINISHED
 => [internal] load build definition from Dockerfile                                                                                       0.1s 
 => => transferring dockerfile: 78B                                                                                                        0.0s 
 => [internal] load .dockerignore                                                                                                          0.0s 
 => => transferring context: 2B                                                                                                            0.0s 
 => [linux/arm64 internal] load metadata for docker.io/library/alpine:3.17                                                                 9.1s 
 => [linux/amd64 internal] load metadata for docker.io/library/alpine:3.17                                                                 9.0s 
 => [auth] library/alpine:pull token for registry-1.docker.io                                                                              0.0s 
 => [linux/arm64 1/1] FROM docker.io/library/alpine:3.17@sha256:f271e74b17ced29b915d351685fd4644785c6d1559dd1f2d4189a5e851ef753a           0.1s 
 => => resolve docker.io/library/alpine:3.17@sha256:f271e74b17ced29b915d351685fd4644785c6d1559dd1f2d4189a5e851ef753a                       0.1s 
 => [linux/amd64 1/1] FROM docker.io/library/alpine:3.17@sha256:f271e74b17ced29b915d351685fd4644785c6d1559dd1f2d4189a5e851ef753a           0.1s 
 => => resolve docker.io/library/alpine:3.17@sha256:f271e74b17ced29b915d351685fd4644785c6d1559dd1f2d4189a5e851ef753a                       0.1s 
 => exporting to image                                                                                                                     5.1s 
 => => exporting layers                                                                                                                    0.0s 
 => => exporting manifest sha256:4463076cf4b016381c6722f6cce481e015487b35318ccc6dc933cf407c212b11                                          0.0s 
 => => exporting config sha256:6057d58c0c6df1fbc55d89e1429ede402558ad4f9a243b06d81e26a40d31eb0d                                            0.0s 
 => => exporting manifest sha256:05276d99512d2cdc401ac388891b0735bee28ff3fc8e08be207a0ef585842cef                                          0.0s 
 => => exporting config sha256:86506d4d3917a7bb85cd3d147e651150b83943ee89199777ba214dd359d30b2e                                            0.0s 
 => => exporting manifest list sha256:a26956bd9bd966b50312b4a7868d8461d596fe9380652272db612faef5ce9798                                     0.0s 
 => => pushing layers                                                                                                                      3.0s 
 => => pushing manifest for docker.io/aneasystone/demo:v2@sha256:a26956bd9bd966b50312b4a7868d8461d596fe9380652272db612faef5ce9798          2.0s 
 => [auth] aneasystone/demo:pull,push token for registry-1.docker.io                                                                       0.0s 
 => [auth] aneasystone/demo:pull,push library/alpine:pull token for registry-1.docker.io   

访问 Docker Hub,可以看到我们的镜像已经成功推送到仓库中了:

demo-v2-image.png

参考

  1. Faster Multi-platform builds: Dockerfile cross-compilation guide (Part 1)
  2. Multi-arch build and images, the simple way
  3. 如何使用 docker buildx 构建跨平台 Go 镜像
  4. 构建多种系统架构支持的 Docker 镜像
  5. 使用buildx来构建支持多平台的Docker镜像(Mac系统)
  6. 使用 Docker Buildx 构建多种系统架构镜像
  7. 使用 buildx 构建多平台 Docker 镜像
  8. 基于QEMU和binfmt-misc透明运行不同架构程序
  9. 多架构镜像三部曲(一)组合
  10. 多架构镜像三部曲(二)构建

更多

使用 QEMU 运行不同架构的程序

在构建好多个架构的镜像之后,我们可以使用 docker run 测试一下:

$ docker run --rm -it aneasystone/demo:v1-amd64
Hello

$ docker run --rm -it aneasystone/demo:v1-arm64
WARNING: The requested image's platform (linux/arm64/v8) does not match the detected host platform (linux/amd64) and no specific platform was requested
Hello

这里可以发现一个非常奇怪的现象,我们的系统明明不是 arm64 的,为什么 arm64 的镜像也能正常运行呢?除了一行 WARNING 信息之外,看上去并没有异样,而且我们也可以使用 sh 进到容器内部正常操作:

> docker run --rm -it aneasystone/demo:v1-arm64 sh
WARNING: The requested image's platform (linux/arm64/v8) does not match the detected host platform (linux/amd64) and no specific platform was requested
/ # ls
bin    dev    etc    home   lib    media  mnt    opt    proc   root   run    sbin   srv    sys    tmp    usr    var
/ #

不过当我们执行 ps 命令时,发现了一些端倪:

/ # ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 {sh} /usr/bin/qemu-aarch64 /bin/sh sh
    8 root      0:00 ps aux

可以看出我们所执行的 sh 命令实际上被 /usr/bin/qemu-aarch64 转换了,而 QEMU 是一款强大的模拟器,可以在 x86 机器上模拟 arm 的指令。关于 QEMU 执行跨架构程序可以参考 这篇文章

查看镜像的 manifest 信息

除了 docker manifest 命令,还有很多其他方法也可以查看镜像的 manifest 信息,比如:

buildx 支持的几种输出类型

在上文中,我们使用了 --push 参数将镜像推送到镜像仓库中:

$ docker buildx build --push --platform=linux/amd64,linux/arm64 -t aneasystone/demo:v2 .

这个命令实际上等同于:

$ docker buildx build --output=type=image,name=aneasystone/demo:v2,push=true --platform=linux/amd64,linux/arm64 .

也等同于:

$ docker buildx build --output=type=registry,name=aneasystone/demo:v2 --platform=linux/amd64,linux/arm64 .

我们通过 --output 参数指定镜像的输出类型,这又被称为 导出器( exporter )buildx 支持如下几种不同的导出器:

  • image - 将构建结果导出到镜像
  • registry - 将构建结果导出到镜像,并推送到镜像仓库
  • local - 将构建的文件系统导出成本地目录
  • tar - 将构建的文件系统打成 tar 包
  • oci - 构建 OCI 镜像格式 的镜像
  • docker - 构建 Docker 镜像格式 的镜像
  • cacheonly - 将构建结果放在构建缓存中

其中 imageregistry 这两个导出器上面已经用过,一般用来将镜像推送到远程镜像仓库。如果我们只想构建本地镜像,而不希望将其推送到远程镜像仓库,可以使用 ocidocker 导出器,比如下面的命令使用 docker 导出器将构建结果导出成本地镜像:

$ docker buildx build --output=type=docker,name=aneasystone/demo:v2-amd64 --platform=linux/amd64 .

也可以使用 docker 导出器将构建结果导出成 tar 文件:

$ docker buildx build --output=type=docker,dest=./demo-v2-docker.tar --platform=linux/amd64 .

这个 tar 文件可以通过 docker load 加载:

$ docker load -i ./demo-v2-docker.tar

因为我本地运行的是 Docker 服务,不支持 OCI 镜像格式,所以指定 type=oci 时会报错:

$ docker buildx build --output=type=oci,name=aneasystone/demo:v2-amd64 --platform=linux/amd64 .
ERROR: output file is required for oci exporter. refusing to write to console

不过我们可以将 OCI 镜像导出成 tar 包:

$ docker buildx build --output=type=oci,dest=./demo-v2-oci.tar --platform=linux/amd64 .

将这个 tar 包解压后,可以看到一个标准的镜像是什么格式:

$ mkdir demo-v2-docker && tar -C demo-v2-docker -xf demo-v2-docker.tar
$ tree demo-v2-docker
demo-v2-docker
├── blobs
│   └── sha256
│       ├── 4463076cf4b016381c6722f6cce481e015487b35318ccc6dc933cf407c212b11
│       ├── 6057d58c0c6df1fbc55d89e1429ede402558ad4f9a243b06d81e26a40d31eb0d
│       └── 8921db27df2831fa6eaa85321205a2470c669b855f3ec95d5a3c2b46de0442c9
├── index.json
├── manifest.json
└── oci-layout

2 directories, 6 files

有一点奇怪的是,OCI 镜像格式的 tar 包和 docker 镜像格式的 tar 包是完全一样的,不知道怎么回事?

如果我们不关心构建结果,而只是想看下构建镜像的文件系统,比如看看它的目录结构是什么样的,或是看看有没有我们需要的文件,可以使用 localtar 导出器。local 导出器将文件系统导到本地的目录:

$ docker buildx build --output=type=local,dest=./demo-v2 --platform=linux/amd64 .

tar 导出器将文件系统导到一个 tar 文件中:

$ docker buildx build --output=type=tar,dest=./demo-v2.tar --platform=linux/amd64 .

值得注意的是,这个 tar 文件并不是标准的镜像格式,所以我们不能使用 docker load 加载,但是我们可以使用 docker import 加载,加载的镜像中只有文件系统,在运行这个镜像时,Dockerfile 中的 CMDENTRYPOINT 等命令是不会生效的:

$ mkdir demo-v2 && tar -C demo-v2 -xf demo-v2.tar
$ ls demo-v2
bin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

不安全的镜像仓库

在上文中,我们使用了两种方法构建了多架构镜像,并将镜像推送到官方的 Docker Hub 仓库,如果需要推送到自己搭建的镜像仓库( 关于如何搭建自己的镜像仓库,可以参考 我的另一篇博客 ),由于这个仓库可能是不安全的,可能会遇到一些问题。

第一种方式是直接使用 docker push 推送,推送前我们需要修改 Docker 的配置文件 /etc/docker/daemon.json,将仓库地址添加到 insecure-registries 配置项中:

{
  "insecure-registries" : ["192.168.1.39:5000"]
}

然后重启 Docker 后即可。

第二种方式是使用 docker buildximageregistry 导出器推送,这个推送工作实际上是由 buildkitd 完成的,所以我们需要让 buildkitd 忽略这个不安全的镜像仓库。我们首先创建一个配置文件 buildkitd.toml

[registry."192.168.1.39:5000"]
  http = true
  insecure = true

关于 buildkitd 的详细配置可以 参考这里。然后使用 docker buildx create 重新创建一个构建器:

$ docker buildx create --config=buildkitd.toml --use

这样就可以让 docker buildx 将镜像推送到不安全的镜像仓库了。

扫描二维码,在手机上阅读!

实战 Kubernetes 的动态扩缩容

Kubernetes 使用小记 中,我们学习了 Kubernetes 中的 Pod、Deployment 和 Service 的一些基础知识,还学习了如何通过 kubectl scale 命令对应用进行扩容或缩容,以及 Kubernetes 的滚动更新机制。

虽然通过 kubectl scale 命令可以实现扩缩容功能,但是这个操作需要运维人员手工进行干预,不仅可能处理不及时,而且还可能误操作导致生产事故。如果我们能够根据系统当前的运行状态自动进行扩缩容,比如当检测到某个应用负载过高时自动对其扩容,这样就可以给运维人员带来极大的方便。为此,Kubernetes 提供了一种新的资源对象:Horizontal Pod Autoscaling(Pod 水平自动伸缩,简称 HPA),HPA 通过监控 Pod 的负载变化来确定是否需要调整 Pod 的副本数量,从而实现动态扩缩容。

Metrics Server

为了实现动态扩缩容,首先我们需要对 Kubernetes 集群的负载情况进行监控,Kubernetes 从 v1.8 开始提出了 Metrics API 的概念来解决这个问题,官方认为核心指标的监控应该是稳定的,版本可控的,并且和其他的 Kubernetes API 一样,可以直接被用户访问(如:kubectl top 命令),或被集群中其他控制器使用(如:HPA),为此专门开发了 Metrics Server 组件。

我们知道 Kubernetes 会在每个节点上运行一个 Kubelet 进程,这个进程对容器进行生命周期的管理,实际上,它还有另外一个作用,那就是监控所在节点的资源使用情况,并且可以通过 Summary API 来查询。Metrics Server 就是通过聚合各个节点的 Kubelet 的 Summary API,然后对外提供 Metrics API,并通过 API Server 的 API 聚合层(API Aggregation Layer) 将接口以 Kubernetes API 的形式暴露给用户或其他程序使用:

resource-metrics-pipeline.png

按照官方文档,我们使用下面的命令安装 Metrics Server:

$ kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

安装之后,使用 kubectl get pods 可以看到 Metrics Server 虽然是 Running 状态,但是一直没有 Ready:

$ kubectl get pods -n kube-system
NAME                                     READY   STATUS    RESTARTS         AGE
metrics-server-847d45fd4f-rhsh4          0/1     Running   0                30s

使用 kubectl logs 查看日志报如下错误:

$ kubectl logs -f metrics-server-847d45fd4f-rhsh4 -n kube-system
E1119 04:47:02.299430       1 scraper.go:140] "Failed to scrape node" err="Get \"https://192.168.65.4:10250/metrics/resource\": x509: cannot validate certificate for 192.168.65.4 because it doesn't contain any IP SANs" node="docker-desktop"

这里的 /metrics/resource 接口就是 Kubelet 的 Summary API,这个报错的意思是证书校验没通过,因为证书中没有包含所请求的 IP 地址,我们不妨随便进一个 Pod 内部,用 openssl 看下这个证书的信息:

# openssl s_client -showcerts -connect 192.168.65.4:10250
CONNECTED(00000003)
depth=1 CN = docker-desktop-ca@1663714811
verify error:num=19:self signed certificate in certificate chain
verify return:0
---
Certificate chain
 0 s:/CN=docker-desktop@1663714811
   i:/CN=docker-desktop-ca@1663714811
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
 1 s:/CN=docker-desktop-ca@1663714811
   i:/CN=docker-desktop-ca@1663714811
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
---
Server certificate
subject=/CN=docker-desktop@1663714811
issuer=/CN=docker-desktop-ca@1663714811

可以看到这是一个自签名的证书,证书只签给了 docker-desktop@1663714811,没有签给 192.168.65.4 这个 IP,所以 Metrics Server 认为证书是无效的,为了解决这个问题,第一种方法是将这个证书加入 Metrics Server 的受信证书中,不过这种方法比较繁琐,另一种方法简单暴力,我们可以直接让 Metrics Server 跳过证书检查。我们将上面安装 Metrics Server 的那个 YAML 文件下载下来,在 Metrics Server 的启动参数中添加 --kubelet-insecure-tls

- args:
  - --cert-dir=/tmp
  - --secure-port=4443
  - --kubelet-insecure-tls
  - --kubelet-preferred-address-types=InternalIP,ExternalIP,Hostname
  - --kubelet-use-node-status-port
  - --metric-resolution=15s

然后使用 kubectl apply 重新安装:

$ kubectl apply -f metrics-server.yaml

再通过 kubectl get pods 确认 Metrics Server 已经成功运行起来了。这时,我们就可以通过 kubectl top 命令来获取 Kubernetes 集群状态了,比如查看所有节点的 CPU 和内存占用:

$ kubectl top nodes
NAME             CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
docker-desktop   489m         6%     1823Mi          29%

或者查看所有 Pod 的运行状态:

$ kubectl top pods -A
NAMESPACE     NAME                                     CPU(cores)   MEMORY(bytes)
default       kubernetes-bootcamp-857b45f5bb-hvkfx     0m           10Mi
kube-system   coredns-95db45d46-jx42f                  5m           23Mi
kube-system   coredns-95db45d46-nbvg9                  5m           62Mi
kube-system   etcd-docker-desktop                      48m          371Mi
kube-system   kube-apiserver-docker-desktop            61m          409Mi
kube-system   kube-controller-manager-docker-desktop   49m          130Mi
kube-system   kube-proxy-zwspl                         1m           62Mi
kube-system   kube-scheduler-docker-desktop            8m           68Mi
kube-system   metrics-server-5db9b4b966-zt6z4          6m           18Mi
kube-system   storage-provisioner                      4m           23Mi
kube-system   vpnkit-controller                        1m           8Mi

也可以查看某个节点的 CPU 和内存占用:

$ kubectl top node docker-desktop
NAME             CPU(cores)   CPU%   MEMORY(bytes)   MEMORY%
docker-desktop   425m         5%     1970Mi          31%

上面讲过,kubectl top 其实是通过 API Server 提供的接口来获取这些指标信息的,所以我们也可以直接通过接口来获取。上面的命令实际上就是调用了下面这个接口:

$ kubectl get --raw "/apis/metrics.k8s.io/v1beta1/nodes/docker-desktop" | jq '.'
{
  "kind": "NodeMetrics",
  "apiVersion": "metrics.k8s.io/v1beta1",
  "metadata": {
    "name": "docker-desktop",
    "creationTimestamp": "2022-11-19T14:57:48Z",
    "labels": {
      "beta.kubernetes.io/arch": "amd64",
      "beta.kubernetes.io/os": "linux",
      "kubernetes.io/arch": "amd64",
      "kubernetes.io/hostname": "docker-desktop",
      "kubernetes.io/os": "linux",
      "node-role.kubernetes.io/control-plane": "",
      "node.kubernetes.io/exclude-from-external-load-balancers": ""
    }
  },
  "timestamp": "2022-11-19T14:57:38Z",
  "window": "12.21s",
  "usage": {
    "cpu": "476038624n",
    "memory": "2008740Ki"
  }
}

我们也可以查看某个 Pod 的运行状态:

$ kubectl top pod kubernetes-bootcamp-857b45f5bb-hvkfx
NAME                                   CPU(cores)   MEMORY(bytes)
kubernetes-bootcamp-857b45f5bb-hvkfx   0m           20Mi

类似的,这个命令和下面这个接口是一样的:

$ kubectl get --raw "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods/kubernetes-bootcamp-857b45f5bb-hvkfx" | jq '.'
{
  "kind": "PodMetrics",
  "apiVersion": "metrics.k8s.io/v1beta1",
  "metadata": {
    "name": "kubernetes-bootcamp-857b45f5bb-hvkfx",
    "namespace": "default",
    "creationTimestamp": "2022-11-19T14:59:11Z",
    "labels": {
      "app": "kubernetes-bootcamp",
      "pod-template-hash": "857b45f5bb"
    }
  },
  "timestamp": "2022-11-19T14:59:01Z",
  "window": "15.038s",
  "containers": [
    {
      "name": "kubernetes-bootcamp",
      "usage": {
        "cpu": "0",
        "memory": "21272Ki"
      }
    }
  ]
}

基于 CPU 自动扩缩容

Metrics Server 安装之后,我们就可以创建 HPA 来对 Pod 自动扩缩容了。首先我们使用 jocatalin/kubernetes-bootcamp:v1 镜像创建一个 Deployment:

$ kubectl create deployment kubernetes-bootcamp --image=jocatalin/kubernetes-bootcamp:v1
deployment.apps/kubernetes-bootcamp created

然后再执行 kubectl autoscale 命令创建一个 HPA:

$ kubectl autoscale deployment kubernetes-bootcamp --cpu-percent=10 --min=1 --max=10
horizontalpodautoscaler.autoscaling/kubernetes-bootcamp autoscaled

上面的命令为 kubernetes-bootcamp 这个 Deployment 创建了一个 HPA,其中 --cpu-percent=10 参数表示 HPA 会根据 CPU 使用率来动态调整 Pod 数量,当 CPU 占用超过 10% 时,HPA 就会自动对 Pod 进行扩容,当 CPU 占用低于 10% 时,HPA 又会自动对 Pod 进行缩容,而且扩缩容的大小由 --min=1 --max=10 参数限定,最小副本数为 1,最大副本数为 10。

除了 kubectl autoscale 命令,我们也可以使用下面的 YAML 来创建 HPA:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: kubernetes-bootcamp
  namespace: default
spec:
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - resource:
      name: cpu
      target:
        averageUtilization: 10
        type: Utilization
    type: Resource
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: kubernetes-bootcamp

创建好 HPA 之后,可以使用 kubectl get hpa 进行查看:

$ kubectl get hpa
NAME                  REFERENCE                        TARGETS         MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp   Deployment/kubernetes-bootcamp   <unknown>/10%   1         10        0          29s

这里的 TARGETS 一列表示 当前 CPU 占用 / 目标 CPU 占用,可以看到这里貌似有点问题,当前 CPU 占用显示的是 <unknown>,我们执行 kubectl describe hpa 看下这个 HPA 的详情:

$ kubectl describe hpa kubernetes-bootcamp
Name:                                                  kubernetes-bootcamp
Namespace:                                             default
Labels:                                                <none>
Annotations:                                           <none>
CreationTimestamp:                                     Sun, 20 Nov 2022 10:51:00 +0800
Reference:                                             Deployment/kubernetes-bootcamp
Metrics:                                               ( current / target )
  resource cpu on pods  (as a percentage of request):  <unknown> / 10%
Min replicas:                                          1
Max replicas:                                          10
Deployment pods:                                       1 current / 0 desired
Conditions:
  Type           Status  Reason                   Message
  ----           ------  ------                   -------
  AbleToScale    True    SucceededGetScale        the HPA controller was able to get the target's current scale
  ScalingActive  False   FailedGetResourceMetric  the HPA was unable to compute the replica count: failed to get cpu utilization: missing request for cpu

从详情中可以看到报错信息 failed to get cpu utilization: missing request for cpu,这是因为我们上面创建 Deployment 时,没有为 Pod 对象配置资源请求,这样 HPA 就不知道 Pod 运行需要多少 CPU,也就无法计算 CPU 的利用率了,所以如果要想让 HPA 生效,对应的 Pod 必须添加资源请求声明。我们使用 YAML 文件重新创建 Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: kubernetes-bootcamp
  name: kubernetes-bootcamp
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: kubernetes-bootcamp
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: kubernetes-bootcamp
    spec:
      containers:
      - image: jocatalin/kubernetes-bootcamp:v1
        imagePullPolicy: IfNotPresent
        name: kubernetes-bootcamp
        resources:
          requests: 
            memory: 50Mi
            cpu: 50m
      restartPolicy: Always

在上面的 YAML 文件中,我们使用 resources.requests.cpuresources.requests.memory 声明了运行这个 Pod 至少需要 50m 的 CPU 和 50MiB 内存。稍等片刻,就能看到 HPA 状态已经正常了:

$ kubectl get hpa
NAME                  REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp   Deployment/kubernetes-bootcamp   0%/10%    1         10        1          65m

接下来,我们对 Pod 进行简单的压测,使用 kubectl exec 进入容器中:

$ kubectl exec -it pod/kubernetes-bootcamp-69d7dddfc-k9wj7 -- bash

并使用一个 while 循环对 Pod 不断发起请求:

root@kubernetes-bootcamp-69d7dddfc-k9wj7:/# while true; do wget -q -O- http://localhost:8080; done

很快,Pod 的 CPU 占用就开始飙升了,而且能看到副本数量也开始不断增加:

$ kubectl get hpa
NAME                  REFERENCE                        TARGETS    MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp   Deployment/kubernetes-bootcamp   219%/10%   1         10        10         93m

一段时间之后,副本数量增加到 10 个,并不再增加:

$ kubectl get pods
NAME                                  READY   STATUS    RESTARTS   AGE
kubernetes-bootcamp-69d7dddfc-9ws7c   1/1     Running   0          2m5s
kubernetes-bootcamp-69d7dddfc-bbpfv   1/1     Running   0          3m6s
kubernetes-bootcamp-69d7dddfc-bqlhj   1/1     Running   0          3m6s
kubernetes-bootcamp-69d7dddfc-fzlnq   1/1     Running   0          2m6s
kubernetes-bootcamp-69d7dddfc-jkx9g   1/1     Running   0          65s
kubernetes-bootcamp-69d7dddfc-k9wj7   1/1     Running   0          28m
kubernetes-bootcamp-69d7dddfc-l5bf7   1/1     Running   0          2m5s
kubernetes-bootcamp-69d7dddfc-q4b5f   1/1     Running   0          2m5s
kubernetes-bootcamp-69d7dddfc-ttptc   1/1     Running   0          65s
kubernetes-bootcamp-69d7dddfc-vtjqj   1/1     Running   0          3m6s

然后我们再停止发起请求,等待一段时间,Pod 数量又重新恢复如初:

$ kubectl get hpa
NAME                  REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp   Deployment/kubernetes-bootcamp   0%/10%    1         10        1          101m

基于内存自动扩缩容

目前,HPA 有两个正式版本:v1 和 v2,v2 又有两个 beta 版本,v2beta1 和 v2beta2,这两个 beta 版本在最新的 Kubernetes 版本中都已经废弃。HPA v1 版本 只支持基于 CPU 的自动扩缩容,如果要使用基于内存的自动扩缩容,必须使用 HPA v2 版本

创建基于内存的 HPA 和基于 CPU 的 HPA 几乎完全一样,我们只需要将 resource 名称改为 memory 即可:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: kubernetes-bootcamp
  namespace: default
spec:
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - resource:
      name: memory
      target:
        averageUtilization: 60
        type: Utilization
    type: Resource
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: kubernetes-bootcamp

创建好的 HPA 如下:

$ kubectl get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp-mem   Deployment/kubernetes-bootcamp   22%/60%   1         10        1          4m17s

接下来可以想办法提高 Pod 的内存占用,在 Kubernetes HPA 使用详解 这篇文章中作者使用了一种比较有趣的方法可供参考。首先需要开启 Pod 的特权模式 securityContext.privileged: true,然后进入 Pod 执行下面的命令:

# mkdir /tmp/memory
# mount -t tmpfs -o size=40M tmpfs /tmp/memory
# dd if=/dev/zero of=/tmp/memory/block
dd: writing to '/tmp/memory/block': No space left on device
81921+0 records in
81920+0 records out
41943040 bytes (42 MB) copied, 0.11175 s, 375 MB/s

原理很简单,通过向 tmpfs 中写入数据来模拟内存占用,执行之后,可以看到 Pod 内存占用变成了差不多 100%,并且触发了 HPA 的动态扩容:

$ kubectl get hpa
NAME                      REFERENCE                        TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
kubernetes-bootcamp-mem   Deployment/kubernetes-bootcamp   99%/60%   1         10        2          4h8m

如果要释放内存占用,执行下面的命令即可:

# umount /tmp/memory

基于自定义指标自动扩缩容

上面提到过,Metrics Server 其实是通过 API Server 将自己的接口暴露出去的,这个接口的地址一般以 /apis/metrics.k8s.io/v1beta1/ 作为前缀,这个接口又被称为 Resource Metrics API,它的作用是暴露诸如 CPU 或内存等核心指标。但是仅仅有 CPU 或内存信息往往不能满足某些自动扩缩容的场景,比如要根据应用的 QPS 来自动扩容,就得使用 QPS 这个自定义指标。为了让 HPA 支持更多的其他指标,人们很快又提出了 Custom Metrics APIExternal Metrics API 规范,而 Prometheus Adapter 就是该规范最常见的一个实现。

这一节将学习如何使用 Prometheus Adapter 来实现自定义指标的自动扩缩容。

部署一个带指标的应用

我们直接使用 Spring Boot 生产就绪特性 Actuator 中的例子。首先需要将其构建成 Docker 镜像并推送到 DockerHub,镜像命名为 aneasystone/hello-actuator:v1

$ cd week014-spring-boot-actuator/demo
$ docker build -t aneasystone/hello-actuator:v1 .
$ docker push aneasystone/hello-actuator:v1

然后编写 hello-actuator.yaml 文件,将该镜像部署到我们的 Kubernetes 集群:

$ kubectl apply -f ./hello-actuator.yaml

查看部署的应用:

$ kubectl get pods -l app=hello-actuator
NAME                             READY   STATUS    RESTARTS   AGE
hello-actuator-b49545c55-l9m59   1/1     Running   0          2m43s

查看应用端口:

$ kubectl get svc -l app=hello-actuator
NAME             TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
hello-actuator   NodePort   10.102.39.146   <none>        8080:31086/TCP   3m39s

我们通过 31086 端口访问应用的 /hello 接口,确保应用能正常访问:

$ curl -s http://localhost:31086/hello
hello

查看 Prometheus 端点,可以看到有一个 counter 类型的指标 hello_counter_total,该指标表示接口的调用总数,每次请求 /hello 接口时指标的值就会自增:

$ curl -s http://localhost:31086/actuator/prometheus | grep hello_counter
# HELP hello_counter_total
# TYPE hello_counter_total counter
hello_counter_total{app="demo",} 1.0

部署 Prometheus Operator

接下来继续部署 Prometheus Operator,参考官方的 快速入门,我们首先下载 kube-prometheus 源码:

$ git clone https://github.com/prometheus-operator/kube-prometheus.git

然后第一步将 Prometheus Operator 相关的命名空间和 CRD 创建好:

$ kubectl create -f manifests/setup

接着部署 Prometheus Operator 其他组件:

$ kubectl create -f manifests/

等待一段时间,确定所有组件都启动完成:

$ kubectl get pods -n monitoring
NAME                                   READY   STATUS             RESTARTS      AGE
alertmanager-main-0                    2/2     Running            0             4m11s
alertmanager-main-1                    2/2     Running            1 (91s ago)   4m11s
alertmanager-main-2                    2/2     Running            1 (85s ago)   4m11s
blackbox-exporter-59cccb5797-fljpj     3/3     Running            0             7m16s
grafana-7b8db9f4d-tk6b9                1/1     Running            0             7m15s
kube-state-metrics-6d68f89c45-2klv4    3/3     Running            0             7m15s
node-exporter-2b6hn                    1/2     CrashLoopBackOff   4 (73s ago)   7m14s
prometheus-adapter-757f9b4cf9-j8qzd    1/1     Running            0             7m13s
prometheus-adapter-757f9b4cf9-tmdt2    1/1     Running            0             7m13s
prometheus-k8s-0                       1/2     Running            0             4m9s
prometheus-k8s-1                       2/2     Running            0             4m9s
prometheus-operator-67f59d65b8-tvdxr   2/2     Running            0             7m13s

我们看到除 node-exporter 之外,其他的组件都已经启动成功了。可以使用 kubectl describe 看下 node-exporter 启动详情:

$ kubectl describe pod node-exporter-2b6hn -n monitoring
...
Events:
  Type     Reason     Age                    From               Message
  ----     ------     ----                   ----               -------
  Normal   Scheduled  8m57s                  default-scheduler  Successfully assigned monitoring/node-exporter-2b6hn to docker-desktop
  Normal   Pulling    8m53s                  kubelet            Pulling image "quay.io/prometheus/node-exporter:v1.4.0"
  Normal   Pulled     6m12s                  kubelet            Successfully pulled image "quay.io/prometheus/node-exporter:v1.4.0" in 2m41.2451104s
  Normal   Pulling    6m11s                  kubelet            Pulling image "quay.io/brancz/kube-rbac-proxy:v0.13.1"
  Normal   Pulled     4m28s                  kubelet            Successfully pulled image "quay.io/brancz/kube-rbac-proxy:v0.13.1" in 1m43.3407752s
  Normal   Created    4m28s                  kubelet            Created container kube-rbac-proxy
  Normal   Started    4m27s                  kubelet            Started container kube-rbac-proxy
  Warning  Failed     4m14s                  kubelet            Error: failed to start container "node-exporter": Error response from daemon: path / is mounted on / but it is not a shared or slave mount
  Warning  BackOff    3m11s (x6 over 4m26s)  kubelet            Back-off restarting failed container
  Normal   Created    2m56s (x5 over 6m11s)  kubelet            Created container node-exporter
  Warning  Failed     2m56s (x4 over 6m11s)  kubelet            Error: failed to start container "node-exporter": Error response from daemon: path /sys is mounted on /sys but it is not a shared or slave mount
  Normal   Pulled     2m56s (x4 over 4m27s)  kubelet            Container image "quay.io/prometheus/node-exporter:v1.4.0" already present on machine

从 Events 中我们可以看到这样的错误日志:path / is mounted on / but it is not a shared or slave mount,于是搜索这个错误日志,在 prometheus-community/helm-charts 项目的 Issues 中我们找到了一个非常类似的问题 Issue-467,查看下面的解决办法是将 nodeExporter.hostRootfs 设置为 false。不过我们这里不是使用 Helm 安装的,于是查看它的源码,了解到这个参数实际上就是将 node-exporter 的启动参数 --path.rootfs=/host/root 以及相关的一些挂载去掉而已,于是打开 nodeExporter-daemonset.yaml 文件,删掉下面这些内容:

...
        - --path.sysfs=/host/sys
        - --path.rootfs=/host/root
...
        volumeMounts:
        - mountPath: /host/sys
          mountPropagation: HostToContainer
          name: sys
          readOnly: true
        - mountPath: /host/root
          mountPropagation: HostToContainer
          name: root
          readOnly: true
...
      volumes:
      - hostPath:
          path: /sys
        name: sys
      - hostPath:
          path: /
        name: root

然后重新部署 node-exporter 即可:

$ kubectl apply -f manifests/nodeExporter-daemonset.yaml

至此,Prometheus Operator 我们就部署好了。如果要访问 Prometheus,可以使用 kubectl port-forward 将其端口暴露出来:

$ kubectl port-forward svc/prometheus-k8s 9090 -n monitoring

然后在浏览器中访问 http://localhost:9090/ 即可。

不过这个时候 Prometheus 还不知道怎么去抓取我们的应用指标,我们需要创建 PodMonitorServiceMonitor 对象告诉 Prometheus 去哪里抓取我们的 Pod 或 Service 暴露的指标,我们不妨创建一个 ServiceMonitor 试试:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  labels:
    app: hello-actuator
  name: hello-actuator
  namespace: default
spec:
  endpoints:
  - interval: 30s
    port: 8080-8080
    path: /actuator/prometheus
  jobLabel: hello-actuator
  namespaceSelector:
    matchNames:
    - default
  selector:
    matchLabels:
      app: hello-actuator

创建之后,就能在 Prometheus 的 Targets 页面看到我们的应用了:

prometheus-targets.png

然后我们运行一段简单的脚本对应用进行测试,每隔 1s 发起一次请求:

$ while true; do wget -q -O- http://localhost:31086/hello; sleep 1; done

持续一段时间后,就能在 Prometheus 的 Graph 页面看到 hello_counter_total 指标在持续增加:

hello-counter-total.png

部署 Prometheus Adapter

其实在上面部署 Prometheus Operator 的时候,Prometheus Adapter 也已经一起部署了。而且我们可以打开 prometheusAdapter-apiService.yaml 文件看看,Prometheus Adapter 也提供了 metrics.k8s.io 指标 API 的实现,所以我们完全可以使用 Prometheus Adapter 来代替 Metrics Server。

我们修改这个文件,加上一个新的 APIService,用于实现 custom.metrics.k8s.io 自定义指标 API:

apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
  labels:
    app.kubernetes.io/component: metrics-adapter
    app.kubernetes.io/name: prometheus-adapter
    app.kubernetes.io/part-of: kube-prometheus
    app.kubernetes.io/version: 0.10.0
  name: v1beta1.metrics.k8s.io
spec:
  group: metrics.k8s.io
  groupPriorityMinimum: 100
  insecureSkipTLSVerify: true
  service:
    name: prometheus-adapter
    namespace: monitoring
  version: v1beta1
  versionPriority: 100
---
apiVersion: apiregistration.k8s.io/v1
kind: APIService
metadata:
  name: v1beta1.custom.metrics.k8s.io
spec:
  group: custom.metrics.k8s.io
  groupPriorityMinimum: 100
  insecureSkipTLSVerify: true
  service:
    name: prometheus-adapter
    namespace: monitoring
  version: v1beta1
  versionPriority: 100

使用下面的命令可以查看 metrics.k8s.io 接口:

$ kubectl get --raw /apis/metrics.k8s.io/v1beta1 | jq .
{
  "kind": "APIResourceList",
  "apiVersion": "v1",
  "groupVersion": "metrics.k8s.io/v1beta1",
  "resources": [
    {
      "name": "nodes",
      "singularName": "",
      "namespaced": false,
      "kind": "NodeMetrics",
      "verbs": [
        "get",
        "list"
      ]
    },
    {
      "name": "pods",
      "singularName": "",
      "namespaced": true,
      "kind": "PodMetrics",
      "verbs": [
        "get",
        "list"
      ]
    }
  ]
}

使用下面的命令可以查看 custom.metrics.k8s.io 接口:

$ kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | jq .
{
    "kind": "APIResourceList",
    "apiVersion": "v1",
    "groupVersion": "custom.metrics.k8s.io/v1beta1",
    "resources": []
}

这两个接口实际上都是被 API Server 转发到 prometheus-adapter 服务的,可以发现目前还没有自定义指标,这是因为我们还没有对 prometheus-adapter 进行配置。打开 prometheusAdapter-configMap.yaml 文件,在 config.yaml 中添加以下内容:

  config.yaml: |-
    "rules":
    - "seriesQuery": 'hello_counter_total{namespace!="",pod!=""}'
      "resources":
        "template": "<<.Resource>>"
      "name":
        "matches": "^(.*)_total"
        "as": "${1}_per_second"
      "metricsQuery": |
        sum by (<<.GroupBy>>) (
          irate (
            <<.Series>>{<<.LabelMatchers>>}[1m]
          )
        )

上面是一个简单的 Prometheus Adapter 规则,每个规则都包括了 4 个参数:

  • seriesQuery:这个参数指定要查询哪个 Prometheus 指标;
  • resources:这个参数指定要将指标和哪个 Kubernetes 资源进行关联;
  • name:为自定义指标进行重命名,由于这里我们要使用 RPS 来对容器组进行扩容,所以将指标重名为 hello_counter_per_second
  • metricsQuery:这个参数表示真实的 Prometheus 查询语句;我们使用 irate() 函数将请求总数指标变成了 RPS 指标;

这 4 个参数也分别对应 Prometheus Adapter 处理的 4 个步骤:发现(Discovery)分配(Association)命名(Naming)查询(Querying)。关于参数的详细说明可以参考 官方文档

然后我们更新配置文件,并重启 Prometheus Adapter:

$ kubectl apply -f manifests/prometheusAdapter-configMap.yaml
$ kubectl rollout restart deployment prometheus-adapter -n monitoring

再次查看 custom.metrics.k8s.io 接口,就能看到我们上面定义的自定义指标了:

$ kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | jq .
{
  "kind": "APIResourceList",
  "apiVersion": "v1",
  "groupVersion": "custom.metrics.k8s.io/v1beta1",
  "resources": [
    {
      "name": "pods/hello_counter_per_second",
      "singularName": "",
      "namespaced": true,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    },
    {
      "name": "services/hello_counter_per_second",
      "singularName": "",
      "namespaced": true,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    },
    {
      "name": "jobs.batch/hello_counter_per_second",
      "singularName": "",
      "namespaced": true,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    },
    {
      "name": "namespaces/hello_counter_per_second",
      "singularName": "",
      "namespaced": false,
      "kind": "MetricValueList",
      "verbs": [
        "get"
      ]
    }
  ]
}

我们还可以使用下面的命令查询 hello_counter_per_second 这个指标的值:

$ kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/hello_counter_per_second" | jq .
{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {},
  "items": [
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "hello-actuator-b49545c55-r6whf",
        "apiVersion": "/v1"
      },
      "metricName": "hello_counter_per_second",
      "timestamp": "2022-11-27T13:11:38Z",
      "value": "967m",
      "selector": null
    }
  ]
}

上面的 "value": "967m" 就是我们自定义指标的值,也就是接口的请求频率。这里要注意的是,指标使用的是 Kubernetes 风格的计量单位,被称为 Quantity967m 其实就是 0.967,这和我们每隔一秒请求一次是能对应上的。

为了验证这个值能准确地反应出我们接口的请求频率,我们不妨将上面那个测试脚本改成每隔 0.5s 发送一次请求:

$ while true; do wget -q -O- http://localhost:31086/hello; sleep 0.5; done

等待片刻之后,我们再次查询 hello_counter_per_second 指标:

$ kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/hello_counter_per_second" | jq .
{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {},
  "items": [
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "hello-actuator-b49545c55-r6whf",
        "apiVersion": "/v1"
      },
      "metricName": "hello_counter_per_second",
      "timestamp": "2022-11-27T13:42:57Z",
      "value": "1833m",
      "selector": null
    }
  ]
}

可以看到,指标的值差不多翻了一倍,和我们的请求频率完全一致。

在我的测试过程中发现了一个非常奇怪的现象,当部署完 Prometheus Operator 之后,整个集群的网络就好像出了问题,从 Pod 内部无法访问 Service 暴露的 IP 和端口。经过反复的调试和验证后发现,如果将 alertmanager-service.yamlprometheus-service.yaml 文件中的 sessionAffinity: ClientIP 配置删除掉就没有这个问题。目前尚不清楚具体原因。

部署 HPA 实现自动扩缩容

万事俱备,只欠东风。接下来,我们就可以通过 HPA 来实现自动扩缩容了,首先创建一个 HPA 如下:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: hello-actuator
  namespace: default
spec:
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - type: Pods
    pods:
      metric:
        name: hello_counter_per_second
      target:
        type: AverageValue
        averageValue: 2
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: hello-actuator

大体内容和上面基于 CPU 或内存的 HPA 是差不多的,只是 metrics 字段有一些区别:首先指标的类型不是 Resource 而是 Pods,其次 target 类型为 AverageValue(表示绝对值) 而不是 Utilization(表示百分比),而且要注意的是,Pods 类型的指标只支持 AverageValue

HPA 创建好了以后,我们通过 kubectl get hpa 查看该 HPA 的状态:

$ kubectl get hpa
NAME             REFERENCE                   TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
hello-actuator   Deployment/hello-actuator   966m/2    1         10        1          2m6s

注意看 TARGETS 列显示的 966m/2,其中分子部分 966m 表示当前我们应用的 RPS 为 0.966 次/秒,分母 2 表示的是当 RPS 达到 2 时开始扩容。如果 TARGETS 列显示 <unknown>/2,可能是因为 HPA 还没有开始抓取指标,需要多刷新几次才能显示;或者因为抓取指标报错了,可以返回上一节确认获取指标的接口是否正常。

接下来我们将接口的请求频率调整为 5 次/秒

$ while true; do wget -q -O- http://localhost:31086/hello; sleep 0.2; done

很快,就触发了 HPA 的扩容:

$ kubectl get hpa
NAME             REFERENCE                   TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
hello-actuator   Deployment/hello-actuator   1949m/2   1         10        2          20m

而且扩容之后,对 Service 的请求平均摊给了两个 Pod,每个 Pod 的 RPS 都是 2 次/秒 左右,达到了稳定状态,从而不再继续扩容。

$ kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/default/pods/*/hello_counter_per_second" | jq .
{
  "kind": "MetricValueList",
  "apiVersion": "custom.metrics.k8s.io/v1beta1",
  "metadata": {},
  "items": [
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "hello-actuator-b49545c55-2zdz7",
        "apiVersion": "/v1"
      },
      "metricName": "hello_counter_per_second",
      "timestamp": "2022-11-28T01:01:20Z",
      "value": "2066m",
      "selector": null
    },
    {
      "describedObject": {
        "kind": "Pod",
        "namespace": "default",
        "name": "hello-actuator-b49545c55-r6whf",
        "apiVersion": "/v1"
      },
      "metricName": "hello_counter_per_second",
      "timestamp": "2022-11-28T01:01:20Z",
      "value": "1933m",
      "selector": null
    }
  ]
}

从 Prometheus 的图形界面可以更直观地看出整个扩容的过程:

prometheus-irate.png

一开始只有一个 Pod,请求频率为 1 次/秒,然后突然请求频率飙升到了 3.5 次/秒 左右,很快便触发了 HPA 扩容,扩容后,两个 Pod 平摊请求,对 Pod 的请求频率又降为 2 次/秒,从而达到稳定状态。

可以看出通过 HPA 的自动扩缩容机制,我们可以很轻松地应付一些突发情况,大大地减轻了运维人员的负担。

参考

  1. Pod 水平自动扩缩
  2. Kubernetes HPA 使用详解
  3. 对 Kubernetes 应用进行自定义指标扩缩容
  4. 自动伸缩 | Kuboard
  5. 自动伸缩-例子 | Kuboard
  6. 你真的理解 K8s 中的 requests 和 limits 吗?
  7. Prometheus Adapter Walkthrough
  8. Prometheus Adapter Configuration Walkthroughs

更多

Kubernetes Autoscaler

HPA 的全称为 Horizontal Pod Autoscaling,表示对 Pod 进行水平自动伸缩,这里水平伸缩的意思是增加或减少 Pod 的副本数。既然有水平伸缩,那么肯定也有垂直伸缩,那就是 VPA,全称为 Vertical Pod Autoscaler,它可以根据 Pod 的负载情况对 Pod 的资源请求和限制进行调整。

另外还有一种 Autoscaler 叫做 Cluster Autoscaler,它可以动态的调整 Kubernetes 集群的大小,比如当集群中某个节点比较空闲,且运行在这个节点上的 Pod 也可以移到其他节点上运行时,那么就可以将这个节点去掉,减小集群规模,从而帮你节约成本。

Custom Metrics API

https://github.com/kubernetes-sigs/custom-metrics-apiserver

Kubernetes Apiserver Aggregation

https://github.com/kubernetes-sigs/apiserver-builder-alpha

扫描二维码,在手机上阅读!

搭建自己的镜像仓库

镜像仓库(Docker Registry)是用于存储和管理镜像的地方,方便将镜像分发到世界各地,镜像仓库一般分为公共仓库和私有仓库两种形式。

Docker 官方的 Docker Hub 是最常用的公共仓库之一,包含很多高质量的官方镜像,这也是 Docker 默认使用的仓库,除此之外,还有 Red Hat 的 Quay.io,Google 的 Google Container Registry(Kubernetes 就是使用 GCR 作为默认的镜像仓库),以及 GitHub 的 ghcr.io 等。国内一些云服务商也提供类似的服务,比如 网易云镜像服务DaoCloud 镜像市场阿里云容器镜像服务(ACR) 等。另外还有些服务商提供了针对 Docker Hub 的镜像服务(Registry Mirror),这些镜像服务被称为 加速器,比如 DaoCloud 加速器,使用加速器会直接从国内的地址下载 Docker Hub 的镜像,比直接从 Docker Hub 下载快得多。

除公开仓库外,用户也可以在本地搭建私有镜像仓库。通过官方提供的 Docker Registry 镜像,可以很容易搭建一个自己的镜像仓库服务,这个仓库服务提供了 Docker Registry API 相关的接口,并没有图形界面,不过对 Docker 命令来说已经足够了。如果还需要一些高级特性,可以尝试 HarborSonatype Nexus,他们不仅提供了图形界面,还具有镜像维护、用户管理、访问控制等高级功能。

使用 Docker Registry 搭建私有镜像仓库

首先下载 Docker Registry 镜像:

$ docker pull registry:latest

目前最新的 registry 版本是 2.8,它是基于 Distribution 实现的,老版本的 registry 是基于 docker-registry 实现的,现在已经几乎不用了。DistributionOCI Distribution Specification 的开源实现,很多其他的镜像仓库项目如 Docker Hub、GitHub Container Registry、GitLab Container Registry、DigitalOcean Container Registry、Harbor Project、VMware Harbor Registry 都是以 Distribution 为基础开发的。

使用 docker 命令启动镜像仓库:

$ docker run -d -p 5000:5000 --name registry registry:latest

这样我们的私有镜像仓库就搭建好了。为了验证这个镜像仓库是否可用,我们可以从官方随便下载一个镜像(这里我使用的是 hello-world 镜像),然后通过 docker tag 在镜像名前面加上私有仓库的地址 localhost:5000/,再通过 docker push 就可以将这个镜像推送到我们的私有仓库里了:

$ docker pull hello-world
$ docker tag hello-world localhost:5000/hello-world
$ docker push localhost:5000/hello-world

使用 Docker Registry API 访问仓库

我们可以通过 Docker Registry API 暴露的一些接口来访问仓库,比如使用 /v2/_catalog 接口查询仓库中有哪些镜像:

$ curl -s http://localhost:5000/v2/_catalog | jq
{
  "repositories": [
    "hello-world"
  ]
}

使用 /v2/<name>/tags/list 接口查询某个镜像的标签:

$ curl -s http://localhost:5000/v2/hello-world/tags/list | jq
{
  "name": "hello-world",
  "tags": [
    "latest"
  ]
}

使用 /v2/<name>/manifests/<reference> 接口查询某个镜像版本的详细信息:

$ curl -s http://localhost:5000/v2/hello-world/manifests/latest | jq
{
  "schemaVersion": 1,
  "name": "hello-world",
  "tag": "latest",
  "architecture": "amd64",
  "fsLayers": [
    {
      "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
    },
    {
      "blobSum": "sha256:2db29710123e3e53a794f2694094b9b4338aa9ee5c40b930cb8063a1be392c54"
    }
  ],
  "history": [
    {
      "v1Compatibility": "{\"architecture\":\"amd64\",\"config\":{\"Hostname\":\"\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/hello\"],\"Image\":\"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"container\":\"8746661ca3c2f215da94e6d3f7dfdcafaff5ec0b21c9aff6af3dc379a82fbc72\",\"container_config\":{\"Hostname\":\"8746661ca3c2\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) \",\"CMD [\\\"/hello\\\"]\"],\"Image\":\"sha256:b9935d4e8431fb1a7f0989304ec86b3329a99a25f5efdc7f09f3f8c41434ca6d\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":{}},\"created\":\"2021-09-23T23:47:57.442225064Z\",\"docker_version\":\"20.10.7\",\"id\":\"a1f125167a7f2cffa48b7851ff3f75e983824c16e8da61f20765eb55f7b3a594\",\"os\":\"linux\",\"parent\":\"cd13bf215b21e9bc78460fa5070860a498671e2ac282d86d15042cf0c26e6e8b\",\"throwaway\":true}"
    },
    {
      "v1Compatibility": "{\"id\":\"cd13bf215b21e9bc78460fa5070860a498671e2ac282d86d15042cf0c26e6e8b\",\"created\":\"2021-09-23T23:47:57.098990892Z\",\"container_config\":{\"Cmd\":[\"/bin/sh -c #(nop) COPY file:50563a97010fd7ce1ceebd1fa4f4891ac3decdf428333fb2683696f4358af6c2 in / \"]}}"
    }
  ],
  "signatures": [
    {
      "header": {
        "jwk": {
          "crv": "P-256",
          "kid": "6GC6:JFLS:HP3P:WWBW:V4RI:BJKW:64GB:NSAO:Y4U6:UT6M:MSLJ:QG6K",
          "kty": "EC",
          "x": "Q1gHvt0A-Q-Pu8hfm2o-hLST0b-XZlEQcn9kYHZzAi0",
          "y": "oNddnJzLNOMcRcEebuEqZiapZHHmQSZHnnnaSkvYUaE"
        },
        "alg": "ES256"
      },
      "signature": "NthpjcYe39XSmnKRz9dlSWZBcpIgqIXuFGhQ4bxALK97NsWAZPE6CSiLwEn3ECjm1ovKzjJthOAuK_CW92ju-Q",
      "protected": "eyJmb3JtYXRMZW5ndGgiOjIwOTIsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAyMi0wOS0xNFQyMzo1ODozM1oifQ"
    }
  ]
}

除了这三个比较常用的查询类接口,Docker Registry API 还有一些用于上传和下载的接口,具体的内容可以查看这里的 接口列表

使用 crane 工具操作仓库

使用 Docker Registry API 操作镜像仓库不是那么方便,使用 docker 命令访问镜像仓库必须依赖 Docker Daemon 服务,有没有一款命令行工具可以直接用来操作镜像仓库呢?

google/go-containerregistry 是 Google 开源的一个专门操作镜像仓库的 Go 类库,并且 Google 还基于此类库实现了一个小工具 crane,大大地方便了我们查看或管理远程仓库中的镜像。

如果我们的镜像仓库开启了用户认证(参见下一节),需要在使用之前先通过 crane auth login 登录:

$ crane auth login localhost:5000 -u admin -p passw0rd

使用 crane catalog 列出所有镜像:

$ crane catalog localhost:5000 --insecure

使用 crane ls 列出某个镜像的所有标签:

$ crane ls localhost:5000/hello-world --insecure

使用 crane pull 下载镜像,并保存到一个 tar 文件中:

$ crane pull localhost:5000/hello-world hello-world.tar --insecure

使用 crane push 将一个 tar 文件上传到镜像仓库:

$ crane push hello-world.tar localhost:5000/hello-world --insecure

删除镜像

使用 crane 删除镜像要稍微复杂一点,首先使用 crane digest 获取某个镜像的摘要值(digest value):

$ crane digest localhost:5000/hello-world:latest --insecure
sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9

然后通过这个摘要值来删除镜像:

$ crane delete localhost:5000/hello-world@sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9 --insecure

不过默认情况下会报错,因为我们没有开启删除镜像的功能:

Error: DELETE https://localhost:5000/v2/hello-world/manifests/sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9: UNSUPPORTED: The operation is unsupported.

可以在启动镜像仓库时加上 REGISTRY_STORAGE_DELETE_ENABLED=true 环境变量,或在配置文件中加上该配置:

registry:
  storage:
    delete:
      enabled: true

删除之后,通过 crane ls 确认 hello-world:latest 已删除:

$ crane ls localhost:5000/hello-world --insecure

不过此时使用 crane catalog 还是能看到这个镜像的,说明只是标签被删了:

$ crane catalog localhost:5000 --insecure
hello-world

根据 distribution 项目上的一个 Issuse 大概了解到,Docker Registry API 只能删除 manifests 和 layers,不能删除 repository。如果真要删除,可以手工把 /var/lib/registry/docker/registry/v2/repositories/hello-world 这个目录删掉。

另外有一点需要注意的是,通过 API 删除镜像并不能删除磁盘上未引用的 Blobs 文件,所以如果你想要释放磁盘空间,还需要手工对 Docker Registry 执行 垃圾回收(Garbage collection)

进入 registry 容器:

$ docker exec -it registry /bin/sh

通过 registry garbage-collect 命令垃圾回收,真正执行前可以先加上一个 --dry-run 参数,可以看到执行后命令会删除哪些内容:

/ # registry garbage-collect --delete-untagged --dry-run /etc/docker/registry/config.yml
hello-world

0 blobs marked, 3 blobs and 0 manifests eligible for deletion
blob eligible for deletion: sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9
blob eligible for deletion: sha256:7f760066df116957589ba45a8ca84fe03373d15fdf1752c9b60f24ecbff5a870
blob eligible for deletion: sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412

确认无误后,开始执行垃圾回收:

/ # registry garbage-collect --delete-untagged /etc/docker/registry/config.yml
hello-world

0 blobs marked, 3 blobs and 0 manifests eligible for deletion
blob eligible for deletion: sha256:3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9
INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/3d/3d1aa3d49e778503d60d3ba718eaf04bc8fa2262bff980edf3fb8c01779cd8a9  go.version=go1.16.15 instance.id=74e3fb6e-ac44-4c96-923a-5cc6d5e5342a service=registry
blob eligible for deletion: sha256:7f760066df116957589ba45a8ca84fe03373d15fdf1752c9b60f24ecbff5a870
INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/7f/7f760066df116957589ba45a8ca84fe03373d15fdf1752c9b60f24ecbff5a870  go.version=go1.16.15 instance.id=74e3fb6e-ac44-4c96-923a-5cc6d5e5342a service=registry
blob eligible for deletion: sha256:feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412
INFO[0000] Deleting blob: /docker/registry/v2/blobs/sha256/fe/feb5d9fea6a5e9606aa995e879d862b825965ba48de054caab5ef356dc6b3412  go.version=go1.16.15 instance.id=74e3fb6e-ac44-4c96-923a-5cc6d5e5342a service=registry

执行结束后查看磁盘空间是否释放:

/ # df -h | grep /var/lib/registry

开启 TLS 和安全认证

上面我们用一行命令就搭建了一个私有仓库,不过这个私有仓库几乎没什么安全性,只能在本地测试用。Docker 默认是以 HTTPS 方式连接除 localhost 之外的仓库的,当从其他机器访问这个不安全的仓库地址时会报下面这样的错:

$ docker pull 192.168.1.39:5000/hello-world
Using default tag: latest
Error response from daemon: Get "https://192.168.1.39:5000/v2/": http: server gave HTTP response to HTTPS client

如果真要用也可以,需要修改 Docker 的配置文件 /etc/docker/daemon.json,将这个地址添加到 insecure-registries 配置项中:

{
  "insecure-registries" : ["192.168.1.39:5000"]
}

然后重启 Docker 后即可。

不过这种方式非常不安全,不仅容易遭受中间人攻击(MITM),而且有一些限制,比如不能开启用户认证,也就是说你这个镜像仓库是赤裸裸的暴露在任何人面前。如果要搭建一套生产环境使用的镜像仓库,我们必须做得更安全一点。

开启 TLS 安全

我们首先需要 获得一份 TLS 证书,官方文档中要求你拥有一个 HTTPS 域名,并从权威的 CA 机构获得一份证书,不过大多数人估计都没这个条件,所以我们 使用自签名证书(self-signed certificate)。

创建一个 certs 目录:

$ mkdir -p certs

使用 openssl req 生成证书:

$ openssl req \
  -newkey rsa:4096 -nodes -sha256 -keyout certs/domain.key \
  -addext "subjectAltName = IP:192.168.1.39" \
  -x509 -days 365 -out certs/domain.crt

注意这里要使用 -addext 参数添加 subjectAltName 扩展项,也就是 Subject Alternative Name,一般缩写为 SAN,表示我们的证书使用者是 IP 192.168.1.39,如果你有自己的域名,可以在 SAN 中指定 DNS:-addext "subjectAltName = DNS:example.hub"

如果没有在证书的 SAN 中指定 IP,会报如下错误:

$ docker push 192.168.1.39:5000/registry:latest
The push refers to repository [192.168.1.39:5000/registry]
Get "https://192.168.1.39:5000/v2/": x509: cannot validate certificate for 192.168.1.39 because it doesn't contain any IP SANs

或者证书中 IP 和实际 IP 不匹配,也会报错:

$ docker push 192.168.1.39:5000/registry:latest
The push refers to repository [192.168.1.39:5000/registry]
Get "https://192.168.1.39:5000/v2/": x509: certificate is valid for 192.168.1.40, not 192.168.1.39

生成证书的过程中,还会提示你填写一些信息,这些信息被称为 Distinguished Name(简称 DN),可以酌情填写重要的,不想填的输入 . 留空即可:

You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:CN
State or Province Name (full name) [Some-State]:AH
Locality Name (eg, city) []:HF
Organization Name (eg, company) [Internet Widgits Pty Ltd]:.
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:example.hub
Email Address []:

命令执行成功后会在 certs 目录生成 domain.crtdomain.key 两个文件,然后通过下面的命令重新启动镜像仓库(先删除之前启动的),开启 TLS 安全功能:

$ docker run -d \
  -v "$(pwd)"/certs:/certs \
  -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
  -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
  -p 5000:5000 \
  --name registry \
  registry:latest

不过由于这个证书是我们自己颁发的,并不是系统可信证书,在使用时会报错:

$ docker push 192.168.1.39:5000/registry:latest
The push refers to repository [192.168.1.39:5000/registry]
Get "https://192.168.1.39:5000/v2/": x509: certificate signed by unknown authority

有两种方法可以解决这个问题,第一种方法和上面的 HTTP 形式一样,在配置文件中添加 insecure-registries 配置:

{
  "insecure-registries" : ["192.168.1.39:5000"]
}

第二种方法是让 Docker 服务信任这个证书。如果是 Linux 机器,可以将 domain.crt 文件放到 Docker 的证书目录 /etc/docker/certs.d/192.168.1.39:5000/ca.crt 下,立即生效,不用重启 Docker 服务;如果是 Windows 机器,直接双击证书文件,将证书安装到当前用户或本地计算机:

windows-cert-install.png

然后选择 根据证书类型,自动选择证书存储,默认会将证书安装到 中间证书颁发机构 下,也可以手工选择安装到 受信任的根证书颁发机构 下。安装完成后可以打开证书管理器(如果安装时存储位置选择的是当前用户,运行 certmgr.msc;如果是本地计算机,运行 certlm.msc)查看我们的证书:

windows-cert.png

需要重启 Docker 服务后生效。

开启用户认证

开启 TLS 之后,为了保证镜像仓库的安全性,还可以通过客户端认证机制只允许特定的客户端连接,配置参数为 http.tls.clientcas,可以配置多个客户端证书:

http:
  tls:
    certificate: /path/to/x509/public
    key: /path/to/x509/private
    clientcas:
      - /path/to/ca.pem
      - /path/to/another/ca.pem

不过这种方式不是很常用,更多的是使用 Basic 认证机制。Docker Registry 支持通过 Apache htpasswd 文件 来实现用户认证功能,首先使用 htpasswd 命令来生成密码文件:

$ mkdir -p auth
$ docker run --entrypoint htpasswd httpd:2 -Bbn admin passw0rd > auth/htpasswd

htpasswd 命令内置在 Apache HTTPD 服务器里,我们这里直接使用了 httpd:2 镜像,生成的密码文件保存在 auth 目录下,密码是通过 Bcrypt 算法 加密的,文件内容是这种格式:

admin:$2y$05$mzoaYiDmF7Bm2p/JWf4kje7naTzkyYpqgg5v8mZPq0HdDuSXZ1d0i

然后运行下面的命令,同时开启 TLS 功能和用户认证功能:

$ docker run -d \
  -v "$(pwd)"/certs:/certs \
  -e REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt \
  -e REGISTRY_HTTP_TLS_KEY=/certs/domain.key \
  -v "$(pwd)"/auth:/auth \
  -e "REGISTRY_AUTH=htpasswd" \
  -e "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm" \
  -e REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd \
  -p 5000:5000 \
  --name registry \
  registry:latest

这时访问镜像仓库报错:

$ docker push 192.168.1.39:5000/hello-world
no basic auth credentials

需要先 docker login 登录:

$ docker login 192.168.1.39:5000

登录成功后,就可以正常操作镜像仓库了。需要特别注意的是,登录后的用户名和密码会以 BASE64 的形式保存在 ~/.docker/config.json 文件中:

{
  "auths": {
    "192.168.1.39:5000": {
      "auth": "YWRtaW46cGFzc3cwcmQ="
    }
  }
}

这是很不安全的,你可以考虑使用其他的 Credentials store 来保存你的用户名和密码。

另外,除了使用 Basic 用户认证,Docker Registry 还支持 使用认证服务器实现更复杂的 OAuth 2.0 认证

参考

  1. 仓库 - Docker — 从入门到实践
  2. 私有仓库 - Docker — 从入门到实践
  3. Docker Registry
  4. How to delete images from a private docker registry?
  5. 你必须知道的Docker镜像仓库的搭建
  6. distribution/distribution - The toolkit to pack, ship, store, and deliver container content
  7. google/go-containerregistry - Go library and CLIs for working with container registries
  8. Housekeep your local image registry

更多

1. 设置镜像仓库的存储驱动

镜像仓库本质上是一个文件存储和内容分发系统,可以通过 Storage API 接口实现不同的存储驱动,官方支持下面这些存储驱动:

2. 使用图形界面管理镜像仓库

这篇文章主要介绍了通过 Docker Registry API 和命令行两种方式对镜像仓库进行管理,这也是管理镜像仓库最基础的方式。篇幅有限,还有很多其他图形化的方式没有介绍,这些项目的底层依赖或实现原理大抵是一样的,下面是一个简单的列表:

  • Docker Registry UI - The simplest and most complete UI for your private registry
  • SUSE/Portus - Authorization service and frontend for Docker registry (v2)
  • Harbor - An open source trusted cloud native registry project that stores, signs, and scans content.
  • Sonatype Nexus - Manage binaries and build artifacts across your software supply chain.
扫描二维码,在手机上阅读!

Kubernetes 使用小记

Kubernetes 常常被简称为 K8S(发音:Kate's),是 Google 在 2014 年发布的一个开源容器编排引擎。它诞生自 Google 内部的一款容器管理系统 Borg,据说,Borg 管理着 Google 数据中心里 20 多亿个容器服务。自发布以来,Kubernetes 迅速获得开源社区的追捧,包括 Red Hat、VMware、Canonical 在内的很多有影响力的公司都加入到它的开发和推广阵营中,目前,AWS、Azure、Google、阿里云、腾讯云等厂商都推出了基于 Kubernetes 的 CaaS 或 PaaS 产品。

Kubernetes 属于平台级技术,覆盖的技术范围非常广,包括计算、网络、存储、高可用、监控、日志等多个方面,而且在 Kubernetes 中有很多新的概念和设计理念,所以有一定的入门门槛。

Kubernetes 安装小记 中,我们学习了如何使用 Kind、Minikube 和 Kubeadmin 安装一个 Kubernetes 集群。这一节我们将照着 官方教程,学习如何使用它,了解并掌握 Kubernetes 的基本概念。

使用 Minikube 创建集群

一个 Kubernetes 集群包含两种类型的资源:

  • Master(也被称为控制平面 Control Plane

用于管理整个集群,比如调度应用、维护应用状态、应用扩容和更新等。

  • Node

每个 Node 上都运行着 Kubelet 程序,它负责运行应用,并且是和 Master 通信的代理。每个 Node 上还要有运行容器的工具,如 Docker 或 rkt。

我们可以使用 Minikube 创建一个单节点的简单集群。首先确保机器上已经安装 Minikube(安装步骤参考 Kubernetes 安装小记):

$ minikube version
minikube version: v1.18.0
commit: ec61815d60f66a6e4f6353030a40b12362557caa-dirty

然后执行 minikube start 启动一个 Kubernetes 集群:

$ minikube start
* minikube v1.18.0 on Ubuntu 18.04 (amd64)
* Using the none driver based on existing profile

X The requested memory allocation of 2200MiB does not leave room for system overhead (total system memory: 2460MiB). You may face stability issues.
* Suggestion: Start minikube with less memory allocated: 'minikube start --memory=2200mb'

* Starting control plane node minikube in cluster minikube
* Running on localhost (CPUs=2, Memory=2460MB, Disk=194868MB) ...
* OS release is Ubuntu 18.04.5 LTS
* Preparing Kubernetes v1.20.2 on Docker 19.03.13 ...
  - kubelet.resolv-conf=/run/systemd/resolve/resolv.conf
  - Generating certificates and keys ...
  - Booting up control plane ...-
  - Configuring RBAC rules ...
* Configuring local host environment ...
* Verifying Kubernetes components...
  - Using image gcr.io/k8s-minikube/storage-provisioner:v4
* Enabled addons: default-storageclass, storage-provisioner
* Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

这样一个 Kubernetes 集群就安装好了,接下来我们就可以使用 kubectl 来管理这个集群。使用 kubectl version 查看客户端和服务端的版本信息:

$ kubectl version
Client Version: version.Info{Major:"1", Minor:"20", GitVersion:"v1.20.4", GitCommit:"e87da0bd6e03ec3fea7933c4b5263d151aafd07c", GitTreeState:"clean", BuildDate:"2021-02-18T16:12:00Z", GoVersion:"go1.15.8", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"20", GitVersion:"v1.20.2", GitCommit:"faecb196815e248d3ecfb03c680a4507229c2a56", GitTreeState:"clean", BuildDate:"2021-01-13T13:20:00Z", GoVersion:"go1.15.5", Compiler:"gc", Platform:"linux/amd64"}

使用 kubectl cluster-info 查看集群详情:

$ kubectl cluster-info
Kubernetes control plane is running at https://10.0.0.8:8443
KubeDNS is running at https://10.0.0.8:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

使用 kubectl get nodes 查看集群中的节点信息:

$ kubectl get nodes
NAME       STATUS   ROLES                  AGE     VERSION
minikube   Ready    control-plane,master   4m51s   v1.20.2

使用 kubectl 创建 Deployment

一旦我们有了一个可用的 Kubernetes 集群,我们就可以在集群里部署应用程序了。在 Kubernetes 中,Deployment 负责创建和更新应用程序的实例,所以我们需要创建一个 Deployment,然后 Kubernetes Master 就会将应用程序实例调度到集群中的各个节点上。

而且,Kubernetes 还提供了一种自我修复机制,当应用程序实例创建之后,Deployment 控制器会持续监视这些实例,如果托管实例的节点关闭或被删除,则 Deployment 控制器会将该实例替换为集群中另一个节点上的实例。

使用命令行工具 kubectl 可以创建和管理 Deployment,它通过 Kubernetes API 与集群进行交互。使用 kubectl create deployment 部署我们的第一个应用程序:

$ kubectl create deployment kubernetes-bootcamp --image=jocatalin/kubernetes-bootcamp:v1
deployment.apps/kubernetes-bootcamp created

其中 kubernetes-bootcamp 为 Deployment 名称,--image 为要运行的应用程序镜像地址。

可以使用 kubectl get deployments 查看所有的 Deployment:

$ kubectl get deployments
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
kubernetes-bootcamp   1/1     1            1           5m8s

这里的几个字段分别表示:

  • NAME - Deployment 的名称。
  • READY - 应用程序的可用副本数,显示格式为:就绪个数/期望个数。
  • UP-TO-DATE - 显示为了达到期望状态已经更新的副本数。
  • AVAILABLE - 可供用户使用的副本数。
  • AGE - 应用程序运行的时间。

可以看到 kubernetes-bootcamp 这个 Deployment 里包含了一个应用实例,并且运行在 Pod 中。

$ kubectl get pods
NAME                                   READY   STATUS    RESTARTS   AGE
kubernetes-bootcamp-57978f5f5d-fwmqq   1/1     Running   0          19m

Pod 处于一个完全隔离的网络,默认情况下,只能从集群内的其他 Pod 或 Service 访问,从集群外面是不能访问的。我们会在后面的内容中学习如何访问 Pod 里的内容。

kubectl 是通过 Kubernetes API 来创建和管理我们的 Pod 的,我们可以使用 kubectl 启动一个代理,通过代理我们也可以访问 Kubernetes API:

$ kubectl proxy
Starting to serve on 127.0.0.1:8001

使用下面的 API 接口检查代理是否正常运行:

$ curl http://localhost:8001/version
{
  "major": "1",
  "minor": "20",
  "gitVersion": "v1.20.2",
  "gitCommit": "faecb196815e248d3ecfb03c680a4507229c2a56",
  "gitTreeState": "clean",
  "buildDate": "2021-01-13T13:20:00Z",
  "goVersion": "go1.15.5",
  "compiler": "gc",
  "platform": "linux/amd64"
}

然后通过下面的 API 接口获取 Pod 信息(其中 kubernetes-bootcamp-57978f5f5d-fwmqq 是 Pod 名称,可以通过上面的 kubectl get pods 查看):

$ curl http://localhost:8001/api/v1/namespaces/default/pods/kubernetes-bootcamp-57978f5f5d-fwmqq/
{
  "kind": "Pod",
  "apiVersion": "v1",
  "metadata": {
    "name": "kubernetes-bootcamp-57978f5f5d-fwmqq",
    "generateName": "kubernetes-bootcamp-57978f5f5d-",
    "namespace": "default",
    "uid": "7bc3c22e-aa33-4290-a1b4-62b80f593cc9",
    "resourceVersion": "714",
    "creationTimestamp": "2022-06-15T23:39:52Z",
    "labels": {
      "app": "kubernetes-bootcamp",
      "pod-template-hash": "57978f5f5d"
    },
    "ownerReferences": [
      {
        "apiVersion": "apps/v1",
        "kind": "ReplicaSet",
        "name": "kubernetes-bootcamp-57978f5f5d",
        "uid": "a786a3e5-9d41-44be-8b1c-44d38e9bc3db",
        "controller": true,
        "blockOwnerDeletion": true
      }
    ],
    "managedFields": [
      {
        "manager": "kube-controller-manager",
        "operation": "Update",
        "apiVersion": "v1",
        "time": "2022-06-15T23:39:52Z",
        "fieldsType": "FieldsV1",
        "fieldsV1": {"f:metadata":{"f:generateName":{},"f:labels":{".":{},"f:app":{},"f:pod-template-hash":{}},"f:ownerReferences":{".":{},"k:{\"uid\":\"a786a3e5-9d41-44be-8b1c-44d38e9bc3db\"}":{".":{},"f:apiVersion":{},"f:blockOwnerDeletion":{},"f:controller":{},"f:kind":{},"f:name":{},"f:uid":{}}}},"f:spec":{"f:containers":{"k:{\"name\":\"kubernetes-bootcamp\"}":{".":{},"f:image":{},"f:imagePullPolicy":{},"f:name":{},"f:resources":{},"f:terminationMessagePath":{},"f:terminationMessagePolicy":{}}},"f:dnsPolicy":{},"f:enableServiceLinks":{},"f:restartPolicy":{},"f:schedulerName":{},"f:securityContext":{},"f:terminationGracePeriodSeconds":{}}}
      },
      {
        "manager": "kubelet",
        "operation": "Update",
        "apiVersion": "v1",
        "time": "2022-06-15T23:39:55Z",
        "fieldsType": "FieldsV1",
        "fieldsV1": {"f:status":{"f:conditions":{"k:{\"type\":\"ContainersReady\"}":{".":{},"f:lastProbeTime":{},"f:lastTransitionTime":{},"f:status":{},"f:type":{}},"k:{\"type\":\"Initialized\"}":{".":{},"f:lastProbeTime":{},"f:lastTransitionTime":{},"f:status":{},"f:type":{}},"k:{\"type\":\"Ready\"}":{".":{},"f:lastProbeTime":{},"f:lastTransitionTime":{},"f:status":{},"f:type":{}}},"f:containerStatuses":{},"f:hostIP":{},"f:phase":{},"f:podIP":{},"f:podIPs":{".":{},"k:{\"ip\":\"172.18.0.6\"}":{".":{},"f:ip":{}}},"f:startTime":{}}}
      }
    ]
  },
  "spec": {
    "volumes": [
      {
        "name": "default-token-cctqg",
        "secret": {
          "secretName": "default-token-cctqg",
          "defaultMode": 420
        }
      }
    ],
    "containers": [
      {
        "name": "kubernetes-bootcamp",
        "image": "jocatalin/kubernetes-bootcamp:v1",
        "resources": {
          
        },
        "volumeMounts": [
          {
            "name": "default-token-cctqg",
            "readOnly": true,
            "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount"
          }
        ],
        "terminationMessagePath": "/dev/termination-log",
        "terminationMessagePolicy": "File",
        "imagePullPolicy": "IfNotPresent"
      }
    ],
    "restartPolicy": "Always",
    "terminationGracePeriodSeconds": 30,
    "dnsPolicy": "ClusterFirst",
    "serviceAccountName": "default",
    "serviceAccount": "default",
    "nodeName": "minikube",
    "securityContext": {
      
    },
    "schedulerName": "default-scheduler",
    "tolerations": [
      {
        "key": "node.kubernetes.io/not-ready",
        "operator": "Exists",
        "effect": "NoExecute",
        "tolerationSeconds": 300
      },
      {
        "key": "node.kubernetes.io/unreachable",
        "operator": "Exists",
        "effect": "NoExecute",
        "tolerationSeconds": 300
      }
    ],
    "priority": 0,
    "enableServiceLinks": true,
    "preemptionPolicy": "PreemptLowerPriority"
  },
  "status": {
    "phase": "Running",
    "conditions": [
      {
        "type": "Initialized",
        "status": "True",
        "lastProbeTime": null,
        "lastTransitionTime": "2022-06-15T23:39:52Z"
      },
      {
        "type": "Ready",
        "status": "True",
        "lastProbeTime": null,
        "lastTransitionTime": "2022-06-15T23:39:55Z"
      },
      {
        "type": "ContainersReady",
        "status": "True",
        "lastProbeTime": null,
        "lastTransitionTime": "2022-06-15T23:39:55Z"
      },
      {
        "type": "PodScheduled",
        "status": "True",
        "lastProbeTime": null,
        "lastTransitionTime": "2022-06-15T23:39:52Z"
      }
    ],
    "hostIP": "10.0.0.9",
    "podIP": "172.18.0.6",
    "podIPs": [
      {
        "ip": "172.18.0.6"
      }
    ],
    "startTime": "2022-06-15T23:39:52Z",
    "containerStatuses": [
      {
        "name": "kubernetes-bootcamp",
        "state": {
          "running": {
            "startedAt": "2022-06-15T23:39:54Z"
          }
        },
        "lastState": {
          
        },
        "ready": true,
        "restartCount": 0,
        "image": "jocatalin/kubernetes-bootcamp:v1",
        "imageID": "docker-pullable://jocatalin/kubernetes-bootcamp@sha256:0d6b8ee63bb57c5f5b6156f446b3bc3b3c143d233037f3a2f00e279c8fcc64af",
        "containerID": "docker://f00a2e64ec2a46d03f98ddd300dffdefde2ef306f545f873e4e596e2fa74c359",
        "started": true
      }
    ],
    "qosClass": "BestEffort"
  }
}

查看 Pod 和工作节点

在上一节中,我们使用 kubectl get pods 查看集群中运行的 PodPod 是 Kubernetes 中的原子单元,当我们在 Kubernetes 上创建 Deployment 时,该 Deployment 会在其中创建包含容器的 Pod,而不是直接创建容器。每个 Pod 都包含了一组应用程序容器(一个或多个),这些容器之间共享存储和网络,它们始终位于同一位置并且共同调度。

一个 Pod 总是运行在工作节点,工作节点是 Kubernetes 中参与计算的机器,每个工作节点由主节点管理,主节点会根据每个工作节点上的可用资源自动调度 Pod。每个工作节点上至少运行着:

  • Kubelet,负责主节点和工作节点之间的通信,它还负责管理工作节点上的 Pod 和容器;
  • 容器运行时

接下来我们使用 kubectl 命令对 Pod 展开更深入的了解,大多数命令和 Docker 命令很类似,如果有一定的 Docker 基础,可以很快上手。使用 kubectl get pods 可以查看 Pod 列表,列表中显示着 Pod 名称和状态一些简单的信息,如果需要更详细的信息,可以使用 kubectl describe 命令:

$ kubectl describe pods
Name:         kubernetes-bootcamp-fb5c67579-8sm7d
Namespace:    default
Priority:     0
Node:         minikube/10.0.0.8
Start Time:   Thu, 16 Jun 2022 22:38:03 +0000
Labels:       app=kubernetes-bootcamp
              pod-template-hash=fb5c67579
Annotations:  <none>
Status:       Running
IP:           172.18.0.3
IPs:
  IP:           172.18.0.3
Controlled By:  ReplicaSet/kubernetes-bootcamp-fb5c67579
Containers:
  kubernetes-bootcamp:
    Container ID:   docker://ac8e5d785a8c7d8a550febdec1720f6d2a1ebe66f90ce970a963340b9f33c032
    Image:          jocatalin/kubernetes-bootcamp:v1
    Image ID:       docker-pullable://jocatalin/kubernetes-bootcamp@sha256:0d6b8ee63bb57c5f5b6156f446b3bc3b3c143d233037f3a2f00e279c8fcc64af
    Port:           8080/TCP
    Host Port:      0/TCP
    State:          Running
      Started:      Thu, 16 Jun 2022 22:38:06 +0000
    Ready:          True
    Restart Count:  0
    Environment:    <none>
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from default-token-rn6wn (ro)
Conditions:
  Type              Status
  Initialized       True 
  Ready             True 
  ContainersReady   True 
  PodScheduled      True 
Volumes:
  default-token-rn6wn:
    Type:        Secret (a volume populated by a Secret)
    SecretName:  default-token-rn6wn
    Optional:    false
QoS Class:       BestEffort
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute op=Exists for 300s
                 node.kubernetes.io/unreachable:NoExecute op=Exists for 300s
Events:
  Type    Reason     Age   From               Message
  ----    ------     ----  ----               -------
  Normal  Scheduled  77s   default-scheduler  Successfully assigned default/kubernetes-bootcamp-fb5c67579-8sm7d to minikube
  Normal  Pulled     75s   kubelet            Container image "jocatalin/kubernetes-bootcamp:v1" already present on machine
  Normal  Created    75s   kubelet            Created container kubernetes-bootcamp
  Normal  Started    74s   kubelet            Started container kubernetes-bootcamp

这里不仅显示了 Pod 的名称和状态,还显示了 Pod 的 IP 地址,Pod 里的容器信息,以及 Pod 生命周期中的一些关键事件。

我们可以直接使用 Pod 这里的 IP 地址来访问应用程序:

$ curl 172.18.0.3:8080
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

但是要注意的是,Kubernetes 每次调度 Pod 的时候,都会随机分配一个新的 IP 地址,所以这种做法是不推荐的,后面我们会学习更好的做法。

当我们的应用程序有问题时,查看日志是最常用的排查问题的方法,要查看 Pod 的日志,使用 kubectl logs 命令:

$ kubectl logs kubernetes-bootcamp-fb5c67579-8sm7d
Kubernetes Bootcamp App Started At: 2022-06-16T22:38:06.372Z | Running On:  kubernetes-bootcamp-fb5c67579-8sm7d 

注意由于我们的 Pod 里只有一个容器,所以不需要指定容器名。

当一个 Pod 是运行中状态时,我们可以使用 kubectl exec 在 Pod 中直接执行命令,比如下面的命令列出容器内的环境变量:

$ kubectl exec kubernetes-bootcamp-fb5c67579-8sm7d -- env
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=kubernetes-bootcamp-fb5c67579-8sm7d
KUBERNETES_PORT_443_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
KUBERNETES_SERVICE_HOST=10.96.0.1
KUBERNETES_SERVICE_PORT=443
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
NPM_CONFIG_LOGLEVEL=info
NODE_VERSION=6.3.1
HOME=/root

下面的命令在容器内启动一个可交互的 Shell:

$ kubectl exec -ti kubernetes-bootcamp-fb5c67579-8sm7d -- bash

在这个 Shell 中我们可以做很多事情,和操作远程 SSH 完全一样,比如使用 cat 查看 server.js 的源码:

root@kubernetes-bootcamp-fb5c67579-8sm7d:/# cat server.js
var http = require('http');
var requests=0;
var podname= process.env.HOSTNAME;
var startTime;
var host;
var handleRequest = function(request, response) {
  response.setHeader('Content-Type', 'text/plain');
  response.writeHead(200);
  response.write("Hello Kubernetes bootcamp! | Running on: ");
  response.write(host);
  response.end(" | v=1\n");
  console.log("Running On:" ,host, "| Total Requests:", ++requests,"| App Uptime:", (new Date() - startTime)/1000 , "seconds", "| Log Time:",new Date());
}
var www = http.createServer(handleRequest);
www.listen(8080,function () {
    startTime = new Date();;
    host = process.env.HOSTNAME;
    console.log ("Kubernetes Bootcamp App Started At:",startTime, "| Running On: " ,host, "\n" );
});

在容器里使用 curl 访问我们的应用:

root@kubernetes-bootcamp-fb5c67579-8sm7d:/# curl localhost:8080
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

注意这里我们访问的是 localhost,这是因为我们现在处于容器内部。最后使用 exit 退出 Shell:

root@kubernetes-bootcamp-fb5c67579-8sm7d:/# exit

使用 Service 暴露你的应用

在上一节中,我们了解到我们的应用程序运行在 Pod 中,外部是无法访问的,虽然通过 Pod 的内部 IP 可以访问,但是要注意的是,Pod 是转瞬即逝的,当一个工作节点挂掉后,该工作节点上的 Pod 也会消亡,ReplicaSet 会自动地创建新的 Pod 让集群恢复到目标状态,保证应用程序正常运行。所以我们需要一种固定的方式来访问 Pod 中的应用,无论 Pod 如何变化,Kubernetes 通过服务(也就是 Service)来实现这一点。

Service 有如下几种类型:

  • ClusterIP - 这是 Service 的默认类型,在集群内部 IP 上公开 Service,这种类型的 Service 只能从集群内部访问。
  • NodePort - 使用 NAT 在集群中每个选定 Node 的相同端口上公开 Service,可以通过 NodeIP:NodePort 从集群外部访问 Service,是 ClusterIP 的超集。
  • LoadBalancer - 在集群中创建一个外部负载均衡器(如果支持的话),并为 Service 分配一个固定的外部 IP,是 NodePort 的超集。
  • ExternalName - 通过返回带有该名称的 CNAME 记录,使用任意名称公开 Service,需要 kube-dns v1.7 或更高版本。

可以使用 kubectl get services 命令查看目前集群中的 Service:

$ kubectl get services
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   71s

从上面可以看出,minikube 在创建集群时默认会创建一个名为 kubernetes 的 Service。使用 kubectl expose 创建一个新的 Service:

$ kubectl expose deployment/kubernetes-bootcamp --type="NodePort" --port 8080
service/kubernetes-bootcamp exposed

这里使用了 --type="NodePort" 参数指定了 Service 的类型为 NodePort,这让我们可以从集群外部访问我们的应用,另外 --port 8080 表示希望将 8080 端口暴露出去,暴露出去的端口号随机生成。再次执行 kubectl get services 命令:

$ kubectl get services
NAME                  TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
kubernetes            ClusterIP   10.96.0.1        <none>        443/TCP          50s
kubernetes-bootcamp   NodePort    10.111.158.145   <none>        8080:31006/TCP   4s

此时集群中多了一个名为 kubernetes-bootcamp 的 Service,这个 Service 在集群内的 IP 为 10.111.158.145,暴露的端口为 8080:31006,这两个端口很容易混淆,8080 为集群内端口,31006 为集群外端口。

使用 Service 在集群内的 IP 和端口来访问服务:

$ curl 10.111.158.145:8080
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

从集群外访问服务:

$ curl $(minikube ip):31006
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

还可以使用 kubectl describe 查看 Service 的详细信息:

$ kubectl describe services/kubernetes-bootcamp
Name:                     kubernetes-bootcamp
Namespace:                default
Labels:                   app=kubernetes-bootcamp
Annotations:              <none>
Selector:                 app=kubernetes-bootcamp
Type:                     NodePort
IP Families:              <none>
IP:                       10.111.158.145
IPs:                      10.111.158.145
Port:                     <unset>  8080/TCP
TargetPort:               8080/TCP
NodePort:                 <unset>  31006/TCP
Endpoints:                172.18.0.3:8080
Session Affinity:         None
External Traffic Policy:  Cluster
Events:                   <none>

注意这里还有一个 IP 地址和上面的 IP 也很容易混淆,那就是 Endpoints,这个是 Pod 的地址。

创建 Service 时,该 Service 下的 Pod 是由标签(Label)和选择器(Selector)来匹配的,可以通过这种方式来对 Kubernetes 中的对象进行逻辑分组。标签(Label)是附加在对象上的键值对,可以以多种方式使用:

  • 用于区分开发,测试和生产环境
  • 用于区分不同的版本
  • 使用 Label 将对象进行分类

实际上,当我们创建 Deployment 时,会为我们的 Pod 自带一个默认的 Label,使用 kubectl describe deployment 可以看出:

$ kubectl describe deployment
Name:                   kubernetes-bootcamp
Namespace:              default
CreationTimestamp:      Sat, 18 Jun 2022 01:24:48 +0000
Labels:                 app=kubernetes-bootcamp
Annotations:            deployment.kubernetes.io/revision: 1
Selector:               app=kubernetes-bootcamp
Replicas:               1 desired | 1 updated | 1 total | 1 available | 0 unavailable
StrategyType:           RollingUpdate
MinReadySeconds:        0
RollingUpdateStrategy:  25% max unavailable, 25% max surge
Pod Template:
  Labels:  app=kubernetes-bootcamp
  Containers:
   kubernetes-bootcamp:
    Image:        jocatalin/kubernetes-bootcamp:v1
    Port:         8080/TCP
    Host Port:    0/TCP
    Environment:  <none>
    Mounts:       <none>
  Volumes:        <none>
Conditions:
  Type           Status  Reason
  ----           ------  ------
  Available      True    MinimumReplicasAvailable
  Progressing    True    NewReplicaSetAvailable
OldReplicaSets:  <none>
NewReplicaSet:   kubernetes-bootcamp-fb5c67579 (1/1 replicas created)
Events:
  Type    Reason             Age   From                   Message
  ----    ------             ----  ----                   -------
  Normal  ScalingReplicaSet  39m   deployment-controller  Scaled up replica set kubernetes-bootcamp-fb5c67579 to 1

执行 kubectl get 命令时,通过 -l 参数可以按指定标签查询列表,比如查询 Pod 列表:

$ kubectl get pods -l app=kubernetes-bootcamp
NAME                                  READY   STATUS    RESTARTS   AGE
kubernetes-bootcamp-fb5c67579-8sm7d   1/1     Running   0          49m

同样的,使用标签查询 Service 列表:

$ kubectl get services -l app=kubernetes-bootcamp
NAME                  TYPE       CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
kubernetes-bootcamp   NodePort   10.111.158.145   <none>        8080:31006/TCP   51m

当然,我们也可以给某个对象手工指定标签,执行 kubectl label 命令:

$ kubectl label pods kubernetes-bootcamp-fb5c67579-8sm7d version=v1
pod/kubernetes-bootcamp-fb5c67579-8sm7d labeled

执行 kubectl describe pods 命令确认标签是否添加成功:

$ kubectl describe pods kubernetes-bootcamp-fb5c67579-8sm7d
Name:         kubernetes-bootcamp-fb5c67579-8sm7d
Namespace:    default
Priority:     0
Node:         minikube/10.0.0.8
Start Time:   Thu, 16 Jun 2022 22:38:03 +0000
Labels:       app=kubernetes-bootcamp
              pod-template-hash=fb5c67579
              version=v1
......

使用新标签查询 Pod 列表:

$ kubectl get pods -l version=v1
NAME                                  READY   STATUS    RESTARTS   AGE
kubernetes-bootcamp-fb5c67579-8sm7d   1/1     Running   0          7m52s

最后,使用 kubectl delete service 删除刚刚创建的 Service,可以使用 -l 根据标签删除指定的 Service:

$ kubectl delete service -l app=kubernetes-bootcamp
service "kubernetes-bootcamp" deleted

确认下 Service 已经删除:

$ kubectl get services
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   8m59s

此时,从集群外已经无法访问我们的服务了:

$ curl $(minikube ip):31006
curl: (7) Failed to connect to 10.0.0.10 port 31006: Connection refused

但是,我们的服务还是处于运行状态的,可以通过 Pod 的 IP 访问或进入 Pod 内部访问:

$ kubectl exec -ti kubernetes-bootcamp-fb5c67579-8sm7d -- curl localhost:8080
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-8sm7d | v=1

如果要停止服务,需要将 Deployment 删除掉才可以。

运行应用程序的多个实例

通过上面的学习,我们创建了一个 Deployment,然后通过 Service 让其可以从外部访问。默认情况下,Deployment 为我们的应用程序创建了一个 Pod,当然,我们可以根据需要,对我们的应用程序进行扩缩(扩容或缩容),改变 Pod 的副本集数量。

ReplicaSet 实现了 Pod 的多副本管理,使用 Deployment 时会自动创建 ReplicaSet,我们通常不需要直接使用 ReplicaSet。Deployment 提供了比 ReplicaSet 更丰富的功能,比如版本记录和回滚等。

使用 kubectl get rs 查看集群中运行中的 ReplicaSet 列表:

$ kubectl get rs
NAME                            DESIRED   CURRENT   READY   AGE
kubernetes-bootcamp-fb5c67579   1         1         1       28m

如果要调整副本集数量,使用 kubectl scale 命令:

$ kubectl scale deployments/kubernetes-bootcamp --replicas=4
deployment.apps/kubernetes-bootcamp scaled

其中,--replicas=4 表示将应用程序扩容到 4 个副本:

$ kubectl get rs
NAME                            DESIRED   CURRENT   READY   AGE
kubernetes-bootcamp-fb5c67579   4         4         4       30m

使用 kubectl get pods 可以看到启动了 4 个 Pod,每个 Pod 都分配了一个独立的 IP:

$ kubectl get pods -o wide
NAME                                  READY   STATUS    RESTARTS   AGE   IP           NODE       NOMINATED NODE   READINESS GATES
kubernetes-bootcamp-fb5c67579-cgwjm   1/1     Running   0          65s   172.18.0.7   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-d7l4n   1/1     Running   0          65s   172.18.0.9   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-mpn68   1/1     Running   0          30m   172.18.0.2   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-tk2lm   1/1     Running   0          65s   172.18.0.8   minikube   <none>           <none>

执行 kubectl describe deployments 可以看到副本集调整的事件:

$ kubectl describe deployments/kubernetes-bootcamp
Name:                   kubernetes-bootcamp
Namespace:              default
CreationTimestamp:      Sun, 19 Jun 2022 03:16:25 +0000
Labels:                 app=kubernetes-bootcamp
Annotations:            deployment.kubernetes.io/revision: 1
Selector:               app=kubernetes-bootcamp
Replicas:               4 desired | 4 updated | 4 total | 4 available | 0 unavailable
StrategyType:           RollingUpdate
MinReadySeconds:        0
RollingUpdateStrategy:  25% max unavailable, 25% max surge
Pod Template:
  Labels:  app=kubernetes-bootcamp
  Containers:
   kubernetes-bootcamp:
    Image:        jocatalin/kubernetes-bootcamp:v1
    Port:         8080/TCP
    Host Port:    0/TCP
    Environment:  <none>
    Mounts:       <none>
  Volumes:        <none>
Conditions:
  Type           Status  Reason
  ----           ------  ------
  Progressing    True    NewReplicaSetAvailable
  Available      True    MinimumReplicasAvailable
OldReplicaSets:  <none>
NewReplicaSet:   kubernetes-bootcamp-fb5c67579 (4/4 replicas created)
Events:
  Type    Reason             Age    From                   Message
  ----    ------             ----   ----                   -------
  Normal  ScalingReplicaSet  32m    deployment-controller  Scaled up replica set kubernetes-bootcamp-fb5c67579 to 1
  Normal  ScalingReplicaSet  2m38s  deployment-controller  Scaled up replica set kubernetes-bootcamp-fb5c67579 to 4

执行 kubectl describe services 可以看到 Endpoints 变成了多个:

$ kubectl describe services/kubernetes-bootcamp
Name:                     kubernetes-bootcamp
Namespace:                default
Labels:                   app=kubernetes-bootcamp
Annotations:              <none>
Selector:                 app=kubernetes-bootcamp
Type:                     NodePort
IP Families:              <none>
IP:                       10.96.61.243
IPs:                      10.96.61.243
Port:                     <unset>  8080/TCP
TargetPort:               8080/TCP
NodePort:                 <unset>  31955/TCP
Endpoints:                172.18.0.2:8080,172.18.0.7:8080,172.18.0.8:8080 + 1 more...
Session Affinity:         None
External Traffic Policy:  Cluster
Events:                   <none>

Service 会对请求自动进行负载均衡,我们发送多次请求,可以看到请求会落到不同的 Pod 上:

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-d7l4n | v=1

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-mpn68 | v=1

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-cgwjm | v=1

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-fb5c67579-tk2lm | v=1

我们再次执行 kubectl scale,将副本数改成 2:

$ kubectl scale deployments/kubernetes-bootcamp --replicas=2
deployment.apps/kubernetes-bootcamp scaled

查看 Deployment 列表显示当前的副本集个数:

$ kubectl get deployments
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
kubernetes-bootcamp   2/2     2            2           45m

再次查看 Pod 列表,可以发现有两个 Pod 被停止(Terminating)了:

$ kubectl get pods -o wide
NAME                                  READY   STATUS        RESTARTS   AGE   IP           NODE       NOMINATED NODE   READINESS GATES
kubernetes-bootcamp-fb5c67579-cgwjm   1/1     Terminating   0          15m   172.18.0.7   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-d7l4n   1/1     Running       0          15m   172.18.0.9   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-mpn68   1/1     Running       0          45m   172.18.0.2   minikube   <none>           <none>
kubernetes-bootcamp-fb5c67579-tk2lm   1/1     Terminating   0          15m   172.18.0.8   minikube   <none>           <none>

执行滚动更新

当我们的应用程序运行多个实例时,我们就可以对我们的应用进行零停机的滚动更新(Rolling Update),而且所有的更新都是经过版本控制的,任何 Deployment 更新都可以恢复到以前的(稳定)版本。

下面使用 kubectl set image 命令执行滚动更新,将应用版本升级到 v2:

$ kubectl set image deployments/kubernetes-bootcamp kubernetes-bootcamp=jocatalin/kubernetes-bootcamp:v2
deployment.apps/kubernetes-bootcamp image updated

等待应用更新完毕后,再次访问我们的应用,成功升级到 v2:

$ curl $(minikube ip):31955
Hello Kubernetes bootcamp! | Running on: kubernetes-bootcamp-7d44784b7c-4ntgh | v=2

也可以使用 kubectl rollout status 查看更新是否成功:

$ kubectl rollout status deployments/kubernetes-bootcamp
deployment "kubernetes-bootcamp" successfully rolled out

如果我们更新的时候出错了,比如更新到一个不存在的镜像:

$ kubectl set image deployments/kubernetes-bootcamp kubernetes-bootcamp=jocatalin/kubernetes-bootcamp:v10
deployment.apps/kubernetes-bootcamp image updated

可以发现 Kubernetes 并不会直接将四个 Pod 一股脑都升级到错误的镜像,而是先停止一个 Pod,等待这个 Pod 更新成功,再更新下一个,保证应用服务一直是可访问的(也就是零停机)。

执行 kubectl get deployments 可以看到当前有 3 个可用的应用实例,有 2 个正在更新:

$ kubectl get deployments
NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
kubernetes-bootcamp   3/4     2            3           13m

使用 kubectl get pods 可以看到有 2 个 Pod 一直处于 ImagePullBackOff 状态(因为这个镜像不存在):

$ kubectl get pods
NAME                                   READY   STATUS             RESTARTS   AGE
kubernetes-bootcamp-59b7598c77-8nfsh   0/1     ImagePullBackOff   0          2m35s
kubernetes-bootcamp-59b7598c77-rcdfq   0/1     ImagePullBackOff   0          2m35s
kubernetes-bootcamp-7d44784b7c-9vwn2   1/1     Running            0          13m
kubernetes-bootcamp-7d44784b7c-qcgnn   1/1     Running            0          13m
kubernetes-bootcamp-7d44784b7c-vfn79   1/1     Running            0          13m

下面是 kubectl rollout status 命令的输出:

$ kubectl rollout status deployments/kubernetes-bootcamp
Waiting for deployment "kubernetes-bootcamp" rollout to finish: 2 out of 4 new replicas have been updated...

这时可以使用 kubectl rollout undo 执行回滚操作,应用程序将会全部恢复到之前的 v2 版本:

$ kubectl rollout undo deployments/kubernetes-bootcamp
deployment.apps/kubernetes-bootcamp rolled back

$ kubectl rollout status deployments/kubernetes-bootcamp
deployment "kubernetes-bootcamp" successfully rolled out

参考

  1. 学习 Kubernetes 基础知识
  2. Kubernetes 术语表
  3. Kubernetes 指南

更多

了解 Kubernetes 基本概念

Cluster

Cluster 是计算、存储和网络资源的集合,Kubernete 利用这些资源运行各种基于容器的应用。

Master

Master 的主要职责是调度,决定将应用放在哪个 Node 上运行。

Node

Node 的主要职责是运行容器应用,同时监控并汇报容器状态。

Pod

Kubernetes 以 Pod 作为最小工作单元进行调度和管理,每个 Pod 包含一个或多个容器,Pod 中的容器会作为一个整体被调度到一个 Node 上运行。

Pod 中的所有容器使用同一个网络,即相同的 IP 地址和 Port 空间,它们可以直接使用 localhost 通信。这些容器之间也共享存储,当 Kubernetes 挂载 Volumne 到 Pod,本质上是将 Volumne 挂载到 Pod 里的每个容器。

Controller

Kubernetes 通常不会直接创建 Pod,而是通过 Controller 来管理 Pod 的。为满足不同的业务场景,Kubernete 提供了多种 Controller:

  1. Deployment - 通过 ReplicaSet 实现 Pod 的多副本管理,还提供版本升级和回滚等功能
  2. ReplicaSet - 实现 Pod 的多副本管理
  3. DaemonSet - 每个 Node 最多只运行一个 Pod 副本
  4. StatefulSet - 保证 Pod 的每个副本在整个生命周期中名称不变
  5. Job - 运行一次性的任务,运行结束后自动删除

Service

Deployment 可以部署多个副本,每个 Pod 都有自己的 IP,但是我们不能通过 Pod 的 IP 直接来访问,因为 Pod 很可能会被频繁地销毁和重启,它们的 IP 会发生变化。

于是引入了 Service 的概念,Service 提供了访问一组特定 Pod 的方式,它有自己的 IP 和端口,并为 Pod 提供负载均衡。

Namespace

Namespace 一般用于多租户隔离,它将一个物理的 Cluster 划分成多个虚拟 Cluster,不同 Namespace 里的资源是完全隔离的。Kubernetes 默认创建了两个 Namespace:

$ kubectl get namespace
NAME                   STATUS   AGE
default                Active   28s
kube-system            Active   29s

创建资源时如果不指定 Namespace,默认会放在 default 中,Kubernetes 自己创建的系统资源放在 kube-system 中。

Kubernetes 架构

Kubernetes 主要由以下几个核心组件组成:

  • etcd 保存了整个集群的状态;
  • kube-apiserver 提供了资源操作的唯一入口,并提供认证、授权、访问控制、API 注册和发现等机制;
  • kube-controller-manager 负责维护集群的状态,比如故障检测、自动扩展、滚动更新等;
  • kube-scheduler 负责资源的调度,按照预定的调度策略将 Pod 调度到相应的机器上;
  • kubelet 负责维持容器的生命周期,同时也负责 Volume(CVI)和网络(CNI)的管理;
  • Container runtime 负责镜像管理以及 Pod 和容器的真正运行(CRI);
  • kube-proxy 负责为 Service 提供 cluster 内部的服务发现和负载均衡;

components.png

Kubernetes 其他教程

https://kubernetes.io/docs/tutorials/

Kubernetes 任务

https://kubernetes.io/docs/tasks/

扫描二维码,在手机上阅读!

Kubernetes 安装小记

Kubernetes 集群环境的安装比较复杂,需要考虑网络、存储等一系列的问题,在这篇笔记中,我们先学习使用 kind 或 minikube 安装单机环境,在对 Kubernetes 的组件和基本概念有一定认识之后,再尝试部署集群环境。

安装 kubectl

在安装 Kubernetes 之前,我们首先需要安装 kubectl,这是 Kubernetes 的命令行工具,用来在 Kubernetes 集群上运行命令,你可以使用 kubectl 来部署应用、监测和管理集群资源以及查看日志。安装 kubectl 最简单的方式是使用 curl 命令,首先执行下面的命令下载 kubectl

$ curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"

然后将 kubectl 安装到 /usr/local/bin 目录:

$ sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

installcp 命令类似,都可以将文件或目录拷贝到指定的地方,不过 install 允许你控制文件的属性。-o, --owner 参数用来设置所有者,-g, --group 参数用来设置组,-m, --mode 类似于 chmod 的设定文件权限模式。

安装完成后,运行 kubectl version 查看版本的详细信息:

[root@localhost ~]# kubectl version --client --output=json
{
  "clientVersion": {
    "major": "1",
    "minor": "24",
    "gitVersion": "v1.24.0",
    "gitCommit": "4ce5a8954017644c5420bae81d72b09b735c21f0",
    "gitTreeState": "clean",
    "buildDate": "2022-05-03T13:46:05Z",
    "goVersion": "go1.18.1",
    "compiler": "gc",
    "platform": "linux/amd64"
  },
  "kustomizeVersion": "v4.5.4"
}

由于此时还没有安装 Kubernetes,所以使用 --client 仅显示客户端的版本。

使用 kind 安装 Kubernetes

kind 是 Kubernetes IN Docker 的简写,是一个使用 Docker 容器作为 Nodes,在本地创建和运行 Kubernetes 集群的工具。适用于在本机创建 Kubernetes 集群环境进行开发和测试。

安装 kind

和安装 kubectl 类似,首先使用 curl 下载:

$ curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.12.0/kind-linux-amd64

再使用 install 安装:

$ sudo install -o root -g root -m 0755 kind /usr/local/bin/kind

使用 kind --help 查看帮助:

[root@localhost ~]# kind --help
kind creates and manages local Kubernetes clusters using Docker container 'nodes'

Usage:
  kind [command]

Available Commands:
  build       Build one of [node-image]
  completion  Output shell completion code for the specified shell (bash, zsh or fish)
  create      Creates one of [cluster]
  delete      Deletes one of [cluster]
  export      Exports one of [kubeconfig, logs]
  get         Gets one of [clusters, nodes, kubeconfig]
  help        Help about any command
  load        Loads images into nodes
  version     Prints the kind CLI version

Flags:
  -h, --help              help for kind
      --loglevel string   DEPRECATED: see -v instead
  -q, --quiet             silence all stderr output
  -v, --verbosity int32   info log verbosity, higher value produces more output
      --version           version for kind

Use "kind [command] --help" for more information about a command.

创建 Kubernetes 集群

使用简单的一句命令 kind create cluster 就可以在本地创建一整套 Kubernetes 集群,这样的环境用于实验再合适不过:

[root@localhost ~]# kind create cluster
Creating cluster "kind" ...
 ✓ Ensuring node image (kindest/node:v1.23.4) 🖼 
 ✓ Preparing nodes 📦  
 ✓ Writing configuration 📜 
 ✓ Starting control-plane 🕹️
 ✓ Installing CNI 🔌 
 ✓ Installing StorageClass 💾 
Set kubectl context to "kind-kind"
You can now use your cluster with:

kubectl cluster-info --context kind-kind

Thanks for using kind! 😊

此时再运行 kubectl version 命令,就可以看到 Kubernetes 服务端的信息了:

[root@localhost ~]# kubectl version --output=json
{
  "clientVersion": {
    "major": "1",
    "minor": "24",
    "gitVersion": "v1.24.0",
    "gitCommit": "4ce5a8954017644c5420bae81d72b09b735c21f0",
    "gitTreeState": "clean",
    "buildDate": "2022-05-03T13:46:05Z",
    "goVersion": "go1.18.1",
    "compiler": "gc",
    "platform": "linux/amd64"
  },
  "kustomizeVersion": "v4.5.4",
  "serverVersion": {
    "major": "1",
    "minor": "23",
    "gitVersion": "v1.23.4",
    "gitCommit": "e6c093d87ea4cbb530a7b2ae91e54c0842d8308a",
    "gitTreeState": "clean",
    "buildDate": "2022-03-06T21:32:53Z",
    "goVersion": "go1.17.7",
    "compiler": "gc",
    "platform": "linux/amd64"
  }
}

使用 docker ps 可以看到一个名为 kind-control-plane 的容器,他暴露出来的端口 127.0.0.1:45332 就是我们 kubectl 访问的端口。

[root@localhost ~]# docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED       STATUS       PORTS                       NAMES
2d2f2ed13eaa   kindest/node:v1.23.4   "/usr/local/bin/entr…"   2 hours ago   Up 2 hours   127.0.0.1:45332->6443/tcp   kind-control-plane

kind 将整个 Kubernetes 组件内置在 kindest/node 镜像中,可以使用该镜像创建多个 Kubernetes 集群。默认创建的集群名为 kind,也可以使用 --name 参数指定集群名:

[root@localhost ~]# kind create cluster --name kind-2

获取集群列表:

[root@localhost ~]# kind get clusters
kind
kind2

使用 kubectl cluster-info 切换集群:

kubectl cluster-info --context kind-kind
kubectl cluster-info --context kind-kind-2

我们使用 docker exec 进入容器内部看看:

[root@localhost ~]# docker exec -it 2d2 bash
root@kind-control-plane:/# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.1  16544  1084 ?        Ss   09:43   0:00 /sbin/init
root         192  0.0  0.1  19448  1504 ?        S<s  09:44   0:00 /lib/systemd/systemd-journald
root         204  3.3  2.2 1437696 23204 ?       Ssl  09:44   4:09 /usr/local/bin/containerd
root         310  0.0  0.3 713276  3600 ?        Sl   09:44   0:02 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 6e308f31e4045e7f5e3f8ab7
root         317  0.0  0.3 713276  3428 ?        Sl   09:44   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 9de51bcebdf67ce484709b90
root         351  0.0  0.3 713276  3432 ?        Sl   09:44   0:02 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id b0094313aab3af65958f7a74
root         363  0.0  0.4 713276  4060 ?        Sl   09:44   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 12136290af44076c5b5faa19
root         483  3.1  4.1 11214772 41872 ?      Ssl  09:44   3:56 etcd --advertise-client-urls=https://172.18.0.2:2379 --cert-file=/etc/kubernetes/pki/
root         562  6.5 18.7 1056224 190292 ?      Ssl  09:44   8:08 kube-apiserver --advertise-address=172.18.0.2 --allow-privileged=true --authorization
root         651  3.1  4.3 1402784 44492 ?       Ssl  09:44   3:55 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kube
root         899  0.0  0.3 713276  3284 ?        Sl   09:46   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 383bf2636b6a0b1e14b8cd08
root         915  0.0  0.3 713020  3596 ?        Sl   09:46   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id b0fc9bc1eaf15846855b4e5e
root         983  0.0  0.7 733188  7908 ?        Ssl  09:46   0:04 /bin/kindnetd
root        1023  0.0  1.0 748152 11136 ?        Ssl  09:46   0:04 /usr/local/bin/kube-proxy --config=/var/lib/kube-proxy/config.conf --hostname-overrid
root        1234  2.9  4.1 767820 42316 ?        Ssl  09:47   3:34 kube-controller-manager --allocate-node-cidrs=true --authentication-kubeconfig=/etc/k
root        1274  0.4  1.9 754000 19516 ?        Ssl  09:47   0:34 kube-scheduler --authentication-kubeconfig=/etc/kubernetes/scheduler.conf --authoriza
root        1367  0.0  0.3 713020  3772 ?        Sl   09:47   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id dc694d8f939cfec4277911fe
root        1371  0.0  0.4 713276  4848 ?        Sl   09:47   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 41b361804234b5c0fc353ff6
root        1393  0.0  0.3 713276  4044 ?        Sl   09:47   0:01 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id e4befc1237963effcb2a594b
root        1499  0.1  1.1 750568 11884 ?        Ssl  09:47   0:12 /coredns -conf /etc/coredns/Corefile
root        1526  0.1  1.1 750568 11772 ?        Ssl  09:47   0:13 /coredns -conf /etc/coredns/Corefile
root        2904  0.2  0.1   4580  1040 pts/1    Ss   11:47   0:00 bash
root        2980  0.6  0.6 136664  6392 ?        Ssl  11:48   0:00 local-path-provisioner --debug start --helper-image k8s.gcr.io/build-image/debian-bas
root        3010  0.0  0.1   6900  1420 pts/1    R+   11:48   0:00 ps aux

可以看到这些进程:

  • kindnetd - 一款简单的 CNI 插件
  • containerd - 使用 containerd 作为容器运行时,弃用 Dockershim 对 kind 没有影响
  • containerd-shim-runc-v2
  • pause
  • coredns - 为集群提供 DNS 和服务发现的功能
  • etcd - 服务发现的后端,并存储集群状态和配置
  • kubelet - 运行在每个节点上的代理,用来处理 Master 节点下发到本节点的任务
  • kube-apiserver - 提供集群管理的 REST API 接口,是模块之间的数据交互和通信的枢纽,只有 apiserver 能访问 etcd
  • kube-proxy - 实现 Kubernetes Service 的通信与负载均衡
  • kube-controller-manager - 是 Kubernetes 的大脑,它通过 apiserver 监控整个集群的状态,并确保集群处于预期的工作状态
  • kube-scheduler - 负责分配调度 Pod 到集群内的节点上,它监听 apiserver,查询还未分配 Node 的 Pod,然后根据调度策略为这些 Pod 分配节点
  • local-path-provisioner - 本地持久化存储

使用 minikube 安装 Kubernetes

minikube 是由 Google 发布的一款轻量级工具,让开发者可以在本机上轻易运行一个 Kubernetes 集群,快速上手 Kubernetes 的指令与环境。minikube 会在本机运行一个容器或虚拟机,并且在这个容器或虚拟机中启动一个 single-node Kubernetes 集群,它不支持 HA,不推荐在生产环境使用。

安装 minikube

minikube 的安装也和上面的 kindkubectl 一样,先使用 curl 下载:

$ curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64

再通过 install 将其安装到 /usr/local/bin 目录:

$ sudo install minikube-linux-amd64 /usr/local/bin/minikube

创建 Kubernetes 集群

使用 minikube 创建 Kubernetes 集群比 kind 稍微多一些限制:

  • 2 CPUs or more
  • 2GB of free memory
  • 20GB of free disk space

否则会报下面这些错误。

CPU 核数不够:

X Exiting due to RSRC_INSUFFICIENT_CORES: Requested cpu count 2 is greater than the available cpus of 1

内存不够:

X Exiting due to RSRC_INSUFFICIENT_CONTAINER_MEMORY: docker only has 990MiB available, less than the required 1800MiB for Kubernetes

另外,当我们使用 Docker 作为驱动时,需要以非 root 用户运行:

X Exiting due to DRV_AS_ROOT: The "docker" driver should not be used with root privileges.

Docker 在安装时会默认创建一个叫 docker 的用户组,可以在 /etc/group 文件中找到 docker 用户组的 id,然后使用 adduser 在该用户组下添加一个 docker 用户,su - docker 切换到 docker 用户就可以以非 root 用户运行 Docker 了:

[root@localhost ~]# grep docker /etc/group
docker:x:995:

[root@localhost ~]# adduser -g 995 -c "Docker" docker

[root@localhost ~]# id docker
uid=1000(docker) gid=995(docker) 组=995(docker)

[root@localhost ~]# su - docker

一切准备就绪,执行 minikube start 创建 Kubernetes 集群:

[docker@localhost ~]$ minikube start
* Centos 7.9.2009 上的 minikube v1.25.2
* 根据现有的配置文件使用 docker 驱动程序
* Starting control plane node minikube in cluster minikube
* Pulling base image ...
    > index.docker.io/kicbase/sta...: 0 B [____________________] ?% ? p/s 6m29s
! minikube was unable to download gcr.io/k8s-minikube/kicbase:v0.0.30, but successfully downloaded docker.io/kicbase/stable:v0.0.30 as a fallback image
* Creating docker container (CPUs=2, Memory=2200MB) ...

X Docker is nearly out of disk space, which may cause deployments to fail! (90% of capacity)
* 建议:

    Try one or more of the following to free up space on the device:
    
    1. Run "docker system prune" to remove unused Docker data (optionally with "-a")
    2. Increase the storage allocated to Docker for Desktop by clicking on:
    Docker icon > Preferences > Resources > Disk Image Size
    3. Run "minikube ssh -- docker system prune" if using the Docker container runtime
* Related issue: https://github.com/kubernetes/minikube/issues/9024

! This container is having trouble accessing https://k8s.gcr.io
* To pull new external images, you may need to configure a proxy: https://minikube.sigs.k8s.io/docs/reference/networking/proxy/
* 正在 Docker 20.10.12 中准备 Kubernetes v1.23.3…
  - kubelet.housekeeping-interval=5m
  - Generating certificates and keys ...
  - Booting up control plane ...
  - Configuring RBAC rules ...
* Verifying Kubernetes components...
  - Using image gcr.io/k8s-minikube/storage-provisioner:v5
* Enabled addons: default-storageclass, storage-provisioner
* Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default

使用 docker ps 可以看到 minikube 使用 kicbase/stable 镜像启动了一个容器,该容器暴露了以下几个端口:

  • 49157->22
  • 49156->2376
  • 49155->5000
  • 49154->8443
  • 49153->32443
[docker@localhost ~]$ docker ps -a
CONTAINER ID   IMAGE                    COMMAND                  CREATED         STATUS         PORTS                                                                                                                                  NAMES
d7e2ffaba188   kicbase/stable:v0.0.30   "/usr/local/bin/entr…"   2 minutes ago   Up 2 minutes   127.0.0.1:49157->22/tcp, 127.0.0.1:49156->2376/tcp, 127.0.0.1:49155->5000/tcp, 127.0.0.1:49154->8443/tcp, 127.0.0.1:49153->32443/tcp   minikube

使用 kubeadm 安装 Kubernetes

kubeadm 是 Kubernetes 社区提供的集群构建工具,它负责构建一个最小化可用集群并执行启动等必要的基本步骤,简单来讲,kubeadm 是 Kubernetes 集群全生命周期管理工具,可用于实现集群的部署、升级/降级及卸载等。按照设计,它只关注启动引导,而非配置机器。同样的,安装各种 “锦上添花” 的扩展,例如 Kubernetes Dashboard、监控方案、以及特定云平台的扩展,都不在讨论范围内。

安装 kubeadm、kubelet 和 kubectl

首先我们需要安装这三个组件:

  • kubeadm - 用于启动集群
  • kubelet - 运行在集群中的每一台机器上,用于启动 Pod 和 容器
  • kubectl - 用于管理集群

虽然官方提供了 yum 和 apt-get 的安装方式,但是这里我打算手工安装下,这样可以更好的加深理解。

下载 CNI 插件

绝大多数 Pod 网络都需要 CNI 插件。

[root@localhost ~]# mkdir -p /opt/cni/bin
[root@localhost ~]# curl -L "https://github.com/containernetworking/plugins/releases/download/v0.8.2/cni-plugins-linux-amd64-v0.8.2.tgz" | sudo tar -C /opt/cni/bin -xz

可以看到这里提供了很多不同的 CNI 插件:

[root@localhost ~]# ls /opt/cni/bin/
bandwidth  bridge  dhcp  firewall  flannel  host-device  host-local  ipvlan  loopback  macvlan  portmap  ptp  sbr  static  tuning  vlan

安装 crictl

crictlCRI 兼容的容器运行时命令行接口。你可以使用它来检查和调试 Kubernetes 节点上的容器运行时和应用程序。

[root@localhost ~]# curl -L "https://github.com/kubernetes-sigs/cri-tools/releases/download/v1.22.0/crictl-v1.22.0-linux-amd64.tar.gz" | sudo tar -C /usr/local/bin -xz

使用 crictl --help 查看帮助:

[root@localhost ~]# crictl --help
NAME:
   crictl - client for CRI

USAGE:
   crictl [global options] command [command options] [arguments...]

VERSION:
   v1.22.0

COMMANDS:
   attach              Attach to a running container
   create              Create a new container
   exec                Run a command in a running container
   version             Display runtime version information
   images, image, img  List images
   inspect             Display the status of one or more containers
   inspecti            Return the status of one or more images
   imagefsinfo         Return image filesystem info
   inspectp            Display the status of one or more pods
   logs                Fetch the logs of a container
   port-forward        Forward local port to a pod
   ps                  List containers
   pull                Pull an image from a registry
   run                 Run a new container inside a sandbox
   runp                Run a new pod
   rm                  Remove one or more containers
   rmi                 Remove one or more images
   rmp                 Remove one or more pods
   pods                List pods
   start               Start one or more created containers
   info                Display information of the container runtime
   stop                Stop one or more running containers
   stopp               Stop one or more running pods
   update              Update one or more running containers
   config              Get and set crictl client configuration options
   stats               List container(s) resource usage statistics
   completion          Output shell completion code
   help, h             Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --config value, -c value            Location of the client config file. If not specified and the default does not exist, the program's directory is searched as well (default: "/etc/crictl.yaml") [$CRI_CONFIG_FILE]
   --debug, -D                         Enable debug mode (default: false)
   --image-endpoint value, -i value    Endpoint of CRI image manager service (default: uses 'runtime-endpoint' setting) [$IMAGE_SERVICE_ENDPOINT]
   --runtime-endpoint value, -r value  Endpoint of CRI container runtime service (default: uses in order the first successful one of [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock]). Default is now deprecated and the endpoint should be set instead. [$CONTAINER_RUNTIME_ENDPOINT]
   --timeout value, -t value           Timeout of connecting to the server in seconds (e.g. 2s, 20s.). 0 or less is set to default (default: 2s)
   --help, -h                          show help (default: false)
   --version, -v                       print the version (default: false)

不过在执行 crictl ps 的时候报错了:

[root@localhost ~]# crictl ps
WARN[0000] runtime connect using default endpoints: [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock]. As the default settings are now deprecated, you should set the endpoint instead. 
ERRO[0002] connect endpoint 'unix:///var/run/dockershim.sock', make sure you are running as root and the endpoint has been started: context deadline exceeded 
WARN[0002] image connect using default endpoints: [unix:///var/run/dockershim.sock unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock]. As the default settings are now deprecated, you should set the endpoint instead. 
ERRO[0004] connect endpoint 'unix:///var/run/dockershim.sock', make sure you are running as root and the endpoint has been started: context deadline exceeded 
FATA[0004] listing containers: rpc error: code = Unimplemented desc = unknown service runtime.v1alpha2.RuntimeService 

前面几行报错信息是因为 crictl 默认会按照顺序:dockershim.sock -> containerd.sock -> crio.sock 来检查系统内是否存在对应的运行时环境,所以会报 dockershim.sock 的连接报错信息,我们这里通过下面的命令将 crictl 的默认运行时修改成 containerd.sock

[root@localhost ~]# crictl config runtime-endpoint unix:///run/containerd/containerd.sock
[root@localhost ~]# crictl config image-endpoint unix:///run/containerd/containerd.sock

而最后一行报错,是一个比较坑的地方,是因为 containerd 的配置有问题,我们检查 containerd 的配置文件 /etc/containerd/config.toml

[root@localhost ~]# cat /etc/containerd/config.toml
#   Copyright 2018-2020 Docker Inc.

#   Licensed under the Apache License, Version 2.0 (the "License");
#   you may not use this file except in compliance with the License.
#   You may obtain a copy of the License at

#       http://www.apache.org/licenses/LICENSE-2.0

#   Unless required by applicable law or agreed to in writing, software
#   distributed under the License is distributed on an "AS IS" BASIS,
#   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#   See the License for the specific language governing permissions and
#   limitations under the License.

disabled_plugins = ["cri"]

#root = "/var/lib/containerd"
#state = "/run/containerd"
#subreaper = true
#oom_score = 0

#[grpc]
#  address = "/run/containerd/containerd.sock"
#  uid = 0
#  gid = 0

#[debug]
#  address = "/run/containerd/debug.sock"
#  uid = 0
#  gid = 0
#  level = "info"

发现里面有一行 disabled_plugins = ["cri"],这是 Docker 默认安装时的配置,我们将这个配置删除,并重启 containerd:

[root@localhost ~]# rm /etc/containerd/config.toml
[root@localhost ~]# systemctl restart containerd

安装 kubeadm、kubelet 和 kubectl

[root@localhost ~]# cd /usr/local/bin
[root@localhost bin]# curl -L --remote-name-all https://storage.googleapis.com/kubernetes-release/release/v1.24.0/bin/linux/amd64/{kubeadm,kubelet,kubectl}
[root@localhost bin]# chmod +x {kubeadm,kubelet,kubectl}

这三个组件安装好之后,我们需要将 kubelet 添加到 systemd 服务。首先直接从官方下载服务定义的模板,修改其中 kubelet 的路径:

[root@localhost ~]# curl -sSL "https://raw.githubusercontent.com/kubernetes/release/v0.4.0/cmd/kubepkg/templates/latest/deb/kubelet/lib/systemd/system/kubelet.service" | sed "s:/usr/bin:/usr/local/bin:g" | tee /etc/systemd/system/kubelet.service

[Unit]
Description=kubelet: The Kubernetes Node Agent
Documentation=https://kubernetes.io/docs/home/
Wants=network-online.target
After=network-online.target

[Service]
ExecStart=/usr/local/bin/kubelet
Restart=always
StartLimitInterval=0
RestartSec=10

[Install]
WantedBy=multi-user.target

然后再下载 kubeadm 的配置文件:

[root@localhost ~]# mkdir -p /etc/systemd/system/kubelet.service.d
[root@localhost ~]# curl -sSL "https://raw.githubusercontent.com/kubernetes/release/v0.4.0/cmd/kubepkg/templates/latest/deb/kubeadm/10-kubeadm.conf" | sed "s:/usr/bin:/usr/local/bin:g" | tee /etc/systemd/system/kubelet.service.d/10-kubeadm.conf

# Note: This dropin only works with kubeadm and kubelet v1.11+
[Service]
Environment="KUBELET_KUBECONFIG_ARGS=--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf"
Environment="KUBELET_CONFIG_ARGS=--config=/var/lib/kubelet/config.yaml"
# This is a file that "kubeadm init" and "kubeadm join" generates at runtime, populating the KUBELET_KUBEADM_ARGS variable dynamically
EnvironmentFile=-/var/lib/kubelet/kubeadm-flags.env
# This is a file that the user can use for overrides of the kubelet args as a last resort. Preferably, the user should use
# the .NodeRegistration.KubeletExtraArgs object in the configuration files instead. KUBELET_EXTRA_ARGS should be sourced from this file.
EnvironmentFile=-/etc/default/kubelet
ExecStart=
ExecStart=/usr/local/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS

最后,启动 kubelet 服务:

[root@localhost ~]# systemctl enable --now kubelet
Created symlink from /etc/systemd/system/multi-user.target.wants/kubelet.service to /etc/systemd/system/kubelet.service.

使用 kubeadm init 创建 Kubernetes 集群

接下来我们使用 kubeadm init 来初始化 Kubernetes 集群,这个命令的作用是帮助你启动和 master 节点相关的组件:kube-apiserverkube-controller-managerkube-scheduleretcd 等。在运行之前,我们可以使用 kubeadm config images list 命令查看使用 kubeadm 创建 Kubernetes 集群所需要的镜像:

[root@localhost ~]# kubeadm config images list
k8s.gcr.io/kube-apiserver:v1.24.0
k8s.gcr.io/kube-controller-manager:v1.24.0
k8s.gcr.io/kube-scheduler:v1.24.0
k8s.gcr.io/kube-proxy:v1.24.0
k8s.gcr.io/pause:3.7
k8s.gcr.io/etcd:3.5.3-0
k8s.gcr.io/coredns/coredns:v1.8.6

使用 kubeadm config images pull 提前将镜像下载下来:

[root@localhost ~]# kubeadm config images pull
failed to pull image "k8s.gcr.io/kube-apiserver:v1.24.0": output: time="2022-05-15T12:18:29+08:00" level=fatal msg="pulling image: rpc error: code = Unimplemented desc = unknown service runtime.v1alpha2.ImageService"
, error: exit status 1
To see the stack trace of this error execute with --v=5 or higher

我们发现下载镜像报错,这是因为国内没办法访问 k8s.gcr.io,而且无论是在环境变量中设置代理,还是为 Docker Daemon 设置代理,都不起作用。后来才意识到,kubeadm config images pull 命令貌似不走 docker 服务,而是直接请求 containerd 服务,所以我们为 containerd 服务设置代理:

[root@localhost ~]# mkdir /etc/systemd/system/containerd.service.d
[root@localhost ~]# vi /etc/systemd/system/containerd.service.d/http_proxy.conf

文件内容如下:

[Service]
Environment="HTTP_PROXY=192.168.1.36:10809"
Environment="HTTPS_PROXY=192.168.1.36:10809"

重启 containerd 服务:

[root@localhost ~]# systemctl daemon-reload
[root@localhost ~]# systemctl restart containerd

然后重新下载镜像:

[root@localhost ~]# kubeadm config images pull
[config/images] Pulled k8s.gcr.io/kube-apiserver:v1.24.0
[config/images] Pulled k8s.gcr.io/kube-controller-manager:v1.24.0
[config/images] Pulled k8s.gcr.io/kube-scheduler:v1.24.0
[config/images] Pulled k8s.gcr.io/kube-proxy:v1.24.0
[config/images] Pulled k8s.gcr.io/pause:3.7
[config/images] Pulled k8s.gcr.io/etcd:3.5.3-0
[config/images] Pulled k8s.gcr.io/coredns/coredns:v1.8.6

接下来使用 kubeadm init 初始化 Kubernetes 的控制平面:

[root@localhost ~]# kubeadm init
W0515 14:36:22.763487   21958 version.go:103] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable-1.txt": Get "https://dl.k8s.io/release/stable-1.txt": x509: certificate has expired or is not yet valid: current time 2022-05-15T14:36:22+08:00 is before 2022-05-17T21:21:32Z
W0515 14:36:22.763520   21958 version.go:104] falling back to the local client version: v1.24.0
[init] Using Kubernetes version: v1.24.0
[preflight] Running pre-flight checks
    [WARNING Firewalld]: firewalld is active, please ensure ports [6443 10250] are open or your cluster may not function correctly
    [WARNING Swap]: swap is enabled; production deployments should disable swap unless testing the NodeSwap feature gate of the kubelet
[preflight] Pulling images required for setting up a Kubernetes cluster
[preflight] This might take a minute or two, depending on the speed of your internet connection
[preflight] You can also perform this action in beforehand using 'kubeadm config images pull'
[certs] Using certificateDir folder "/etc/kubernetes/pki"
[certs] Generating "ca" certificate and key
[certs] Generating "apiserver" certificate and key
[certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local localhost.localdomain] and IPs [10.96.0.1 10.0.2.10]
[certs] Generating "apiserver-kubelet-client" certificate and key
[certs] Generating "front-proxy-ca" certificate and key
[certs] Generating "front-proxy-client" certificate and key
[certs] Generating "etcd/ca" certificate and key
[certs] Generating "etcd/server" certificate and key
[certs] etcd/server serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/peer" certificate and key
[certs] etcd/peer serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/healthcheck-client" certificate and key
[certs] Generating "apiserver-etcd-client" certificate and key
[certs] Generating "sa" key and public key
[kubeconfig] Using kubeconfig folder "/etc/kubernetes"
[kubeconfig] Writing "admin.conf" kubeconfig file
[kubeconfig] Writing "kubelet.conf" kubeconfig file
[kubeconfig] Writing "controller-manager.conf" kubeconfig file
[kubeconfig] Writing "scheduler.conf" kubeconfig file
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Starting the kubelet
[control-plane] Using manifest folder "/etc/kubernetes/manifests"
[control-plane] Creating static Pod manifest for "kube-apiserver"
[control-plane] Creating static Pod manifest for "kube-controller-manager"
[control-plane] Creating static Pod manifest for "kube-scheduler"
[etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests"
[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests". This can take up to 4m0s
[kubelet-check] Initial timeout of 40s passed.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.
[kubelet-check] It seems like the kubelet isn't running or healthy.
[kubelet-check] The HTTP call equal to 'curl -sSL http://localhost:10248/healthz' failed with error: Get "http://localhost:10248/healthz": dial tcp [::1]:10248: connect: connection refused.

Unfortunately, an error has occurred:
    timed out waiting for the condition

This error is likely caused by:
    - The kubelet is not running
    - The kubelet is unhealthy due to a misconfiguration of the node in some way (required cgroups disabled)

If you are on a systemd-powered system, you can try to troubleshoot the error with the following commands:
    - 'systemctl status kubelet'
    - 'journalctl -xeu kubelet'

Additionally, a control plane component may have crashed or exited when started by the container runtime.
To troubleshoot, list all containers using your preferred container runtimes CLI.
Here is one example how you may list all running Kubernetes containers by using crictl:
    - 'crictl --runtime-endpoint unix:///var/run/containerd/containerd.sock ps -a | grep kube | grep -v pause'
    Once you have found the failing container, you can inspect its logs with:
    - 'crictl --runtime-endpoint unix:///var/run/containerd/containerd.sock logs CONTAINERID'
error execution phase wait-control-plane: couldn't initialize a Kubernetes cluster
To see the stack trace of this error execute with --v=5 or higher

根据报错信息,是因为 kubelet 服务没有启动成功。使用 systemctl status 查看 kubelet 服务状态为 code=exited, status=1/FAILURE

[root@localhost ~]# systemctl status kubelet
● kubelet.service - kubelet: The Kubernetes Node Agent
   Loaded: loaded (/etc/systemd/system/kubelet.service; enabled; vendor preset: disabled)
  Drop-In: /etc/systemd/system/kubelet.service.d
           └─10-kubeadm.conf
   Active: activating (auto-restart) (Result: exit-code) since 五 2022-05-20 06:46:25 CST; 951ms ago
     Docs: https://kubernetes.io/docs/home/
  Process: 2787 ExecStart=/usr/local/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_CONFIG_ARGS $KUBELET_KUBEADM_ARGS $KUBELET_EXTRA_ARGS (code=exited, status=1/FAILURE)
 Main PID: 2787 (code=exited, status=1/FAILURE)

使用 journalctl -xefu kubelet 进一步排查日志,我们发现这么一行线索(日志非常多,排查需要一点耐心):

5月 20 06:49:29 localhost.localdomain kubelet[3009]: Error: failed to run Kubelet: running with swap on is not supported, please disable swap! or set --fail-swap-on flag to false.

应该是 swap 的问题,通过下面的命令关闭 swap

[root@localhost ~]# swapoff  -a

然后重新执行 kubeadm init,注意要先执行 kubeadm reset

[root@localhost ~]# kubeadm reset
[reset] Reading configuration from the cluster...
[reset] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml'
W0515 15:03:11.771080   25796 reset.go:103] [reset] Unable to fetch the kubeadm-config ConfigMap from cluster: failed to get config map: Get "https://10.0.2.10:6443/api/v1/namespaces/kube-system/configmaps/kubeadm-config?timeout=10s": dial tcp 10.0.2.10:6443: connect: connection refused
W0515 15:03:11.773814   25796 preflight.go:55] [reset] WARNING: Changes made to this host by 'kubeadm init' or 'kubeadm join' will be reverted.
[reset] Are you sure you want to proceed? [y/N]: y
[preflight] Running pre-flight checks
W0515 15:03:13.272040   25796 removeetcdmember.go:84] [reset] No kubeadm config, using etcd pod spec to get data directory
[reset] Stopping the kubelet service
[reset] Unmounting mounted directories in "/var/lib/kubelet"
[reset] Deleting contents of directories: [/etc/kubernetes/manifests /etc/kubernetes/pki]
[reset] Deleting files: [/etc/kubernetes/admin.conf /etc/kubernetes/kubelet.conf /etc/kubernetes/bootstrap-kubelet.conf /etc/kubernetes/controller-manager.conf /etc/kubernetes/scheduler.conf]
[reset] Deleting contents of stateful directories: [/var/lib/etcd /var/lib/kubelet /var/lib/dockershim /var/run/kubernetes /var/lib/cni]

The reset process does not clean CNI configuration. To do so, you must remove /etc/cni/net.d

The reset process does not reset or clean up iptables rules or IPVS tables.
If you wish to reset iptables, you must do so manually by using the "iptables" command.

If your cluster was setup to utilize IPVS, run ipvsadm --clear (or similar)
to reset your system's IPVS tables.

The reset process does not clean your kubeconfig files and you must remove them manually.
Please, check the contents of the $HOME/.kube/config file.

再次执行 kubeadm init 成功:

[root@localhost ~]# kubeadm init
W0515 15:03:21.229843   25821 version.go:103] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable-1.txt": Get "https://dl.k8s.io/release/stable-1.txt": x509: certificate has expired or is not yet valid: current time 2022-05-15T15:03:21+08:00 is before 2022-05-17T21:21:32Z
W0515 15:03:21.229869   25821 version.go:104] falling back to the local client version: v1.24.0
[init] Using Kubernetes version: v1.24.0
[preflight] Running pre-flight checks
    [WARNING Firewalld]: firewalld is active, please ensure ports [6443 10250] are open or your cluster may not function correctly
[preflight] Pulling images required for setting up a Kubernetes cluster
[preflight] This might take a minute or two, depending on the speed of your internet connection
[preflight] You can also perform this action in beforehand using 'kubeadm config images pull'
[certs] Using certificateDir folder "/etc/kubernetes/pki"
[certs] Generating "ca" certificate and key
[certs] Generating "apiserver" certificate and key
[certs] apiserver serving cert is signed for DNS names [kubernetes kubernetes.default kubernetes.default.svc kubernetes.default.svc.cluster.local localhost.localdomain] and IPs [10.96.0.1 10.0.2.10]
[certs] Generating "apiserver-kubelet-client" certificate and key
[certs] Generating "front-proxy-ca" certificate and key
[certs] Generating "front-proxy-client" certificate and key
[certs] Generating "etcd/ca" certificate and key
[certs] Generating "etcd/server" certificate and key
[certs] etcd/server serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/peer" certificate and key
[certs] etcd/peer serving cert is signed for DNS names [localhost localhost.localdomain] and IPs [10.0.2.10 127.0.0.1 ::1]
[certs] Generating "etcd/healthcheck-client" certificate and key
[certs] Generating "apiserver-etcd-client" certificate and key
[certs] Generating "sa" key and public key
[kubeconfig] Using kubeconfig folder "/etc/kubernetes"
[kubeconfig] Writing "admin.conf" kubeconfig file
[kubeconfig] Writing "kubelet.conf" kubeconfig file
[kubeconfig] Writing "controller-manager.conf" kubeconfig file
[kubeconfig] Writing "scheduler.conf" kubeconfig file
[kubelet-start] Writing kubelet environment file with flags to file "/var/lib/kubelet/kubeadm-flags.env"
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[kubelet-start] Starting the kubelet
[control-plane] Using manifest folder "/etc/kubernetes/manifests"
[control-plane] Creating static Pod manifest for "kube-apiserver"
[control-plane] Creating static Pod manifest for "kube-controller-manager"
[control-plane] Creating static Pod manifest for "kube-scheduler"
[etcd] Creating static Pod manifest for local etcd in "/etc/kubernetes/manifests"
[wait-control-plane] Waiting for the kubelet to boot up the control plane as static Pods from directory "/etc/kubernetes/manifests". This can take up to 4m0s
[apiclient] All control plane components are healthy after 22.259518 seconds
[upload-config] Storing the configuration used in ConfigMap "kubeadm-config" in the "kube-system" Namespace
[kubelet] Creating a ConfigMap "kubelet-config" in namespace kube-system with the configuration for the kubelets in the cluster
[upload-certs] Skipping phase. Please see --upload-certs
[mark-control-plane] Marking the node localhost.localdomain as control-plane by adding the labels: [node-role.kubernetes.io/control-plane node.kubernetes.io/exclude-from-external-load-balancers]
[mark-control-plane] Marking the node localhost.localdomain as control-plane by adding the taints [node-role.kubernetes.io/master:NoSchedule node-role.kubernetes.io/control-plane:NoSchedule]
[bootstrap-token] Using token: cjpeqg.yvf2lka5i5epqcis
[bootstrap-token] Configuring bootstrap tokens, cluster-info ConfigMap, RBAC Roles
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to get nodes
[bootstrap-token] Configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials
[bootstrap-token] Configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token
[bootstrap-token] Configured RBAC rules to allow certificate rotation for all node client certificates in the cluster
[bootstrap-token] Creating the "cluster-info" ConfigMap in the "kube-public" namespace
[kubelet-finalize] Updating "/etc/kubernetes/kubelet.conf" to point to a rotatable kubelet client certificate and key
[addons] Applied essential addon: CoreDNS
[addons] Applied essential addon: kube-proxy

Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a regular user:

  mkdir -p $HOME/.kube
  sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
  sudo chown $(id -u):$(id -g) $HOME/.kube/config

Alternatively, if you are the root user, you can run:

  export KUBECONFIG=/etc/kubernetes/admin.conf

You should now deploy a pod network to the cluster.
Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:
  https://kubernetes.io/docs/concepts/cluster-administration/addons/

Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 10.0.2.10:6443 --token cjpeqg.yvf2lka5i5epqcis \
    --discovery-token-ca-cert-hash sha256:2c662bccbb9491d97b141a2b4b578867f240614ddcc399949c803d1f5093bba5 

根据提示,我们将配置文件复制到 ~/.kube 目录:

[root@localhost ~]# mkdir -p $HOME/.kube
[root@localhost ~]# cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
[root@localhost ~]# chown $(id -u):$(id -g) $HOME/.kube/config

然后安装一个 网络插件,网络插件是用于 Pod 之间的网络通信,这里我们选择安装 flannel

[root@localhost ~]# curl -LO https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml

[root@localhost ~]# kubectl apply -f kube-flannel.yml
Warning: policy/v1beta1 PodSecurityPolicy is deprecated in v1.21+, unavailable in v1.25+
podsecuritypolicy.policy/psp.flannel.unprivileged created
clusterrole.rbac.authorization.k8s.io/flannel created
clusterrolebinding.rbac.authorization.k8s.io/flannel created
serviceaccount/flannel created
configmap/kube-flannel-cfg created
daemonset.apps/kube-flannel-ds created

安装结束后,可以使用 kubectl get pods -A 查看 Pod 的状态:

[root@localhost ~]# kubectl get pods -A
NAMESPACE     NAME                                            READY   STATUS              RESTARTS        AGE
kube-system   coredns-6d4b75cb6d-6xvx5                        0/1     ContainerCreating   0               41m
kube-system   coredns-6d4b75cb6d-d4pd6                        0/1     ContainerCreating   0               41m
kube-system   etcd-localhost.localdomain                      1/1     Running             0               42m
kube-system   kube-apiserver-localhost.localdomain            1/1     Running             0               42m
kube-system   kube-controller-manager-localhost.localdomain   1/1     Running             0               42m
kube-system   kube-flannel-ds-2mgf5                           0/1     CrashLoopBackOff    9 (2m31s ago)   24m
kube-system   kube-proxy-ftmb9                                1/1     Running             0               42m
kube-system   kube-scheduler-localhost.localdomain            1/1     Running             0               42m

我们发现 flannel 一直处于 CrashLoopBackOff 的状态,使用 kubectl logs 检查 flannel 的启动日志:

[root@localhost ~]# kubectl logs kube-flannel-ds-2mgf5 -n kube-system
Defaulted container "kube-flannel" out of: kube-flannel, install-cni-plugin (init), install-cni (init)
I0515 07:43:21.480968       1 main.go:205] CLI flags config: {etcdEndpoints:http://127.0.0.1:4001,http://127.0.0.1:2379 etcdPrefix:/coreos.com/network etcdKeyfile: etcdCertfile: etcdCAFile: etcdUsername: etcdPassword: version:false kubeSubnetMgr:true kubeApiUrl: kubeAnnotationPrefix:flannel.alpha.coreos.com kubeConfigFile: iface:[] ifaceRegex:[] ipMasq:true subnetFile:/run/flannel/subnet.env publicIP: publicIPv6: subnetLeaseRenewMargin:60 healthzIP:0.0.0.0 healthzPort:0 iptablesResyncSeconds:5 iptablesForwardRules:true netConfPath:/etc/kube-flannel/net-conf.json setNodeNetworkUnavailable:true}
W0515 07:43:21.480968       1 client_config.go:614] Neither --kubeconfig nor --master was specified.  Using the inClusterConfig.  This might not work.
I0515 07:43:21.882364       1 kube.go:120] Waiting 10m0s for node controller to sync
I0515 07:43:21.975972       1 kube.go:378] Starting kube subnet manager
I0515 07:43:22.977270       1 kube.go:127] Node controller sync successful
I0515 07:43:22.977294       1 main.go:225] Created subnet manager: Kubernetes Subnet Manager - localhost.localdomain
I0515 07:43:22.977298       1 main.go:228] Installing signal handlers
I0515 07:43:22.977380       1 main.go:454] Found network config - Backend type: vxlan
I0515 07:43:22.977398       1 match.go:189] Determining IP address of default interface
I0515 07:43:22.978102       1 match.go:242] Using interface with name enp0s3 and address 10.0.2.10
I0515 07:43:22.978128       1 match.go:264] Defaulting external address to interface address (10.0.2.10)
I0515 07:43:22.978176       1 vxlan.go:138] VXLAN config: VNI=1 Port=0 GBP=false Learning=false DirectRouting=false
E0515 07:43:22.978490       1 main.go:317] Error registering network: failed to acquire lease: node "localhost.localdomain" pod cidr not assigned
I0515 07:43:22.978337       1 main.go:434] Stopping shutdownHandler...
W0515 07:43:22.979266       1 reflector.go:436] github.com/flannel-io/flannel/subnet/kube/kube.go:379: watch of *v1.Node ended with: an error on the server ("unable to decode an event from the watch stream: context canceled") has prevented the request from succeeding

其中有一行错误信息比较明显:Error registering network: failed to acquire lease: node "localhost.localdomain" pod cidr not assigned。想起来在执行 kubeadm init 时确实有一个 cidr 的参数,但是我并没有设置,因为不知道设置啥,我们可以打开 kube-flannel.yml 文件,其中的 ConfigMap 里的内容给了我们答案(当然,不同的网络插件 cidr 也可能不一样,你也可以改成自己想要的值):

kind: ConfigMap
apiVersion: v1
metadata:
  name: kube-flannel-cfg
  namespace: kube-system
  labels:
    tier: node
    app: flannel
data:
  cni-conf.json: |
    {
      "name": "cbr0",
      "cniVersion": "0.3.1",
      "plugins": [
        {
          "type": "flannel",
          "delegate": {
            "hairpinMode": true,
            "isDefaultGateway": true
          }
        },
        {
          "type": "portmap",
          "capabilities": {
            "portMappings": true
          }
        }
      ]
    }
  net-conf.json: |
    {
      "Network": "10.244.0.0/16",
      "Backend": {
        "Type": "vxlan"
      }
    }

于是使用 kubeadm resetkubeadm init 重新安装一次:

[root@localhost ~]# kubeadm reset
[root@localhost ~]# kubeadm init --pod-network-cidr 10.244.0.0/16

然后和上面的步骤一样,先复制配置文件到 ~/.kube/config,然后安装 flannel,最终 flannel 安装完成:

[root@localhost ~]# kubectl get pods -A
NAMESPACE     NAME                                            READY   STATUS    RESTARTS      AGE
kube-system   coredns-6d4b75cb6d-kfsrb                        0/1     Running   0             6m25s
kube-system   coredns-6d4b75cb6d-mwmhg                        0/1     Running   0             6m25s
kube-system   etcd-localhost.localdomain                      1/1     Running   2 (14s ago)   6m44s
kube-system   kube-apiserver-localhost.localdomain            1/1     Running   1             7m1s
kube-system   kube-controller-manager-localhost.localdomain   1/1     Running   0             7m
kube-system   kube-flannel-ds-s89f2                           1/1     Running   0             5m6s
kube-system   kube-proxy-9rsb4                                1/1     Running   0             6m24s
kube-system   kube-scheduler-localhost.localdomain            1/1     Running   3             6m44s

可以看到 coredns 的状态也变成了 Running,在网络插件没有安装时,它的状态一直是 ContainerCreating

使用 kubeadm join 加入 Kubernetes 集群

这时 Kubernetes 的 master 节点(又叫做 控制平面)就安装好了,接下来可以在另一台机器上执行 kubeadm join 将工作节点加入 Kubernetes 集群(这台机器也需要提前安装好 kubeadm):

[root@localhost ~]# kubeadm join 10.0.2.10:6443 --token cjpeqg.yvf2lka5i5epqcis \
    --discovery-token-ca-cert-hash sha256:2c662bccbb9491d97b141a2b4b578867f240614ddcc399949c803d1f5093bba5 

不过我这里就只有一台机器,能不能让一台机器既是 master 节点又是工作节点呢?当然可以!

默认情况下 Kubernetes 在分配 Pod 的时候,是不会分配到 master 节点的,这是因为 kubeadm 给我们的 master 节点打上了一个 taint 信息(又被称为 污点)。我们通过下面的 kubectl taint 命令,将 master 节点的污点信息去掉:

[root@localhost ~]# kubectl taint node localhost.localdomain node-role.kubernetes.io/master-
node/localhost.localdomain untainted

其中 node-role.kubernetes.io/master 就是污点的名字,后面的 - 号表示删除,localhost.localdomain 是 master 节点的名字,可以通过 kubectl get nodes 查看:

[root@localhost ~]# kubectl get nodes
NAME                    STATUS   ROLES           AGE   VERSION
localhost.localdomain   Ready    control-plane   42m   v1.24.0

在最新版本的 Kubernetes 中,污点的名字被改为了 node-role.kubernetes.io/control-plane,我们也把它去掉:

[root@localhost ~]# kubectl taint node localhost.localdomain node-role.kubernetes.io/control-plane-
node/localhost.localdomain untainted

如果要查看一个节点的污点信息,可以使用 kubectl describe node <node-name> 命令。

到这里一个单机版的 Kubernetes 最小集群就搭建好了。

参考

  1. kubectl 安装文档
  2. kind 官方文档
  3. kind:Kubernetes in Docker,单机运行 Kubernetes 群集的最佳方案
  4. minikube 官方文档
  5. Bootstrapping clusters with kubeadm
  6. How to use a Single VM as a Kubernetes Cluster with Kubeadm
  7. 一文搞懂容器运行时 Containerd
  8. 重学容器02: 部署容器运行时Containerd

更多

1. 为 Docker 设置代理

第一种情况是 为 Docker Daemon 设置代理,影响 docker pull 下载镜像。首先创建如下目录:

[root@localhost ~]# mkdir -p /etc/systemd/system/docker.service.d

在该目录下创建文件 http-proxy.conf

[root@localhost ~]# cd /etc/systemd/system/docker.service.d
[root@localhost docker.service.d]# vi http-proxy.conf

文件内容如下:

[Service]
Environment="HTTP_PROXY=192.168.1.36:10809"
Environment="HTTPS_PROXY=192.168.1.36:10809"

重启 Docker 服务:

[root@localhost ~]# systemctl daemon-reload
[root@localhost ~]# systemctl restart docker

验证代理设置是否生效:

[root@localhost ~]# systemctl show --property=Environment docker
Environment=HTTP_PROXY=192.168.1.36:10809 HTTPS_PROXY=192.168.1.36:10809

第二种情况是 为 Docker 容器设置代理,影响容器内访问外部网络。这个配置比较简单,只需要在用户目录下创建一个 ~/.docker/config.json 文件:

[root@localhost ~]# mkdir -p ~/.docker
[root@localhost ~]# vi ~/.docker/config.json

文件内容如下:

{
  "proxies":
  {
    "default":
    {
      "httpProxy": "192.168.1.36:10809",
      "httpsProxy": "192.168.1.36:10809"
    }
  }
}

使用 alpine/curl 镜像启动一个容器,验证配置是否生效:

[root@localhost ~]# docker run --rm alpine/curl -fsSL ifconfig.me
103.168.154.81

2. 其他部署或管理 Kubernetes 的工具

部署工具

管理平台

扫描二维码,在手机上阅读!

Envoy 学习笔记

Envoy 是一款专为大型的 SOA 架构(面向服务架构,service oriented architectures)设计的 L7 代理和通信总线,它的诞生源于以下理念:

对应用程序而言,网络应该是透明的。当网络和应用程序出现故障时,应该能够很容易确定问题的根源。

要实现上面的目标是非常困难的,为了做到这一点,Envoy 提供了以下特性:

  • 进程外架构

Envoy 是一个独立的进程,伴随着每个应用程序运行。所有的 Envoy 形成一个透明的通信网络,每个应用程序发送消息到本地主机或从本地主机接收消息,不需要知道网络拓扑。进程外架构的好处是与应用程序的语言无关,Envoy 可以和任意语言的应用程序一起工作,另外,Envoy 的部署和升级也非常方便。

这种模式也被称为 边车模式(Sidecar)

  • L3/L4 过滤器架构

Envoy 是一个 L3/L4 网络代理,通过插件化的 过滤器链(filter chain) 机制处理各种 TCP/UDP 代理任务,支持 TCP 代理,UDP 代理,TLS 证书认证,Redis 协议,MongoDB 协议,Postgres 协议等。

  • HTTP L7 过滤器架构

Envoy 不仅支持 L3/L4 代理,也支持 HTTP L7 代理,通过 HTTP 连接管理子系统(HTTP connection management subsystem) 可以实现诸如缓存、限流、路由等代理任务。

  • 支持 HTTP/2

在 HTTP 模式下,Envoy 同时支持 HTTP/1.1 和 HTTP/2。在 service to service 配置中,官方也推荐使用 HTTP/2 协议。

  • 支持 HTTP/3(alpha)

从 1.19.0 版本开始,Envoy 支持 HTTP/3。

  • HTTP L7 路由

Envoy 可以根据请求的路径(path)、认证信息(authority)、Content Type、运行时参数等来配置路由和重定向。这在 Envoy 作为前端代理或边缘代理时非常有用。

  • 支持 gRPC

gRPC 是 Google 基于 HTTP/2 开发的一个 RPC 框架。Envoy 完美的支持 HTTP/2,也可以很方便的支持 gRPC。

  • 服务发现和动态配置

Envoy 可以通过一套动态配置 API 来进行中心化管理,这套 API 被称为 xDS:EDS(Endpoint Discovery Service)、CDS(Cluster Discovery Service)、RDS(Route Discovery Service)、VHDS(Virtual Host Discovery Service)、LDS(Listener Discovery Service)、SDS(Secret Discovery Service)等等。

  • 健康状态检查

Envoy 通过对上游服务集群进行健康状态检查,并根据服务发现和健康检查的结果来决定负载均衡的目标。

  • 高级负载均衡

Envoy 支持很多高级负载均衡功能,比如:自动重试、熔断、全局限流、流量跟踪(request shadowing)、异常检测(outlier detection)等。

  • 支持前端代理和边缘代理

Envoy 一般有三种部署方式:

  1. Front Proxy:前端代理,也叫边缘代理,通常是部署在整个服务网格的边缘,用于接收来自于服务网格外的请求;
  2. Ingress Listener:服务代理,通常部署在服务网格内服务的前面,用于接收发给该服务的请求,并转发给该服务;
  3. Egress Listener:与 Ingress Listener 相反,用于代理服务发出的所有请求,并将请求转发给其他服务(可能是网格内服务,也可能是网格外服务)。

envoy-deployment.png

  • 可观测性

Envoy 的主要目标是使网络透明,可以生成许多流量方面的统计数据,这是其它代理软件很难取代的地方,内置 stats 模块,可以集成诸如 prometheus/statsd 等监控方案。还可以集成分布式追踪系统,对请求进行追踪。

Envoy 整体架构与基本概念

下图是 Envoy 代理的整体架构图:图片来源

envoy-architecture.png

Envoy 接收到请求后,会经过过滤器链(filter chain),通过 L3/L4 或 L7 的过滤器对请求进行微处理,然后路由到指定集群,并通过负载均衡获取一个目标地址,最后再转发出去。这个过程中的每一个环节,可以静态配置,也可以通过 xDS 动态配置。

  • Downstream:即客户端(Client),向 Envoy 发起请求的终端。
  • Upstream:后端服务器,处理客户端请求的服务。
  • Listener:监听器,它的作用就是打开一个监听端口,用于接收来自 Downstream 的请求。
  • Cluster:一组逻辑上相似的上游主机组成一个集群。
  • Route:用于将请求路由到不同的集群。
  • xDS:各种服务发现 API 的统称,如:CDS、EDS、LDS、RDS 和 SDS 等。

安装和运行 Envoy

安装 Envoy 最简单的方式是使用官方的 Docker 镜像,首先获取镜像:

[root@localhost ~]# docker pull envoyproxy/envoy:v1.22-latest

使用 docker run 运行:

[root@localhost ~]# docker run -d -p 10000:10000 -p 9901:9901 envoyproxy/envoy:v1.22-latest

此时使用的是 Envoy 的默认配置文件,默认会监听两个端口,9901 为 Envoy 的管理端口,10000 为 Envoy 监听的代理端口,后端地址为 Envoy 官网:www.envoyproxy.io

我们进入容器,查看 Envoy 配置文件如下:

root@localhost:/# cat /etc/envoy/envoy.yaml 
admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901
static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        protocol: TCP
        address: 0.0.0.0
        port_value: 10000
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          scheme_header_transformation:
            scheme_to_overwrite: https
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  host_rewrite_literal: www.envoyproxy.io
                  cluster: service_envoyproxy_io
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
  - name: service_envoyproxy_io
    connect_timeout: 30s
    type: LOGICAL_DNS
    # Comment out the following line to test on v6 networks
    dns_lookup_family: V4_ONLY
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: service_envoyproxy_io
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: www.envoyproxy.io
                port_value: 443
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        sni: www.envoyproxy.io

我们打开浏览器,访问 http://127.0.0.1:10000 就可以看到 Envoy 的首页了。

如果要使用自己的配置文件,可以写一个 yaml 文件,并挂载到容器中覆盖 /etc/envoy/envoy.yaml 文件,或者通过 -c 参数指定配置文件:

[root@localhost ~]# docker run -d \
    -v $(pwd)/envoy-custom.yaml:/envoy-custom.yaml \
    -p 10000:10000 \
    -p 9901:9901 \
    envoyproxy/envoy:v1.22-latest -c /envoy-custom.yaml

Envoy 静态配置

Envoy 的配置遵循 xDS API v3 规范,Evnoy 的配置文件也被称为 Bootstrap 配置,使用的接口为 config.bootstrap.v3.Bootstrap,它的整体结构如下:

{
  "node": "{...}",
  "static_resources": "{...}",
  "dynamic_resources": "{...}",
  "cluster_manager": "{...}",
  "hds_config": "{...}",
  "flags_path": "...",
  "stats_sinks": [],
  "stats_config": "{...}",
  "stats_flush_interval": "{...}",
  "stats_flush_on_admin": "...",
  "watchdog": "{...}",
  "watchdogs": "{...}",
  "tracing": "{...}",
  "layered_runtime": "{...}",
  "admin": "{...}",
  "overload_manager": "{...}",
  "enable_dispatcher_stats": "...",
  "header_prefix": "...",
  "stats_server_version_override": "{...}",
  "use_tcp_for_dns_lookups": "...",
  "dns_resolution_config": "{...}",
  "typed_dns_resolver_config": "{...}",
  "bootstrap_extensions": [],
  "fatal_actions": [],
  "default_socket_interface": "...",
  "inline_headers": [],
  "perf_tracing_file_path": "..."
}

Envoy 自带的默认配置文件中包含了两个部分:adminstatic_resources

admin 部分是 Envoy 管理接口的配置,接口定义为 config.bootstrap.v3.Admin

admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901

该配置让 Envoy 暴露出 9901 的管理端口,我们可以通过 http://127.0.0.1:9901 访问 Envoy 的管理页面。

static_resources 部分就是 Envoy 的静态配置,接口定义为 config.bootstrap.v3.Bootstrap.StaticResources,结构如下:

{
  "listeners": [],
  "clusters": [],
  "secrets": []
}

其中,listeners 用于配置 Envoy 的监听地址,Envoy 会暴露一个或多个 Listener 来监听客户端的请求。而 clusters 用于配置服务集群,Envoy 通过服务发现定位集群成员并获取服务,具体路由到哪个集群成员由负载均衡策略决定。

listeners 的接口定义为 config.listener.v3.Listener,其中最重要的几项有:nameaddressfilter_chain

listeners:
- name: listener_0
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 10000
  filter_chains:
  - filters:
    - name: envoy.filters.network.http_connection_manager
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
        scheme_header_transformation:
          scheme_to_overwrite: https
        stat_prefix: ingress_http
        route_config:
          name: local_route
          virtual_hosts:
          - name: local_service
            domains: ["*"]
            routes:
            - match:
                prefix: "/"
              route:
                host_rewrite_literal: www.envoyproxy.io
                cluster: service_envoyproxy_io
        http_filters:
        - name: envoy.filters.http.router
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

其中 address 表示 Envoy 监听的地址,filter_chain 为过滤器链,Envoy 通过一系列的过滤器对请求进行处理,下面是 Envoy 包含的过滤器链示意图:

envoy-filters.png

这里使用的是 http_connection_manager 来代理 HTTP 请求,route_config 为路由配置,当请求路径以 / 开头时(match prefix "/"),路由到 service_envoyproxy_io 集群。集群使用 clusters 来配置,它的接口定义为 config.cluster.v3.Cluster,配置的内容如下:

clusters:
- name: service_envoyproxy_io
  connect_timeout: 30s
  type: LOGICAL_DNS
  # Comment out the following line to test on v6 networks
  dns_lookup_family: V4_ONLY
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: service_envoyproxy_io
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: www.envoyproxy.io
              port_value: 443
  transport_socket:
    name: envoy.transport_sockets.tls
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
      sni: www.envoyproxy.io

Envoy 通过服务发现来定位集群成员,服务发现的方式 有以下几种:

然后 Envoy 使用某种负载均衡策略从集群中找出一个服务来调用,Envoy 支持的 负载均衡策略有

而在我们的例子中,集群的服务发现方式为 LOGICAL_DNS,并使用 ROUND_ROBIN 方式来负载均衡。

Envoy 动态配置

在上面的例子中,我们配置的地址都是固定的,但在实际应用中,我们更希望以一种动态的方式来配置,比如 K8S 环境下服务的地址随 Pod 地址变化而变化,我们不可能每次都去手工修改 Envoy 的配置文件。Envoy 使用了一套被称为 xDS 的 API 来动态发现资源。xDS 包括 LDS(Listener Discovery Service)、CDS(Cluster Discovery Service)、RDS(Route Discovery Service)、EDS(Endpoint Discovery Service),以及 ADS(Aggregated Discovery Service),每个 xDS 都对应着配置文件中的一小块内容,如下所示(图片来源):

xds.png

Envoy 通过订阅的方式来获取资源,如监控指定路径下的文件、启动 gRPC 流或轮询 REST-JSON URL。后两种方式会发送 DiscoveryRequest 请求消息,发现的对应资源则包含在响应消息 DiscoveryResponse 中。

基于文件的动态配置

基于文件的动态配置比较简单,Envoy 通过监听文件的变动来动态更新配置,我们创建一个文件 envoy.yaml,内容如下:

node:
  id: id_1
  cluster: test

admin:
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 9901

dynamic_resources:
  cds_config:
    path_config_source:
      path: /var/lib/envoy/cds.yaml
  lds_config:
    path_config_source:
      path: /var/lib/envoy/lds.yaml

这里我们可以看出,和静态配置的 static_resources 不一样,没有 clusterslisteners 的配置了,而是在 dynamic_resources 中定义了 cds_configlds_config,并指定了 clusterslisteners 的配置文件的路径。

注意:在动态配置中,node 参数是必须的,用于区分 Envoy 是属于哪个集群。

我们再分别创建 cds.yaml 文件和 lds.yaml 文件。cds.yaml 的内容如下:

resources:
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
  name: service_envoyproxy_io
  connect_timeout: 30s
  type: LOGICAL_DNS
  # Comment out the following line to test on v6 networks
  dns_lookup_family: V4_ONLY
  lb_policy: ROUND_ROBIN
  load_assignment:
    cluster_name: service_envoyproxy_io
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            socket_address:
              address: www.envoyproxy.io
              port_value: 443
  transport_socket:
    name: envoy.transport_sockets.tls
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
      sni: www.envoyproxy.io

和静态配置的 clusters 是一样的,只不过换成了 resources 配置,并指定了 typeenvoy.config.cluster.v3.Clusterlds.yaml 的内容如下:

resources:
- "@type": type.googleapis.com/envoy.config.listener.v3.Listener
  name: listener_0
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 10000
  filter_chains:
  - filters:
    - name: envoy.filters.network.http_connection_manager
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
        scheme_header_transformation:
          scheme_to_overwrite: https
        stat_prefix: ingress_http
        route_config:
          name: local_route
          virtual_hosts:
          - name: local_service
            domains: ["*"]
            routes:
            - match:
                prefix: "/"
              route:
                host_rewrite_literal: www.envoyproxy.io
                cluster: service_envoyproxy_io
        http_filters:
        - name: envoy.filters.http.router
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

这个配置和静态配置的 listeners 是一样的,只不过换成了 resources 配置,并指定了 typeenvoy.config.listener.v3.Listener

然后,运行下面的命令:

[root@localhost ~]# docker run -d \
    -v $(pwd):/var/lib/envoy \
    -p 10000:10000 \
    -p 9901:9901 \
    envoyproxy/envoy:v1.22-latest -c /var/lib/envoy/envoy.yaml

我们打开浏览器,访问 http://127.0.0.1:10000 就可以看到 Envoy 的首页了。

然后我们使用 sed 将文件中的 www.envoy.io 替换为 www.baidu.com

[root@localhost ~]# sed -i s/www.envoyproxy.io/www.baidu.com/ lds.yaml cds.yaml

刷新浏览器,可以看到页面变成了 Baidu 的首页了,我们没有重启 Envoy,就实现了配置的动态更新。

这里有一点需要特别注意,如果我们直接使用 vi 去编辑 lds.yamlcds.yaml 文件,有时候可能不会生效,这时我们可以将文件复制一份出来,编辑,然后再替换原文件,才可以让配置生效。而 sed -i 命令的 inplace edit 功能就是这样实现的。为什么需要这样做呢?因为 Docker 在有些环境下对 inotify 的支持不是很好,特别是 VirtualBox 环境。

基于控制平面(Control Plane)的动态配置

网络层一般被分为 数据平面(Data Plane)控制平面(Control Plane)。控制平面主要为数据包的快速转发准备必要信息;而数据平面则主要负责高速地处理和转发数据包。这样划分的目的是把不同类型的工作分离开,避免不同类型的处理相互干扰。数据平面的转发工作无疑是网络层的重要工作,需要最高的优先级;而控制平面的路由协议等不需要在短时间内处理大量的包,可以将其放到次一级的优先级中。数据平面可以专注使用定制序列化等各种技术来提高传输速率,而控制平面则可以借助于通用库来达到更好的控制与保护效果。

得益于 Envoy 的高性能易扩展等特性,Envoy 可以说已经是云原生时代数据平面的事实标准。新兴微服务网关如 GlooAmbassador 都基于 Envoy 进行扩展开发;而在服务网格中,Istio、Kong 社区的 Kuma、亚马逊的 AWS App Mesh 都使用 Envoy 作为默认的数据平面。那么作为数据平面的 Envoy 要怎么通过控制平面来动态配置呢?答案就是:xDS 协议

有很多已经实现的控制平面可以直接使用:

control-plane.png

也可以使用 Envoy 的 xDS API 开发自己的控制平面,官方提供了 Go 和 Java 的示例可供参考:

在官网的 Dynamic configuration (control plane) 例子中,使用 Go 实现了一个控制平面。

在控制平面服务启动之后,Envoy 的配置文件类似下面这样:

node:
  cluster: test-cluster
  id: test-id

dynamic_resources:
  ads_config:
    api_type: GRPC
    transport_api_version: V3
    grpc_services:
    - envoy_grpc:
        cluster_name: xds_cluster
  cds_config:
    resource_api_version: V3
    ads: {}
  lds_config:
    resource_api_version: V3
    ads: {}

static_resources:
  clusters:
  - type: STRICT_DNS
    typed_extension_protocol_options:
      envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
        "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
        explicit_http_config:
          http2_protocol_options: {}
    name: xds_cluster
    load_assignment:
      cluster_name: xds_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: go-control-plane
                port_value: 18000

其中,static_resources 中定义了控制平面的地址,而 dynamic_resources 中的 ads_config 则指定使用控制平面来动态获取配置。

参考

  1. Envoy 官方文档
  2. Envoy 官方文档中文版(ServiceMesher)
  3. Istio 服务网格进阶实战(ServiceMesher)
  4. Envoy 官方文档中文版(CloudNative)
  5. Envoy 基础教程(Jimmy Song)
  6. Kubernetes 中文指南(Jimmy Song)
  7. Envoy Handbook(米开朗基杨)
  8. What is Envoy
  9. Envoy基础介绍
  10. 史上最全的高性能代理服务器 Envoy 中文实战教程 !
  11. Envoy 中的 xDS REST 和 gRPC 协议详解
  12. Dynamic configuration (filesystem)
  13. Dynamic configuration (control plane)
  14. 通过 xDS 实现 Envoy 动态配置
  15. 如何为 Envoy 构建一个控制面来管理集群网络流量

更多

1. Envoy 的管理页面

默认情况下,Envoy 会暴露出 9901 的管理端口,我们访问 http://127.0.0.1:9901 可以看到 Envoy 的管理页面:

envoy-admin.png

这里可以看到有很多很有用的功能,比如:查看 Envoy 统计信息,查看 Prometheus 监控指标,开启或关闭 CPU Profiler,开启或关闭 Heap Profiler 等等。

关于管理接口可以参考官方文档 Administration interface

2. 体验 Sandboxes

Envoy 通过 Docker Compose 创建了很多沙盒环境用于测试 Envoy 的特性,感兴趣的同学可以挨个体验一下:

3. 如何从 Nginx 迁移到 Envoy?

https://github.com/yangchuansheng/envoy-handbook/blob/master/content/zh/docs/practice/migrating-from-nginx-to-envoy.md

扫描二维码,在手机上阅读!

Dapr 学习笔记

Dapr 的全称为 Distributed Application Runtime(分布式应用运行时),顾名思义,它的目的就是为分布式应用提供运行所依赖的的执行环境。Dapr 为开发者提供了服务间调用(service to service invocation)、消息队列(message queue)和事件驱动(event driven)等服务模型,它可以让开发人员更聚焦业务代码,而不用去关心分布式系统所带来的其他复杂挑战,比如:服务发现(service discovery)、状态存储(state management)、加密数据存储(secret management)、可观察性(observability)等。

下面这张图说明了 Dapr 在分布式系统中所承担的作用:

service-invocation.png

这是分布式系统中最常用的一种场景,你的应用需要去调用系统中的另一个应用提供的服务。在引入 Dapr 之后,Dapr 通过边车模式运行在你的应用之上,Dapr 会通过服务发现机制为你去调用另一个应用的服务,并使用 mTLS 提供了服务间的安全访问,而且每个 Dapr 会集成 OpenTelemetry 自动为你提供服务之间的链路追踪、日志和指标等可观察性功能。

下图是基于事件驱动模型的另一种调用场景:

pubsub.png

安装 Dapr

1. 安装 Dapr CLI

首先使用下面的一键安装脚本安装 Dapr CLI

[root@localhost ~]# wget -q https://raw.githubusercontent.com/dapr/cli/master/install/install.sh -O - | /bin/bash
Getting the latest Dapr CLI...
Your system is linux_amd64
Installing Dapr CLI...

Installing v1.6.0 Dapr CLI...
Downloading https://github.com/dapr/cli/releases/download/v1.6.0/dapr_linux_amd64.tar.gz ...
dapr installed into /usr/local/bin successfully.
CLI version: 1.6.0 
Runtime version: n/a

To get started with Dapr, please visit https://docs.dapr.io/getting-started/

使用 dapr -v 校验是否安装成功:

[root@localhost ~]# dapr -v
CLI version: 1.6.0 
Runtime version: n/a

注意,这里我们可以看到 Runtime versionn/a,所以下一步我们还需要安装 Dapr runtime

2. 初始化 Dapr

虽然 Dapr 也可以在非 Docker 环境下运行,但是官方更推荐使用 Docker,首先确保机器上已经安装有 Docker 环境,然后执行下面的 dapr init 命令:

[root@localhost ~]# dapr init
> Making the jump to hyperspace...
>> Installing runtime version 1.6.1
Dapr runtime installed to /root/.dapr/bin, you may run the following to add it to your path if you want to run daprd directly:
    export PATH=$PATH:/root/.dapr/bin
> Downloading binaries and setting up components...
> Downloaded binaries and completed components set up.
>> daprd binary has been installed to /root/.dapr/bin.
>> dapr_placement container is running.
>> dapr_redis container is running.
>> dapr_zipkin container is running.
>> Use `docker ps` to check running containers.
> Success! Dapr is up and running. To get started, go here: https://aka.ms/dapr-getting-started

从运行结果可以看到,Dapr 的初始化过程包含以下几个部分:

  • 安装 Dapr 运行时(daprd),安装位置为 /root/.dapr/bin,同时会创建一个 components 目录用于默认组件的定义
  • 运行 dapr_placement 容器,dapr placement 服务 用于实现本地 actor 支持
  • 运行 dapr_redis 容器,用于本地状态存储(local state store)和消息代理(message broker
  • 运行 dapr_zipkin 容器,用于实现服务的可观察性(observability

初始化完成后,再次使用 dapr -v 校验是否安装成功:

[root@localhost ~]# dapr -v
CLI version: 1.6.0 
Runtime version: 1.6.1

并使用 docker ps 查看容器运行状态:

[root@localhost ~]# docker ps
CONTAINER ID   IMAGE               COMMAND                  CREATED             STATUS                       PORTS                                                 NAMES
63dd751ec5eb   daprio/dapr:1.6.1   "./placement"            About an hour ago   Up About an hour             0.0.0.0:50005->50005/tcp, :::50005->50005/tcp         dapr_placement
a8d3a7c93e12   redis               "docker-entrypoint.s…"   About an hour ago   Up About an hour             0.0.0.0:6379->6379/tcp, :::6379->6379/tcp             dapr_redis
52586882abea   openzipkin/zipkin   "start-zipkin"           About an hour ago   Up About an hour (healthy)   9410/tcp, 0.0.0.0:9411->9411/tcp, :::9411->9411/tcp   dapr_zipkin

使用 Dapr API

dapr run 是最常用的 Dapr 命令之一, 这个命令用于启动一个应用,同时启动一个 Dapr 边车进程,你也可以不指定应用,只启动 Dapr 边车:

[root@localhost ~]# dapr run --app-id myapp --dapr-http-port 3500
WARNING: no application command found.
ℹ️  Starting Dapr with id myapp. HTTP Port: 3500. gRPC Port: 39736
ℹ️  Checking if Dapr sidecar is listening on HTTP port 3500
INFO[0000] starting Dapr Runtime -- version 1.6.1 -- commit 2fa6bd832d34f5a78c5e336190207d46b761093a  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] log level set to: info                        app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] metrics server started on :46773/             app_id=myapp instance=localhost.localdomain scope=dapr.metrics type=log ver=1.6.1
INFO[0000] standalone mode configured                    app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] app id: myapp                                 app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] mTLS is disabled. Skipping certificate request and tls validation  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] local service entry announced: myapp -> 10.0.2.8:45527  app_id=myapp instance=localhost.localdomain scope=dapr.contrib type=log ver=1.6.1
INFO[0000] Initialized name resolution to mdns           app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] loading components                            app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] component loaded. name: pubsub, type: pubsub.redis/v1  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] waiting for all outstanding components to be processed  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] detected actor state store: statestore        app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] component loaded. name: statestore, type: state.redis/v1  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] all outstanding components processed          app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] enabled gRPC tracing middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.api type=log ver=1.6.1
INFO[0000] enabled gRPC metrics middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.api type=log ver=1.6.1
INFO[0000] API gRPC server is running on port 39736      app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] enabled metrics http middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.http type=log ver=1.6.1
INFO[0000] enabled tracing http middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.http type=log ver=1.6.1
INFO[0000] http server is running on port 3500           app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] The request body size parameter is: 4         app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] enabled gRPC tracing middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.internal type=log ver=1.6.1
INFO[0000] enabled gRPC metrics middleware               app_id=myapp instance=localhost.localdomain scope=dapr.runtime.grpc.internal type=log ver=1.6.1
INFO[0000] internal gRPC server is running on port 45527  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
WARN[0000] app channel is not initialized. did you make sure to configure an app-port?  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] actor runtime started. actor idle timeout: 1h0m0s. actor scan interval: 30s  app_id=myapp instance=localhost.localdomain scope=dapr.runtime.actor type=log ver=1.6.1
WARN[0000] app channel not initialized, make sure -app-port is specified if pubsub subscription is required  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
WARN[0000] failed to read from bindings: app channel not initialized   app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
INFO[0000] dapr initialized. Status: Running. Init Elapsed 267.56237799999997ms  app_id=myapp instance=localhost.localdomain scope=dapr.runtime type=log ver=1.6.1
ℹ️  Checking if Dapr sidecar is listening on GRPC port 39736
ℹ️  Dapr sidecar is up and running.
✅  You're up and running! Dapr logs will appear here.

INFO[0002] placement tables updated, version: 0          app_id=myapp instance=localhost.localdomain scope=dapr.runtime.actor.internal.placement type=log ver=1.6.1

上面的命令通过参数 --app-id myapp 启动了一个名为 myapp 的空白应用,并让 Dapr 监听 3500 HTTP 端口(--dapr-http-port 3500)。由于没有指定组件目录,Dapr 会使用 dapr init 时创建的默认组件定义,可以在 /root/.dapr/components 目录查看:

[root@localhost ~]# ls /root/.dapr/components
pubsub.yaml  statestore.yaml

这个目录默认有两个组件:pubsub 和 statestore。我们查看组件的定义,可以发现两个组件都是基于 Redis 实现的。

pubsub 定义:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: pubsub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""

statestore 定义:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: statestore
spec:
  type: state.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""
  - name: actorStateStore
    value: "true"

statestore 组件可以为分布式应用提供状态管理功能,你可以使用 Dapr 的状态管理 API 来对状态进行保存、读取和查询等。通过配置,Dapr 支持 不同类型的存储后端,如:Redis、MySQL、MongoDB、Zookeeper 等,如下图所示:

state-management-overview.png

下面我们来体验下 Dapr 的状态管理 API。

使用 POST 请求向 statestore 中保存一个 keynamevalueBruce Wayne 的键值对(注意这里的 statestore 必须和组件定义中的 name 一致):

[root@localhost ~]# curl -X POST -H "Content-Type: application/json" -d '[{ "key": "name", "value": "Bruce Wayne"}]' http://localhost:3500/v1.0/state/statestore

使用 GET 请求查询 statestorekeyname 的值:

[root@localhost ~]# curl http://localhost:3500/v1.0/state/statestore/name
"Bruce Wayne"

为了进一步了解状态信息是如何保存在 Redis 中的,可以使用下面的命令进到容器里查看:

[root@localhost ~]# docker exec -it dapr_redis redis-cli
127.0.0.1:6379> keys *
1) "myapp||name"
127.0.0.1:6379> hgetall myapp||name
1) "data"
2) "\"Bruce Wayne\""
3) "version"
4) "1"
127.0.0.1:6379> exit

组件和构建块

Dapr 有两个基本概念我们需要了解:组件(component)构建块(building block)。每个组件代表一个原子功能,有一个接口定义,并且可以有不同的实现,比如 statestore 组件,可以使用 Redis 实现,也可以使用 MySQL 实现。而构建块是基于一个或多个组件实现的一套用于在分布式系统中使用的 API 接口(HTTP 或 gRPC),比如上面的例子中我们使用的接口 /v1.0/state/** 就是 一个 State management 构建块,它是由 statestore 组件组成的,而这个 statestore 组件是由 Redis 实现的。下面是构建块和组件的示例图:

concepts-building-blocks.png

Dapr 提供了下面这些组件:

可以在 这里 查看 Dapr 支持的组件接口定义和实现。

Dapr 提供了下面这些构建块:

下面我们来体验下最常用的两个构建块:Service-to-service invocationPublish and subscribe

服务调用(Service-to-service Invocation)

使用 Dapr 的服务调用构建块,可以让你的应用可靠并安全地与其他应用进行通信。

service-invocation-overview.png

Dapr 采用了边车架构,每个应用都有一个 Dapr 作为反向代理,在调用其他应用时,实际上是调用本应用的 Dapr 代理,并由 Dapr 来调用其他应用的 Dapr 代理,最后请求到你要调用的应用。这样做的好处是由 Dapr 来管理你的所有请求,实现了下面这些特性:

  • 命名空间隔离(Namespace scoping)
  • 服务调用安全(Service-to-service security)
  • 访问控制(Access control)
  • 重试(Retries)
  • 插件化的服务发现机制(Pluggable service discovery)
  • mDNS 负载均衡(Round robin load balancing with mDNS)
  • 链路跟踪和监控指标(Tracing and metrics with observability)
  • 服务调用 API(Service invocation API)
  • gRPC 代理(gRPC proxying)

关于服务调用构建块,官方提供了多种语言的示例,可以下载下面的源码后,在 service_invocation 目录下找到你需要的语言:

[root@localhost ~]# git clone https://github.com/dapr/quickstarts.git

这里我们使用 Java 的示例来体验一下 Dapr 的服务调用,首先使用 mvn clean package 分别构建 checkoutorder-processor 两个项目,得到 CheckoutService-0.0.1-SNAPSHOT.jarOrderProcessingService-0.0.1-SNAPSHOT.jar 两个文件。

我们先使用 dapr run 启动 order-processor 服务:

[root@localhost ~]# dapr run --app-id order-processor \
  --app-port 6001 \
  --app-protocol http \
  --dapr-http-port 3501 \
  -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar

这时我们同时启动了两个进程,Dapr 服务监听在 3501 端口,order-processor 服务监听在 6001 端口。order-processor 服务是一个非常简单的 Spring Boot Web 项目,暴露了一个 /orders API 模拟订单处理流程,我们可以使用 curl 直接访问它的接口:

[root@localhost ~]# curl -H Content-Type:application/json \
  -X POST \
  --data '{"orderId":"123"}' \
  http://127.0.0.1:6001/orders

当然这里我们更希望通过 Dapr 来调用它的接口:

[root@localhost ~]# curl -H Content-Type:application/json \
  -H dapr-app-id:order-processor \
  -X POST \
  --data '{"orderId":"123"}' \
  http://127.0.0.1:3501/orders

这里我们请求的是 Dapr 代理的端口,接口地址仍然是 /orders,特别注意的是,我们在请求里加了一个 dapr-app-id 头部,它的值就是我们要调用的应用 ID order-processor。但是这种做法官方是不推荐的,官方推荐每个应用都调用自己的 Dapr 代理,而不要去调别人的 Dapr 代理。

于是我们使用 dapr run 启动 checkout 服务:

[root@localhost ~]# dapr run --app-id checkout \
  --app-protocol http \
  --dapr-http-port 3500 \
  -- java -jar target/CheckoutService-0.0.1-SNAPSHOT.jar

这个命令同样会启动两个进程,checkout 服务是一个简单的命令行程序,它的 Dapr 代理服务监听在 3500 端口,可以看到想让 checkout 调用 order-processor,只需要调用它自己的 Dapr 代理即可,并在请求里加了一个 dapr-app-id 头部:

private static final String DAPR_HTTP_PORT = System.getenv().getOrDefault("DAPR_HTTP_PORT", "3500");

public static void main(String[] args) throws InterruptedException, IOException {
  String dapr_url = "http://localhost:"+DAPR_HTTP_PORT+"/orders";
  for (int i=1; i<=10; i++) {
    int orderId = i;
    JSONObject obj = new JSONObject();
    obj.put("orderId", orderId);

    HttpRequest request = HttpRequest.newBuilder()
        .POST(HttpRequest.BodyPublishers.ofString(obj.toString()))
        .uri(URI.create(dapr_url))
        .header("Content-Type", "application/json")
        .header("dapr-app-id", "order-processor")
        .build();

    HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    System.out.println("Order passed: "+ orderId);
    TimeUnit.MILLISECONDS.sleep(1000);
  }
}

我们还可以使用同一个 app-id 启动多个实例(要注意端口号不要和之前的冲突):

[root@localhost ~]# dapr run --app-id order-processor \
  --app-port 6002 \
  --app-protocol http \
  --dapr-http-port 3502 \
  -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar --server.port=6002

这样在请求 order-processor 服务时,Dapr 会为我们自动进行负载均衡。

发布订阅(Publish and Subscribe)

在分布式系统中,发布订阅是另一种常见的服务模型,应用之间通过消息或事件来进行通信,而不是直接调用,可以实现应用之间的解耦。

Dapr 的发布订阅构建块如下图所示:

pubsub-diagram.png

和上面一样,我们使用 Java 语言的示例来体验一下 Dapr 的发布订阅。使用 mvn clean package 分别构建 checkoutorder-processor 两个项目,得到 CheckoutService-0.0.1-SNAPSHOT.jarOrderProcessingService-0.0.1-SNAPSHOT.jar 两个文件。

使用 dapr run 启动 order-processor 服务:

[root@localhost ~]# dapr run --app-id order-processor \
  --app-port 6001 \
  --components-path ./components \
  -- java -jar target/OrderProcessingService-0.0.1-SNAPSHOT.jar

和上面的服务调用示例相比,少了 --app-protocol http--dapr-http-port 3501 两个参数,这是因为我们不再使用 HTTP 来进行服务间调用了,而是改成通过消息来进行通信。既然是消息通信,那么就少不了消息中间件,Dapr 支持常见的消息中间件来作为 Pub/sub brokers,如:Kafka、RabbitMQ、Redis 等。

我们在 components 目录下准备了一个 pubsub.yaml 文件,在这里对 Pub/sub brokers 组件进行配置:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: order_pub_sub
spec:
  type: pubsub.redis
  version: v1
  metadata:
  - name: redisHost
    value: localhost:6379
  - name: redisPassword
    value: ""

其中,metadata.name 定义了组件名称为 order_pub_sub,应用就是使用这个名称来和组件进行通信的;spec.type 定义了组件类型是 pubsub,并使用 redis 实现;spec.metadata 为组件的一些配置信息。

在运行 dapr run 时,通过 --components-path 参数来指定应用运行所需要的组件的定义,components 目录下可同时包含多个组件定义。

和之前的例子一样,order-processor 服务是一个非常简单的 Spring Boot Web 项目,它仍然是暴露 /orders 接口来模拟订单处理流程,我们可以使用 curl 直接访问它的接口:

[root@localhost ~]# curl -H Content-Type:application/json \
  -X POST \
  --data '{"data":{"orderId":"123"}}' \
  http://127.0.0.1:6001/orders

不过这里的数据结构发生了一点变化,对于消息通信的应用,Dapr 使用了一个统一的类来表示接受到的消息:

public class SubscriptionData<T> {
    private T data;
}

查看 order-processor 的日志,可以看到它成功接收到了订单:

== APP == 2022-04-03 16:26:27.265  INFO 16263 --- [nio-6001-exec-6] c.s.c.OrderProcessingServiceController   : Subscriber received: 123

既然 order-processor 也是使用 /orders 接口,那和服务调用的例子有啥区别呢?仔细翻看代码我们可以发现,order-processor 除了有一个 /orders 接口,还新增了一个 /dapr/subscribe 接口:

@GetMapping(path = "/dapr/subscribe", produces = MediaType.APPLICATION_JSON_VALUE)
public DaprSubscription[] getSubscription() {
    DaprSubscription daprSubscription = DaprSubscription.builder()
            .pubSubName("order_pub_sub")
            .topic("orders")
            .route("orders")
            .build();
    logger.info("Subscribed to Pubsubname {} and topic {}", "order_pub_sub", "orders");
    DaprSubscription[] arr = new DaprSubscription[]{daprSubscription};
    return arr;
}

这是一个简单的 GET 接口,直接返回下面这样的数据:

[
    {
        "pubSubName": "order_pub_sub",
        "topic": "orders",
        "route": "orders"
    }
]

这实际上是 Dapr 的一种接口规范,被称为 Programmatically subscriptions(编程式订阅)。Dapr 在启动时会调用这个接口,这样 Dapr 就知道你的应用需要订阅什么主题,接受到消息后通过什么接口来处理。

除了编程式订阅,Dapr 还支持一种被称为 Declarative subscriptions(声明式订阅)的方式,这种方式的好处是对代码没有侵入性,可以不改任何代码就能将一个已有应用改造成主题订阅的模式。我们将声明式订阅定义在 subscription.yaml 文件中:

apiVersion: dapr.io/v1alpha1
kind: Subscription
metadata:
  name: order-pub-sub
spec:
  topic: orders
  route: orders
  pubsubname: order_pub_sub
scopes:
- order-processor
- checkout

声明式订阅和编程式订阅的效果是一样的,其目的都是让 Dapr 订阅 某个 Pub/subpubsubname: order_pub_sub)的 某个主题topic: orders),并将接受到的消息路由到 某个接口route: orders)来处理。区别在于,声明式订阅方便一个主题注册多个应用,编程式订阅方便一个应用注册多个主题。

我们使用 dapr publish 发布一个消息:

[root@localhost ~]# dapr publish --publish-app-id order-processor \
  --pubsub order_pub_sub \
  --topic orders \
  --data '{"orderId": 100}'
✅  Event published successfully

查看 order-processor 的日志,可以看到它又一次成功接收到了订单。这一次我们不是直接调用 /orders 接口,而是通过 order-processor 的 Dapr 将消息发送到 Redis,同时 order-processor 的 Dapr 通过订阅接受到该消息,并转发给 /orders 接口。

实际上这里我偷懒了,发布和订阅用的都是 order-processor 的 Dapr,在真实场景下,发布者会使用发布者自己的 Dapr。我们启一个新的 Dapr 实例(注意使用和 order-processor 相同的组件):

[root@localhost ~]# dapr run --app-id checkout \
  --dapr-http-port 3601 \
  --components-path ./components/

然后通过 checkout 发布消息:

[root@localhost ~]# dapr publish --publish-app-id checkout \
  --pubsub order_pub_sub \
  --topic orders \
  --data '{"orderId": 100}'
✅  Event published successfully

到这里我们实现了两个 Dapr 之间的消息通信,一个 Dapr 发布消息,另一个 Dapr
订阅消息,并将接受到的消息转发给我们的应用接口。不过这里我们是使用 dapr publish 命令来发布消息的,在我们的应用代码中,当然不能使用这种方式,而应用使用发布订阅构建块提供的接口。

我们使用 dapr run 启动 checkout 服务:

[root@localhost ~]# dapr run --app-id checkout \
  --components-path ./components \
  -- java -jar target/CheckoutService-0.0.1-SNAPSHOT.jar

查看 checkout 和 order-processor 的日志,它们分别完成了订单信息的发布和接受处理。浏览代码可以发现,checkout 服务其实使用了发布订阅构建块提供的 /v1.0/publish 接口:

private static final String PUBSUB_NAME = "order_pub_sub";
private static final String TOPIC = "orders";
private static String DAPR_HOST = System.getenv().getOrDefault("DAPR_HOST", "http://localhost");
private static String DAPR_HTTP_PORT = System.getenv().getOrDefault("DAPR_HTTP_PORT", "3500");

public static void main(String[] args) throws InterruptedException, IOException {
    String uri = DAPR_HOST +":"+ DAPR_HTTP_PORT + "/v1.0/publish/"+PUBSUB_NAME+"/"+TOPIC;
    for (int i = 0; i <= 10; i++) {
        int orderId = i;
        JSONObject obj = new JSONObject();
        obj.put("orderId", orderId);

        // Publish an event/message using Dapr PubSub via HTTP Post
        HttpRequest request = HttpRequest.newBuilder()
                .POST(HttpRequest.BodyPublishers.ofString(obj.toString()))
                .uri(URI.create(uri))
                .header("Content-Type", "application/json")
                .build();

        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        logger.info("Published data: {}", orderId);
        TimeUnit.MILLISECONDS.sleep(3000);
    }
}

知道原理后,使用 curl 命令也可以模拟出完全相同的请求:

[root@localhost ~]# curl -H Content-Type:application/json \
  -X POST \
  --data '{"orderId": "100"}' \
  http://localhost:3601/v1.0/publish/order_pub_sub/orders

参考

  1. https://github.com/dapr/dapr
  2. Dapr 官方文档
  3. Dapr 知多少 | 分布式应用运行时

更多

1. Dapr Tutorials

https://docs.dapr.io/getting-started/tutorials/

2. Dapr 组件一览

  • State stores

    • Aerospike
    • Apache Cassandra
    • Couchbase
    • Hashicorp Consul
    • Hazelcast
    • Memcached
    • MongoDB
    • MySQL
    • PostgreSQL
    • Redis
    • RethinkDB
    • Zookeeper
    • AWS DynamoDB
    • GCP Firestore
    • Azure Blob Storage
    • Azure CosmosDB
    • Azure SQL Server
    • Azure Table Storage
    • OCI Object Storage
  • Name resolution

    • HashiCorp Consul
    • mDNS
    • Kubernetes
  • Pub/sub brokers

    • Apache Kafka
    • Hazelcast
    • MQTT
    • NATS Streaming
    • In Memory
    • JetStream
    • Pulsar
    • RabbitMQ
    • Redis Streams
    • AWS SNS/SQS
    • GCP Pub/Sub
    • Azure Event Hubs
    • Azure Service Bus
  • Bindings

    • Apple Push Notifications (APN)
    • Cron (Scheduler)
    • GraphQL
    • HTTP
    • InfluxDB
    • Kafka
    • Kubernetes Events
    • Local Storage
    • MQTT
    • MySQL
    • PostgreSql
    • Postmark
    • RabbitMQ
    • Redis
    • SMTP
    • Twilio
    • Twitter
    • SendGrid
    • Alibaba Cloud DingTalk
    • Alibaba Cloud OSS
    • Alibaba Cloud Tablestore
    • AWS DynamoDB
    • AWS S3
    • AWS SES
    • AWS SNS
    • AWS SQS
    • AWS Kinesis
    • GCP Cloud Pub/Sub
    • GCP Storage Bucket
    • Azure Blob Storage
    • Azure CosmosDB
    • Azure CosmosDBGremlinAPI
    • Azure Event Grid
    • Azure Event Hubs
    • Azure Service Bus Queues
    • Azure SignalR
    • Azure Storage Queues
    • Zeebe Command
    • Zeebe Job Worker
  • Secret stores

    • Local environment variables
    • Local file
    • HashiCorp Vault
    • Kubernetes secrets
    • AWS Secrets Manager
    • AWS SSM Parameter Store
    • GCP Secret Manager
    • Azure Key Vault
    • AlibabaCloud OOS Parameter Store
  • Configuration stores

    • Redis
  • Middleware

    • Rate limit
    • OAuth2
    • OAuth2 client credentials
    • Bearer
    • Open Policy Agent
    • Uppercase

3. 开发自己的组件

https://github.com/dapr/components-contrib/blob/master/docs/developing-component.md

扫描二维码,在手机上阅读!