Fork me on GitHub

分类 云原生 下的文章

在 Kubernetes 中调度 GPU 资源

在人工智能越来越普及的今天,GPU 也变得越来越常见,无论是传统的机器学习和深度学习,还是现在火热的大语言模型和文生图模型,GPU 都是绕不开的话题。最近在工作中遇到一个需求,需要在 Kubernetes 中动态地调度和使用 GPU 资源,关于 GPU 这块一直是我的知识盲区,于是趁着业余时间恶补下相关的知识。

GPU 环境准备

学习 GPU 有一定的门槛,不仅是因为好点的显卡都价格不菲,而且使用它还要搭配有相应的硬件环境,虽然笔记本也可以通过显卡扩展坞来使用,但是性能有一定的损失。对于有条件的同学,网上有很多关于如何搭建自己的深度学习工作站的教程可供参考,对此我也没有什么经验,此处略过;对于没有条件的同学,网上也有很多白嫖 GPU 的攻略,我在 使用 Google Colab 体验 AI 绘画 这篇博客中也介绍了如何在 Google Colab 中免费使用 GPU 的方法;不过这些环境一般都是做机器学习相关的实验,如果想在上面做一些更底层的实验,比如安装 Docker,部署 Kubernetes 集群等,就不太合适了。

正在无奈之际,我突然想到了阿里云的云服务器 ECS 有一个按量付费的功能,于是便上去瞅了瞅,发现有一种规格叫 共享型 GPU 实例,4 核 CPU,8G 内存,显卡为 NVIDIA A10,显存 2G,虽然配置不高,但是足够我们做实验的了,价格也相当便宜,一个小时只要一块八:

aliyun-ecs.png

于是便抱着试一试的态度下了一单,然后开始了下面的实验。但是刚开始就遇到了问题,安装 NVIDIA 驱动的时候一直报 Unable to load the kernel module 'nvidia.ko' 这样的错误:

nvidia-driver-error.png

在网上搜了很多解决方案都没有解决,最后才在阿里云的产品文档中找到了答案:阿里云的 GPU 产品有 计算型虚拟化型 两种实例规格族,可以从它们的命名上进行区分,比如上面我买的这个实例规格为 ecs.sgn7i-vws-m2s.xlarge,其中 sgn 表示这是一台采用 NVIDIA GRID vGPU 加速的共享型实例,它和 vgn 一样,都属于虚拟化型,使用了 NVIDIA GRID 虚拟 GPU 技术,所以需要安装 GRID 驱动,具体步骤可以 参考这里;如果希望手工安装 NVIDIA 驱动,我们需要购买计算型的 GPU 实例。

阿里云的产品文档中有一篇 NVIDIA 驱动安装指引,我觉得整理的挺好,文档中对不同的规格、不同的使用场景、不同的操作系统都做了比较详情的介绍。

于是我重新下单,又买了一台规格为 ecs.gn5i-c2g1.large计算型 GPU 实例,2 核 CPU,8G 内存,显卡为 NVIDIA P4,显存 8G,价格一个小时八块多。

购买计算型实例纯粹是为了体验一下 NVIDIA 驱动的安装过程,如果只想进行后面的 Kubernetes 实验,直接使用虚拟化型实例也是可以的。另外,在购买计算型实例时可以选择自动安装 NVIDIA 驱动,对应版本的 CUDA 和 CUDNN 也会一并安装,使用还是很方便的。

安装 NVIDIA 驱动

登录刚买的服务器,我们可以通过 lspci 看到 NVIDIA 的这张显卡:

# lspci | grep NVIDIA
00:07.0 3D controller: NVIDIA Corporation GP104GL [Tesla P4] (rev a1)

此时这个显卡还不能直接使用,因为还需要安装 NVIDIA 的显卡驱动。访问 NVIDIA Driver Downloads,在这里选择你的显卡型号和操作系统并搜索:

nvidia-driver-download.png

从列表中可以看到驱动的不同版本,第一条是最新版本 535.129.03,我们点击链接进入下载页面并复制链接地址,然后使用下面的命令下载之:

# curl -LO https://us.download.nvidia.com/tesla/535.129.03/NVIDIA-Linux-x86_64-535.129.03.run

这个文件其实是一个可执行文件,直接运行即可:

# sh NVIDIA-Linux-x86_64-535.129.03.run

安装过程中会出现一些选项,保持默认即可,等待驱动安装成功后,运行 nvidia-smi 命令应该能看到显卡状态:

# nvidia-smi
Thu Nov 24 08:16:38 2023       
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 535.129.03             Driver Version: 535.129.03   CUDA Version: 12.2     |
|-----------------------------------------+----------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |         Memory-Usage | GPU-Util  Compute M. |
|                                         |                      |               MIG M. |
|=========================================+======================+======================|
|   0  Tesla P4                       Off | 00000000:00:07.0 Off |                    0 |
| N/A   41C    P0              23W /  75W |      0MiB /  7680MiB |      2%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+
                                                                                         
+---------------------------------------------------------------------------------------+
| Processes:                                                                            |
|  GPU   GI   CI        PID   Type   Process name                            GPU Memory |
|        ID   ID                                                             Usage      |
|=======================================================================================|
|  No running processes found                                                           |
+---------------------------------------------------------------------------------------+

安装 CUDA

CUDA(Compute Unified Device Architecture) 是 NVIDIA 推出的一种通用并行计算平台和编程模型,允许开发人员使用 C、C++ 等编程语言编写高性能计算应用程序,它利用 GPU 的并行计算能力解决复杂的计算问题,特别是在深度学习、科学计算、图形处理等领域。所以一般情况下,安装完 NVIDIA 驱动后,CUDA 也可以一并安装上。

在下载 NVIDIA 驱动时,每个驱动版本都对应了一个 CUDA 版本,比如上面我们在下载驱动版本 535.129.03 时可以看到,它对应的 CUDA 版本为 12.2,所以我们就按照这个版本号来安装。首先进入 CUDA Toolkit Archive 页面,这里列出了所有的 CUDA 版本:

cuda-download.png

找到 12.2 版本进入下载页面:

cuda-download-2.png

选择操作系统、架构、发行版本和安装类型,下面就会出现相应的下载地址和运行命令,按照提示在服务器中执行即可:

# wget https://developer.download.nvidia.com/compute/cuda/12.2.2/local_installers/cuda_12.2.2_535.104.05_linux.run
# sh cuda_12.2.2_535.104.05_linux.run

这个安装过程会比较长,当安装成功后,可以看到下面这样的信息:

===========
= Summary =
===========

Driver:   Installed
Toolkit:  Installed in /usr/local/cuda-12.2/

Please make sure that
 -   PATH includes /usr/local/cuda-12.2/bin
 -   LD_LIBRARY_PATH includes /usr/local/cuda-12.2/lib64, or, add /usr/local/cuda-12.2/lib64 to /etc/ld.so.conf and run ldconfig as root

To uninstall the CUDA Toolkit, run cuda-uninstaller in /usr/local/cuda-12.2/bin
To uninstall the NVIDIA Driver, run nvidia-uninstall
Logfile is /var/log/cuda-installer.log

在 Docker 容器中使用 GPU 资源

GPU 环境准备好之后,接下来我们先试着在 Docker 容器中使用它。由于是新买的系统,并没有 Docker 环境,所以我们要先安装 Docker,可以参考我之前写的 在 VirtualBox 上安装 Docker 服务 这篇博客。

安装完 Docker 之后,执行下面的命令确认版本:

# docker --version
Docker version 24.0.7, build afdd53b

然后执行下面的命令来测试下 GPU 是否可以在容器中使用:

# docker run --gpus all --rm centos:latest nvidia-smi
docker: Error response from daemon: could not select device driver "" with capabilities: [[gpu]].

可以看到命令执行报错了,稍微 Google 一下这个错就知道,想在 Docker 中使用 NVIDIA GPU 还必须安装 nvidia-container-runtime 运行时。

安装 nvidia-container-runtime 运行时

我们一般使用 NVIDIA Container Toolkit 来安装 nvidia-container-runtime 运行时,根据官方文档,首先将 nvidia-container-toolkit.repo 文件添加到 yum 的仓库目录 /etc/yum.repos.d 中:

# curl -s -L https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo | \
    tee /etc/yum.repos.d/nvidia-container-toolkit.repo

也可以使用 yum-config-manager --add-repo 来添加:

# yum install -y yum-utils
# yum-config-manager --add-repo https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo

然后使用 yum install 安装:

# yum install -y nvidia-container-toolkit

安装 NVIDIA Container Toolkit 之后,再使用下面的命令将 Docker 的运行时配置成 nvidia-container-runtime

# nvidia-ctk runtime configure --runtime=docker
INFO[0000] Config file does not exist; using empty config 
INFO[0000] Wrote updated config to /etc/docker/daemon.json 
INFO[0000] It is recommended that docker daemon be restarted.

这个命令的作用是修改 /etc/docker/daemon.json 配置文件:

# cat /etc/docker/daemon.json
{
    "runtimes": {
        "nvidia": {
            "args": [],
            "path": "nvidia-container-runtime"
        }
    }
}

按照提示,重启 Docker 服务:

# systemctl restart docker

在容器中使用 GPU 资源

配置完 nvidia-container-runtime 运行时之后,重新执行下面的命令:

# docker run --gpus all --rm centos:latest nvidia-smi
Sat Nov 25 02:31:58 2023       
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 470.161.03   Driver Version: 470.161.03   CUDA Version: 11.4     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                               |                      |               MIG M. |
|===============================+======================+======================|
|   0  NVIDIA A10-2Q       On   | 00000000:00:07.0 Off |                  N/A |
| N/A   N/A    P0    N/A /  N/A |     64MiB /  1889MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+
                                                                               
+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

此时我换成了一台共享型 GPU 实例,所以显示的是 A10,驱动版本和 CUDA 版本要低一点,命令的输出表明我们在容器中已经可以访问 GPU 资源了。值得注意的是,我们运行的 centos:latest 镜像里本来是没有 nvidia-smi 命令的:

# docker run --rm centos:latest nvidia-smi
exec: "nvidia-smi": executable file not found in $PATH: unknown.

但是加上 --gpus all 参数之后就有这个命令了,真是神奇。

使用 --gpus all 参数可以让容器内访问宿主机上的所有显卡,也可以指定某张卡在容器中使用:

# docker run --gpus 1 --rm centos:latest nvidia-smi

或者这样:

# docker run --gpus 'device=0' --rm centos:latest nvidia-smi

接下来,我们再利用 tensorflow 的镜像来测试下是否可以在程序中使用 GPU 资源:

# docker run --rm -it --gpus all tensorflow/tensorflow:2.6.0-gpu bash
root@bacd1c7c8b6c:/# python3
Python 3.6.9 (default, Jan 26 2021, 15:33:00) 
[GCC 8.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

在使用 tensorflow 镜像时要注意与 CUDA 版本的兼容性,这里 有一份 tensorflow 版本和 CUDA 版本之间的对应关系,如果不兼容,会出现如下的报错:

# docker run --rm -it --gpus all tensorflow/tensorflow:latest-gpu bash
nvidia-container-cli: requirement error: unsatisfied condition: cuda>=12.3, please update your driver to a newer version, or use an earlier cuda container: unknown.

然后使用一段简单的 Python 代码来测试 GPU 功能:

>>> import tensorflow as tf
>>> print(tf.test.gpu_device_name())

如果一切正常,就会打印出 GPU 的设备名称:

/device:GPU:0

Docker 19.03 之前

上面介绍的 --gpus 参数是在 Docker 19.03 版本之后才引入了,在 Docker 19.03 之前,我们也有几种方式来使用 GPU,第一种也是最原始的方式,通过 --device 参数将显卡设备挂载到容器里:

# docker run --rm -it \
    --device /dev/nvidia0:/dev/nvidia0 \
    --device /dev/nvidiactl:/dev/nvidiactl \
    --device /dev/nvidia-uvm:/dev/nvidia-uvm \
    tensorflow/tensorflow:2.6.0-gpu bash

第二种是使用英伟达公司开发的 nvidia-docker 工具,这个工具对 docker 进行了一层封装,使得在容器中也可以访问 GPU 资源,它在使用上和 docker 几乎完全一样:

# nvidia-docker run --rm -it tensorflow/tensorflow:2.6.0-gpu bash

nvidia-docker 有两个版本:nvidia-dockernvidia-docker2。nvidia-docker 是一个独立的守护进程,它以 Volume Plugin 的形式存在,它与 Docker 生态系统的兼容性较差,比如它和 docker-compose、docker swarm、Kubernetes 都不能很好地一起工作,因此很快被废弃。随后,官方推出了 nvidia-docker2,它不再是 Volume Plugin,而是作为一个 Docker Runtime,实现机制上的差异,带来了巨大改进,从而和 Docker 生态实现了更好的兼容性,使用上也完全兼容 docker 命令,加一个 --runtime=nvidia 参数即可:

# docker run --rm -it --runtime=nvidia tensorflow/tensorflow:2.6.0-gpu bash

然而,随着 Docker 19.03 版本的发布,NVIDIA GPU 作为 Docker Runtime 中的设备得到了官方支持,因此 nvidia-docker2 目前也已经被弃用了。

在 Kubernetes 中调度 GPU 资源

终于到了这篇博客的主题,接下来我们实践一下如何在 Kubernetes 集群中调度 GPU 资源。和 Docker 一样,新买的服务器上也没有 Kubernetes 环境,我们需要先安装 Kubernetes,可以参考我之前写的 Kubernetes 安装小记 这篇博客。

我们知道,Kubernetes 具有对机器的资源进行分配和使用的能力,比如可以指定容器最多使用多少内存以及使用多少 CPU 计算资源,同样,我们也可以指定容器使用多少 GPU 资源,但在这之前,我们需要先安装 nvidia-container-runtime 运行时,以及 NVIDIA 的设备插件。

安装 nvidia-container-runtime 运行时

通过上面的学习,我们通过安装 nvidia-container-runtime 运行时,在 Docker 容器中访问了 GPU 设备,在 Kubernetes 中调度 GPU 资源同样也需要安装这个 nvidia-container-runtime。如果 Kubernetes 使用的容器运行时是 Docker,直接参考上面的章节进行安装配置即可;但 Kubernetes 从 1.24 版本开始,改用 containerd 作为容器运行时,为了在 containerd 容器中使用 NVIDIA 的 GPU 设备,配置步骤稍微有些区别。

首先我们还是先安装 NVIDIA Container Toolkit,然后通过下面的命令将 nvidia-container-runtime 加入 containerd 的运行时列表中:

# nvidia-ctk runtime configure --runtime=containerd

这个命令实际上是对 containerd 的配置文件 /etc/containerd/config.toml 进行修改,内容如下:

    [plugins."io.containerd.grpc.v1.cri".containerd]
      default_runtime_name = "runc"
      snapshotter = "overlayfs"

      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes]

        [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia]
          runtime_engine = ""
          runtime_root = ""
          runtime_type = "io.containerd.runc.v2"

          [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia.options]
            BinaryName = "/usr/bin/nvidia-container-runtime"
            SystemdCgroup = true

注意这个命令并不会修改 default_runtime_name 配置,我们需要手动将这个值修改为 nvidia:

      default_runtime_name = "nvidia"

然后重启 containerd 服务:

# systemctl restart containerd

安装 NVIDIA 设备插件

接下来,我们继续安装 NVIDIA 设备插件设备插件(Device Plugins) 是 Kubernetes 用于管理和调度容器中设备资源的一种插件机制,它可以将物理设备(如 GPU、FPGA 等)暴露给容器,从而提供更高级别的资源管理和调度能力。

通过下面的命令将 NVIDIA 设备插件部署到集群中:

# kubectl create -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.3/nvidia-device-plugin.yml
daemonset.apps/nvidia-device-plugin-daemonset created

从运行结果可以看出,设备插件本质上是一个 DaemonSet,运行 kubectl get daemonset 命令查看其是否启动成功:

# kubectl get daemonset -n kube-system
NAME                             DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR            AGE
kube-proxy                       1         1         1       1            1           kubernetes.io/os=linux   90m
nvidia-device-plugin-daemonset   1         1         1       1            1           <none>                   43s

运行 kubectl logs 命令查看其启动日志:

# kubectl logs nvidia-device-plugin-daemonset-s97vk -n kube-system
I1126 04:46:34.020261       1 main.go:154] Starting FS watcher.
I1126 04:46:34.020321       1 main.go:161] Starting OS watcher.
I1126 04:46:34.020578       1 main.go:176] Starting Plugins.
I1126 04:46:34.020591       1 main.go:234] Loading configuration.
I1126 04:46:34.020668       1 main.go:242] Updating config with default resource matching patterns.
I1126 04:46:34.020829       1 main.go:253] 
Running with config:
{
  "version": "v1",
  "flags": {
    "migStrategy": "none",
    "failOnInitError": false,
    "nvidiaDriverRoot": "/",
    "gdsEnabled": false,
    "mofedEnabled": false,
    "plugin": {
      "passDeviceSpecs": false,
      "deviceListStrategy": [
        "envvar"
      ],
      "deviceIDStrategy": "uuid",
      "cdiAnnotationPrefix": "cdi.k8s.io/",
      "nvidiaCTKPath": "/usr/bin/nvidia-ctk",
      "containerDriverRoot": "/driver-root"
    }
  },
  "resources": {
    "gpus": [
      {
        "pattern": "*",
        "name": "nvidia.com/gpu"
      }
    ]
  },
  "sharing": {
    "timeSlicing": {}
  }
}
I1126 04:46:34.020840       1 main.go:256] Retreiving plugins.
I1126 04:46:34.021064       1 factory.go:107] Detected NVML platform: found NVML library
I1126 04:46:34.021090       1 factory.go:107] Detected non-Tegra platform: /sys/devices/soc0/family file not found
I1126 04:46:34.032304       1 server.go:165] Starting GRPC server for 'nvidia.com/gpu'
I1126 04:46:34.033008       1 server.go:117] Starting to serve 'nvidia.com/gpu' on /var/lib/kubelet/device-plugins/nvidia-gpu.sock
I1126 04:46:34.037402       1 server.go:125] Registered device plugin for 'nvidia.com/gpu' with Kubelet

如果看到日志显示 Registered device plugin for 'nvidia.com/gpu' with Kubelet,表示 NVIDIA 设备插件已经安装成功了。此时,我们也可以在 kubelet 的设备插件目录下看到 NVIDIA GPU 的 socket 文件:

# ll /var/lib/kubelet/device-plugins/ | grep nvidia-gpu.sock

但是安装也不一定是一帆风顺的,如果看到下面这样的日志:

I1126 04:34:05.352152       1 main.go:256] Retreiving plugins.
W1126 04:34:05.352505       1 factory.go:31] No valid resources detected, creating a null CDI handler
I1126 04:34:05.352539       1 factory.go:107] Detected non-NVML platform: could not load NVML library: libnvidia-ml.so.1: cannot open shared object file: No such file or directory
I1126 04:34:05.352569       1 factory.go:107] Detected non-Tegra platform: /sys/devices/soc0/family file not found
E1126 04:34:05.352573       1 factory.go:115] Incompatible platform detected
E1126 04:34:05.352576       1 factory.go:116] If this is a GPU node, did you configure the NVIDIA Container Toolkit?
E1126 04:34:05.352578       1 factory.go:117] You can check the prerequisites at: https://github.com/NVIDIA/k8s-device-plugin#prerequisites
E1126 04:34:05.352582       1 factory.go:118] You can learn how to set the runtime at: https://github.com/NVIDIA/k8s-device-plugin#quick-start
E1126 04:34:05.352585       1 factory.go:119] If this is not a GPU node, you should set up a toleration or nodeSelector to only deploy this plugin on GPU nodes
I1126 04:34:05.352590       1 main.go:287] No devices found. Waiting indefinitely.

表示没有找到 NVIDIA 设备,请检查显卡驱动是否安装,或者 containerd 的配置是否正确。

调度 GPU 资源

接下来,我们创建一个测试文件:

# vi gpu-test.yaml

文件内容如下:

apiVersion: v1
kind: Pod
metadata:
  name: gpu-test
spec:
  restartPolicy: OnFailure
  containers:
    - name: gpu-test
      image: tensorflow/tensorflow:2.6.0-gpu
      command:
        - python3
        - /app/test.py
      volumeMounts:
        - name: gpu-test-script
          mountPath: /app/
      resources:
        limits:
          nvidia.com/gpu: 1
  volumes:
    - name: gpu-test-script
      configMap:
        name: gpu-test-script
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: gpu-test-script
data:
  test.py: |
    import tensorflow as tf
    print(tf.test.gpu_device_name())

这里我们仍然使用 tensorflow/tensorflow:2.6.0-gpu 这个镜像来测试 GPU 功能,我们通过 ConfigMap 将一段 Python 测试脚本挂载到容器中并运行,另外通过 resources.limits.nvidia.com/gpu: 1 这样的配置告诉 Kubernetes,容器的运行需要使用一张 GPU 显卡资源,Kubernetes 会自动根据 NVIDIA 设备插件汇报的情况找到符合条件的节点,然后在该节点上启动 Pod,启动 Pod 时,由于 containerd 的默认运行时是 nvidia-container-runtime,所以会将 NVIDIA GPU 挂载到容器中。

运行 kubectl apply 命令创建 Pod 和 ConfigMap:

# kubectl apply -f gpu-test.yaml
pod/gpu-test created
configmap/gpu-test-script created

运行 kubectl get pods 命令查看 Pod 的运行状态:

# kubectl get pods
NAME       READY   STATUS             RESTARTS      AGE
gpu-test   0/1     Pending            0             5s

如果像上面这样一直处理 Pending 状态,可以运行 kubectl describe pod 命令查看 Pod 的详细情况:

# kubectl describe pod gpu-test
Name:             gpu-test
Namespace:        default
Priority:         0
Service Account:  default
...
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  19s   default-scheduler  0/1 nodes are available: 1 Insufficient nvidia.com/gpu. preemption: 0/1 nodes are available: 1 No preemption victims found for incoming pod.

出现上面这样的情况可能有两种原因:

  1. 显卡资源已经被其他 Pod 所占用,默认情况下 NVIDIA 设备插件只支持卡级别的调度,并且显卡资源是独占的,我的这台服务器上只有一张显卡,如果已经有 Pod 占用了一张卡,那么其他的 Pod 就不能再使用该卡了;如果希望多个 Pod 共享一张卡,可以参考官方文档中的 Shared Access to GPUs with CUDA Time-Slicing
  2. NVIDIA 设备插件未成功安装,请参考上面的章节确认 NVIDIA 设备插件已经成功启动。

如果一切正常,Pod 的状态应该是 Completed:

# kubectl get pods
NAME       READY   STATUS      RESTARTS   AGE
gpu-test   0/1     Completed   0          7s

运行 kubectl logs 命令查看 Pod 日志:

# kubectl logs gpu-test
2023-11-26 05:05:14.779693: I tensorflow/core/platform/cpu_feature_guard.cc:142] This TensorFlow binary is optimized with oneAPI Deep Neural Network Library (oneDNN) to use the following CPU instructions in performance-critical operations:  AVX2 AVX512F FMA
To enable them in other operations, rebuild TensorFlow with the appropriate compiler flags.
2023-11-26 05:05:16.508041: I tensorflow/stream_executor/cuda/cuda_gpu_executor.cc:937] successful NUMA node read from SysFS had negative value (-1), but there must be at least one NUMA node, so returning NUMA node zero
2023-11-26 05:05:16.508166: I tensorflow/core/common_runtime/gpu/gpu_device.cc:1510] Created device /device:GPU:0 with 1218 MB memory:  -> device: 0, name: NVIDIA A10-2Q, pci bus id: 0000:00:07.0, compute capability: 8.6
/device:GPU:0

可以看到脚本成功打印出了 GPU 的设备名称,说明现在我们已经可以在 Kubernetes 中使用 GPU 资源了。

参考

更多

监控 GPU 资源的使用

设备插件原理

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

学习 Kubernetes 流量管理之 Ingress

学习 Kubernetes 流量管理之 Service 这篇笔记中我们学习了 Kubernetes 是如何使用 Service 进行流量管理的,我们可以通过 NodePortLoadBalancer 这两种类型的 Service 让应用程序暴露到集群外部,不过这两种方式都有各自的问题:

  • NodePort 会在所有节点上暴露端口,外部应用需要知道集群内部节点的 IP 才能访问,一旦集群节点发生变化,外部应用也会受影响,可用性无法保证;而且端口的范围是受限的,默认只能使用 30000 到 32767 之间的端口,外部应用使用起来会感觉怪怪的;另外,每个端口只能对应一个 Service,如果 Service 数量过多,暴露的端口也会过多,不仅安全性难以保障,而且管理起来也会很麻烦;
  • LoadBalancer 依赖于外部负载均衡器作为流量的入口,它在云平台中使用非常广泛,一般使用云供应商提供的 LB 服务,它会有一个自己的 IP 地址来转发所有流量,不过要注意的是,你暴露的每个 Service 都对应一个 LB 服务,而每个 LB 都需要独立付费,如果你暴露的 Service 很多,这将是非常昂贵的。

什么是 Ingress

为了解决上面的问题,Kubernetes 提出了一种新的 API 对象,叫做 Ingress,它通过定义不同的 HTTP 路由规则,将集群内部的 Service 通过 HTTP 的方式暴露到集群外部:

ingress.png

可以将 Ingress 理解为 Service 的网关,它是所有流量的入口,通过 Ingress 我们就能以一个集群外部可访问的 URL 来访问集群内部的 Service,不仅如此,它还具有如下特性:

  • Load Balancing
  • SSL Termination
  • Name-based Virtual Hosting

Ingress 实践

这一节将继续延用之前的 kubernetes-bootcamp 示例,通过 Ingress 将应用程序暴露到集群外部访问。

部署 Ingress Controller

Ingress 本身其实并不具备集群内外通信的能力,它只是一系列的路由转发规则而已,要让这些路由规则生效,必须先部署 Ingress Controller 才行。

由 Kubernetes 支持和维护的 Ingress Controller 有三个:

除此之外,这里 还列出了很多由第三方社区维护的其他 Ingress Controller 可供选择。

下面我们就以 Ingress NGINX Controller 为例,学习如何部署 Ingress Controller。

目前有两个基于 Nginx 实现的 Ingress Controller 比较有名,一个是由 Kubernetes 官方维护的 kubernetes/ingress-nginx,被称为 Ingress NGINX Controller,另一个是由 Nginx 官方维护的 nginxinc/kubernetes-ingress,被称为 NGINX Ingress Controller,两者在技术实现和功能特性上有很多区别,大家在使用时要特别留意。

根据 Ingress NGINX Controller 官方的部署文档,我们大致有两种方式来部署它,第一种是通过 Helm 部署:

# helm upgrade --install ingress-nginx ingress-nginx \
  --repo https://kubernetes.github.io/ingress-nginx \
  --namespace ingress-nginx --create-namespace

第二种是通过 kubectl apply 部署,我比较喜欢这种方式,可以从 YAML 中看到整个部署的细节:

# kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.2/deploy/static/provider/cloud/deploy.yaml
namespace/ingress-nginx created
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created

从上面的输出可以看到,Ingress NGINX Controller 首先创建一个名为 ingress-nginx 的命名空间,然后在这个命名空间下创建了一堆相关的资源,包括 ServiceAccount、Role、ConfigMap、Deployment、Service、Job 等等,这中间,最重要的是 deployment.apps/ingress-nginx-controllerservice/ingress-nginx-controller 这两项;其实,Ingress Controller 本质上就是一个 Deployment 加上一个 Service,这个 Deployment 通过监听 Ingress 对象的变动来更新路由规则,而用户访问集群的入口仍然是通过 Service 实现的,所以想让用户通过 Ingress 来访问集群,还是得靠 Service 的两种外部通信方式:NodePortLoadBalancer

查看上面这个 YAML,可以发现它使用的就是 LoadBalancer 类型的 Service,一般适用于云环境,如果你没有云环境,官方也提供了几种在物理机环境部署的方式:

其中最简单的方式是使用 NodePort 类型的 Service,直接使用下面这个 YAML 部署即可:

# kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.8.2/deploy/static/provider/baremetal/deploy.yaml

部署完成后,通过下面的命令检查 Ingress NGINX Controller 是否运行成功:

# kubectl get deployment -n ingress-nginx
NAME                       READY   UP-TO-DATE   AVAILABLE   AGE
ingress-nginx-controller   1/1     1            1           29h

通过下面的命令确定 Ingress NGINX Controller 的 NodePort 是多少:

# kubectl get svc -n ingress-nginx
NAME                                 TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)                      AGE
ingress-nginx-controller             NodePort    10.96.0.183   <none>        80:26360/TCP,443:23476/TCP   29h
ingress-nginx-controller-admission   ClusterIP   10.96.1.25    <none>        443/TCP                      29h

此时,我们就可以通过 NodePort 来访问集群了,只不过因为我们还没有配置任何路由,所以访问会报 404 Not Found

# curl http://172.31.164.40:26360
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>

注意这里实际上暴露了两个 NodePort,一个是 HTTP 端口,另一个是 HTTPS 端口,这个 HTTPS 端口我们也可以访问(-k 表示忽略证书校验):

# curl -k https://172.31.164.40:23476
<html>
<head><title>404 Not Found</title></head>
<body>
<center><h1>404 Not Found</h1></center>
<hr><center>nginx</center>
</body>
</html>

创建 Ingress

接下来,我们创建一个简单的路由规则来验证 Ingress 是否有效:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-ingress
spec:
  ingressClassName: nginx
  rules:
  - http:
      paths:
      - path: /hello
        pathType: Prefix
        backend:
          service:
            name: myapp
            port:
              number: 38080

这个路由规则很容易理解,就是将 /hello 路径映射到后端名为 myapp 的 Service 的 38080 端口。在使用 Ingress 时要注意你的 Kubernetes 版本,不同的 Kubernetes 版本中 Ingress 的 apiVersion 字段略有不同:

Kubernetes 版本Ingress 的 apiVersion
v1.5 - v1.17extensions/v1beta1
v1.8 - v1.18networking.k8s.io/v1beta1
v1.19+networking.k8s.io/v1

另一点值得注意的是 ingressClassName: nginx 这个配置,细心的同学可能已经发现,在上面部署 Ingress NGINX Controller 的时候,默认还创建了一个 IngressClass 资源:

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: ingress-nginx
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
    app.kubernetes.io/version: 1.8.2
  name: nginx
spec:
  controller: k8s.io/ingress-nginx

我们可以将 IngressClass 理解成面向对象中的类这个概念,而 Ingress 则是类的具体示例。在 Ingress NGINX Controller 的启动参数里,我们能看到 --ingress-class=nginx 这样的参数:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
spec:
  minReadySeconds: 0
  revisionHistoryLimit: 10
  template:
    spec:
      containers:
      - args:
        - /nginx-ingress-controller
        - --election-id=ingress-nginx-leader
        - --controller-class=k8s.io/ingress-nginx
        - --ingress-class=nginx

表示它会监听名为 nginxIngressClass,一个集群中可能会部署多个 Ingress Controller,这样就会有多个 IngressClass,所以上面创建 Ingress 时指定 ingressClassName: nginx 表示将这个路由规则应用到刚部署的 Ingress NGINX Controller。

通过 curl 验证 Ingress 是否生效:

# curl http://172.31.164.40:26360/hello
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-9xm5j | v=1

可以看出,虽然 myapp 这个 Service 类型为 ClusterIP,但是通过 Ingress 我们也可以从集群外部对其进行访问了。

默认 IngressClass

我们可以给某个 IngressClass 加上 ingressclass.kubernetes.io/is-default-class 注解,并将值设置为字符串 "true",表示这是集群中默认的 IngressClass

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  annotations:
    ingressclass.kubernetes.io/is-default-class: "true"
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: ingress-nginx
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
    app.kubernetes.io/version: 1.8.2
  name: nginx
spec:
  controller: k8s.io/ingress-nginx

当集群中存在默认的 IngressClass 时,创建 Ingress 时就可以不用指定 ingressClassName 参数了:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-ingress
spec:
  rules:
  - http:
      paths:
      - path: /hello
        pathType: Prefix
        backend:
          service:
            name: myapp
            port:
              number: 38080

注意,一个集群中最多只应该存在一个默认的 IngressClass,如果有多个 IngressClass 被设置成默认,那么创建 Ingress 时还是得指定 ingressClassName 参数。

深入 Ingress Controller

为了更进一步地了解 Ingress Controller 的工作原理,我们不妨进入 ingress-nginx-controller 容器内部:

# kubectl exec -it ingress-nginx-controller-6c68b88b5d-wdk96 -n ingress-nginx -- bash

在这里我们可以看到 nginx.conf 这个熟悉的身影:

ingress-nginx-controller-6c68b88b5d-wdk96:/etc/nginx$ ls
fastcgi.conf            geoip                   mime.types              nginx.conf              owasp-modsecurity-crs   uwsgi_params
fastcgi.conf.default    koi-utf                 mime.types.default      nginx.conf.default      scgi_params             uwsgi_params.default
fastcgi_params          koi-win                 modsecurity             opentelemetry.toml      scgi_params.default     win-utf
fastcgi_params.default  lua                     modules                 opentracing.json        template

这个文件和普通的 Nginx 配置文件并无二致,查看文件内容可以发现,上面所配置的 Ingress 规则其实都被转换成了 Nginx 规则,此外,我们还发现,Ingress NGINX Controller 是基于 Nginx + Lua 实现的:

ingress-nginx-controller-6c68b88b5d-wdk96:/etc/nginx$ cat nginx.conf

  ## start server _
  server {
    server_name _ ;
    
    listen 80 default_server reuseport backlog=511 ;
    listen [::]:80 default_server reuseport backlog=511 ;
    listen 443 default_server reuseport backlog=511 ssl http2 ;
    listen [::]:443 default_server reuseport backlog=511 ssl http2 ;
    
    location /hello/ {
      
      set $namespace      "default";
      set $ingress_name   "my-ingress";
      set $service_name   "myapp";
      set $service_port   "38080";
      set $location_path  "/hello";
      set $global_rate_limit_exceeding n;
      
      rewrite_by_lua_block {
        lua_ingress.rewrite({
          force_ssl_redirect = false,
          ssl_redirect = true,
          force_no_ssl_redirect = false,
          preserve_trailing_slash = false,
          use_port_in_redirects = false,
          global_throttle = { namespace = "", limit = 0, window_size = 0, key = { }, ignored_cidrs = { } },
        })
        balancer.rewrite()
        plugins.run()
      }
      
      header_filter_by_lua_block {
        lua_ingress.header()
        plugins.run()
      }
      
      body_filter_by_lua_block {
        plugins.run()
      }

      set $proxy_upstream_name "default-myapp-38080";
      
      proxy_pass http://upstream_balancer;
      
    }
  }
  ## end server _
  

其中 upstream_balancer 的定义如下:

  upstream upstream_balancer {
    ### Attention!!!
    #
    # We no longer create "upstream" section for every backend.
    # Backends are handled dynamically using Lua. If you would like to debug
    # and see what backends ingress-nginx has in its memory you can
    # install our kubectl plugin https://kubernetes.github.io/ingress-nginx/kubectl-plugin.
    # Once you have the plugin you can use "kubectl ingress-nginx backends" command to
    # inspect current backends.
    #
    ###
    
    server 0.0.0.1; # placeholder
    
    balancer_by_lua_block {
      balancer.balance()
    }
    
    keepalive 320;
    keepalive_time 1h;
    keepalive_timeout  60s;
    keepalive_requests 10000;
    
  }

通过这里的注释我们了解到,Ingress NGINX Controller 转发的后端地址是动态的,由 Lua 脚本实现,如果想看具体的后端地址,可以安装 ingress-nginx 插件,安装 ingress-nginx 插件最简单的方式是使用 krew 来安装,所以我们先安装 krew,首先下载并解压 krew 的最新版本

# curl -LO https://github.com/kubernetes-sigs/krew/releases/download/v0.4.4/krew-linux_amd64.tar.gz
# tar zxvf krew-linux_amd64.tar.gz

然后运行下面的命令进行安装:

# ./krew-linux_amd64 install krew
Adding "default" plugin index from https://github.com/kubernetes-sigs/krew-index.git.
Updated the local copy of plugin index.
Installing plugin: krew
Installed plugin: krew
\
 | Use this plugin:
 |      kubectl krew
 | Documentation:
 |      https://krew.sigs.k8s.io/
 | Caveats:
 | \
 |  | krew is now installed! To start using kubectl plugins, you need to add
 |  | krew's installation directory to your PATH:
 |  |
 |  |   * macOS/Linux:
 |  |     - Add the following to your ~/.bashrc or ~/.zshrc:
 |  |         export PATH="${KREW_ROOT:-$HOME/.krew}/bin:$PATH"
 |  |     - Restart your shell.
 |  |
 |  |   * Windows: Add %USERPROFILE%\.krew\bin to your PATH environment variable
 |  |
 |  | To list krew commands and to get help, run:
 |  |   $ kubectl krew
 |  | For a full list of available plugins, run:
 |  |   $ kubectl krew search
 |  |
 |  | You can find documentation at
 |  |   https://krew.sigs.k8s.io/docs/user-guide/quickstart/.
 | /
/

根据提示,将 export PATH="${KREW_ROOT:-$HOME/.krew}/bin:$PATH" 添加到 ~/.bashrc 文件中,然后重新打开 Shell,这样 krew 就安装完成了。接下来,使用 kubectl krew install 命令安装 ingress-nginx 插件:

# kubectl krew install ingress-nginx
Updated the local copy of plugin index.
Installing plugin: ingress-nginx
Installed plugin: ingress-nginx
\
 | Use this plugin:
 |      kubectl ingress-nginx
 | Documentation:
 |      https://kubernetes.github.io/ingress-nginx/kubectl-plugin/
/

插件安装之后,使用 kubectl ingress-nginx backends 命令查看 Ingress NGINX Controller 的后端地址信息:

# kubectl ingress-nginx backends -n ingress-nginx
[
  {
    "name": "default-myapp-38080",
    "service": {
      "metadata": {
        "creationTimestamp": null
      },
      "spec": {
        "ports": [
          {
            "name": "http",
            "protocol": "TCP",
            "port": 38080,
            "targetPort": "myapp-port"
          }
        ],
        "selector": {
          "app": "myapp"
        },
        "clusterIP": "10.96.3.215",
        "clusterIPs": [
          "10.96.3.215"
        ],
        "type": "ClusterIP",
        "sessionAffinity": "None",
        "ipFamilies": [
          "IPv4"
        ],
        "ipFamilyPolicy": "SingleStack",
        "internalTrafficPolicy": "Cluster"
      },
      "status": {
        "loadBalancer": {}
      }
    },
    "port": 38080,
    "sslPassthrough": false,
    "endpoints": [
      {
        "address": "100.84.80.88",
        "port": "8080"
      },
      {
        "address": "100.121.213.72",
        "port": "8080"
      },
      {
        "address": "100.121.213.109",
        "port": "8080"
      }
    ],
    "sessionAffinityConfig": {
      "name": "",
      "mode": "",
      "cookieSessionAffinity": {
        "name": ""
      }
    },
    "upstreamHashByConfig": {
      "upstream-hash-by-subset-size": 3
    },
    "noServer": false,
    "trafficShapingPolicy": {
      "weight": 0,
      "weightTotal": 0,
      "header": "",
      "headerValue": "",
      "headerPattern": "",
      "cookie": ""
    }
  },
  {
    "name": "upstream-default-backend",
    "port": 0,
    "sslPassthrough": false,
    "endpoints": [
      {
        "address": "127.0.0.1",
        "port": "8181"
      }
    ],
    "sessionAffinityConfig": {
      "name": "",
      "mode": "",
      "cookieSessionAffinity": {
        "name": ""
      }
    },
    "upstreamHashByConfig": {},
    "noServer": false,
    "trafficShapingPolicy": {
      "weight": 0,
      "weightTotal": 0,
      "header": "",
      "headerValue": "",
      "headerPattern": "",
      "cookie": ""
    }
  }
]

Ingress 类型

根据 Ingress 的使用场景可以将其分成几个不同的类型:

单服务 Ingress

这是最简单的 Ingress 类型,当你只有一个后端 Service 时可以使用它,它不用配置任何路由规则,直接配置一个 defaultBackend 指定后端 Service 即可:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress-default-backend
spec:
  defaultBackend:
    service:
      name: myapp
      port:
        number: 38080

使用 kubectl describe ingress 查看该 Ingress 详情:

# kubectl describe ingress ingress-default-backend
Name:             ingress-default-backend
Labels:           <none>
Namespace:        default
Address:          172.31.164.67
Ingress Class:    nginx
Default backend:  myapp:38080 (100.121.213.109:8080,100.121.213.72:8080,100.84.80.88:8080)
Rules:
  Host        Path  Backends
  ----        ----  --------
  *           *     myapp:38080 (100.121.213.109:8080,100.121.213.72:8080,100.84.80.88:8080)
Annotations:  <none>
Events:       <none>

可以看到,无论什么 Host,无论什么 Path,全部路由到 myapp:38080 这个后端服务。

这种 Ingress 和直接使用 NodePortLoadBalancer 类型的 Service 没有区别,不过 defaultBackend 不只是单独使用,也可以和 rules 结合使用,表示兜底路由,当所有的路由规则都不匹配时请求该后端。

多服务 Ingress

这是最常见的一种 Ingress,通过不同的路由规则映射到后端不同的 Service 端口,这种 Ingress 又被称为 Fan Out Ingress,形如其名,它的结构像下面这样成扇形散开:

ingress-simple-fanout.png

下面是多服务 Ingress 的一个示例:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress-simple-fanout
  annotations:
    nginx.ingress.kubernetes.io/use-regex: "true"
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  rules:
  - http:
      paths:
      - path: /test(/|$)(.*)
        pathType: ImplementationSpecific
        backend:
          service:
            name: myapp
            port:
              number: 38080
      - path: /test2(/|$)(.*)
        pathType: ImplementationSpecific
        backend:
          service:
            name: hello-actuator
            port:
              number: 8080

使用 kubectl describe ingress 可以查看该 Ingress 的详情:

# kubectl describe ingress ingress-simple-fanout
Name:             ingress-simple-fanout
Labels:           <none>
Namespace:        default
Address:          
Ingress Class:    nginx
Default backend:  <default>
Rules:
  Host        Path  Backends
  ----        ----  --------
  *           
              /test(/|$)(.*)    myapp:38080 (100.121.213.109:8080,100.121.213.72:8080,100.84.80.88:8080)
              /test2(/|$)(.*)   hello-actuator:8080 (100.121.213.108:8080,100.84.80.87:8080)
Annotations:  nginx.ingress.kubernetes.io/rewrite-target: /$2
              nginx.ingress.kubernetes.io/use-regex: true
Events:       <none>

可以看到,当请求路径满足 /test(/|$)(.*) 时,就路由到后端的 myapp:38080 服务,当请求满足 /test2(/|$)(.*) 时,就路由到后端的 hello-actuator:8080 服务。这里有三个参数需要注意:path 表示请求的路径,pathType 表示请求的路径匹配类型,annotations 则是 Ingress Controller 特定的一些注解。

每一个 path 都必须设置 pathTypepathType 有三种取值:

  • Exact:完全匹配,表示当请求的路径和 path 值完全一样时才匹配,比如 path 值为 /foo,请求路径必须为 /foo 才能匹配,如果是 /foo/xxx 或者 /foo/ 都不匹配;
  • Prefix:前缀匹配,表示请求的路径以 path 为前缀时才匹配,比如 path 值为 /foo,请求路径为 /foo/xxx 或者 /foo/ 都可以匹配,但是这里的前缀并完全是字符串前缀匹配,比如请求路径 /foobar 就不能匹配;另外,如果有多个路径都满足匹配规则,那么匹配最严格的那条规则,比如有三个 path,分别是 //aaa/aaa/bbb,当请求路径为 /aaa/bbb 时,匹配的应该是最长的 /aaa/bbb 这个规则;
  • ImplementationSpecific:匹配规则不确定,由 Ingress Controller 来定义;在上面这个例子中,我们就使用了这种匹配类型,我们在 path 中使用了正则表达式,通过正则表达式的分组捕获功能,我们可以在 Ingress NGINX Controller 的 Rewrite annotations 中用来做路由重写。

当我们请求 /test2/actuator/info 这个路径时,默认情况下,Ingress 会将我们的请求转发到后端服务的 /test2/actuator/info 地址,如果希望忽略 /test2 前缀,而转发到后端的 /actuator/info 地址,那就要开启路径重写,Ingress NGINX Controller 提供了一个注解 nginx.ingress.kubernetes.io/rewrite-target 来实现路径重写功能,路径重写一般和 Ingress Path Matching 一起使用,在定义 path 时,先使用正则表达式来匹配路径,比如 /test2(/|$)(.*),然后将 rewrite-target 设置为正则的第二个分组 /$2

虚拟主机 Ingress

Ingress 还支持配置多虚拟主机,将来自不同主机的请求映射到不同的后端服务,如下图所示:

ingress-virtual-host.png

下面是虚拟主机 Ingress 的一个示例:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress-virtual-host
spec:
  rules:
  - host: foo.bar.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: myapp
            port:
              number: 38080
  - host: bar.foo.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: hello-actuator
            port:
              number: 8080

这里我们将来自 foo.bar.com 的请求映射到 myapp:38080 服务,将来自 bar.foo.com 的请求映射到 hello-actuator:8080 服务。将这两个域名添加到 /etc/hosts 文件中,使用 curl 验证之:

# curl http://foo.bar.com:26360
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-9xm5j | v=1

如果不修改 /etc/hosts 文件,也可以通过 curl--resolve 参数手动解析域名:

# curl http://foo.bar.com:26360 --resolve foo.bar.com:26360:172.31.164.40
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-mb8l2 | v=1

TLS Ingress

我们还可以配置 TLS 证书来加强 Ingress 的安全性,这个证书需要放在一个 Secret 对象中。为了验证这个功能,我们先使用 openssl req 命令生成证书和私钥:

$ openssl req \
  -x509 -sha256 -nodes -days 365 -newkey rsa:2048 \
  -keyout tls.key -out tls.crt \
  -subj "/CN=foo.bar.com/O=foo.bar.com"

这个命令会在当前目录生成两个文件:tls.key 为私钥,tls.crt 为证书,生成的证书中需要指定 CN(Common Name),也被称为 FQDN(Fully Qualified Domain Name),这个一般就是你的域名,对应下面 Ingress 配置中的 host 字段。

然后我们再使用 kubectl create secret tls 命令创建一个 TLS 类型的 Secret,并将这两个文件保存进去:

$ kubectl create secret tls tls-secret --key tls.key --cert tls.crt

创建好的 Secret 如下所示:

# kubectl get secret tls-secret -o yaml
apiVersion: v1
kind: Secret
metadata:
  name: tls-secret
  namespace: default
data:
  tls.crt: LS0t...
  tls.key: LS0t...
type: kubernetes.io/tls

注意,Secret 中必须包含 tls.crttls.key 这两个键。

然后创建 Ingress 时,通过 tls.secretName 参数关联上这个 Secret 名称,就可以开启 TLS 功能了:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ingress-tls
spec:
  tls:
  - hosts:
      - foo.bar.com
    secretName: tls-secret
  rules:
  - host: foo.bar.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: myapp
            port:
              number: 38080

访问 Ingress 的 HTTPS 端口进行验证:

# curl -k https://foo.bar.com:23476 --resolve foo.bar.com:23476:172.31.164.40
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-mb8l2 | v=1

使用 TLS Ingress 时有几点要注意:

  • 首先,Ingress 的 TLS 特性被称为 TLS 终止(TLS termination),这意味着从用户到 Ingress 之间的连接是加密的,但是从 Ingress 到 Service 或 Pod 之间的连接仍然是明文的;
  • 其次,Ingress 只支持一个 TLS 端口,当 Ingress 中配置多个主机名时,需要 Ingress Controller 支持 TLS 的 SNI 扩展,Ingress 通过 SNI 来确定使用哪个主机名;
  • 另外,不同 Ingress Controller 支持的 TLS 功能不尽相同,比如 这里 是 Ingress NGINX Controller 关于 TLS/HTTPS 的文档。

其他特性

Ingress NGINX Controller 通过注解和 ConfigMap 还能实现一些其他有用的特性,比如:

更多特性可以参考这里的 注解列表

参考

更多

APISIX Ingress Controller

Kong Ingress Controller

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

学习 Kubernetes 流量管理之 Service

Kubernetes 使用小记 这篇笔记中我们学习了 Kubernetes 的基本用法和概念,通过 Deployment 部署应用程序,然后通过 Service 将应用程序暴露给其他人访问。其中 Service 是 Kubernetes 最基础的流量管理机制之一,它的主要目的有:

  • 以一个固定的地址来访问应用程序;
  • 实现多个副本之间的负载均衡;
  • 让应用程序可以在集群外部进行访问;

这篇笔记将继续使用之前的示例,通过一系列的实验更进一步地学习 Service 的工作原理。

准备实验环境

首先,创建一个 Deployment 部署应用程序,这里直接使用之前示例中的 jocatalin/kubernetes-bootcamp:v1 镜像,副本数设置为 3:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: myapp
    version: v1
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
      version: v1
  template:
    metadata:
      labels:
        app: myapp
        version: v1
    spec:
      containers:
      - image: jocatalin/kubernetes-bootcamp:v1
        name: myapp

等待三个副本都启动成功:

# kubectl get deploy myapp -o wide
NAME    READY   UP-TO-DATE   AVAILABLE   AGE   CONTAINERS   IMAGES                             SELECTOR
myapp   3/3     3            3           13m   myapp        jocatalin/kubernetes-bootcamp:v1   app=myapp

然后创建一个 Service:

apiVersion: v1
kind: Service
metadata:
  labels:
    app: myapp
  name: myapp
spec:
  ports:
  - port: 38080
    targetPort: 8080
  selector:
    app: myapp
  type: ClusterIP

通过 kubectl get svc 查询 Service 的地址和端口:

# kubectl get svc myapp -o wide
NAME    TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)     AGE     SELECTOR
myapp   ClusterIP   10.96.3.215   <none>        38080/TCP   7m22s   app=myapp

通过 Service 的地址验证服务能正常访问,多请求几次,可以看到会自动在副本之间轮询访问:

# curl 10.96.3.215:38080
Hello Kubernetes bootcamp! | Running on: myapp-fdb95659d-fl5c4 | v=1
# curl 10.96.3.215:38080
Hello Kubernetes bootcamp! | Running on: myapp-fdb95659d-dd5vv | v=1
# curl 10.96.3.215:38080
Hello Kubernetes bootcamp! | Running on: myapp-fdb95659d-4xf4g | v=1
# curl 10.96.3.215:38080
Hello Kubernetes bootcamp! | Running on: myapp-fdb95659d-fl5c4 | v=1

Service 配置细节

上面是一个简单的 Service 示例,这一节对其配置参数进行详细说明。

端口配置

在上面的 Service 定义中,第一个重要参数是 spec.ports 端口配置:

spec:
  ports:
  - port: 38080
    targetPort: 8080

其中 port 表示 Service 的端口,targetPort 表示 Pod 的端口。Service 创建成功之后,Kubernetes 会为该 Service 分配一个 IP 地址,Service 从自己的 IP 地址和 port 端口接收请求,并将请求映射到符合条件的 Pod 的 targetPort

多端口配置

可以在一个 Service 对象中定义多个端口,此时,我们必须为每个端口定义一个名字:

spec:
  ports:
  - name: http
    port: 38080
    targetPort: 8080
  - name: https
    port: 38083
    targetPort: 8083

协议配置

此外,可以给 Service 的端口指定协议:

spec:
  ports:
  - name: http
    protocol: TCP
    port: 38080
    targetPort: 8080

Service 支持的协议有以下几种:

  • TCP - 所有的 Service 都支持 TCP 协议,这也是默认值;
  • UDP - 几乎所有的 Service 都支持 UDP 协议,对于 LoadBalancer 类型的 Service,是否支持取决于云供应商;
  • SCTP - 这是一种比较少见的协议,叫做 流控制传输协议(Stream Control Transmission Protocol),和 TCP/UDP 属于同一层,常用于信令传输网络中,比如 4G 核心网的信令交互就是使用的 SCTP,WebRTC 中的 Data Channel 也是基于 SCTP 实现的;如果你的 Kubernetes 安装了支持 SCTP 协议的网络插件,那么大多数 Service 也就支持 SCTP 协议,同样地,对于 LoadBalancer 类型的 Service,是否支持取决于云供应商(大多数都不支持);

具体的内容可以参考 Kubernetes 的官网文档 Protocols for Services,文档中对于 TCP 协议,还列出了一些特殊场景,这些大多是对于 LoadBalancer 类型的 Service,需要使用云供应商所提供的特定注解:

具名端口

在应用程序升级时,服务的端口可能会发生变动,如果希望 Service 同时选择新老两个版本的 Pod,那么 targetPort 就不能写死。Kubernetes 支持为每个端口赋一个名称,然后我们将新老版本的端口名称保持一致,再将 targetPort 配置成该名称即可。

首先修改 Deployment 的定义,为端口赋上名称:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: myapp
    version: v1
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
      version: v1
  template:
    metadata:
      labels:
        app: myapp
        version: v1
    spec:
      containers:
      - image: jocatalin/kubernetes-bootcamp:v1
        name: myapp
        ports:
        - name: myapp-port
          containerPort: 8080
          protocol: TCP

然后修改 Service 定义中的 targetPort 为端口名称即可:

apiVersion: v1
kind: Service
metadata:
  labels:
    app: myapp
  name: myapp
spec:
  ports:
  - port: 38080
    targetPort: myapp-port
  selector:
    app: myapp
  type: ClusterIP

标签选择器

Service 中的另一个重要字段是 spec.selector 选择器:

spec:
  selector:
    app: myapp

Service 通过 标签选择器 选择符合条件的 Pod,并将选中的 Pod 作为网络服务的提供者。并且 Service 能持续监听 Pod 集合,一旦 Pod 集合发生变动,Service 就会同步被更新。

注意,标签选择器有两种类型:

  • 基于等值的需求(Equality-based):比如 environment = productiontier != frontend
  • 基于集合的需求(Set-based):比如 environment in (production, qa)tier notin (frontend, backend)

Service 只支持基于等值的选择器。

在上面的例子中,Service 的选择器为 app: myapp,而 Pod 有两个标签:app: myappversion: v1,很显然是能够选中的。选中的 Pod 会自动加入到 Service 的 Endpoints 中,可以通过 kubectl describe svc 确认 Service 绑定了哪些 Endpoints:

# kubectl describe svc myapp
Name:              myapp
Namespace:         default
Labels:            app=myapp
Annotations:       <none>
Selector:          app=myapp
Type:              ClusterIP
IP Family Policy:  SingleStack
IP Families:       IPv4
IP:                10.96.3.215
IPs:               10.96.3.215
Port:              http  38080/TCP
TargetPort:        8080/TCP
Endpoints:         100.121.213.101:8080,100.121.213.103:8080,100.84.80.80:8080
Session Affinity:  None
Events:            <none>

也可以直接使用 kubectl get endpoints 查看:

# kubectl get endpoints myapp
NAME    ENDPOINTS                                                     AGE
myapp   100.121.213.101:8080,100.121.213.103:8080,100.84.80.80:8080   22m

使用选择器可以很灵活的控制要暴露哪些 Pod。假设我们的服务现在要升级,同时老版本的服务还不能下线,那么可以给新版本的 Pod 打上 app: myappversion: v2 标签:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: myapp
    version: v2
  name: myapp2
spec:
  replicas: 1
  selector:
    matchLabels:
      app: myapp
      version: v2
  template:
    metadata:
      labels:
        app: myapp
        version: v2
    spec:
      containers:
      - image: jocatalin/kubernetes-bootcamp:v2
        name: myapp2

这样 Service 就可以同时选择 v1 和 v2 的服务。

不带选择器的 Service

正如上面所说,Service 通过标签选择器选择符合条件的 Pod,并将选中的 Pod 加入到 Service 的 Endpoints 中。但是 Kubernetes 还支持一种特殊的不带选择器的 Service,如下所示:

apiVersion: v1
kind: Service
metadata:
  name: svc-no-selector
spec:
  ports:
  - port: 38081
    targetPort: 80
  type: ClusterIP

由于这个 Service 没有选择器,所以也就不会扫描 Pod,也就不会自动创建 Endpoints,不过我们可以手动创建一个 Endpoints 对象:

apiVersion: v1
kind: Endpoints
metadata:
  name: svc-no-selector
subsets:
  - addresses:
      - ip: 47.93.22.98
    ports:
      - port: 80

Endpoints 和 Service 的名称保持一致,这样这个 Service 就会映射到我们手动指定的 IP 地址和端口了。这种 Service 在很多场景下都非常有用:

  • 可以在 Kubernetes 集群内部以 Service 的方式访问集群外部的地址;
  • 可以将 Service 指向另一个名称空间中的 Service,或者另一个 Kubernetes 集群中的 Service;
  • 可以将系统中一部分应用程序迁移到 Kubernetes 中,另一部分仍然保留在 Kubernetes 之外;

ExternalName 类型的 Service 也是一种不带选择器的 Service,它通过返回外部服务的 DNS 名称来实现的,参考下面的章节。

EndpointSlice API

Endpoints API 存在一个很明显的问题:每个 Service 对象都只能对应一个 Endpoints 对象,也就意味着 Endpoints 对象需要保存和 Service 关联的所有后端 Pod 的地址和端口,这对于大多数人来说可能不是什么问题,但是一旦集群规模变大,Endpoints 对象将变得非常庞大,比如 Pod 数量如果达到 5000,那么一个 Endpoints 对象大约有 1.5MB,而 etcd 存储默认限制的大小就是 1.5MB,再多就存不下了。

除了集群的规模受到限制之外,还有另一个比较严重的问题,只要一个 Pod 发生变动,整个 Endpoints 都要跟着变动,想象这样一个场景:我们要对这 5000 个 Pod 进行滚动更新,那么 Endpoints 至少要变动 5000 次,如果集群中存在 3000 个节点,每个节点都监听着 Endpoints 的变动,这个 Endpoints 对象在集群中传输所消耗的流量高达 1.5MB * 5000 * 3000 = 22TB

于是在 KEP-0752 中,Kubernetes 提出了一个新的 EndpointSlice API,这个 API 已经在 v1.19 版本中开始默认启用,它的思想很简单,将一个 Endpoints 对象分成多个 EndpointSlice 对象:

endpoint-slices.png

这样集群规模将不受限制,而且 Pod 变动后,只需变动少量的 EndpointSlice 即可,大大提高了集群的扩展性和可靠性。

继续上面那个不带选择器的 Service 的例子,我们也可以手动创建一个 EndpointSlice 对象:

apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: svc-no-selector-1
  labels:
    kubernetes.io/service-name: svc-no-selector
addressType: IPv4
ports:
  - appProtocol: http
    protocol: TCP
    port: 80
endpoints:
  - addresses:
      - 47.93.22.98

EndpointSlice 的命名规则一般是在 Service 名称后加上一串随机字符,保证唯一即可,它和 Service 之间的关系是通过标签 kubernetes.io/service-name 来关联的。

Service 类型

Service 中第三个重要字段是 spec.type 服务类型:

spec:
  type: ClusterIP

Kubernetes 使用小记 这篇笔记中我们了解到,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 或更高版本;

这一节将更深入地学习这几种类型的使用。

ClusterIP

ClusterIP 是 Service 的默认类型,这种类型的 Service 只能从集群内部访问,它的调用示意图如下:

service-type-clusterip.png

可以看到,从 Pod 中访问 Service 时写死了 IP 地址,虽然说 Service 没有 Pod 那么易变,但是也可能出现误删的情况,重新创建 Service 之后,它的 IP 地址还是会发生变化,这时那些使用固定 IP 访问 Service 的 Pod 都需要调整了,Kubernetes 支持通过 spec.clusterIP 字段自定义集群 IP 地址:

spec:
  type: ClusterIP
  clusterIP: 10.96.3.215

这样可以让 Service 的 IP 地址固定下来,不过要注意的是,该 IP 地址必须在 kube-apiserver 的 --service-cluster-ip-range 配置参数范围内,这个参数可以从 kube-apiserver 的 Pod 定义中找到:

# kubectl get pods -n kube-system kube-apiserver-xxx -o yaml
...
spec:
  containers:
  - command:
    - kube-apiserver
    - --service-cluster-ip-range=10.96.0.0/22
...

我们还可以将 spec.clusterIP 字段设置为 None,这是一种特殊的 Service,被称为 Headless Service,这种 Service 没有自己的 IP 地址,一般通过 DNS 形式访问,而且只能在集群内部访问。如果配置了选择器,则通过选择器查找符合条件的 Pod 创建 Endpoints,并将 Pod 的 IP 地址添加到 DNS 记录中;如果没有配置选择器,则不创建 Endpoints,对 ExternalName 类型的 Service,返回 CNAME 记录,对于其他类型的 Service,返回与 Service 同名的 Endpoints 的 A 记录。

服务发现

像上面那样写死 IP 地址终究不是最佳实践,Kubernetes 提供了两种服务发现机制来解决这个问题:

  • 环境变量
  • DNS

第一种方式是环境变量,kubelet 在启动容器时会扫描所有的 Service,并将 Service 信息通过环境变量的形式注入到容器中。我们随便进入一个容器:

# kubectl exec -it myapp-b9744c975-dv4qr -- bash

通过 env 命令查看环境变量:

root@myapp-b9744c975-dv4qr:/# env | grep MYAPP
MYAPP_SERVICE_HOST=10.96.3.215
MYAPP_SERVICE_PORT=38080
MYAPP_PORT=tcp://10.96.3.215:38080
MYAPP_PORT_38080_TCP_PROTO=tcp
MYAPP_PORT_38080_TCP_ADDR=10.96.3.215
MYAPP_PORT_38080_TCP_PORT=38080
MYAPP_PORT_38080_TCP=tcp://10.96.3.215:38080

最常用的两个环境变量是 {SVCNAME}_SERVICE_HOST{SVCNAME}_SERVICE_PORT,其中 {SVCNAME} 表示 Service 的名称,被转换为大写形式。

在使用基于环境变量的服务发现方式时要特别注意一点,必须先创建 Service,再创建 Pod,否则,Pod 中不会有该 Service 对应的环境变量。

第二种方式是 DNS,它没有创建顺序的问题,但是它依赖 DNS 服务,在 Kubernetes v1.10 之前的版本中,使用的是 kube-dns 服务,后来的版本使用的是 CoreDNS 服务。kubelet 在启动容器时会生成一个 /etc/resolv.conf 文件:

root@myapp-b9744c975-dv4qr:/# cat /etc/resolv.conf 
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

这里的 nameserver 10.96.0.10 就是 CoreDNS 对应的 Service 地址:

# kubectl get svc kube-dns -n kube-system
NAME       TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)                  AGE
kube-dns   ClusterIP   10.96.0.10   <none>        53/UDP,53/TCP,9153/TCP   342d

CoreDNS 监听 Kubernetes API 上创建和删除 Service 的事件,并为每一个 Service 创建一条 DNS 记录,这条 DNS 记录的格式如下:service-name.namespace-name

比如我们这里的 myapp 在 default 命名空间中,所以生成的 DNS 记录为 myapp.default,集群中所有的 Pod 都可以通过这个 DNS 名称解析到它的 IP 地址:

root@myapp-b9744c975-dv4qr:/# curl http://myapp.default:38080
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-kvgcl | v=1

当位于同一个命名空间时,命名空间可以省略:

root@myapp-b9744c975-dv4qr:/# curl http://myapp:38080
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-kvgcl | v=1

这是通过上面 /etc/resolv.conf 文件中的 search 参数实现的,DNS 会按照 default.svc.cluster.local -> svc.cluster.local -> cluster.local 这个顺序进行解析。很显然,如果使用 myapp.default 第一条会解析失败,第二条才解析成功,而使用 myapp 第一条就解析成功了,所以如果在同一个命名空间下时,应该优先使用省略的域名格式,这样可以减少解析次数。

我们也可以使用 nslookup 查看解析的过程:

# nslookup -debug -type=a myapp
Server:        10.96.0.10
Address:    10.96.0.10:53

Query #2 completed in 1ms:
** server can't find myapp.cluster.local: NXDOMAIN

Query #0 completed in 1ms:
Name:    myapp.default.svc.cluster.local
Address: 10.96.3.215

Query #1 completed in 1ms:
** server can't find myapp.svc.cluster.local: NXDOMAIN

注:从输出可以看到三次解析其实是并发进行的,而不是串行的。

当然,我们也可以使用域名的全路径:

root@myapp-b9744c975-dv4qr:/# curl http://myapp.default.svc.cluster.local:38080
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-dv4qr | v=1

这时会直接解析,不会有多余的步骤:

# nslookup -debug -type=a myapp.default.svc.cluster.local
Server:        10.96.0.10
Address:    10.96.0.10:53

Query #0 completed in 1ms:
Name:    myapp.default.svc.cluster.local
Address: 10.96.3.215

此外,当 Service 的端口有名称时,DNS 还支持解析 SRV 记录,其格式为 port-name.protocol-name.service-name.namespace-name.svc.cluster.local

# nslookup -debug -type=srv http.tcp.myapp.default.svc.cluster.local
Server:        10.96.0.10
Address:    10.96.0.10:53

Query #0 completed in 3ms:
http.tcp.myapp.default.svc.cluster.local    service = 0 100 38080 myapp.default.svc.cluster.local

具体内容可参考 Kubernetes 的文档 DNS for Services and Pods

注意,我们无法直接通过 curl 来访问这个地址,因为 curl 还不支持 SRV,事实上,支持 SRV 这个需求在 curl 的 TODO 列表 上已经挂了好多年了。

NodePort

NodePortClusterIP 的超集,这种类型的 Service 可以从集群外部访问,我们可以通过集群中的任意一台主机来访问它,调用示意图如下:

service-type-nodeport.png

要创建 NodePort 类型的 Service,我们需要将 spec.type 修改为 NodePort,并且在 spec.ports 中添加一个 nodePort 字段:

spec:
  type: NodePort
  ports:
  - name: http
    port: 38080
    nodePort: 30000
    targetPort: myapp-port

注意这个端口必须在 kube-apiserver 的 --service-node-port-range 配置参数范围内,这个参数可以从 kube-apiserver 的 Pod 定义中找到:

# kubectl get pods -n kube-system kube-apiserver-xxx -o yaml
...
spec:
  containers:
  - command:
    - kube-apiserver
    - --service-node-port-range=30000-32767
...

如果不设置 nodePort 字段,会在这个范围内随机生成一个端口。

通过 kubectl get svc 查看 Service 信息:

# kubectl get svc myapp
NAME    TYPE       CLUSTER-IP   EXTERNAL-IP   PORT(S)           AGE
myapp   NodePort   10.96.0.95   <none>        38080:30000/TCP   3s

可以看到,NodePort 类型的 Service 和 ClusterIP 类型一样,也分配有一个 CLUSTER-IP,我们仍然可以通过这个地址在集群内部访问(所以说 NodePortClusterIP 的超集):

# curl 10.96.0.95:38080
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-28r5w | v=1

ClusterIP 类型不一样的是,PORT(S) 这一列现在有两个端口 38080:30000/TCP,其中 38080 是 ClusterIP 对应的端口,30000 是 NodePort 对应的端口,这个端口暴露在集群中的每一台主机上,我们可以从集群外通过 nodeIp:nodePort 来访问:

# curl 172.31.164.40:30000
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-mb8l2 | v=1
# curl 172.31.164.67:30000
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-9xm5j | v=1
# curl 172.31.164.75:30000
Hello Kubernetes bootcamp! | Running on: myapp-b9744c975-28r5w | v=1

LoadBalancer

NodePort 类型的 Service 虽然解决了集群外部访问的问题,但是让集群外部知道集群内每个节点的 IP 仍然不是好的做法,当集群扩缩容时,节点 IP 依然可能会变动。于是 LoadBalancer 类型被提出来了,通过在集群边缘部署一个负载均衡器,解决了集群节点暴露的问题。

LoadBalancerNodePort 的超集,所以这种类型的 Service 也可以从集群外部访问,而且它是以一个统一的负载均衡器地址来访问的,所以调用方不用关心集群中的主机地址,调用示意图如下:

service-type-loadbalancer.png

如果要创建 LoadBalancer 类型的 Service,大部分情况下依赖于云供应商提供的 LoadBalancer 服务,比如 AWS 的 ELB(Elastic Load Balancer),阿里云的 SLB(Server Load Balancer)等,不过我们也可以使用一些开源软件搭建自己的 Load Balancer,比如 OpenELBMatelLB 等。

ExternalName

ExternalName 是一种特殊类型的 Service,这也是一种不带选择器的 Service,不会生成后端的 Endpoints,而且它不用定义端口,而是指定外部服务的 DNS 名称:

apiVersion: v1
kind: Service
metadata:
  name: svc-external-name
spec:
  type: ExternalName
  externalName: www.aneasystone.com

查询该 Service 信息可以看到,这个 Service 没有 CLUSTER-IP,只有 EXTERNAL-IP

# kubectl get svc svc-external-name
NAME                TYPE           CLUSTER-IP   EXTERNAL-IP           PORT(S)   AGE
svc-external-name   ExternalName   <none>       www.aneasystone.com   <none>    40m

要访问这个 Service,我们需要进到 Pod 容器里,随便找一个容器:

# kubectl exec -it myapp-b9744c975-ftgdx -- bash

然后通过这个 Service 的域名 svc-external-name.default.svc.cluster.local 来访问:

root@myapp-b9744c975-ftgdx:/# curl https://svc-external-name.default.svc.cluster.local -k

当以域名的方式访问 Service 时,集群的 DNS 服务将返回一个值为 www.aneasystone.com 的 CNAME 记录,整个过程都发生在 DNS 层,不会进行代理或转发。

CNAME 全称为 Canonical Name,它通过一个域名来表示另一个域名的别名,当一个站点拥有多个子域时,CNAME 非常有用,譬如可以将 www.example.comftp.example.com 都通过 CNAME 记录指向 example.com,而 example.com 则通过 A 记录指向服务的真实 IP 地址,这样就可以方便地在同一个地址上运行多个服务。

Service 实现原理

安装完 Kubernetes 之后,我们可以在 kube-system 命名空间下看到有一个名为 kube-proxy 的 DaemonSet,这个代理服务运行在集群中的每一个节点上,它是实现 Service 的关键所在:

# kubectl get daemonset kube-proxy -n kube-system
NAME         DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR            AGE
kube-proxy   3         3         3       3            3           kubernetes.io/os=linux   343d

kube-proxy 负责为 Service 提供虚拟 IP 访问,作为 Kubernetes 集群中的网络代理和负载均衡器,它的作用是将发送到 Service 的请求转发到具体的后端。

kube-proxy 有三种不同的代理模式:

  • userspace 代理模式,从 v1.0 开始支持;
  • iptables 代理模式,从 v1.1 开始支持,从 v1.2 到 v1.11 作为默认方式;
  • ipvs 代理模式,从 v1.8 开始支持,从 v1.12 开始作为默认方式;

userspace 代理模式

userspace 代理模式在 Kubernetes 第一个版本中就支持了,是 kube-proxy 最早期的实现方式。这种模式下,kube-proxy 进程在用户空间监听一个本地端口,然后通过 iptables 规则将发送到 Service 的流量转发到这个本地端口,然后 kube-proxy 将请求转发到具体的后端;由于这是在用户空间的转发,虽然比较稳定,但效率不高,目前已经不推荐使用。

它的工作流程如下:

kube-proxy-userspace.png

  1. 首先 kube-proxy 监听 apiserver 获得创建和删除 Service 的事件;
  2. 当监听到 Service 创建时,kube-proxy 在其所在的节点上为 Service 打开一个随机端口;
  3. 然后 kube-proxy 创建 iptables 规则,将发送到该 Service 的请求重定向到这个随机端口;
  4. 同时,kube-proxy 也会监听 apiserver 获得创建和删除 Endpoints 的事件,因为 Endpoints 对应着后端可用的 Pod,所以任何发送到该随机端口的请求将被代理转发到该 Service 的后端 Pod 上;

iptables 代理模式

Kubernetes 从 v1.1 开始引入了 iptables 代理模式,并且从 v1.2 到 v1.11 一直作为默认方式。和 userspace 代理模式的区别是,它创建的 iptables 规则,不是将请求转发到 kube-proxy 进程,而是直接转发到 Service 对应的后端 Pod。由于 iptables 是基于 netfilter 框架实现的,整个转发过程都在内核空间,所以性能更高。

这种模式的缺点是,iptables 规则的数量和 Service 的数量是呈线性增长的,当集群中 Service 的数量达到一定量级时,iptables 规则的数量将变得很大,导致新增和更新 iptables 规则变得很慢,此时将会出现性能问题。

iptables 代理模式的工作流程如下:

kube-proxy-iptables.png

  1. 首先 kube-proxy 监听 apiserver 获得创建和删除 Service 的事件;
  2. 当监听到 Service 创建时,kube-proxy 在其所在的节点上为 Service 创建对应 iptables 规则;
  3. 同时,kube-proxy 也会监听 apiserver 获得创建和删除 Endpoints 的事件,对于 Service 中的每一个 Endpoints,kube-proxy 都创建一个 iptables 规则,所以任何发送到 Service 的请求将被转发到该 Service 的后端 Pod 上;

使用 iptables 代理模式时,会随机选择一个后端 Pod 创建连接,如果该 Pod 没有响应,则创建连接失败,这和 userspace 代理模式是不一样的;使用 userspace 代理模式时,如果 Pod 没有响应,kube-proxy 会自动尝试连接另外的 Pod;所以一般推荐配置 Pod 的就绪检查(readinessProbe),这样 kube-proxy 只会将正常的 Pod 加入到 iptables 规则中,从而避免了请求被转发到有问题的 Pod 上。

ipvs 代理模式

为了解决 iptables 代理模式上面所说的性能问题,Kubernetes 从 v1.8 开始引入了一种新的 ipvs 模式,并从 v1.12 开始成为 kube-proxy 的默认代理模式。

ipvs 全称 IP Virtual Server,它运行在 Linux 主机内核中,提供传输层负载均衡的功能,也被称为四层交换机,它作为 LVS 项目的一部分,从 2.4.x 开始进入 Linux 内核的主分支。IPVS 也是基于 netfilter 框架实现的,它通过虚拟 IP 将 TCP/UDP 请求转发到真实的服务器上。

ipvs 相对于 iptables 来说,在大规模 Kubernetes 集群中有着更好的扩展性和性能,而且它支持更加复杂的负载均衡算法,还支持健康检查和连接重试等功能。

ipvs 代理模式的工作流程如下:

kube-proxy-ipvs.png

  1. 首先 kube-proxy 监听 apiserver 获得创建和删除 Service/Endpoints 的事件;
  2. 根据监听到的事件,调用 netlink 接口,创建 ipvs 规则;并且将 Service/Endpoints 的变化同步到 ipvs 规则中;
  3. 当访问一个 Service 时,ipvs 将请求重定向到后端 Pod 上;

可以使用 ipvsadm 命令查看所有的 ipvs 规则:

# ipvsadm -ln
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
  -> RemoteAddress:Port           Forward Weight ActiveConn InActConn
...
TCP  10.96.3.215:38080 rr
  -> 100.84.80.88:8080            Masq    1      0          3         
  -> 100.121.213.72:8080          Masq    1      0          2         
  -> 100.121.213.109:8080         Masq    1      0          3         
...

这里的 10.96.3.215:38080 就是 myapp 这个 Service 的 ClusterIP 和端口,rr 表示负载均衡的方式为 round-robin,所有发送到这个 Service 的请求都会转发到下面三个 Pod 的 IP 上。

参考

  1. Kubernetes Service
  2. Kubernetes 教程 | Kuboard
  3. Kubernetes 练习手册
  4. Service - Kubernetes 指南
  5. Service · Kubernetes 中文指南
  6. 数据包在 Kubernetes 中的一生(1)
  7. Kubernetes(k8s)kube-proxy、Service详解
  8. 华为云在 K8S 大规模场景下的 Service 性能优化实践
  9. Kubernetes 从1.10到1.11升级记录(续):Kubernetes kube-proxy开启IPVS模式
  10. 浅谈 Kubernetes Service 负载均衡实现机制
  11. 八 Service 配置清单
  12. Scaling Kubernetes Networking With EndpointSlices

更多

ipvs 代理模式实践

iptables 代理模式实践

CoreDNS

使用 Network Policy 管理 Kubernetes 流量

搭建自己的 Load Balancer

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

容器运行时 containerd 学习笔记

2016 年 12 月,Docker 公司宣布将 containerd 项目从 Docker Engine 中分离出来,形成一个独立的开源项目,并捐赠给 CNCF 基金会,旨在打造一个符合工业标准的容器运行时。Docker 公司之所以做出这样的决定,是因为当时在容器编排的市场上 Docker 面临着 Kubernetes 的极大挑战,将 containerd 分离,是为了方便开展 Docker Swarm 项目,不过结果大家都知道,Docker Swarm 在 Kubernetes 面前以惨败收场。

containerd 并不是直接面向最终用户的,而是主要用于集成到更上层的系统里,比如 Docker Swarm、Kubernetes 或 Mesos 等容器编排系统。containerd 通过 unix domain docket 暴露很低层的 gRPC API,上层系统可以通过这些 API 对机器上的容器整个生命周期进行管理,包括镜像的拉取、容器的启动和停止、以及底层存储和网络的管理等。下面是 containerd 官方提供的架构图:

containerd-architecture.png

从上图可以看出,containerd 的核心主要由一堆的 Services 组成,通过 Content Store、Snapshotter 和 Runtime 三大技术底座,实现了 Containers、Content、Images、Leases、Namespaces 和 Snapshots 等的管理。

其中 Runtime 部分和容器的关系最为紧密,可以看到 containerd 通过 containerd-shim 来支持多种不同的 OCI runtime,其中最为常用的 OCI runtime 就是 runc,所以只要是符合 OCI 标准的容器,都可以由 containerd 进行管理,值得一提的是 runc 也是由 Docker 开源的。

OCI 的全称为 Open Container Initiative,也就是开放容器标准,主要致力于创建一套开放的容器格式和运行时行业标准,目前包括了 Runtime、Image 和 Distribution 三大标准。

containerd 与 Docker 和 Kubernetes 的关系

仔细观察 containerd 架构图的上面部分,可以看出 containerd 通过提供 gRPC API 来供上层应用调用,上层应用可以直接集成 containerd client 来访问它的接口,诸如 Docker Engine、BuildKit 以及 containerd 自带的命令行工具 ctr 都是这样实现的;所以从 Docker 1.11 开始,当我们执行 docker run 命令时,整个流程大致如下:

docker-to-containerd.png

Docker Client 和 Docker Engine 是典型的 CS 架构,当用户执行 docker run 命令时,Docker Client 调用 Docker Engine 的接口,但是 Docker Engine 并不负责容器相关的事情,而是调用 containerd 的 gRPC 接口交给 containerd 来处理;不过 containerd 收到请求后,也并不会直接去创建容器,因为我们在上面提到,创建容器实际上已经有一个 OCI 标准了,这个标准有很多实现,其中 runc 是最常用的一个,所以 containerd 也不用再去实现这套标准了,而是直接调用这些现成的 OCI Runtime 即可。

不过创建容器有一点特别需要注意的地方,我们创建的容器进程需要一个父进程来做状态收集、维持 stdin 等工作的,这个父进程如果是 containerd 的话,那么如果 containerd 挂掉的话,整个机器上的所有容器都得退出了,为了解决这个问题,containerd 引入了 containerd-shim 组件;shim 的意思是垫片,正如它的名字所示,它其实是一个代理,充当着容器进程和 containerd 之间的桥梁;每当用户启动容器时,都会先启动一个 containerd-shim 进程,containerd-shim 然后调用 runc 来启动容器,之后 runc 会退出,而 containerd-shim 则会成为容器进程的父进程,负责收集容器进程的状态,上报给 containerd,并在容器中 PID 为 1 的进程退出后接管容器中的子进程进行清理,确保不会出现僵尸进程。

介绍完 containerd 与 Docker 之间的关系,我们再来看看它与 Kuberntes 的关系,从历史上看,Kuberntes 和 Docker 相爱相杀多年,一直是开源社区里热门的讨论话题。

在 Kubernetes 早期的时候,由于 Docker 风头正盛,所以 Kubernetes 选择通过直接调用 Docker API 来管理容器:

kubelet-to-docker.png

后来随着容器技术的发展,出现了很多其他的容器运行时,为了让 Kubernetes 平台支持更多的容器运行时,而不仅仅是和 Docker 绑定,Google 于是联合 Red Hat 一起推出了 CRI 标准。CRI 的全称为 Container Runtime Interface,也就是容器运行时接口,它是 Kubernetes 定义的一组与容器运行时进行交互的接口,只要你实现了这套接口,就可以对接到 Kubernetes 平台上来。不过在那个时候,并没有多少容器运行时会直接去实现 CRI 接口,而是通过 shim 来适配不同的容器运行时,其中 dockershim 就是 Kubernetes 将 Docker 适配到 CRI 接口的一个实现:

kubelet-to-docker-shim.png

很显然,这个链路太长了,好在 Docker 将 containerd 项目独立出来了,那么 Kubernetes 是否可以绕过 Docker 直接与 containerd 通信呢?答案当然是肯定的,从 containerd 1.0 开始,containerd 开发了 CRI-Containerd,可以直接与 containerd 通信,从而取代了 dockershim(从 Kubernetes 1.24 开始,dockershim 已经从 Kubernetes 的代码中删除了,cri-dockerd 目前交由社区维护):

kubelet-cri-containerd.png

到了 containerd 1.1 版本,containerd 又进一步将 CRI-Containerd 直接以插件的形式集成到了 containerd 主进程中,也就是说 containerd 已经原生支持 CRI 接口了,这使得调用链路更加简洁:

kubelet-to-containerd.png

这也是目前 Kubernetes 默认的容器运行方案。不过,这条调用链路还可以继续优化下去,在 CNCF 中,还有另一个和 containerd 齐名的容器运行时项目 cri-o,它不仅支持 CRI 接口,而且创建容器的逻辑也更简单,通过 cri-o,kubelet 可以和 OCI 运行时直接对接,减少任何不必要的中间开销:

kubelet-to-crio.png

快速开始

这一节主要学习 containerd 的安装和使用。

安装 containerd

首先从 containerd 的 Release 页面 下载最新版本:

$ curl -LO https://github.com/containerd/containerd/releases/download/v1.7.2/containerd-1.7.2-linux-amd64.tar.gz

然后将其解压到 /usr/local/bin 目录:

$ tar Cxzvf /usr/local containerd-1.7.2-linux-amd64.tar.gz 
bin/
bin/containerd-shim-runc-v1
bin/containerd-shim-runc-v2
bin/containerd-stress
bin/containerd
bin/containerd-shim
bin/ctr

其中,containerd 是服务端,我们可以直接运行:

$ containerd
INFO[2023-06-18T14:28:14.867212652+08:00] starting containerd revision=0cae528dd6cb557f7201036e9f43420650207b58 version=v1.7.2
...
INFO[2023-06-18T14:28:14.922388455+08:00] serving... address=/run/containerd/containerd.sock.ttrpc
INFO[2023-06-18T14:28:14.922477258+08:00] serving... address=/run/containerd/containerd.sock
INFO[2023-06-18T14:28:14.922529910+08:00] Start subscribing containerd event
INFO[2023-06-18T14:28:14.922570820+08:00] Start recovering state
INFO[2023-06-18T14:28:14.922636858+08:00] Start event monitor
INFO[2023-06-18T14:28:14.922653276+08:00] Start snapshots syncer
INFO[2023-06-18T14:28:14.922662467+08:00] Start cni network conf syncer for default
INFO[2023-06-18T14:28:14.922671149+08:00] Start streaming server
INFO[2023-06-18T14:28:14.922689846+08:00] containerd successfully booted in 0.060348s

ctr 是客户端,运行 ctr version 确认 containerd 是否安装成功:

$ ctr version
Client:
  Version:  v1.7.2
  Revision: 0cae528dd6cb557f7201036e9f43420650207b58
  Go version: go1.20.4

Server:
  Version:  v1.7.2
  Revision: 0cae528dd6cb557f7201036e9f43420650207b58
  UUID: 9eb2cbd4-8c1d-4321-839b-a8a4fc498de8

以 systemd 方式启动 containerd

官方已经为我们准备好了 containerd.service 文件,我们只需要将其下载下来,放在 systemd 的配置目录下即可:

$ mkdir -p /usr/local/lib/systemd/system/
$ curl -L https://raw.githubusercontent.com/containerd/containerd/main/containerd.service -o /usr/local/lib/systemd/system/containerd.service

containerd.service 文件内容如下:

[Unit]
Description=containerd container runtime
Documentation=https://containerd.io
After=network.target local-fs.target

[Service]
#uncomment to enable the experimental sbservice (sandboxed) version of containerd/cri integration
#Environment="ENABLE_CRI_SANDBOXES=sandboxed"
ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/containerd

Type=notify
Delegate=yes
KillMode=process
Restart=always
RestartSec=5
# Having non-zero Limit*s causes performance problems due to accounting overhead
# in the kernel. We recommend using cgroups to do container-local accounting.
LimitNPROC=infinity
LimitCORE=infinity
LimitNOFILE=infinity
# Comment TasksMax if your systemd version does not supports it.
# Only systemd 226 and above support this version.
TasksMax=infinity
OOMScoreAdjust=-999

[Install]
WantedBy=multi-user.target

其中有两个配置很重要,Delegate=yes 表示允许 containerd 管理自己创建容器的 cgroups,否则 systemd 会将进程移到自己的 cgroups 中,导致 containerd 无法正确获取容器的资源使用情况;默认情况下,systemd 在停止或重启服务时会在进程的 cgroup 中查找并杀死所有子进程,KillMode=process 表示让 systemd 只杀死主进程,这样可以确保升级或重启 containerd 时不影响现有的容器。

然后我们使用 systemd 守护进程的方式启动 containerd 服务:

$ systemctl enable --now containerd

这样当系统重启后,containerd 服务也会自动启动了。

安装 runc

安装好 containerd 之后,我们就可以使用 ctr 执行一些基本操作了,比如使用 ctr image pull 下载镜像:

$ ctr image pull docker.io/library/nginx:alpine
docker.io/library/nginx:alpine:                                                   resolved
index-sha256:2d194184b067db3598771b4cf326cfe6ad5051937ba1132b8b7d4b0184e0d0a6:    exists  
manifest-sha256:2d4efe74ef541248b0a70838c557de04509d1115dec6bfc21ad0d66e41574a8a: exists  
layer-sha256:768e67c521a97f2acf0382a9750c4d024fc1e541e22bab2dec1aad36703278f1:    exists  
config-sha256:4937520ae206c8969734d9a659fc1e6594d9b22b9340bf0796defbea0c92dd02:   exists  
layer-sha256:4db1b89c0bd13344176ddce2d093b9da2ae58336823ffed2009a7ea4b62d2a95:    exists  
layer-sha256:bd338968799fef766509223449d72392692f1f56802da9059ae3f0965c2885e2:    exists  
layer-sha256:6a107772494d184e0fddf5d99c877e2fa8d07d1d47b714c17b7d20eba1da01c6:    exists  
layer-sha256:9f05b0cc5f6e8010689a6331bad9ca02c62caa226b7501a64d50dcca0847dcdb:    exists  
layer-sha256:4c5efdb87c4a2350cc1c2781a80a4d3e895447007d9d8eac1e743bf80dd75c84:    exists  
layer-sha256:c8794a7158bff7f518985e76c590029ccc6b4c0f6e66e82952c3476c095225c9:    exists  
layer-sha256:8de2a93581dcb1cc62dd7b6e1620bc8095befe0acb9161d5f053a9719e145678:    exists  
elapsed: 2.8 s                                                                    total:   0.0 B (0.0 B/s)
unpacking linux/amd64 sha256:2d194184b067db3598771b4cf326cfe6ad5051937ba1132b8b7d4b0184e0d0a6...
done: 23.567287ms    

注意这里和 docker pull 的不同,镜像名称需要写全称。

不过这个时候,我们还不能运行镜像,我们不妨用 ctr run 命令运行一下试试:

$ ctr run docker.io/library/nginx:alpine nginx
ctr: failed to create shim task: 
    OCI runtime create failed: 
        unable to retrieve OCI runtime error (open /run/containerd/io.containerd.runtime.v2.task/default/nginx/log.json: no such file or directory):
            exec: "runc": executable file not found in $PATH: unknown

正如前文所述,这是因为 containerd 依赖 OCI runtime 来进行容器管理,containerd 默认的 OCI runtime 是 runc,我们还没有安装它。runc 的安装也非常简单,直接从其项目的 Releases 页面 下载最新版本:

$ curl -LO https://github.com/opencontainers/runc/releases/download/v1.1.7/runc.amd64

并将其安装到 /usr/local/sbin 目录即可:

$ install -m 755 runc.amd64 /usr/local/sbin/runc

使用 ctr container rm 删除刚刚运行失败的容器:

$ ctr container rm nginx

然后再使用 ctr run 重新运行:

$ ctr run docker.io/library/nginx:alpine nginx
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
/docker-entrypoint.sh: Sourcing /docker-entrypoint.d/15-local-resolvers.envsh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2023/06/18 07:04:52 [notice] 1#1: using the "epoll" event method
2023/06/18 07:04:52 [notice] 1#1: nginx/1.25.1
2023/06/18 07:04:52 [notice] 1#1: built by gcc 12.2.1 20220924 (Alpine 12.2.1_git20220924-r4) 
2023/06/18 07:04:52 [notice] 1#1: OS: Linux 3.10.0-1160.el7.x86_64
2023/06/18 07:04:52 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1024:1024
2023/06/18 07:04:52 [notice] 1#1: start worker processes
2023/06/18 07:04:52 [notice] 1#1: start worker process 30

可以看到此时容器正常启动了,不过目前这个容器还不具备网络能力,所以我们无法从外部访问它,可以使用 ctr task exec 进入容器:

$ ctr task exec -t --exec-id nginx nginx sh

在容器内部验证 nginx 服务是否正常:

/ # curl localhost:80
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

安装 CNI 插件

正如上一节所示,默认情况下 containerd 创建的容器只有 lo 网络,无法从容器外部访问,如果希望将容器内的网络端口暴露出来,我们还需要安装 CNI 插件。和 CRI 一样,CNI 也是一套规范接口,全称为 Container Network Interface,即容器网络接口,它提供了一种将容器网络插件化的解决方案。CNI 涉及两个基本概念:容器和网络,它的接口也是围绕着这两个基本概念进行设计的,主要有两个:ADD 负责将容器加入网络,DEL 负责将容器从网络中删除,有兴趣的同学可以阅读 CNI Specification 了解更具体的信息。

实战 Docker 容器网络 这篇笔记中,我们曾经学习过 Docker 的 CNM 网络模型,它和 CNI 相比要复杂一些。CNM 和 CNI 是目前最流行的两种容器网络方案,关于他俩的区别,可以参考 docker的网络-Container network interface(CNI)与Container network model(CNM)

官方提供了很多 CNI 接口的实现,比如 bridgeipvlanmacvlan 等,这些都被称为 CNI 插件,此外,很多开源的容器网络项目,比如 calicoflannelweave 等也实现了 CNI 插件。其实,CNI 插件就是一堆的可执行文件,我们可以从 CNI 插件的 Releases 页面 下载最新版本:

$ curl -LO https://github.com/containernetworking/plugins/releases/download/v1.3.0/cni-plugins-linux-amd64-v1.3.0.tgz

然后将其解压到 /opt/cni/bin 目录(这是 CNI 插件的默认目录):

$ mkdir -p /opt/cni/bin
$ tar Cxzvf /opt/cni/bin cni-plugins-linux-amd64-v1.3.0.tgz 
./
./loopback
./bandwidth
./ptp
./vlan
./host-device
./tuning
./vrf
./sbr
./tap
./dhcp
./static
./firewall
./macvlan
./dummy
./bridge
./ipvlan
./portmap
./host-local

可以看到目录中包含了很多插件,这些插件按功能可以分成三大类:Main、IPAM 和 Meta:

  • Main:负责创建网络接口,支持 bridgeipvlanmacvlanptphost-devicevlan 等类型的网络;
  • IPAM:负责 IP 地址的分配,支持 dhcphost-localstatic 三种分配方式;
  • Meta:包含一些其他配置插件,比如 tuning 用于配置网络接口的 sysctl 参数,portmap 用于主机和容器之间的端口映射,bandwidth 用于限流等等。

CNI 插件是通过 JSON 格式的文件进行配置的,我们首先创建 CNI 插件的配置目录 /etc/cni/net.d

$ mkdir -p /etc/cni/net.d

然后在这个目录下新建一个配置文件:

$ vi /etc/cni/net.d/10-mynet.conf
{
    "cniVersion": "0.2.0",
    "name": "mynet",
    "type": "bridge",
    "bridge": "cni0",
    "isGateway": true,
    "ipMasq": true,
    "ipam": {
        "type": "host-local",
        "subnet": "10.22.0.0/16",
        "routes": [
            { "dst": "0.0.0.0/0" }
        ]
    }
}

其中 "name": "mynet" 表示网络的名称,"type": "bridge" 表示创建的是一个网桥网络,"bridge": "cni0" 表示创建网桥的名称,isGateway 表示为网桥分配 IP 地址,ipMasq 表示开启 IP Masquerade 功能,关于 bridge 插件的更多配置,可以参考 bridge plugin 文档

下面的 ipam 部分是 IP 地址分配的相关配置,"type": "host-local" 表示将使用 host-local 插件来分配 IP,这是一种简单的本地 IP 地址分配方式,它会从一个地址范围内来选择分配 IP,关于 host-local 插件的更多配置,可以参考 host-local 文档

除了网桥网络,我们再新建一个 loopback 网络的配置文件:

$ vi /etc/cni/net.d/99-loopback.conf
{
    "cniVersion": "0.2.0",
    "name": "lo",
    "type": "loopback"
}

CNI 项目中内置了一些简单的 Shell 脚本用于测试 CNI 插件的功能:

$ git clone https://github.com/containernetworking/cni.git
$ cd cni/scripts/
$ ls
docker-run.sh  exec-plugins.sh  priv-net-run.sh  release.sh

其中 exec-plugins.sh 脚本用于执行 CNI 插件,创建网络,并将某个容器加入该网络:

$ ./exec-plugins.sh 
Usage: ./exec-plugins.sh add|del CONTAINER-ID NETNS-PATH
  Adds or deletes the container specified by NETNS-PATH to the networks
  specified in $NETCONFPATH directory

该脚本有三个参数,第一个参数为 adddel 表示将容器添加到网络或将容器从网络中删除,第二个参数 CONTAINER-ID 表示容器 ID,一般没什么要求,保证唯一即可,第三个参数 NETNS-PATH 表示这个容器进程的网络命名空间位置,一般位于 /proc/${PID}/ns/net,所以,想要将上面运行的 nginx 容器加入网络中,我们需要知道这个容器进程的 PID,这个可以通过 ctr task list 得到:

$ ctr task ls
TASK     PID      STATUS    
nginx    20350    RUNNING

然后执行下面的命令:

$ CNI_PATH=/opt/cni/bin ./exec-plugins.sh add nginx /proc/20350/ns/net

前面的 CNI_PATH=/opt/cni/bin 是必不可少的,告诉脚本从这里执行 CNI 插件,执行之后,我们可以在主机上执行 ip addr 进行确认:

$ ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 08:00:27:7f:8e:9a brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.15/24 brd 10.0.2.255 scope global noprefixroute dynamic enp0s3
       valid_lft 72476sec preferred_lft 72476sec
    inet6 fe80::e0ae:69af:54a5:f8d0/64 scope link noprefixroute 
       valid_lft forever preferred_lft forever
3: cni0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 72:87:b6:07:19:07 brd ff:ff:ff:ff:ff:ff
    inet 10.22.0.1/16 brd 10.22.255.255 scope global cni0
       valid_lft forever preferred_lft forever
    inet6 fe80::7087:b6ff:fe07:1907/64 scope link 
       valid_lft forever preferred_lft forever
11: vethc5e583fc@if4: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master cni0 state UP group default 
    link/ether 92:5c:c3:6a:a0:56 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet6 fe80::905c:c3ff:fe6a:a056/64 scope link 
       valid_lft forever preferred_lft forever

可以看出主机上多了一个名为 cni0 的网桥设备,这个就对应我们创建的网络,执行 ip route 也可以看到主机上多了一条到 cni0 的路由:

$ ip route
default via 10.0.2.2 dev enp0s3 proto dhcp metric 100 
10.0.2.0/24 dev enp0s3 proto kernel scope link src 10.0.2.15 metric 100 
10.22.0.0/16 dev cni0 proto kernel scope link src 10.22.0.1

另外,我们还能看到一个 veth 设备,在 实战 Docker 容器网络 这篇笔记中我们已经学习过 veth 是一种虚拟的以太网隧道,其实就是一根网线,网线一头插在主机的 cni0 网桥上,另一头则插在容器里。我们可以进到容器里面进一步确认:

$ ctr task exec -t --exec-id nginx nginx sh
/ # ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN 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
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
4: eth0@if11: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP 
    link/ether 0a:c2:11:63:ea:8c brd ff:ff:ff:ff:ff:ff
    inet 10.22.0.9/16 brd 10.22.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::8c2:11ff:fe63:ea8c/64 scope link 
       valid_lft forever preferred_lft forever

容器除了 lo 网卡之外,多了一张 eth0 网卡,它的 IP 地址是 10.22.0.9,这个正是我们在 10-mynet.conf 配置文件中定义的范围。这时,我们就可以在主机上通过这个 IP 来访问容器内部了:

$ curl 10.22.0.9:80

exec-plugins.sh 脚本会遍历 /etc/cni/net.d/ 目录下的所有配置来创建网络接口,我们也可以使用 cnitool 来创建特定的网络接口。

通过将容器添加到指定网络,可以让容器具备和外界通信的能力,除了这种方式之外,我们也可以直接以主机网络模式启动容器:

$ ctr run --net-host docker.io/library/nginx:alpine nginx

使用 ctr 操作 containerd

经过上面的步骤,containerd 服务已经在我们的系统中安装和配置好了,接下来我们将学习命令行工具 ctr 对 containerd 进行操作,ctr 是 containerd 部署包中内置的命令行工具,功能比较简单,一般用于 containerd 的调试,其实在上面的安装步骤中已经多次使用过它,这一节对 ctr 做个简单的总结。

ctr image 命令

命令含义
ctr image list查看镜像列表
ctr image list -q查看镜像列表,只显示镜像名称
ctr image pull docker.io/library/nginx:alpine拉取镜像,注意镜像名称的前缀不能少
ctr image pull --platform linux/amd64 docker.io/library/nginx:alpine拉取指定平台的镜像
ctr image pull --all-platforms docker.io/library/nginx:alpine拉取所有平台的镜像
ctr image tag docker.io/library/nginx:alpine 192.168.1.109:5000/nginx:alpine给镜像打标签
ctr image push 192.168.1.109:5000/nginx:alpine推送镜像
ctr image push --user username:password 192.168.1.109:5000/nginx:alpine推送镜像到带认证的镜像仓库
ctr image rm 192.168.1.109:5000/nginx:alpine删除镜像
ctr image export nginx.tar docker.io/library/nginx:alpine导出镜像
ctr image import hello.tar导入镜像
ctr image import --platform linux/amd64 hello.tar导入指定平台的镜像,如果导出的镜像文件只包含一个平台,导入时可能会报错 ctr: content digest sha256:xxx: not found,必须带上 --platform 这个参数
ctr image mount docker.io/library/nginx:alpine ./nginx将镜像挂载到主机目录
ctr image unmount ./nginx将镜像从主机目录卸载

ctr container 命令

命令含义
ctr container list查看容器列表
ctr container list -q查看容器列表,只显示容器名称
ctr container create docker.io/library/nginx:alpine nginx创建容器
ctr container info nginx查看容器详情,类似于 docker inspect
ctr container rm nginx删除容器

ctr task 命令

命令含义
ctr task list查看任务列表,使用 ctr container create 创建容器时并没有运行,它只是一个静态的容器,包含了容器运行所需的资源和配置数据
ctr task start nginx启动容器
ctr task exec -t --exec-id nginx nginx sh进入容器进行操作,注意 --exec-id 参数随便写,只要唯一就行
ctr task metrics nginx查看容器的 CPU 和内存使用情况
ctr task ps nginx查看容器中的进程对应宿主机中的 PID
ctr task pause nginx暂停容器,暂停后容器状态变成 PAUSED
ctr task resume nginx恢复容器继续运行
ctr task kill nginx停止容器,停止后容器状态变成 STOPPED
ctr task rm nginx删除任务

ctr run 命令

命令含义
ctr run docker.io/library/nginx:alpine nginx创建容器并运行,相当于 ctr container create + ctr task start
ctr run --rm docker.io/library/nginx:alpine nginx退出容器时自动删除容器
ctr run -d docker.io/library/nginx:alpine nginx运行容器,运行之后从终端退出(detach)但容器不停止
ctr run --mount type=bind,src=/root/test,dst=/test,options=rbind:rw docker.io/library/nginx:alpine nginx挂载本地目录或文件到容器
ctr run --env USER=root docker.io/library/nginx:alpine nginx为容器设置环境变量
ctr run --null-io docker.io/library/nginx:alpine nginx运行容器,并将控制台输出重定向到 /dev/null
ctr run --log-uri file:///var/log/nginx.log docker.io/library/nginx:alpine nginx运行容器,并将控制台输出写到文件中
ctr run --net-host docker.io/library/nginx:alpine nginx使用主机网络运行容器
ctr run --with-ns=network:/var/run/netns/nginx docker.io/library/nginx:alpine nginx使用指定命名空间文件运行容器

命名空间

命令含义
ctr ns list查看命名空间列表
ctr ns create test创建命名空间
ctr ns rm test删除命名空间
ctr -n test image list查看特定命名空间下的镜像列表
ctr -n test container list查看特定命名空间下的容器列表

containerd 通过命名空间进行资源隔离,当没有指定命名空间时,默认使用 default 命名空间,Docker 和 Kubernetes 都可以基于 containerd 来管理容器,Docker 使用的是 moby 命名空间,Kubernetes 使用的是 k8s.io 命名空间,所以如果想查看 Kubernetes 运行的容器,可以通过 ctr -n k8s.io container list 查看。

除了上面的一些常用命令,还有一些不常用的命令,比如 pluginscontentleasessnapshotsleasesshim 等,这里就不一一介绍了,感兴趣的同学可以使用 ctrctr help 获取更多的帮助信息。

虽然使用 ctr 可以进行大部分 containerd 的日常操作,但是这些操作偏底层,对用户很不友好,比如不支持镜像构建,网络配置非常繁琐,所以 ctr 一般是供开发人员测试 containerd 用的;如果希望找一款更简单的命令行工具,可以使用 nerdctl,它的操作和 Docker 非常类似,对 Docker 用户来说会感觉非常亲近,nerdctl 相对于 ctr 来说,有着以下几点区别:

  • nerdctl 支持使用 Dockerfile 构建镜像;
  • nerdctl 支持使用 docker-compose.yaml 定义和管理多个容器;
  • nerdctl 支持在容器内运行 systemd;
  • nerdctl 支持使用 CNI 插件来配置容器网络;

除了 ctr 和 nerdctl,我们还可以使用 crictl 来操作 containerd,crictl 是 Kubernetes 提供的 CRI 客户端工具,由于 containerd 实现了 CRI 接口,所以 crictl 也可以充当 containerd 的客户端。此外,官方还提供了一份教程可以让我们 实现自己的 containerd 客户端

参考

更多

使用 ctr run --with-ns 让容器在启动时加入已存在命名空间

上面我们是通过往容器进程的网络命名空间中增加网络接口来实现的,我们也可以先创建网络命名空间:

$ ip netns add nginx

这个网络命名空间的文件位置位于 /var/run/netns/nginx

$ ls /var/run/netns
nginx

然后在这个网络命名空间中配置网络接口,可以执行 exec-plugins.sh 脚本:

$ CNI_PATH=/opt/cni/bin ./exec-plugins.sh add nginx /var/run/netns/nginx

或执行 cnitool 命令:

$ CNI_PATH=/opt/cni/bin cnitool add mynet /var/run/netns/nginx

ctr run 命令在启动容器的时候可以使用 --with-ns 参数让容器在启动时候加入到一个已存在的命名空间,所以可以通过这个参数加入到上面配置好的网络命名空间中:

$ ctr run --with-ns=network:/var/run/netns/nginx docker.io/library/nginx:alpine nginx

containerd 的配置文件

使用 containerd config default 命令生成默认配置文件:

$ mkdir -p /etc/containerd
$ containerd config default > /etc/containerd/config.toml
扫描二维码,在手机上阅读!

基于 Argo CD 的 GitOps 实践笔记

GitOps 这个概念最早是由 Weaveworks 的 CEO Alexis Richardson 在 2017 年提出的,它是一种全新的基于 Git 仓库来管理 Kubernetes 集群和交付应用程序的方式。它包含以下四个基本原则:

  1. 声明式(Declarative):整个系统必须通过声明式的方式进行描述,比如 Kubernetes 就是声明式的,它通过 YAML 来描述系统的期望状态;
  2. 版本控制和不可变(Versioned and immutable):所有的声明式描述都存储在 Git 仓库中,通过 Git 我们可以对系统的状态进行版本控制,记录了整个系统的修改历史,可以方便地回滚;
  3. 自动拉取(Pulled automatically):我们通过提交代码的形式将系统的期望状态提交到 Git 仓库,系统从 Git 仓库自动拉取并做出变更,这种被称为 Pull 模式,整个过程不需要安装额外的工具,也不需要配置 Kubernetes 的认证授权;而传统的 CI/CD 工具如 Jenkins 或 CircleCI 等使用的是 Push 模式,这种模式一般都会在 CI 流水线运行完成后通过执行命令将应用部署到系统中,这不仅需要安装额外工具(比如 kubectl),还需要配置 Kubernetes 的授权,而且这种方式无法感知部署状态,所以也就无法保证集群状态的一致性了;
  4. 持续调谐(Continuously reconciled):通过在目标系统中安装一个 Agent,一般使用 Kubernetes Operator 来实现,它会定期检测实际状态与期望状态是否一致,一旦检测到不一致,Agent 就会自动进行修复,确保系统达到期望状态,这个过程就是调谐(Reconciliation);这样做的好处是将 Git 仓库作为单一事实来源,即使集群由于误操作被修改,Agent 也会通过持续调谐自动恢复。

其实,在提出 GitOps 概念之前,已经有另一个概念 IaC (Infrastructure as Code,基础设施即代码)被提出了,IaC 表示使用代码来定义基础设施,方便编辑和分发系统配置,它作为 DevOps 的最佳实践之一得到了社区的广泛关注。关于 IaC 和 GitOps 的区别,可以参考 The GitOps FAQ

Argo CD 快速入门

基于 GitOps 理念,很快诞生出了一批 声明式的持续交付(Declarative Continuous Deployment) 工具,比如 Weaveworks 的 Flux CD 和 Intuit 的 Argo CD,虽然 Weaveworks 是 GitOps 概念的提出者,但是从社区的反应来看,似乎 Argo CD 要更胜一筹。

Argo 项目最初是由 Applatix 公司于 2017 年创建,2018 年这家公司被 Intuit 收购,Argo 项目就由 Intuit 继续维护和演进。Argo 目前包含了四个子项目:Argo WorkflowsArgo EventsArgo CDArgo Rollouts,主要用于运行和管理 Kubernetes 上的应用程序和任务,所有的 Argo 项目都是基于 Kubernetes 控制器和自定义资源实现的,它们组合在一起,提供了创建应用程序和任务的三种模式:服务模式、工作流模式和基于事件的模式。2020 年 4 月 7 日,Argo 项目加入 CNCF 开始孵化,并于 2022 年 12 月正式毕业,成为继 Kubernetes、Prometheus 和 Envoy 之后的又一个 CNCF 毕业项目。

这一节我们将学习 Argo CD,学习如何通过 Git 以及声明式描述来部署 Kubernetes 资源。

安装 Argo CD

Argo CD 提供了两种 安装形式多租户模式(Multi-Tenant)核心模式(Core)。多租户模式提供了 Argo CD 的完整特性,包括 UI、SSO、多集群管理等,适用于多个团队用户共同使用;而核心模式只包含核心组件,不包含 UI 及多租户功能,适用于集群管理员独自使用。

使用比较多的是多租户模式,官方还为其提供了两份部署配置:非高可用配置 install.yaml 和高可用配置 ha/install.yaml,生产环境建议使用高可用配置,开发和测试环境可以使用非高可用配置,下面就使用非高可用配置来安装 Argo CD。

首先,创建一个 argocd 命名空间:

$ kubectl create namespace argocd
namespace/argocd created

接着将 Argo CD 部署到该命名空间中:

$ kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
customresourcedefinition.apiextensions.k8s.io/applications.argoproj.io created
customresourcedefinition.apiextensions.k8s.io/applicationsets.argoproj.io created
customresourcedefinition.apiextensions.k8s.io/appprojects.argoproj.io created
serviceaccount/argocd-application-controller created
serviceaccount/argocd-applicationset-controller created
serviceaccount/argocd-dex-server created
serviceaccount/argocd-notifications-controller created
serviceaccount/argocd-redis created
serviceaccount/argocd-repo-server created
serviceaccount/argocd-server created
role.rbac.authorization.k8s.io/argocd-application-controller created
role.rbac.authorization.k8s.io/argocd-applicationset-controller created
role.rbac.authorization.k8s.io/argocd-dex-server created
role.rbac.authorization.k8s.io/argocd-notifications-controller created
role.rbac.authorization.k8s.io/argocd-server created
clusterrole.rbac.authorization.k8s.io/argocd-application-controller created
clusterrole.rbac.authorization.k8s.io/argocd-server created
rolebinding.rbac.authorization.k8s.io/argocd-application-controller created
rolebinding.rbac.authorization.k8s.io/argocd-applicationset-controller created
rolebinding.rbac.authorization.k8s.io/argocd-dex-server created
rolebinding.rbac.authorization.k8s.io/argocd-notifications-controller created
rolebinding.rbac.authorization.k8s.io/argocd-redis created
rolebinding.rbac.authorization.k8s.io/argocd-server created
clusterrolebinding.rbac.authorization.k8s.io/argocd-application-controller created
clusterrolebinding.rbac.authorization.k8s.io/argocd-server created
configmap/argocd-cm created
configmap/argocd-cmd-params-cm created
configmap/argocd-gpg-keys-cm created
configmap/argocd-notifications-cm created
configmap/argocd-rbac-cm created
configmap/argocd-ssh-known-hosts-cm created
configmap/argocd-tls-certs-cm created
secret/argocd-notifications-secret created
secret/argocd-secret created
service/argocd-applicationset-controller created
service/argocd-dex-server created
service/argocd-metrics created
service/argocd-notifications-controller-metrics created
service/argocd-redis created
service/argocd-repo-server created
service/argocd-server created
service/argocd-server-metrics created
deployment.apps/argocd-applicationset-controller created
deployment.apps/argocd-dex-server created
deployment.apps/argocd-notifications-controller created
deployment.apps/argocd-redis created
deployment.apps/argocd-repo-server created
deployment.apps/argocd-server created
statefulset.apps/argocd-application-controller created
networkpolicy.networking.k8s.io/argocd-application-controller-network-policy created
networkpolicy.networking.k8s.io/argocd-applicationset-controller-network-policy created
networkpolicy.networking.k8s.io/argocd-dex-server-network-policy created
networkpolicy.networking.k8s.io/argocd-notifications-controller-network-policy created
networkpolicy.networking.k8s.io/argocd-redis-network-policy created
networkpolicy.networking.k8s.io/argocd-repo-server-network-policy created
networkpolicy.networking.k8s.io/argocd-server-network-policy created

另外,还可以通过 Helm 来部署 Argo CD,这里是社区维护的 Helm Charts

通过 Web UI 访问 Argo CD

Argo CD 部署好之后,默认情况下,API Server 从集群外是无法访问的,这是因为 API Server 的服务类型是 ClusterIP

$ kubectl get svc argocd-server -n argocd
NAME            TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGE
argocd-server   ClusterIP   10.111.209.6   <none>        80/TCP,443/TCP               23h

我们可以使用 kubectl patch 将其改为 NodePortLoadBalancer

$ kubectl patch svc argocd-server -n argocd -p '{"spec": {"type": "NodePort"}}'

修改之后,Kubernetes 会为 API Server 随机分配端口:

$ kubectl get svc argocd-server -n argocd
NAME            TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGE
argocd-server   NodePort   10.111.209.6   <none>        80:32130/TCP,443:31205/TCP   23h

这时我们就可以通过 localhost:32130localhost:31205 来访问 API Server 了:

argocd-login.png

可以看到 API Server 需要登录才能访问,初始用户名为 admin,初始密码在部署时随机生成,并保存在 argocd-initial-admin-secret 这个 Secret 里:

$ kubectl get secrets argocd-initial-admin-secret -n argocd -o yaml
apiVersion: v1
data:
  password: SlRyZDYtdEpOT1JGcXI3QQ==
kind: Secret
metadata:
  creationTimestamp: "2023-05-04T00:14:19Z"
  name: argocd-initial-admin-secret
  namespace: argocd
  resourceVersion: "17363"
  uid: 0cce4b4a-ff9d-44b3-930d-48bc5530bef0
type: Opaque

密码以 BASE64 形式存储,可以使用下面的命令快速得到明文密码:

$ kubectl get secrets argocd-initial-admin-secret -n argocd --template={{.data.password}} | base64 -d
JTrd6-tJNORFqr7A

输入用户名和密码登录成功后,进入 Argo CD 的应用管理页面:

argocd-ui.png

除了修改服务类型,官方还提供了两种方法暴露 API Server:一种是 使用 Ingress 网关,另一种是使用 kubectl port-forward 命令进行端口转发:

$ kubectl port-forward svc argocd-server -n argocd 8080:443

通过 CLI 访问 Argo CD

我们也可以使用命令行客户端来访问 Argo CD,首先使用 curl 命令下载:

$ curl -LO https://github.com/argoproj/argo-cd/releases/download/v2.7.1/argocd-linux-amd64

然后使用 install 命令安装:

$ sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocd

使用 argocd version 查看 Argo CD 的版本信息,验证安装是否成功:

$ argocd version
argocd: v2.7.1+5e54351
  BuildDate: 2023-05-02T16:54:25Z
  GitCommit: 5e543518dbdb5384fa61c938ce3e045b4c5be325
  GitTreeState: clean
  GoVersion: go1.19.8
  Compiler: gc
  Platform: linux/amd64
FATA[0000] Argo CD server address unspecified

由于此时还没有配置服务端,所以 argocd version 只显示了 Argo CD 客户端的版本信息,没有服务端的版本信息。通过上一节的方法将 API Server 暴露之后,就可以使用 argocd login 连接服务端:

$ argocd login localhost:32130
WARNING: server certificate had error: x509: certificate signed by unknown authority. Proceed insecurely (y/n)? y
Username: admin
Password:
'admin:login' logged in successfully
Context 'localhost:32130' updated

登录成功后,再次查看版本:

$ argocd version
argocd: v2.7.1+5e54351
  BuildDate: 2023-05-02T16:54:25Z
  GitCommit: 5e543518dbdb5384fa61c938ce3e045b4c5be325
  GitTreeState: clean
  GoVersion: go1.19.8
  Compiler: gc
  Platform: linux/amd64
argocd-server: v2.7.1+5e54351.dirty
  BuildDate: 2023-05-02T16:35:40Z
  GitCommit: 5e543518dbdb5384fa61c938ce3e045b4c5be325
  GitTreeState: dirty
  GoVersion: go1.19.6
  Compiler: gc
  Platform: linux/amd64
  Kustomize Version: v5.0.1 2023-03-14T01:32:48Z
  Helm Version: v3.11.2+g912ebc1
  Kubectl Version: v0.24.2
  Jsonnet Version: v0.19.1

部署应用

这一节我们将学习如何通过 Argo CD 来部署一个 Kubernetes 应用,官方在 argoproj/argocd-example-apps 仓库中提供了很多示例应用可供我们直接使用,这里我们将使用其中的 guestbook 应用。

通过 Web UI 部署应用

最简单的方法是通过 Argo CD 提供的可视化页面 Web UI 来部署应用,打开 Argo CD 的应用管理页面,点击 + NEW APP 按钮,弹出新建应用的对话框:

new-app.png

对话框中的选项比较多,但是我们只需要填写红框部分的内容即可,包括:

  • 通用配置

    • 应用名称:guestbook
    • 项目名称:default
    • 同步策略:Manual
  • 源配置

    • Git 仓库地址:https://github.com/argoproj/argocd-example-apps.git
    • 分支:HEAD
    • 代码路径:guestbook
  • 目标配置

    • 集群地址:https://kubernetes.default.svc
    • 命名空间:default

其他的选项暂时可以不用管,如果想了解具体内容可以参考官方文档 Sync Options,填写完成后,点击 CREATE 按钮即可创建应用:

new-app-not-sync.png

因为刚刚填写的同步策略是手工同步,所以我们能看到应用的状态还是 OutOfSync,点击应用详情:

new-app-not-sync-detail.png

可以看到 Argo CD 已经从 Git 仓库的代码中解析出应用所包含的 Kubernetes 资源了,guestbook 应用包含了一个 Deployment 和 一个 Service,点击 SYNC 按钮触发同步,Deployment 和 Service(以及它们关联的资源)被成功部署到 Kubernetes 集群中:

new-app-sync.png

测试完成后,点击 DELETE 删除应用,该应用关联的资源将会被级联删除。

通过 CLI 部署应用

我们还可以通过 Argo CD 提供的命令行工具来部署应用,经过上一节的步骤,我们已经登录了 API Server,我们只需要执行下面的 argocd app create 命令即可创建 guestbook 应用:

$ argocd app create guestbook \
  --repo https://github.com/argoproj/argocd-example-apps.git \
  --path guestbook \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace default
application 'guestbook' created

也可以使用 YAML 文件声明式地创建 Argo CD 应用:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: guestbook
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/argoproj/argocd-example-apps.git
    targetRevision: HEAD
    path: guestbook
  destination:
    server: https://kubernetes.default.svc
    namespace: guestbook

我们创建一个 YAML 文件,包含以上内容,然后执行 kubectl apply -f 即可,和 Web UI 操作类似,刚创建的应用处于 OutOfSync 状态,我们可以使用 argocd app get 命令查询应用详情进行确认:

$ argocd app get guestbook
Name:               argocd/guestbook
Project:            default
Server:             https://kubernetes.default.svc
Namespace:          default
URL:                https://localhost:32130/applications/guestbook
Repo:               https://github.com/argoproj/argocd-example-apps.git
Target:
Path:               guestbook
SyncWindow:         Sync Allowed
Sync Policy:        <none>
Sync Status:        OutOfSync from  (53e28ff)
Health Status:      Missing

GROUP  KIND        NAMESPACE  NAME          STATUS     HEALTH   HOOK  MESSAGE
       Service     default    guestbook-ui  OutOfSync  Missing
apps   Deployment  default    guestbook-ui  OutOfSync  Missing

接着我们执行 argocd app sync 命令,手工触发同步:

$ argocd app sync guestbook
TIMESTAMP                  GROUP        KIND   NAMESPACE                  NAME    STATUS    HEALTH        HOOK  MESSAGE
2023-05-06T08:22:53+08:00            Service     default          guestbook-ui  OutOfSync  Missing
2023-05-06T08:22:53+08:00   apps  Deployment     default          guestbook-ui  OutOfSync  Missing
2023-05-06T08:22:53+08:00            Service     default          guestbook-ui    Synced  Healthy
2023-05-06T08:22:54+08:00            Service     default          guestbook-ui    Synced   Healthy              service/guestbook-ui created
2023-05-06T08:22:54+08:00   apps  Deployment     default          guestbook-ui  OutOfSync  Missing              deployment.apps/guestbook-ui created
2023-05-06T08:22:54+08:00   apps  Deployment     default          guestbook-ui    Synced  Progressing              deployment.apps/guestbook-ui created

Name:               argocd/guestbook
Project:            default
Server:             https://kubernetes.default.svc
Namespace:          default
URL:                https://localhost:32130/applications/guestbook
Repo:               https://github.com/argoproj/argocd-example-apps.git
Target:
Path:               guestbook
SyncWindow:         Sync Allowed
Sync Policy:        <none>
Sync Status:        Synced to  (53e28ff)
Health Status:      Progressing

Operation:          Sync
Sync Revision:      53e28ff20cc530b9ada2173fbbd64d48338583ba
Phase:              Succeeded
Start:              2023-05-06 08:22:53 +0800 CST
Finished:           2023-05-06 08:22:54 +0800 CST
Duration:           1s
Message:            successfully synced (all tasks run)

GROUP  KIND        NAMESPACE  NAME          STATUS  HEALTH       HOOK  MESSAGE
       Service     default    guestbook-ui  Synced  Healthy            service/guestbook-ui created
apps   Deployment  default    guestbook-ui  Synced  Progressing        deployment.apps/guestbook-ui created

等待一段时间后,应用关联资源就部署完成了:

$ kubectl get all
NAME                               READY   STATUS    RESTARTS   AGE
pod/guestbook-ui-b848d5d9d-rtzwf   1/1     Running   0          67s

NAME                   TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
service/guestbook-ui   ClusterIP   10.107.51.212   <none>        80/TCP    67s
service/kubernetes     ClusterIP   10.96.0.1       <none>        443/TCP   30d

NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/guestbook-ui   1/1     1            1           67s

NAME                                     DESIRED   CURRENT   READY   AGE
replicaset.apps/guestbook-ui-b848d5d9d   1         1         1       67s

测试完成后,执行 argocd app delete 命令删除应用,该应用关联的资源将会被级联删除:

$ argocd app delete guestbook
Are you sure you want to delete 'guestbook' and all its resources? [y/n] y
application 'guestbook' deleted

参考

更多

部署其他的应用类型

Argo CD 不仅支持原生的 Kubernetes 配置清单,也支持 Helm ChartKustomize 或者 Jsonnet 部署清单,甚至可以通过 Config Management Plugins 实现自定义配置。

Flux CD

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

使用 Istio 和 Envoy 打造 Service Mesh 微服务架构

周志明 老师在他的 《凤凰架构》 中将分布式服务通信的演化历史分成五个阶段:

  • 第一阶段:将通信的非功能性需求视作业务需求的一部分,通信的可靠性由程序员来保障

stage-1.png

这个阶段是分布式系统发展最早期时的技术策略,这些系统一开始仅仅是通过 RPC 技术去访问远程服务,当遇到通信问题时,就在业务代码中引入相应的处理逻辑,比如服务发现、网络重试或降级等。这些通信的非功能性逻辑和业务逻辑耦合在一起,让系统变得越来越复杂。

  • 第二阶段:将代码中的通信功能抽离重构成公共组件库,通信的可靠性由专业的平台程序员来保障

stage-2.png

这个阶段人们逐渐意识到通信功能应该从业务逻辑中抽离出来,于是形成了一些公共组件库,比如 FinagleSpring Cloud 等。不过这些组件库大多是和语言绑定的,比如 Spring Cloud 技术栈只能用在 Java 项目中,遇到其他语言的项目就无能为力了。

  • 第三阶段:将负责通信的公共组件库分离到进程之外,程序间通过网络代理来交互,通信的可靠性由专门的网络代理提供商来保障

stage-3.png

为了将通信功能做成语言无关的,这个阶段发展出了专门负责可靠通信的网络代理,比如 Netflix Prana。业务系统和这类代理之间通过回环设备或 Unix 套接字进行通信,网络代理对业务系统的网络流量进行拦截,从而在代理上完成流控、重试或降级等几乎所有的分布式通信功能。

这种网络代理后来演化出了两种形态:一种是微服务网关,它位于整个分布式系统的入口,同时代理整个系统中所有服务的网络流量;另一种是边车代理,它和某个进程共享一个网络命名空间,专门针对性地代理该服务的网络流量。

  • 第四阶段:将网络代理以边车的形式注入到应用容器,自动劫持应用的网络流量,通信的可靠性由专门的通信基础设施来保障

stage-4.png

在使用上一阶段的网络代理时,我们必须在应用程序中手工指定网络代理的地址才能生效,而边车代理有一个很大的优势,它通过 iptables 等技术自动劫持代理进程的网络流量,所以它对应用是完全透明的,无需对应用程序做任何改动就可以增强应用的通信功能。目前边车代理的代表性产品有 LinkerdEnvoyMOSN 等。不过,边车代理也有一个很大的缺点,随着系统中代理服务的增多,对边车代理的维护和管理工作就成了运维最头疼的问题,于是服务网格应运而生。

  • 第五阶段:将边车代理统一管控起来实现安全、可控、可观测的通信,将数据平面与控制平面分离开来,实现通用、透明的通信,这项工作就由专门的服务网格框架来保障

服务网格(Service Mesh)一词是 Buoyant 公司的 CEO William Morgan 于 2017 年在他的一篇博客 《What's a service mesh? And why do I need one?》 中首次提出的,他是世界上第一款服务网格产品 Linkerd 的创始人之一,在博客中,William Morgan 对服务网格做了如下定义:

服务网格是一种用于管控服务间通信的的基础设施,它负责为现代云原生应用程序在复杂服务拓扑中可靠地传递请求。在实践中,服务网格通常以轻量级网络代理阵列的形式实现,这些代理与应用程序部署在一起,对应用程序来说无需感知代理的存在。

服务网格将上一阶段的边车代理联合起来形成如下所示的网格状结构:

stage-5.png

在服务网格中,主要由数据平面与控制平面组成。数据平面是所有边车代理的集合,负责拦截所有服务流入和流出的流量,并配置控制平面对流量进行管理;控制平面对数据平面进行管理,完成配置分发、服务发现、授权鉴权等功能。如上图所示,整个系统中的通信包括两个部分:实线表示数据平面之间的通信,虚线表示控制平面和数据平面之间的通信。

服务网格的概念一经提出,其价值迅速被业界所认可,业界几乎所有的云原生玩家都积极参与了进来:

  • 2016 年,Buoyant 公司 推出 Linkerd,同年,Lyft 公司 推出 Envoy
  • 2017 年,Linkerd 加入 CNCF,同年,Google、IBM 和 Lyft 共同发布 Istio,为了和 Istio 展开竞争,Buoyant 公司将自家的 Conduit 产品合并到 Linkerd 中发布了 Linkerd 2
  • 2018 年后,Google、亚马逊、微软分别推出各自的公有云版本 Service Mesh 产品,国内的阿里巴巴也推出了基于 Istio 的修改版 SOFAMesh(目前已经废弃),并开源了自己研发的 MOSN 代理

随着各巨头的参与,Istio 逐渐超过了 Linkerd 的地位,几乎成了原云生环境下服务网格中控制平面的事实标准,而 Envoy 凭借其卓越的性能和强大的动态配置功能,成为了服务网格中数据平面的不二选择,下图是使用 Istio 作为服务网格方案后的典型架构:

istio-envoy.png

Istio 服务网格的数据平面由 Envoy 代理实现,在 Envoy 学习笔记 中我们学习过 Envoy 的基本用法,这是一个用 C++ 开发的高性能代理,用于协调和控制微服务之间的网络流量,并为这些服务透明地提供了许多 Envoy 内置的功能特性:

  • 动态服务发现
  • 负载均衡
  • TLS 终端
  • HTTP/2 与 gRPC 代理
  • 熔断器
  • 健康检查
  • 基于百分比流量分割的分阶段发布
  • 故障注入
  • 丰富的指标

不仅如此,这些服务同时还具备了 Istio 所提供的功能特性:

  • 流量控制特性:通过丰富的 HTTP、gRPC、WebSocket 和 TCP 流量路由规则来执行细粒度的流量控制;
  • 网络弹性特性:重试设置、故障转移、熔断器和故障注入;
  • 安全性和身份认证特性:执行安全性策略,并强制实行通过配置 API 定义的访问控制和速率限制;
  • 基于 WebAssembly 的可插拔扩展模型,允许通过自定义策略执行和生成网格流量的遥测。

Istio 服务网格的控制平面由 Istiod 实现,它提供了诸如服务发现(Pilot),配置(Galley),证书生成(Citadel)和可扩展性(Mixer)等功能;它通过 Envoy API 实现了对数据平面的管理,所以 Istio 的数据平面并不仅限于 Envoy,其他符合 Envoy API 规范的代理都可以作为 Istio 的数据平面。

快速开始

这篇笔记将以 Istio 的官方示例来学习如何使用 Istio 和 Envoy 打造一个基于服务网格的微服务架构。

安装 Istio

首先从 Istio 的 Release 页面 找到最新版本的安装包并下载和解压:

$ curl -LO https://github.com/istio/istio/releases/download/1.17.1/istio-1.17.1-linux-amd64.tar.gz
$ tar zxvf istio-1.17.1-linux-amd64.tar.gz

解压后的目录中包含几个文件和目录:

$ tree -L 1 istio-1.17.1
istio-1.17.1
├── LICENSE
├── README.md
├── bin
├── manifest.yaml
├── manifests
├── samples
└── tools

4 directories, 3 files

其中,bin 目录下包含了 istioctl 可执行文件,我们可以将这个目录添加到 PATH 环境变量,配置之后,就可以使用 istioctl install 命令安装 Istio 了:

$ istioctl install --set profile=demo
This will install the Istio 1.17.1 demo profile with ["Istio core" "Istiod" "Ingress gateways" "Egress gateways"] components into the cluster. Proceed? (y/N) y
✔ Istio core installed
✔ Istiod installed
✔ Ingress gateways installed
✔ Egress gateways installed
✔ Installation complete

Istio 内置了几种 不同的安装配置

$ istioctl profile list
Istio configuration profiles:
    ambient
    default
    demo
    empty
    external
    minimal
    openshift
    preview
    remote

所有的配置都位于 manifests/profiles 目录下,你也可以 定制自己的配置。这里为了演示和体验,我们使用了 demo 配置,它相对于默认的 default 配置来说,开启了一些用于演示的特性,日志级别也比较高,性能会有一定影响,所以不推荐在生产环境使用。这些配置通过统一的 IstioOperator API 来定义,我们可以通过 istioctl profile dump 查看配置详情:

$ istioctl profile dump demo

或者通过 istioctl profile diff 对比两个配置之间的差异:

$ istioctl profile diff default demo

执行 istioctl 命令时会创建了一个名为 installed-state 的自定义资源,内容就是上面所看到的 demo 配置:

$ kubectl get IstioOperator installed-state -n istio-system -o yaml

从安装的输出结果可以看到,demo 配置安装内容包括下面四个部分:

  • Istio core
  • Istiod
  • Ingress gateways
  • Egress gateways

可以使用 kubectl get deployments 来看看 Istio 具体安装了哪些服务:

$ kubectl get deployments -n istio-system
NAME                   READY   UP-TO-DATE   AVAILABLE   AGE
istio-egressgateway    1/1     1            1           20m
istio-ingressgateway   1/1     1            1           20m
istiod                 1/1     1            1           21m

除此之外,istioctl 命令还生成了很多其他的 Kubernetes 资源,我们可以使用 istioctl manifest generate 生成最原始的 Kubernetes YAML 定义:

$ istioctl manifest generate --set profile=demo > demo.yaml

生成上面的 YAML 后,我们甚至可以通过执行 kubectl apply -f 来安装 Istio,不过这种安装方式有很多 需要注意的地方,官方并不推荐这种做法,但这也不失为一种深入了解 Istio 原理的好方法。

除了 使用 Istioctl 安装,官方还提供了很多 其他的安装方式,比如 使用 Helm 安装虚拟机安装使用 Istio Operator 安装 等,各个云平台 也对 Istio 提供了支持。

如果要卸载 Istio,可以执行 istioctl uninstall 命令:

$ istioctl uninstall -y --purge

一个简单的例子

为了充分发挥 Istio 的所有特性,网格中的每个应用服务都必须有一个边车代理,这个边车代理可以拦截应用服务的所有出入流量,这样我们就可以利用 Istio 控制平面为应用提供服务发现、限流、可观测性等功能了。

边车代理的注入 一般有两种方法:自动注入和手动注入。上面在解压 Istio 安装包时,可以看到有一个 samples 目录,这个目录里包含了一些官方的示例程序,用于体验 Istio 的不同功能特性,其中 samples/sleep 目录下是一个简单的 sleep 应用,这一节就使用这个简单的例子来演示如何在我们的应用服务中注入一个边车代理。

首先,执行下面的命令部署 sleep 应用:

$ kubectl apply -f samples/sleep/sleep.yaml
serviceaccount/sleep created
service/sleep created
deployment.apps/sleep created

默认情况下,一个 Pod 里只有一个容器(从下面的 READY 字段是 1/1 可以看出来):

$ kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
sleep-78ff5975c6-hw2lv   1/1     Running   0          23s

接下来,我们给 default 命名空间打上 istio-injection=enabled 标签:

$ kubectl label namespace default istio-injection=enabled
namespace/default labeled

这个标签可以让 Istio 部署应用时自动注入 Envoy 边车代理,这个过程是在 Pod 创建时自动完成的,Istio 使用了 Kubernetes 的 准入控制器(Admission Controllers),通过 MutatingAdmissionWebhook 可以对创建的 Pod 进行修改,从而将边车代理容器注入到原始的 Pod 定义中。

我们将刚刚的 Pod 删除,这样 Deployment 会尝试创建一个新的 Pod,从而触发自动注入的过程:

$ kubectl delete pod sleep-78ff5975c6-hw2lv
pod "sleep-78ff5975c6-hw2lv" deleted

再次查看 Pod 列表,READY 字段已经变成了 2/2,说明每个 Pod 里变成了两个容器:

$ kubectl get pods
NAME                     READY   STATUS    RESTARTS   AGE
sleep-78ff5975c6-v6qg4   2/2     Running   0          12s

也可以使用 kubectl describe 查看 Pod 详情:

$ kubectl describe pod sleep-78ff5975c6-v6qg4
Name:             sleep-78ff5975c6-v6qg4
Namespace:        default
Priority:         0
Service Account:  sleep
Init Containers:
  istio-init:
    Container ID:  docker://c724b0c29ffd828e497cfdff45706061855b1b8c93073f9f037acc112367bceb
    Image:         docker.io/istio/proxyv2:1.17.1
Containers:
  sleep:
    Container ID:  docker://44f35a4934841c5618eb68fb5615d75ea5dd9c5dd826cb6a99a6ded6efaa6707
    Image:         curlimages/curl
  istio-proxy:
    Container ID:  docker://10a9ff35f45c7f07c1fcf88d4f8daa76282d09ad96912e026e59a4d0a99f02cf
    Image:         docker.io/istio/proxyv2:1.17.1

Events:
  Type     Reason     Age    From               Message
  ----     ------     ----   ----               -------
  Normal   Created    4m22s  kubelet            Created container istio-init
  Normal   Started    4m21s  kubelet            Started container istio-init
  Normal   Created    4m20s  kubelet            Created container sleep
  Normal   Started    4m20s  kubelet            Started container sleep
  Normal   Created    4m20s  kubelet            Created container istio-proxy
  Normal   Started    4m20s  kubelet            Started container istio-proxy

可以看到除了原始的 sleep 容器,多了一个 istio-proxy 容器,这就是边车代理,另外还多了一个 istio-init 初始化容器,它使用 iptables 将网络流量自动转发到边车代理,对应用程序完全透明。

另一种方法是手工注入边车代理,先将 default 命名空间的 istio-injection 标签移除:

$ kubectl label namespace default istio-injection-
namespace/default unlabeled

同时删除 sleep 应用:

$ kubectl delete -f samples/sleep/sleep.yaml
serviceaccount "sleep" deleted
service "sleep" deleted
deployment.apps "sleep" deleted

然后再重新部署 sleep 应用,并使用 istioctl kube-inject 命令手工注入边车代理:

$ istioctl kube-inject -f samples/sleep/sleep.yaml | kubectl apply -f -
serviceaccount/sleep created
service/sleep created
deployment.apps/sleep created

默认情况下,Istio 使用集群中的默认配置模版来生成边车配置,这个默认配置模版保存在名为 istio-sidecar-injector 的 ConfigMap 中:

# 配置模版
$ kubectl -n istio-system get configmap istio-sidecar-injector -o=jsonpath='{.data.config}'

# 配置值
$ kubectl -n istio-system get configmap istio-sidecar-injector -o=jsonpath='{.data.values}'

除了官方提供的默认模版,还可以通过在 Pod 中添加一个 istio-proxy 容器来自定义注入内容,或者通过 inject.istio.io/templates 注解来设置自定义模版,更多内容可以 参考官方文档

体验 Bookinfo 示例应用

这一节我们来部署一个更复杂的例子,在 samples/bookinfo 目录下是名为 Bookinfo 的示例应用,我们就使用这个应用来体验 Istio 的功能。为了方便起见,我们还是给 default 命名空间打上 istio-injection=enabled 标签开启自动注入功能,然后,执行下面的命令部署 Bookinfo 示例应用:

$ kubectl apply -f samples/bookinfo/platform/kube/bookinfo.yaml
service/details created
serviceaccount/bookinfo-details created
deployment.apps/details-v1 created
service/ratings created
serviceaccount/bookinfo-ratings created
deployment.apps/ratings-v1 created
service/reviews created
serviceaccount/bookinfo-reviews created
deployment.apps/reviews-v1 created
deployment.apps/reviews-v2 created
deployment.apps/reviews-v3 created
service/productpage created
serviceaccount/bookinfo-productpage created
deployment.apps/productpage-v1 created

这是一个简单的在线书店应用,包含了四个微服务,这些微服务由不同的语言编写:

  • productpage - 产品页面,它会调用 detailsreviews 两个服务;
  • details - 书籍详情服务;
  • reviews - 书籍评论服务,它有三个不同的版本,v1 版本不包含评价信息,v2 和 v3 版本会调用 ratings 服务获取书籍评价,不过 v2 显示的是黑色星星,v3 显示的是红色星星;
  • ratings - 书籍评价服务;

部署时 Istio 会对每个微服务自动注入 Envoy 边车代理,我们可以通过 kubectl get pods 命令进行确认,确保每个 Pod 里都有两个容器在运行,其中一个是真实服务,另一个是代理服务:

$ kubectl get pods
NAME                             READY   STATUS    RESTARTS   AGE
details-v1-5ffd6b64f7-r6pr5      2/2     Running   0          14m
productpage-v1-979d4d9fc-gdmzh   2/2     Running   0          14m
ratings-v1-5f9699cfdf-trqj6      2/2     Running   0          14m
reviews-v1-569db879f5-gd9st      2/2     Running   0          14m
reviews-v2-65c4dc6fdc-5lph4      2/2     Running   0          14m
reviews-v3-c9c4fb987-kzjfk       2/2     Running   0          14m

部署之后整个系统的架构图如下所示:

bookinfo.png

这个时候从外部还不能访问该服务,只能在集群内访问,进入 ratings 服务所在的 Pod,验证 productpage 服务能否正常访问:

$ kubectl exec -it ratings-v1-5f9699cfdf-trqj6 -- sh
$ curl -s productpage:9080/productpage | grep "<title>"
    <title>Simple Bookstore App</title>

为了能从外部访问该服务,我们需要创建一个入站网关:

$ kubectl apply -f samples/bookinfo/networking/bookinfo-gateway.yaml
gateway.networking.istio.io/bookinfo-gateway created
virtualservice.networking.istio.io/bookinfo created

这个 YAML 中包含两个部分,第一部分定义了名为 bookinfo-gateway网关(Gateway)

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: bookinfo-gateway
spec:
  selector:
    istio: ingressgateway # use istio default controller
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "*"

注意这里使用了 istio-ingressgateway 作为网关,这在我们安装 Istio 的时候已经自动安装好了。

第二部分定义了名为 bookinfo虚拟服务(Virtual Service)

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: bookinfo
spec:
  hosts:
  - "*"
  gateways:
  - bookinfo-gateway
  http:
  - match:
    - uri:
        exact: /productpage
    - uri:
        prefix: /static
    - uri:
        exact: /login
    - uri:
        exact: /logout
    - uri:
        prefix: /api/v1/products
    route:
    - destination:
        host: productpage
        port:
          number: 9080

将这个虚拟服务和上面的网关关联起来,这样做的好处是,我们可以像管理网格中其他数据平面的流量一样去管理网关流量。

有了这个网关后,我们就可以在浏览器中输入 http://localhost/productpage 访问我们的在线书店了:

bookinfo-productpage.png

因为这里我们部署了三个版本的 reviews 服务,所以多刷新几次页面,可以看到页面上会随机展示 reviews 服务的不同版本的效果(可能不显示星星,也可能显示红色或黑色的星星)。

配置请求路径

由于部署了三个版本的 reviews 服务,所以每次访问应用页面看到的效果都不一样,如果我们想访问固定的版本,该怎么做呢?

检查上面的 bookinfo.yaml 文件,之所以每次访问的效果不一样,是因为 reviews 服务的 Service 使用了 app=reviews 选择器,所以请求会负载到所有打了 app=reviews 标签的 Pod:

apiVersion: v1
kind: Service
metadata:
  name: reviews
  labels:
    app: reviews
    service: reviews
spec:
  ports:
  - port: 9080
    name: http
  selector:
    app: reviews

reviews-v1reviews-v2reviews-v3 三个版本的 Deployment 都使用了 app=reviews 标签:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: reviews-v1
  labels:
    app: reviews
    version: v1
spec:
  ...
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: reviews-v2
  labels:
    app: reviews
    version: v2
spec:
  ...
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: reviews-v3
  labels:
    app: reviews
    version: v3
spec:
  ...

所以请求会负载到所有版本的 reviews 服务,这是 Kubernetes Service 的默认行为。不过要知道,现在所有 Pod 的流量都在我们的边车代理管控之下,我们当然可以使用 Istio 来改变请求的路径。首先,我们注意到这三个版本的 reviews 服务虽然 app=reviews 标签都一样,但是 version 标签是不一样的,这个特点可以用来定义 reviews目标规则(Destination Rules)

$ kubectl apply -f samples/bookinfo/networking/destination-rule-reviews.yaml
destinationrule.networking.istio.io/reviews created

destination-rule-reviews.yaml 文件内容如下:

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: reviews
spec:
  host: reviews
  trafficPolicy:
    loadBalancer:
      simple: RANDOM
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2
  - name: v3
    labels:
      version: v3

在 Istio 里,目标规则和虚拟服务一样,是一个非常重要的概念。在目标规则中可以定义负载策略,并根据标签来将应用划分成不同的服务子集(subset),在上面的例子中,我们将 reviews 服务划分成了 v1v2v3 三个服务子集。接下来再为 reviews 定义一个虚拟服务:

$ kubectl apply -f samples/bookinfo/networking/virtual-service-reviews-v3.yaml
virtualservice.networking.istio.io/reviews created

virtual-service-reviews-v3.yaml 文件内容如下:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts:
    - reviews
  http:
  - route:
    - destination:
        host: reviews
        subset: v3

在这个虚拟服务中我们指定了 v3 服务子集,稍等片刻,再次刷新页面,可以发现每次显示的都是红色的星星,我们成功的通过 Istio 控制了服务之间的流量。

基于匹配条件的路由

虚拟服务如果只能指定固定的服务子集,那么和 Kubernetes Service 也就没什么区别了。之所以在 Istio 引入虚拟服务的概念,就是为了 将客户端请求的目标地址与真实响应请求的目标工作负载进行解耦,这使得 A/B 测试很容易实现,比如我们可以让指定用户路由到特定的服务子集:

$ kubectl apply -f samples/bookinfo/networking/virtual-service-reviews-jason-v2-v3.yaml
virtualservice.networking.istio.io/reviews configured

virtual-service-reviews-jason-v2-v3.yaml 文件内容如下:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts:
  - reviews
  http:
  - match:
    - headers:
        end-user:
          exact: jason
    route:
    - destination:
        host: reviews
        subset: v2
  - route:
    - destination:
        host: reviews
        subset: v3

在这个虚拟服务中,不仅定义了一个默认路由,目标是 reviews 的 v3 服务子集,而且还定义了一个目标是 v2 服务子集的路由,但是这个路由必须满足条件 headers.end-user.exact = jason,也就是 HTTP 请求头中必须包含值为 jasonend-user 字段,为了实现这一点,我们使用 jason 用户登录即可,这时看到的书籍评价就是黑色星星:

bookinfo-productpage-jason.png

退出登录后,看到的仍然是红色的星星,这样我们就实现了 A/B 测试的功能。

除了将 headers 作为匹配条件,我们还可以使用 urimethodqueryParams 等参数,具体内容可参考 HTTPMatchRequest

基于权重的路由

在上面的例子中我们实现了指定用户的 A/B 测试,一般来说,我们的服务上线后,先通过指定的测试账号进行验证,验证没有问题后就可以开始迁移流量了,我们可以先迁移 10% 的流量到新版本:

$ kubectl apply -f samples/bookinfo/networking/virtual-service-reviews-90-10.yaml
virtualservice.networking.istio.io/reviews configured

virtual-service-reviews-90-10.yaml 文件内容如下:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts:
    - reviews
  http:
  - route:
    - destination:
        host: reviews
        subset: v1
      weight: 90
    - destination:
        host: reviews
        subset: v2
      weight: 10

在这个虚拟服务中,通过 weight 参数指定不同服务子集的权重。如果运行一段时间后没有问题,我们可以继续迁移 20% 的流量到新版本:

$ kubectl apply -f samples/bookinfo/networking/virtual-service-reviews-80-20.yaml
virtualservice.networking.istio.io/reviews configured

然后继续迁移 50%,80%,直到 100% 的流量,至此服务升级就完成了,这种升级方式也被称为 金丝雀部署

其他流量管理功能

上面演示了如何使用 Istio 对不同版本的微服务进行路由配置,这是 Istio 流量管理的基本功能。除此之外,Istio 还提供了很多其他的 流量管理功能

可观测性

可观测性是服务网格的另一个重要特性,Istio 对很多开源组件提供了集成,比如使用 Prometheus 收集指标,使用 GrafanaKiali 对指标进行可视化,使用 JaegerZipkinApache SkyWalking 进行分布式追踪。

samples/addons 目录下提供了 Prometheus、Grafana、Kiali 和 Jaeger 这几个组件的精简版本,我们通过下面的命令安装:

$ kubectl apply -f samples/addons
serviceaccount/grafana created
configmap/grafana created
service/grafana created
deployment.apps/grafana created
configmap/istio-grafana-dashboards created
configmap/istio-services-grafana-dashboards created
deployment.apps/jaeger created
service/tracing created
service/zipkin created
service/jaeger-collector created
serviceaccount/kiali created
configmap/kiali created
clusterrole.rbac.authorization.k8s.io/kiali-viewer created
clusterrole.rbac.authorization.k8s.io/kiali created
clusterrolebinding.rbac.authorization.k8s.io/kiali created
role.rbac.authorization.k8s.io/kiali-controlplane created
rolebinding.rbac.authorization.k8s.io/kiali-controlplane created
service/kiali created
deployment.apps/kiali created
serviceaccount/prometheus created
configmap/prometheus created
clusterrole.rbac.authorization.k8s.io/prometheus created
clusterrolebinding.rbac.authorization.k8s.io/prometheus created
service/prometheus created
deployment.apps/prometheus created

安装完成后,使用下面的命令打开 Prometheus UI:

$ istioctl dashboard prometheus

在 Expression 对话框中输入指标名称即可查询相应的指标,比如查询 istio_requests_total 指标:

prometheus.png

使用下面的命令打开 Grafana UI:

$ istioctl dashboard grafana

Istio 内置了几个常用的 Grafana Dashboard 可以直接使用:

grafana.png

比如查看 Istio Control Plane Dashboard,这里显示的是控制平面 Istiod 的一些指标图:

grafana-ctl-plane-dashboard.png

要注意的是 Istio 默认的采样率为 1%,所以这时去查看 Istio Mesh Dashboard 还看不到数据,可以通过下面的脚本模拟 100 次请求:

$ for i in `seq 1 100`; do curl -s -o /dev/null http://localhost/productpage; done

使用下面的命令打开 Kiali UI:

$ istioctl dashboard kiali

选择左侧的 Graph 菜单,然后在 Namespace 下拉列表中选择 default,就可以看到 Bookinfo 示例应用的整体概览,以及各个服务之间的调用关系:

kiali.png

最后,使用下面的命令打开 Jaeger UI:

$ istioctl dashboard jaeger

从仪表盘左边面板的 Service 下拉列表中选择 productpage.default,并点击 Find Traces 按钮查询该服务的所有 Trace,随便点击一个 Trace,查看它的详细信息:

jaeger.png

上面对几种常见的可观测性组件做了一个大概的介绍,如果想进一步学习各个组件的高级特性,可以参考 Istio 的官方文档 以及各个组件的官方文档。

参考

更多

其他官方学习文档

Kubernetes Gateway API

在最早的版本中,Istio 使用 Kubernetes 提供的 Ingress API 来进行流量管理,但是这个 API 在管理大型应用系统或非 HTTP 协议服务时存在一定的缺点和限制,Istio 在 2018 年 推出了新的流量管理 API v1alpha3,在新的 API 中定义了下面这些资源:

  • Gateway
  • VirtualService
  • DestinationRule
  • ServiceEntry

关于这几个概念的意义,可以通过 Istio 的 官方文档 来学习。

2022 年 7 月,Kubernetes 发布了 Gateway API 的 Beta 版本,Istio 立即宣布了对 Kubernetes Gateway API 的支持,Istio API 也跟着升级到了 Beta 版本,并表示 Gateway API 将作为未来所有 Istio 流量管理的默认 API。

目前,用户可以选择 Kubernetes Ingress、Istio classic 和 Gateway API 三种方式来管理网格中的流量,相关内容可以参考 Kubernetes IngressIstio GatewayKubernetes Gateway API

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

gRPC 快速入门

RPC 又被称为 远程过程调用,英文全称为 Remote Procedure Call,是一种服务间的通信规范,它可以让你像调用本地方法一样调用远程服务提供的方法,而不需要关心底层的通信细节。RPC 的概念早在上个世纪七八十年代就已经被提出,1984 年,Birrell 和 Nelson 在 ACM Transactions on Computer Systems 期刊上发表了一篇关于 RPC 的经典论文 Implementing remote procedure calls,论文中首次给出了实现 RPC 的基本框架:

rpc.png

从这个框架中可以看到很多现代 RPC 框架的影子,比如客户端和服务端的 Stub、序列化和反序列化等,事实上,所有后来的 RPC 框架几乎都是源自于这个原型。

不过在那个年代,RPC 的争议是非常大的,由于网络环境的不可靠性,RPC 永远都不可能做到像调用本地方法一样。大家提出了一堆问题,比如:故障恢复、请求重试、异步请求、服务寻址等,在那个互联网都还没有出现的年代,一堆大神们就已经在讨论分布式系统间的调用问题了,而他们讨论的问题焦点,基本上都演变成了 RPC 历史中永恒的话题。

为了解决这些问题,软件架构经历了一代又一代的发展和演进。1988 年,Sun 公司推出了第一个商业化的 RPC 库 Sun RPC ,并被定义为标准的 RPC 规范;1991 年,非营利性组织 OMG 发布 CORBA,它通过接口定义语言 IDL 中的抽象类型映射让异构环境之间的互操作成为了可能;不过由于其复杂性,很快就被微软推出的基于 XML 的 SOAP 技术所打败,随后 SOAP 作为 W3C 标准大大推动了 Web Service 概念的发展;像 SOAP 这种基于 XML 的 RPC 技术被称为 XML-RPC,它最大的问题是 XML 报文内容过于冗余,对 XML 的解析效率也很低,于是 JSON 应运而生,进而导致 RESTful 的盛行;不过无论是 XML 还是 JSON,都是基于文本传输,性能都无法让人满意,直到 2008 年,Google 开源 Protocol Buffers,这是一种高效的结构化数据存储格式,可以用于结构化数据的序列化,非常适合做数据存储或 RPC 数据交换格式;可能是由于微服务的流行,之后的 RPC 框架如雨后春笋般蓬勃发展,同年,Facebook 向 Apache 贡献了开源项目 Thrift,2009 年,Hadoop 之父 Doug Cutting 开发出 Avro,成为 Hadoop 的一个子项目,随后又脱离 Hadoop 成为 Apache 顶级项目;2011 年,阿里也开源了它自研的 RPC 框架 Dubbo,和前两个一样,最后也贡献给了 Apache;2015 年,Google 开源 gRPC 框架,开创性地使用 HTTP/2 作为传输协议,基于 HTTP/2 的多路复用和服务端推送技术,gRPC 支持双向流式通信,这使得 RPC 框架终于不再拘泥于万年不变的 C/S 模型了。

2017 年,gRPC 作为孵化项目成为 CNCF 的一员,不论是 Envoy 还是 Istio 等 Service Mesh 方案,都将 gRPC 作为一等公民,可以预见的是,谷歌正在将 gRPC 打造成云原生时代通信层事实上的标准。

Hello World 开始

这一节我们使用 Go 语言实现一个简单的 Hello World 服务,学习 gRPC 的基本概念。首先,我们通过 go mod init 初始化示例项目:

$ mkdir demo && cd demo
$ go mod init example.com/demo
go: creating new go.mod: module example.com/demo
go: to add module requirements and sums:
        go mod tidy

然后获取 grpc 依赖:

$ go get google.golang.org/grpc@latest
go: downloading golang.org/x/net v0.5.0
go: downloading golang.org/x/sys v0.4.0
go: downloading google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f
go: downloading golang.org/x/text v0.6.0
go: added github.com/golang/protobuf v1.5.2
go: added golang.org/x/net v0.5.0
go: added golang.org/x/sys v0.4.0
go: added golang.org/x/text v0.6.0
go: added google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f
go: added google.golang.org/grpc v1.53.0
go: added google.golang.org/protobuf v1.28.1

编写 .proto 文件

正如前文所述,Google 在 2009 年开源了一种高效的结构化数据存储格式 Protocol Buffers,这种格式非常适合用于 RPC 的数据交换,所以顺理成章的,Google 在开发 gRPC 时就采用了 Protocol Buffers 作为默认的数据格式。不过要注意的是 Protocol Buffers 不仅仅是一种数据格式,而且也是一种 IDL(Interface Description Language,接口描述语言),它通过一种中立的方式来描述接口和数据类型,从而实现跨语言和跨平台开发。

一般使用 .proto 后缀的文件来定义接口和数据类型,所以接下来,我们要创建一个 hello.proto 文件,我们将其放在 proto 目录下:

$ mkdir proto && cd proto
$ vim hello.proto

文件内容如下:

syntax = "proto3";

option go_package = "example.com/demo/proto";

service HelloService {
  rpc SayHello (HelloRequest) returns (HelloResponse) {}
}

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

我们在第一行指定使用 proto3 语法,这是目前推荐的版本,如果不指定,默认将使用 proto2,可能会导致一些版本兼容性的问题。随后我们用关键字 service 定义了一个 HelloService 服务,该服务包含一个 SayHello 方法,方法的入参为 HelloRequest,出参为 HelloResponse,这两个消息类型都在后面通过关键字 message 所定义。Protocol Buffers 的语法非常直观,也比较容易理解,这里只是使用了一些简单的语法,其他更复杂的语法可以参考 Protocol Buffers 的官方文档,另外这里有一份 中文语法指南 也可供参考。

编写好 hello.proto 文件之后,我们还需要一些工具将其转换为 Go 语言。这些工具包括:

  • protoc
  • protoc-gen-go
  • protoc-gen-go-grpc

protoc 是 Protocol Buffers 编译器,用于将 .proto 文件转换为其他编程语言,而不同语言的转换工作由不同语言的插件来实现。Go 语言的插件有两个:protoc-gen-goprotoc-gen-go-grpc,插件 protoc-gen-go 会生成一个后缀为 .pb.go 的文件,其中包含 .proto 文件中定义数据类型和其序列化方法;插件 protoc-gen-go-grpc 会生成一个后缀为 _grpc.pb.go 的文件,其中包含供客户端调用的服务方法和服务端要实现的接口类型。

protoc 可以从 Protocol Buffers 的 Release 页面 下载,下载后将 bin 目录添加到 PATH 环境变量即可:

$ curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v22.0/protoc-22.0-linux-x86_64.zip

protoc-gen-goprotoc-gen-go-grpc 两个插件可以通过 go install 命令直接安装:

$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2.0

安装完成后使用 --version 参数检测各个命令是否正常:

$ protoc --version
libprotoc 22.0

$ protoc-gen-go --version
protoc-gen-go v1.28.1

$ protoc-gen-go-grpc --version
protoc-gen-go-grpc 1.2.0

一切就绪后,就可以使用下面这行命令生成相应的 Go 代码了:

$ cd proto
$ protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    hello.proto

这个命令在当前目录下生成了 hello.pb.gohello_grpc.pb.go 两个文件。

实现服务端

在生成的 hello_grpc.pb.go 文件中,定义了一个 HelloServiceServer 接口:

// HelloServiceServer is the server API for HelloService service.
// All implementations must embed UnimplementedHelloServiceServer
// for forward compatibility
type HelloServiceServer interface {
    SayHello(context.Context, *HelloRequest) (*HelloResponse, error)
    mustEmbedUnimplementedHelloServiceServer()
}

并且在接口的下面提供了一个默认实现:

type UnimplementedHelloServiceServer struct {
}

func (UnimplementedHelloServiceServer) SayHello(context.Context, *HelloRequest) (*HelloResponse, error) {
    return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented")
}
func (UnimplementedHelloServiceServer) mustEmbedUnimplementedHelloServiceServer() {}

注意看 HelloServiceServer 的上面有一行注释:All implementations must embed UnimplementedHelloServiceServer for forward compatibility,为了保证向前兼容性,我们自己在实现这个接口时必须要嵌入 UnimplementedHelloServiceServer 这个默认实现,这篇文章 对此有一个简单的说明。

接下来我们创建一个 server 目录,并创建一个 main.go 文件:

$ mkdir server && cd server
$ vim main.go

定义 server 结构体,继承 UnimplementedHelloServiceServer 并重写 SayHello 方法:

type server struct {
    proto.UnimplementedHelloServiceServer
}

func (s *server) SayHello(ctx context.Context, request *proto.HelloRequest) (*proto.HelloResponse, error) {
    log.Printf("Request recieved: %v\n", request.GetName())
    return &proto.HelloResponse{
        Message: "Hello " + request.GetName(),
    }, nil
}

然后在入口方法中,通过 proto.RegisterHelloServiceServer(s, &server{}) 将我们的实现注册到 grpc Server 中:

func main() {

    lis, err := net.Listen("tcp", ":8080")
    if err != nil {
        log.Fatalf("Server listen failed!")
    }
    log.Printf("Server listening at: %s", lis.Addr())

    s := grpc.NewServer()
    proto.RegisterHelloServiceServer(s, &server{})
    if err := s.Serve(lis); err != nil {
        log.Fatalf("Server serve failed!")
    }
}

使用 go run 运行该代码:

$ go run ./server/main.go
2023/03/02 07:40:50 Server listening at: [::]:8080

一个 gRPC 的服务端就启动成功了!

实现客户端

接下来,我们来实现客户端。其实,在 hello_grpc.pb.go 文件中,protoc 也为我们定义了一个 HelloServiceClient 接口:

type HelloServiceClient interface {
    SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloResponse, error)
}

并提供了该接口的默认实现:

type helloServiceClient struct {
    cc grpc.ClientConnInterface
}

func NewHelloServiceClient(cc grpc.ClientConnInterface) HelloServiceClient {
    return &helloServiceClient{cc}
}

func (c *helloServiceClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloResponse, error) {
    out := new(HelloResponse)
    err := c.cc.Invoke(ctx, "/HelloService/SayHello", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

HelloServiceServer 不同的是,这个客户端实现我们无需修改,可以直接使用。首先我们创建一个 client 目录,并创建一个 main.go 文件:

$ mkdir client && cd client
$ vim main.go

然后在入口方法中,通过 grpc.Dial 创建一个和服务端的连接:

conn, err := grpc.Dial("localhost:8080", grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
    log.Fatalf("Connect grpc server failed: %v", err)
}
defer conn.Close()

注意我们的服务端没有开启 TLS,连接是不安全的,所以我们需要加一个不安全证书的连接选项,否则连接的时候会报错:

Connect grpc server failed: grpc: no transport security set (use grpc.WithTransportCredentials(insecure.NewCredentials()) explicitly or set credentials)

然后使用 hello_grpc.pb.go 文件中提供的 NewHelloServiceClient 方法创建一个客户端实例:

c := proto.NewHelloServiceClient(conn)

同时使用 context 创建一个带超时的上下文:

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()

使用创建的客户端调用 SayHello 方法:

r, err := c.SayHello(ctx, &proto.HelloRequest{Name: "zhangsan"})
if err != nil {
    log.Fatalf("Call SayHello failed: %v", err)
}
log.Printf("SayHello response: %s", r.GetMessage())

从调用的代码上看起来,确实和调用本地方法一样,传入 HelloRequest 请求,得到 HelloResponse 响应。至此,一个简单的客户端就编写完成了,使用 go run 运行该代码:

$ go run ./client/main.go
2023/03/03 07:03:34 SayHello response: Hello zhangsan

测试服务端

除了编写客户端,我们也可以使用其他的工具来测试服务端,对于 HTTP 服务,我们一般使用 curlPostman 之类的工具;而对于 gRPC 服务,也有类似的工具,比如 grpcurlgrpcui 等,这里整理了一份 关于 gRPC 工具的清单

这里使用 grpcurl 来对我们的服务端进行简单的测试。首先从它的 Release 页面 下载并安装 grpcurl

$ curl -LO https://github.com/fullstorydev/grpcurl/releases/download/v1.8.7/grpcurl_1.8.7_linux_x86_64.tar.gz

grpcurl 中最常使用的是 list 子命令,它可以列出服务端支持的所有服务:

$ grpcurl -plaintext localhost:8080 list
Failed to list services: server does not support the reflection API

不过这要求我们的服务端必须开启 反射 API,打开 server/main.go 文件,在其中加上下面这行代码:

reflection.Register(s)

这样我们就通过 Go 语言中提供的 reflection 包开启了反射 API,然后使用 grpcurllist 命令重新列出服务端的所有服务:

$ grpcurl -plaintext localhost:8080 list
HelloService
grpc.reflection.v1alpha.ServerReflection

如果服务端没有开启反射 API,grpc 也支持直接使用 Proto 文件Protoset 文件

我们还可以使用 list 命令继续列出 HelloService 服务的所有方法:

$ grpcurl -plaintext localhost:8080 list HelloService
HelloService.SayHello

如果要查看某个方法的详细信息,可以使用 describe 命令:

$ grpcurl -plaintext localhost:8080 describe HelloService.SayHello
HelloService.SayHello is a method:
rpc SayHello ( .HelloRequest ) returns ( .HelloResponse );

可以看出,这和我们在 proto 文件中的定义是一致的。最后,使用 grpcurl 来调用这个方法:

$ grpcurl -plaintext -d '{"name": "zhangsan"}' localhost:8080 HelloService.SayHello
{
  "message": "Hello zhangsan"
}

如果入参比较大,可以将其保存在一个文件中,使用下面的方法来调用:

$ cat input.json | grpcurl -plaintext -d @ localhost:8080 HelloService.SayHello
{
  "message": "Hello zhangsan"
}

gRPC 的四种形式

gRPC 支持四种不同的通信方式:

  • 简单 RPC(Simple RPC
  • 服务端流 RPC(Server-side streaming RPC
  • 客户端流 RPC(Client-side streaming RPC
  • 双向流 RPC(Bidirectional streaming RPC

上一节中的 SayHello 就是一个简单 RPC 的例子:

rpc SayHello (HelloRequest) returns (HelloResponse) {}

这种 RPC 有时候也被称为 Unary RPC,除此之外,gRPC 还支持三种流式通信方法,也即 Streaming RPC

服务端流 RPC(Server-side streaming RPC

第一种叫服务端流 RPC,它接受一个正常的请求,并以流的形式向客户端发送多个响应。在下面的例子中,客户端向服务端发送一个字符串,服务端对字符串进行分词,并将分词结果以流式返回给客户端。首先,我们在 .proto 文件中定义 Split 方法和相应的消息体:

rpc Split (SplitRequest) returns (stream SplitResponse) {}

然后,使用 protoc 生成服务端和客户端的代码,接着在 server/main.go 文件中添加服务端实现:

func (s *server) Split(request *proto.SplitRequest, stream proto.HelloService_SplitServer) error {
    log.Printf("Request recieved: %v\n", request.GetSentence())
    words := strings.Split(request.GetSentence(), " ")
    for _, word := range words {
        if err := stream.Send(&proto.SplitResponse{Word: word}); err != nil {
            return err
        }
    }
    return nil
}

和简单 RPC 的 SayHello 方法相比,服务端流 RPC 的 Split 方法在参数上有一些细微的差别,少了一个 ctx context.Context 参数,而多了一个 stream proto.HelloService_SplitServer 参数,这是 protoc 自动生成的一个接口:

type HelloService_SplitServer interface {
    Send(*SplitResponse) error
    grpc.ServerStream
}

这个接口继承自 grpc.ServerStream 接口,并具有一个 Send 方法,用来向客户端发送响应。

client/main.go 文件中添加客户端实现:

stream, err := c.Split(ctx, &proto.SplitRequest{Sentence: "Hello World"})
if err != nil {
    log.Fatalf("Call Split failed: %v", err)
}
for {
    r, err := stream.Recv()
    if err == io.EOF {
        break
    }
    if err != nil {
        log.Fatalf("%v.Split(_) = _, %v", c, err)
    }
    log.Printf("Split response: %s", r.GetWord())
}

和简单 RPC 的客户端代码相比,Split 方法不是直接返回 SplitResponse,而是返回一个 stream 流,它的类型为 HelloService_SplitClient 接口:

type HelloService_SplitClient interface {
    Recv() (*SplitResponse, error)
    grpc.ClientStream
}

这个接口继承自 grpc.ClientStream 接口,并具有一个 Recv 方法,用来接受服务端发送的响应,当服务端发送结束后,Recv 方法将返回 io.EOF 错误。

客户端流 RPC(Client-side streaming RPC

第二种叫客户端流 RPC,它以流的形式接受客户端发送来的多个请求,服务端处理之后返回一个正常的响应。在下面的例子中,客户端向服务端发送多个数字,服务端收集之后进行求和,并将求和结果返回给客户端。首先,我们在 .proto 文件中定义 Sum 方法和相应的消息体:

rpc Sum (stream SumRequest) returns (SumResponse) {}

然后,使用 protoc 生成服务端和客户端的代码,接着在 server/main.go 文件中添加服务端实现:

func (s *server) Sum(stream proto.HelloService_SumServer) error {
    var sum int32 = 0
    for {
        r, err := stream.Recv()
        if err == io.EOF {
            return stream.SendAndClose(&proto.SumResponse{Sum: sum})
        }
        if err != nil {
            return err
        }
        sum = sum + r.GetNum()
    }
}

从上面的代码可以看到,Sum 方法没有了 request 参数,只有一个 stream 参数,请求参数通过 stream.Recv 以流的形式读取,当读取结束后,stream.Recv 方法将返回 io.EOF 错误,这时我们通过 stream.SendAndClose 将处理之后的结果返回给客户端,并关闭连接。

client/main.go 文件中添加客户端实现:

stream2, err := c.Sum(ctx)
if err != nil {
    log.Fatalf("%v.Sum(_) = _, %v", c, err)
}
nums := []int32{1, 2, 3, 4, 5, 6, 7}
for _, num := range nums {
    if err := stream2.Send(&proto.SumRequest{Num: num}); err != nil {
        log.Fatalf("%v.Send(%v) = %v", stream, num, err)
    }
}
response, err := stream2.CloseAndRecv()
if err != nil {
    log.Fatalf("%v.CloseAndRecv() failed: %v", stream2, err)
}
log.Printf("Sum response: %v", response.GetSum())

在上面的代码中,Sum 方法返回一个 stream 变量,然后通过 stream.Send 不断向服务端发送请求,当数据发送结束后,再通过 stream.CloseAndRecv 关闭连接,并接受服务端响应。

双向流 RPC(Bidirectional streaming RPC

第三种叫双向流 RPC,这有点像网络聊天,服务端和客户端双方以任意的顺序互相通信,服务端可以在每次接受客户端请求时就返回一次响应,也可以接受多个请求后再返回一次响应。首先,我们在 .proto 文件中定义 Chat 方法和相应的消息体:

rpc Chat (stream ChatRequest) returns (stream ChatResponse) {}

然后,使用 protoc 生成服务端和客户端的代码,接着在 server/main.go 文件中添加服务端实现:

func (s *server) Chat(stream proto.HelloService_ChatServer) error {
    for {
        r, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        if err = stream.Send(&proto.ChatResponse{Message: "Reply to " + r.GetMessage()}); err != nil {
            return err
        }
    }
}

上面的代码和客户端流 RPC 比较类似,只不过服务端的响应变得更及时,每次接受到客户端请求时都会响应,而不是等客户端请求结束后再响应。

client/main.go 文件中添加客户端实现:

stream3, err := c.Chat(ctx)
if err != nil {
    log.Fatalf("%v.Chat(_) = _, %v", c, err)
}
waitc := make(chan struct{})
go func() {
    for {
        in, err := stream3.Recv()
        if err == io.EOF {
            close(waitc)
            return
        }
        if err != nil {
            log.Fatalf("Failed to receive: %v", err)
        }
        log.Printf("Chat response: %s", in.GetMessage())
    }
}()

messages := []string{"Hello", "How're you?", "Bye"}
for _, message := range messages {
    if err := stream3.Send(&proto.ChatRequest{Message: message}); err != nil {
        log.Fatalf("Failed to send: %v", err)
    }
}
stream3.CloseSend()
<-waitc

双向流 RPC 的客户端实现要稍微复杂一点。首先,我们通过 stream.Send 来发送请求,由于发送和接受都是流式的,所以我们没法像客户端流 RPC 那样通过 stream.CloseAndRecv() 来获取响应,我们只能调用 stream.CloseSend() 告诉服务端发送结束,然后我们需要创建一个新的 goroutine 来接受响应,另外,我们创建了一个 channel,用于在响应接受结束后通知主线程,以便程序能正常退出。

参考

更多

gRPC 安全认证

根据官方的 gRPC 认证指南,gRPC 支持多种不同的认证方法,包括:SSL/TLS 认证ALTS 认证 以及一些基于 token 的认证方法,如 OAuth2、GCE 等。

除了这些原生的认证方法,我们也可以通过 Metadata 来传送认证信息,从而实现 gRPC 的认证功能;另外,gRPC 还支持拦截器特性,通过拦截器也可以实现安全认证,Go gRPC Middleware 提供了很多拦截器的例子。

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

实战 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

可视化管理工具

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

构建多架构容器镜像实战

最近在一个国产化项目中遇到了这样一个场景,在同一个 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 将镜像推送到不安全的镜像仓库了。

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