Fork me on GitHub

分类 工具技巧 下的文章

构建多架构容器镜像实战

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

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

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

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

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

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

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

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

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

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

alpine-image.png

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

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

使用 docker manifest 创建多架构镜像

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

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

然后使用 docker build 构建镜像:

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

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

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

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

$ docker image inspect alpine:3.17 | grep Architecture

        "Architecture": "amd64",

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

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

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

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

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

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

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

Dockerfile-arm64 文件内容如下:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

$ docker manifest push aneasystone/demo:v1

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

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

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

demo-image.png

使用 docker buildx 创建多架构镜像

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

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

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

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

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

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

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

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

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

$ docker buildx create --use
nice_cartwright

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

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

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

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

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

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

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

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

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

demo-v2-image.png

参考

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

更多

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

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

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

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

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

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

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

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

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

查看镜像的 manifest 信息

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

buildx 支持的几种输出类型

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

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

这个命令实际上等同于:

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

也等同于:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2 directories, 6 files

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

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

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

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

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

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

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

不安全的镜像仓库

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

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

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

然后重启 Docker 后即可。

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

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

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

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

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

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

实战 Kubernetes 的动态扩缩容

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

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

Metrics Server

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

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

resource-metrics-pipeline.png

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

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

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

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

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

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

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

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

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

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

然后使用 kubectl apply 重新安装:

$ kubectl apply -f metrics-server.yaml

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

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

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

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

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

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

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

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

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

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

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

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

基于 CPU 自动扩缩容

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

基于内存自动扩缩容

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

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

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

创建好的 HPA 如下:

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

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

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

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

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

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

# umount /tmp/memory

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

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

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

部署一个带指标的应用

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

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

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

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

查看部署的应用:

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

查看应用端口:

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

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

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

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

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

部署 Prometheus Operator

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

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

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

$ kubectl create -f manifests/setup

接着部署 Prometheus Operator 其他组件:

$ kubectl create -f manifests/

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

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

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

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

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

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

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

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

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

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

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

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

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

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

prometheus-targets.png

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

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

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

hello-counter-total.png

部署 Prometheus Adapter

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

部署 HPA 实现自动扩缩容

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

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

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

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

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

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

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

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

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

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

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

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

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

prometheus-irate.png

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

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

参考

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

更多

Kubernetes Autoscaler

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

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

Custom Metrics API

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

Kubernetes Apiserver Aggregation

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

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

基于 OpenTelemetry 的可观测性实战

可观测性(Observability)这个词来源于控制理论,它是由匈牙利裔美国工程师 Rudolf E. Kálmán 针对线性动态控制系统所提出的一个概念,表示 通过系统外部输出推到其内部状态的程度

Observability is a measure of how well internal states of a system can be inferred from knowledge of its external outputs.

在可观测性这个概念被引入软件行业之前,我们对一个软件系统的观测一般都是从日志、指标和链路跟踪三个方面独立进行,并且在每个领域都积累了丰富的经验,也诞生了大量优秀的产品。比如说到日志收集和分析方面,大家基本上都会想到 Elastic Stack 技术栈(ELK、EFK);而对于指标监控,Prometheus 差不多已经成为了这方面的事实标准;另外,还有 SkyWalkingZipkinJaeger 这些链路跟踪的开源项目。

渐渐地,大家也意识到这三个方面并不是完全独立的,而是存在互相重合的现象,比如运维人员在查看系统 CPU 或内存等指标的图表时,如果发现异常,我们希望能快速定位到这个时间段的日志,看看有没有什么错误信息(从指标到日志);或者在日志系统中看到一条错误日志时,我们希望追踪到链路的入口位置,看看最源头的请求参数是什么(从日志到链路)。

metrics-to-logs.png

2017 年,德国工程师 Peter Bourgon 写了一篇非常有名的博客文章Metrics, Tracing, and Logging,他在这篇文章中系统阐述了指标、日志和链路跟踪三者的定义和它们之间的关系:

metrics-logging-tracing.png

他总结到:

  • 指标的特点是 它是可聚合的(Aggregatable),比如接收的 HTTP 请求数这个指标我们可以建模为计数器(counter),每次 HTTP 请求就是对其做加法聚合;
  • 日志的特点是 它是对离散事件的处理(Events),比如我们经常在代码中打印的调试日志或错误日志,系统的审计日志等;
  • 链路跟踪的特点是 它是对请求范围内的信息的处理(Request-scoped),任何数据都可以绑定到这个事务对象的生命周期中,比如对于一个 HTTP 请求的链路,我们可以记录每个请求节点的状态,节点的耗时,谁接收了这个请求等等;

我们从图中可以看到这三者之间是有部分重合的,比如上面讲的 HTTP 请求数这个指标,很显然可以绑定到这个请求的链路中,这被称为 请求范围内的指标(Request-scoped metrics),当然也有些指标不是请求范围内的,比如机器的 CPU 利用率、内存占用、磁盘空间等。而对于一些日志,比如请求报错,也可以绑定到请求链路中,称为 请求范围内的事件(Request-scoped events)

通过这样的划分,我们可以对系统中的日志和指标等数据进行更合理地设计,也对后来所有的可观测性产品提供了边界。2018 年,Apple 的工程师 Cindy Sridharan 在他新出版的书籍 Distributed Systems Observability 中正式提出了分布式系统可观测性的概念,介绍了可观测性和传统监控的区别,以及如何通过可观测性的三大支柱(指标、日志和链路跟踪)构建完整的观测模型,从而实现分布式系统的故障诊断、根因分析和快速恢复。同年,CNCF 社区也将可观测性引入 Cloud Native Landscape 作为云原生领域不可或缺的一部分。

cncf-landscape.png

OpenTelemetry 缘起

看着 Cloud Native Landscape 上琳琅满目的项目,可以看出可观测性这个领域已经是一片红海,那么为什么现在又要推出 OpenTelemetry 这样一个新项目呢?而且通过上图中三大类产品的组合,我们也可以快速搭建一个可观测性系统出来。

不过也正是因为这方面的产品太多,各个产品的数据模型都不一样,因此每个产品都有自己的数据处理和数据分析组件,这也导致了用户需要部署的组件很多,维护的成本也很高,而且这三套系统是完全独立的,不能很好的处理指标、日志和链路跟踪之间的关联关系,用户需要在不同产品之间来回切换,定位和排查问题非常痛苦。

针对这个问题,CNCF 在 2019 年正式推出 OpenTelemetry 项目(也被简写为 OTel),该项目雄心勃勃,旨在统一指标、日志和链路跟踪三种数据,实现可观测性的大一统。从 A brief history of OpenTelemetry (So Far) 这篇文章中我们了解到,在 OpenTelemetry 推出之前,其实已经有一些项目在做这方面的尝试了,比如早在 2016 年 11 月,CNCF 就推出了 OpenTracing 项目,这是一套与平台无关、与厂商无关、与语言无关的追踪协议规范,只要遵循 OpenTracing 规范,任何公司的追踪探针、存储、界面都可以随时切换,也可以相互搭配使用,很快,几乎所有业界有名的追踪系统,譬如 Zipkin、Jaeger、SkyWalking 等纷纷宣布支持 OpenTracing;不过谁也没想到,半路却杀出了个程咬金,这个时候 Google 突然跳出来反对,而且还提出了一个自己的 OpenCensus 规范,更令人想不到的是,随后又得到了 Microsoft 的大力支持。面对这两大巨头的搅局,可观测性的标准和规范不仅没有得到改善,反而变得更加混乱了。好在最终双方握手言和,在 2019 年,OpenTracing 和 OpenCensus 宣布合并,并提出了一个可观测性的终极解决方案,这就是 OpenTelemetry。

OpenTelemetry 具备以下特点:

  • 它为指标、日志和链路跟踪提出了统一的数据模型,可以轻松地实现互相关联;
  • 它采用了统一的 Agent 对所有可观察性数据进行采集和传输,使系统整体架构变得更加简单;
  • 它是厂商无关的,用户可以自由选择和更换适合自己的服务提供商;
  • 它具备很好的兼容性,可以和 CNCF 下各种可观察性方案进行集成;

OpenTelemetry 最核心的功能总结为一句话就是,以统一的数据模型对可观测性数据进行采集、处理和导出,至于数据的可视化或分析工作则交给后端的各种其他服务,整体架构如下图所示:

otel.png

其中包括两个主要部分:

  • OTel Library:也就是 OpenTelemetry API 各种语言的 SDK 实现,用于生成统一格式的可观测性数据;
  • OTel Collector:用来接收这些可观测性数据,并支持把数据传输到各种类型的后端系统。

快速开始

为了让用户能快速地体验和上手 OpenTelemetry,官方提供了一个名为 Astronomy Shop 的演示服务,接下来我们就按照 Quick Start 的步骤,使用 Docker 来部署这个演示服务,一睹 OpenTelemetry 的真容。

除了使用 Docker 部署,官方也提供了 Kubernetes 部署方式

首先下载仓库代码:

$ git clone https://github.com/open-telemetry/opentelemetry-demo.git

进入代码目录后直接执行 docker compose up 命令:

$ cd opentelemetry-demo/
$ docker compose up --no-build

参数 --no-build 用于直接从镜像仓库拉取镜像,如果去掉这个参数则会使用本地的代码来构建镜像。这个命令会启动 20 个容器:

[+] Running 20/0
 ⠿ Container prometheus               Created                                                                          0.0s
 ⠿ Container postgres                 Created                                                                          0.0s
 ⠿ Container grafana                  Created                                                                          0.0s
 ⠿ Container feature-flag-service     Created                                                                          0.0s
 ⠿ Container jaeger                   Created                                                                          0.0s
 ⠿ Container redis-cart               Created                                                                          0.0s
 ⠿ Container otel-col                 Created                                                                          0.0s
 ⠿ Container payment-service          Created                                                                          0.0s
 ⠿ Container ad-service               Created                                                                          0.0s
 ⠿ Container shipping-service         Created                                                                          0.0s
 ⠿ Container email-service            Created                                                                          0.0s
 ⠿ Container product-catalog-service  Created                                                                          0.0s
 ⠿ Container recommendation-service   Created                                                                          0.0s
 ⠿ Container quoteservice             Created                                                                          0.0s
 ⠿ Container currency-service         Created                                                                          0.0s
 ⠿ Container cart-service             Created                                                                          0.0s
 ⠿ Container checkout-service         Created                                                                          0.0s
 ⠿ Container frontend                 Created                                                                          0.0s
 ⠿ Container load-generator           Created                                                                          0.0s
 ⠿ Container frontend-proxy           Created                                                                          0.0s
Attaching to ad-service, cart-service, checkout-service, currency-service, email-service, feature-flag-service, frontend, frontend-proxy, grafana, jaeger, load-generator, otel-col, payment-service, postgres, product-catalog-service, prometheus, quoteservice, recommendation-service, redis-cart, shipping-service

耐心等待所有的镜像下载完毕,且所有的服务启动成功后,即可通过浏览器访问下面这些页面:

演示服务架构

这个演示服务中包含了很多微服务,并且为了起到演示作用,使用了各种不同的编程语言进行开发,用户可以根据自己的兴趣了解不同服务的具体实现:

这些微服务组成的架构图如下所示:

demo-architecture.png

除了这些微服务组件,还部署了下面这些中间件:

  • Prometheus
  • Postgres
  • Grafana
  • Jaeger
  • Redis
  • OpenTelemetry Collector
  • Envoy(Frontend Proxy)

体验演示服务

这个演示服务是一个天文爱好者的网上商城,访问 http://localhost:8080/ 进入商城首页:

demo-shop.png

商城具有浏览商品、商品推荐、添加购物车、下单等功能:

demo-shop-cart.png

商城运行起来之后,Load Generator 服务就会自动对商城进行负载测试,它是一个使用开源工具 Locust 编写的负载测试服务,可以模拟用户访问网站。

访问 http://localhost:8080/loadgen/ 进入 Load Generator 页面:

locust-load-gen.png

可以在这里查看测试用例,开启或停止测试,修改模拟的用户数和用户访问的频率等。还提供了图表页面展示测试的 RPS(Request per Second)、响应时间、活跃用户数等指标:

locust-rps.png

locust-response-times.png

体验 OpenTelemetry

我们知道,OpenTelemetry 的作用是以统一的方式采集和导出可观测性数据,上面所列出来的这些微服务分别使用了不同语言的 OpenTelemetry SDK 采集数据,对于采集的数据,我们有两种处理方式:一种是直接将其导出到某个后端服务,比如直接导出到 Prometheus、Jaeger 等,这种方式简单明了,适用于开发环境或小规模环境;另一种方式是导出到 OpenTelemetry Collector 服务,这也是官方推荐的做法,这样你的服务可以更聚焦于快速地导出数据,而对于请求重试、批量导出、数据加密、数据压缩或敏感数据过滤这些数据处理操作统统交给 OpenTelemetry Collector 来处理。

在这个演示服务中,我们就是通过 OpenTelemetry Collector 来收集数据的,打开 docker-compose.yaml 文件,我们来看看 OpenTelemetry Collector 的配置:

otelcol:
  image: otel/opentelemetry-collector-contrib:0.61.0
  container_name: otel-col
  deploy:
    resources:
      limits:
        memory: 100M
  restart: always
  command: [ "--config=/etc/otelcol-config.yml", "--config=/etcotelcol-config-extras.yml" ]
  volumes:
    - ./src/otelcollector/otelcol-config.yml:/etc/otelcol-config.yml
    - ./src/otelcollector/otelcol-config-extras.yml:/etc/otelcol-config-extras.yml
  ports:
    - "4317"          # OTLP over gRPC receiver
    - "4318:4318"     # OTLP over HTTP receiver
    - "9464"          # Prometheus exporter
    - "8888"          # metrics endpoint
  depends_on:
    - jaeger
  logging: *logging

这里可以看到 OpenTelemetry Collector 暴露了四个端口:43174318 这两个端口是用于收集数据的,一个是 gRPC 协议,一个是 HTTP 协议,数据必须符合 OTLP 规范(OpenTelemetry Protocol);94648888 这两个端口是 OpenTelemetry Collector 的指标端口,8888 暴露的是 OpenTelemetry Collector 本身的指标:

otelcol-metrics.png

9464 暴露是的 OpenTelemetry Collector 收集到的指标:

otelcol-service-metrics.png

这两个端口都配置在 Prometheus 的配置文件 prometheus-config.yaml 中:

global:
  evaluation_interval: 30s
  scrape_interval: 5s
scrape_configs:
- job_name: otel
  static_configs:
  - targets:
    - 'otelcol:9464'
- job_name: otel-collector
  static_configs:
  - targets:
    - 'otelcol:8888'

我们打开 Prometheus 的 Targets 页面:

prometheus-targets.png

接下来我们看下 OpenTelemetry Collector 的配置文件 otelcol-config.yml,文件的内容如下:

receivers:
  otlp:
    protocols:
      grpc:
      http:
        cors:
          allowed_origins:
            - "http://*"
            - "https://*"

exporters:
  otlp:
    endpoint: "jaeger:4317"
    tls:
      insecure: true
  logging:
  prometheus:
    endpoint: "otelcol:9464"

processors:
  batch:
  spanmetrics:
    metrics_exporter: prometheus

OpenTelemetry Collector 主要由以下三部分组成:

暴露给 Prometheus 的指标端口 9464 我们在上面的 Prometheus 配置中已经看到了,导出到 Jaeger 的端口 4317 也可以在 Jaeger 的配置文件中看到:

jaeger:
  image: jaegertracing/all-in-one
  container_name: jaeger
  command: ["--memory.max-traces", "10000", "--query.base-path", "/jaeger/ui"]
  deploy:
    resources:
      limits:
        memory: 275M
  restart: always
  ports:
    - "16686"                    # Jaeger UI
    - "4317"                     # OTLP gRPC default port
  environment:
    - COLLECTOR_OTLP_ENABLED=true
  logging: *logging

Jaeger 从 v1.35 版本开始支持 OTLP 协议的链路跟踪数据,通过在环境变量中添加 COLLECTOR_OTLP_ENABLED=true 即可开启该功能。

除了上面配置的这些,我们还可以在 opentelemetry-collectoropentelemetry-collector-contrib 找到更多的官方或第三方的 receivers、processors 和 exporters。

OpenTelemetry Collector 的总体示意图如下所示:

otel-collector.png

配置好 receivers、processors 和 exporters 之后,我们还需要通过在 服务(service) 中添加流水线(pipeline)将其串起来:

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [spanmetrics, batch]
      exporters: [logging, otlp]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [prometheus, logging]

上面的配置中我们开启了两个流水线:

  • traces 流水线用于处理链路跟踪数据:通过 otlp 接收数据,再通过 spanmetrics 和 batch 处理器进行处理,最后导出到 logging 和 otlp;
  • metrics 流水线用于处理指标数据:通过 otlp 接收数据,再通过 batch 处理器进行处理,最后导出到 prometheus 和 logging;

除此之外,还可以添加 logs 流水线用于处理日志数据。

在这个演示服务中 Prometheus 的端口并没有对外暴露,而是通过 Grafana 来做可视化展示,我们打开 http://localhost:8080/grafana/ 页面,可以在 Grafana 中找到两个面板,一个是 OpenTelemetry 本身的指标面板:

grafana-otel-metrics.png

这个面板中展示了 receivers、processors 和 exporters 的指标信息。另一个是 OpenTelemetry 收集的服务的指标面板:

grafana-service-metrics.png

这个面板中展示了每个服务的 CPU 利用率,内存,调用次数,错误率等指标信息。

在 Grafana 中配置了两个数据源,一个 Prometheus,一个 Jaeger,不过 Jaeger 数据源貌似没有用。我们可以打开 http://localhost:8080/jaeger/ui/ 页面,在 Search 页面搜索不同服务的 trace 数据:

jaeger-search.png

随便点击一条 trace 数据,可以进入该 trace 的详细页面,这个页面展示了该 trace 在不同服务之间的详细链路:

jaeger-trace-detail.png

更厉害的是,Jaeger 还能根据 trace 数据生成整个系统的架构图(连线处还标出了服务之间的调用量):

jaeger-system-architecture.png

使用 OpenTelemetry 快速排错

示例服务提供了几个 特性开关(Feature Flags) 用于模拟服务异常的场景。我们在浏览器中输入 http://localhost:8080/feature/ 进入 Feature Flag UI 页面:

feature-flags-ui.png

这里有两个特性开关:

  • productCatalogFailure - 打开这个开关后,访问商品 OLJCESPC7Z 时会报错;
  • recommendationCache - 打开这个开关后,recommendationservice 会偶发性的报内存溢出错误;

Using Metrics and Traces to diagnose a memory leak 这篇官方文档中,给出了排查内存溢出错误的步骤。我们这里不妨就排查下商品接口报错这个问题,首先,我们将 productCatalogFailure 这个特性开关打开,然后在浏览器中访问 http://localhost:8080/product/OLJCESPC7Z 页面:

product-error.png

果然,页面报错了。在下面的开发者工具中可以看到是 http://localhost:8080/api/products/OLJCESPC7Z 这个接口报了 500 Internal Server Error 错误。

为了排查这个错误,我们打开 Jaeger UI 进入 Search 页面,在 Service 列表中选择 productcatalogservice,Tags 中输入 error=true,可以在搜索结果中看到很多红色的点,这些都是出错的请求:

jaeger-errors.png

我们点开其中一个查看详情:

jaeger-error-detail.png

在左侧的树形结构中,我们看到出错的服务上都有一个红色的感叹号,展开 productcatalogservice,在 Logs 中就能看到错误的日志了:

Error: ProductCatalogService Fail Feature Flag Enabled

开发指南

这一节我们将学习如何在自己的项目中集成 OpenTelemetry SDK,OpenTelemetry 支持大多数开发语言,其中 对 Java 的支持 最为完善。不仅提供了 Java API 和 SDK,我们可以使用 SDK 手动采集数据,而且还提供了 Java Agent 让我们不用写一行代码就能 自动采集数据,并且支持 大多数的 Java 库和框架

Automatic Instrumentation

首先准备两个简单的 Spring Boot 项目:demo-server 作为服务端,提供了一个 /greeting 接口;demo-client 作为客户端,使用 RestTemplate 调用 /greeting 接口。

然后下载最新版本的 opentelemetry-javaagent.jar,将其作为 -javaagent 参数启动 demo-server

$ java -javaagent:../opentelemetry-javaagent.jar \
  -Dotel.service.name=demo-server \
  -Dotel.exporter.otlp.endpoint=http://localhost:4317 \
  -jar ./target/demo-server-0.0.1-SNAPSHOT.jar

然后再启动 demo-client

$ java -javaagent:../opentelemetry-javaagent.jar \
  -Dotel.service.name=demo-client \
  -Dotel.exporter.otlp.endpoint=http://localhost:4317 \
  -jar ./target/demo-client-0.0.1-SNAPSHOT.jar

上面通过参数 otel.service.name 指定服务名称,通过参数 otel.exporter.otlp.endpoint 指定 OpenTelemetry Collector 地址。

运行之后,打开 Jaeger UI 页面,就可以看到 demo-client 的这次请求:

jaeger-demo.png

点开可以看到完整的链路详情:

jaeger-demo-detail.png

Manual Instrumentation

通过 Java Agent 自动收集指标虽然简单,但是有时候我们还需要手动收集一些其他信息,比如处理过程中的一些步骤日志或耗时指标等。

官方在 这里提供了大量的示例 供初学者学习参考,上面 Astronomy Shop 演示服务中的 Ad Service 也是使用 Java 实现的,我们也可以 参考它的代码

首先我们在 demo-serverpom.xml 文件中添加依赖:

<project>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-bom</artifactId>
        <version>1.20.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.opentelemetry</groupId>
      <artifactId>opentelemetry-api</artifactId>
    </dependency>
  </dependencies>
</project>

然后在处理 /greeting 请求时,对当前的 Span 添加一个新的属性:

@GetMapping("/greeting")
public String greeting(@RequestParam(value = "name", defaultValue = "World") String name) {

  Span span = Span.current();
  span.setAttribute("user.name", name);

  return String.format("Hello, %s", name);
}

重新编译和启动,再次运行 demo-client,可以在 Jaeger UI 中看到我们添加的属性:

jaeger-demo-span-attr.png

参考

  1. OpenTelemetry Documentation
  2. OpenTelemetry 中文文档
  3. OpenTelemetry 可观测性的未来 - 作者 Ted Young,译者 Jimmy Song
  4. OpenTelemetry 简析 - 阿里云云原生
  5. End-to-end tracing with OpenTelemetry - Nicolas Fränkel
  6. OpenTelemetry初體驗:實踐Chaos Engineering來Drive the Observability's best practice - Johnny Pan
  7. 淺談DevOps與Observability 系列
  8. 可观测性 - 凤凰架构
  9. Kratos 学习笔记 - 基于 OpenTelemetry 的链路追踪

更多

1. 执行 docker compose up 报错 'compose' is not a docker command.

Docker Compose V2docker-compose 的重大版本升级,使用 Go 完全重写了对之前 V1 的 Python 代码,并且和 V1 不同的是,V2 不再是独立的可执行程序,而是作为 Docker 的命令行插件来运行。所以需要先将其安装到 Docker 的插件目录:

$ mkdir -p ~/.docker/cli-plugins
$ curl -fsSL "https://github.com/docker/compose/releases/download/v2.12.2/docker-compose-linux-x86_64" -o ~/.docker/cli-plugins/docker-compose
$ chmod +x ~/.docker/cli-plugins/docker-compose

安装完成后检查是否生效:

$ docker compose version
Docker Compose version v2.12.2

如果你需要兼容 Docker Compose V1 时的 docker-compose 命令,官方提供了一个名为 Compose Switch 的工具,它可以将 docker-compose 命令自动转换为 docker compose 命令。如果你的机器上没有安装过 Docker Compose V1,可以直接下载 compose-switch 并改名为 docker-compose

$ sudo curl -fsSL https://github.com/docker
/compose-switch/releases/download/v1.0.5/docker-compose-linux-amd64 -o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose

如果已经安装过 Docker Compose V1,你可以将其先卸载掉再安装 compose-switch,或者根据官方文档使用 update-alternatives 之类的工具进行版本切换。

2. Dapr 的可观测性实战

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

WebAssembly 学习笔记

WebAssembly(简称 WASM)是一种以安全有效的方式运行可移植程序的新兴 Web 技术,下面是引用 MDN 上对它的定义

WebAssembly 是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如 C/C++ 等语言提供一个编译目标,以便它们可以在 Web 上运行。它也被设计为可以与 JavaScript 共存,允许两者一起工作。

也就是说,无论你使用的是哪一种语言,我们都可以将其转换为 WebAssembly 格式,并在浏览器中以原生的性能运行。WebAssembly 的开发团队来自 Mozilla、Google、Microsoft 和 Apple,分别代表着四大网络浏览器 Firefox、Chrome、Microsoft Edge 和 Safari,从 2017 年 11 月开始,这四大浏览器就开始实验性的支持 WebAssembly。当时 WebAssembly 还没有形成标准,这么多的浏览器开发商对某个尚未标准化的技术 达成如此一致的意见,这在历史上是很罕见的,可以看出这绝对是一项值得关注的技术,被号称为 the future of web development

four-browsers.png

WebAssembly 在 2019 年 12 月 5 日被万维网联盟(W3C)推荐为标准,与 HTML,CSS 和 JavaScript 一起,成为 Web 的第四种语言。

WebAssembly 之前的历史

JavaScript 诞生于 1995 年 5 月,一个让人津津乐道的故事是,当时刚加入网景的 Brendan Eich 仅仅花了十天时间就开发出了 JavaScript 语言。开发 JavaScript 的初衷是为 HTML 提供一种脚本语言使得网页变得更动态,当时根本就没有考虑什么浏览器兼容性、安全性、移植性这些东西,对性能也没有特别的要求。但随着 Web 技术的发展,网页要解决的问题已经远不止简单的文本信息,而是包括了更多的高性能图像处理和 3D 渲染等方面,这时,JavaScript 的性能问题就凸显出来了。于是,如何让 JavaScript 执行的更快,变成了各大浏览器生产商争相竞逐的目标。

浏览器性能之战

这场关于浏览器的性能之战在 2008 年由 Google 带头打响,这一年的 9 月 2 日,Google 发布了一款跨时代的浏览器 Chrome,具备简洁的用户界面和极致的用户体验,内置的 V8 引擎采用了全新的 JIT 编译(Just-in-time compilation,即时编译)技术,使得浏览器的响应速度得到了几倍的提升。次年,Apple 发布了他们的浏览器新版本 Safari 4,其中引入新的 Nitro 引擎(也被称为 SquirrelFish 或 JavaScriptCore),同样使用的是 JIT 技术。紧接着,Mozilla 在 Firefox 3.5 中引入 TraceMonkey 技术,Microsoft 在 2011 年也推出 Chakra) 引擎。

使用 JIT 技术,极大的提高了 JavaScript 的性能。那么 JIT 是如何工作的呢?我们知道,JavaScript 是解释型语言,因此传统的 JavaScript 引擎需要逐行读取 JavaScript 代码,并将其翻译成可执行的机器码。很显然这是极其低效的,如果有一段代码需要执行上千次,那么 JavaScript 引擎也会傻傻的翻译上千次。JIT 技术的基本思路就是缓存,它将执行频次比较高的代码实时编译成机器码,并缓存起来,当下次执行到同样代码时直接使用相应的机器码替换掉,从而获得极大的性能提升。另外,对于执行频次非常高的代码,JIT 引擎还会使用优化编译器(Optimising Compiler)编译出更高效的机器码。关于 JIT 技术的原理可以参考 A crash course in just-in-time (JIT) compilers 这篇文章。

JIT 技术推出之后,JavaScript 的性能得到了飞速提升:

jit-performance.png

随着性能的提升,JavaScript 的应用范围也得到了极大的扩展,Web 内容变得更加丰富,图片、视频、游戏,等等等等,甚至有人将 JavaScript 用于后端开发(Node.js)。不过 JIT 也不完全是 “性能银弹”,因为通过 JIT 优化也是有一定代价的,比如存储优化后的机器码需要更多的内存,另外 JIT 优化对变量类型非常敏感,但是由于 JavaScript 动态类型 的特性,用户代码中对某个变量的类型并不会严格固定,这时 JIT 优化的效果将被大打折扣。比如下面这段简单的代码:

function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr[i];
  }
}

假设 JIT 检测到 sum += arr[i]; 这行代码被执行了很多次,开始对其进行编译优化,它首先需要确认 sumarriarr[i] 这些变量的类型,如果 arr[i]int 类型,这就是整数相加的操作,但如果 arr[i]string 类型,这又变成了字符串拼接的操作,这两种情况编译成的机器码是完全不同的。所以 JIT 引擎会先根据代码执行情况假设变量为某种类型,然后再进行优化,当执行时会对类型进行检测,一旦检测到类型不同时,这个 JIT 优化将被作废,这个过程叫做 去优化(deoptimization,或者 bailing out)。假如用户写出这样的代码:

arr = [1, "hello"];

JavaScript 这种动态类型的特点对 JIT 引擎是非常不友好的,反复的优化和去优化不仅无法提高性能,甚至会有副作用。所以在实际的生产环境中,JIT 的效果往往没有那么显著,通过 JIT 的优化很快就遇到了瓶颈。

但是日益丰富的 Web 内容对 JavaScript 的性能提出了更高的要求,尤其是 3D 游戏,这些游戏在 PC 上跑都很吃力,更别说在浏览器里运行了。如何让 JavaScript 执行地更快,是摆在各大浏览器生产商面前的一大难题,很快,Google 和 Mozilla 交出了各自的答卷。

Google 的 NaCl 解决方案

Google 在 2008 年开源了 NaCl 技术,并在 2011 年的 Chrome 14 中正式启用。NaCl 的全称为 Native Client,这是一种可以在浏览器中执行原生代码(native code)的技术,听起来很像是 Microsoft 当时所使用的 ActiveX 技术,不过 ActiveX 由于其安全性一直被人所诟病。而 NaCl 定义了一套原生代码的安全子集,执行于独立的沙盒环境之中,并通过一套被称为 PPAPI(Pepper Plugin API)的接口来和 JavaScript 交互,避免了可能的安全问题。NaCl 采取了和 JIT 截然不同的 AOT 编译(Ahead-of-time compilation,即提前编译)技术,所以在性能上的表现非常突出,几乎达到了和原生应用一样的性能。不过由于 NaCl 应用是 C/C++ 语言编写的,与 CPU 架构强关联,不具有可移植性,因此需要针对不同的平台进行开发以及编译,用户使用起来非常痛苦。

为了解决这个问题,Google 在 2013 年又推出了 PNaCl 技术(Portable Native Client),PNaCl 的创新之处在于使用 LLVM IR(Intermediate Representation)来分发应用,而不是直接分发原生代码,LLVM IR 也被称为 Bitcode,它是一种平台无关的中间语言表示,实现了和 Java 一样的目标:一次编译,到处运行。

如果我们站在今天的视角来看,PNaCl 这项技术是非常超前的,它的核心理念和如今的 WebAssembly 如出一辙,只不过它出现的时机不对,当时很多人都对在浏览器中执行原生代码持怀疑态度,担心可能出现和 ActiveX 一样的安全问题,而且当时 HTML5 技术正发展的如火如荼,人们都在想着如何从浏览器中移除诸如 Flash 或 Java Applet 这些 JavaScript 之外的技术,所以 PNaCl 技术从诞生以来,一直不温不火,尽管后来 Firefox 和 Opera 等浏览器也开始支持 NaCl 和 PPAPI,但是一直无法得到普及(当时的 IE 还占领着浏览器市场的半壁江山)。

随着 WebAssembly 技术的发展,Google Chrome 最终在 2018 年移除了对 PNaCl 的支持,决定全面拥抱 WebAssembly 技术。

Mozilla 的 asm.js 解决方案

2010 年,刚刚加入 Mozilla 的 Alon Zakai 在工作之余突发奇想,能不能将自己编写的 C/C++ 游戏引擎运行在浏览器上?当时 NaCl 技术还没怎么普及,Alon Zakai 一时之间并没有找到什么好的技术方案。好在 C/C++ 是强类型语言,JavaScript 是弱类型语言,所以将 C/C++ 代码转换为 JavaScript 在技术上是完全可行的。Alon Zakai 于是便开始着手编写这样的一个编译器,Emscripten 便由此诞生了!

Emscripten 和传统的编译器很类似,都是将某种语言转换为另一种语言形式,不过他们之间有着本质的区别。传统的编译器是将一种语言编译成某种 low-level 的语言,比如将 C/C++ 代码编译成二进制文件(机器码),这种编译器被称为 Compiler;而 Emscripten 是将 C/C++ 代码编译成和它 same-level 的 JavaScript 代码,这种编译器被称为 Transpiler 或者 Source to source compiler

Emscripten 相比于 NaCl 来说兼容性更好,于是很快就得到了 Mozilla 的认可。之后 Alon Zakai 被邀请加入 Mozilla 的研究团队并全职负责 Emscripten 的开发,以及通过 Emscripten 编译生成的 JavaScript 代码的性能优化上。在 2013 年,Alon Zakai 联合 Luke Wagner,David Herman 一起发布了 asm.js 规范,同年,Mozilla 也发布了 Firefox 22,并内置了新一代的 OdinMonkey 引擎,它是第一个支持 asm.js 规范的 JavaScript 引擎。

asm.js 的思想很简单,就是尽可能的在 JavaScript 中使用类型明确的参数,并通过 TypedArray 取消了垃圾回收机制,这样可以让 JIT 充分利用和优化,进而提高 JavaScript 的执行性能。比如下面这样一段 C 代码:

int f(int i) {
  return i + 1;
}

使用 Emscripten 编译生成的 JavaScript 代码如下:

function f(i) {
  i = i|0;
  return (i + 1)|0;
}

通过在变量和返回值后面加上 |0 这样的操作,我们明确了参数和返回值的数据类型,当 JIT 引擎检测到这样的代码时,便可以跳过语法分析和类型推断这些步骤,将代码直接转成机器语言。据称,使用 asm.js 能达到原生代码 50% 左右的速度,虽然没有 NaCl 亮眼,但是这相比于普通的 JavaScript 代码而言已经是极大的性能提升了。而且我们可以看出 asm.js 采取了和 NaCl 截然不同的思路,asm.js 其实和 JavaScript 没有区别,它只是 JavaScript 的一个子集而已,这样做不仅可以充分发挥出 JIT 的最大功效,而且能兼容所有的浏览器。

但是 asm.js 也存在着不少的问题。首先由于它还是和 JavaScript一样是文本格式,因此加载和解析都会花费比较长的时间,这被称为慢启动问题;其次,asm.js 除了在变量后面加 |0 之外,还有很多类似这样的标注代码:

asmjs.png

很显然,这让代码的可读性和可扩展性都变的很差;最后,仍然是性能问题,通过 asm.js 无论怎么优化最终生成的都还是 JavaScript 代码,性能自然远远比不上原生代码;因此这并不是一个非常理想的技术方案。

其他解决方案

除了 NaCl 和 asm.js,实际上还有一些其他的解决方案,但最终的结果要么夭折,要么被迫转型。其中值得一提的是 Google 发明的 Dart 语言,Dart 语言的野心很大,它最初的目的是要取代 JavaScript 成为 Web 的首选语言,为此 Google 还开发了一款新的浏览器 Dartium,内置 Dart 引擎可以执行 Dart 程序,而且对于不支持 Dart 程序的浏览器,它还提供了相应的工具将 Dart 转换为 JavaScript。这一套组合拳可谓是行云流水,可是结果如何可想而知,不仅很难得到用户的承认,而且也没得到其他浏览器的认可,最终 Google 在 2015 年取消了该计划。目前 Dart 语言转战移动开发领域,比如跨平台开发框架 Flutter 就是采用 Dart 开发的。

WebAssembly = NaCl + asm.js

随着技术的发展,Mozilla 和 Google 的工程师出现了很多次的交流和合作,通过汲取 NaCl 和 asm.js 两者的优点,双方推出了一种全新的技术方案:

  • 和 NaCl/PNaCl 一样,基于二进制格式,从而能够被快速解析,达到原生代码的运行速度;
  • 和 PNaCl 一样,依赖于通用的 LLVM IR,这样既具备可移植性,又便于其他语言快速接入;
  • 和 asm.js 一样,使用 Emscripten 等工具链进行编译;另外,Emscripten 同时支持生成 asm.js 和二进制格式,当浏览器不兼容新的二进制格式时,asm.js 可以作为降级方案;
  • 和 asm.js 一样,必须以非常自然的方式直接操作 Web API,而不用像 PNaCl 一样需要处理与 JavaScript 之间的通信;

这个技术方案在 2015 年正式命名为 WebAssembly,2017 年各大浏览器生产商纷纷宣布支持 WebAssembly,2019 年 WebAssembly 正式成为 W3C 标准,一场关于浏览器的性能革命已经悄然展开。

wasm-performance.png

WebAssembly 入门示例

从上面的学习中我们知道,WebAssembly 是一种通用的编码格式,并且已经有很多编程语言支持将源码编译成这种格式了,官方的 Getting Started 有一个详细的列表。这一节我们就跟着官方的教程实践一下下面这三种语言:

将 C/C++ 程序编译成 WebAssembly

首先我们参考 Emscripten 的官方文档 上的步骤下载并安装 Emscripten SDK,安装完成后通过下面的命令检查环境是否正常:

$ emcc --check
emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) 3.1.24 (68a9f990429e0bcfb63b1cde68bad792554350a5)
shared:INFO: (Emscripten: Running sanity checks)

环境准备就绪后,我们就可以将 C/C++ 的代码编译为 WebAssembly 了。写一个简单的 Hello World 程序 hello.c

#include <stdio.h>

int main() {
    printf("Hello World\n");
    return 0;
}

然后使用 emcc 进行编译:

$ emcc hello.c -o hello.html

上面这个命令会生成三个文件:

  • hello.wasm - 这就是生成的 WebAssembly 二进制字节码文件
  • hello.js - 包含一段胶水代码(glue code)通过 JavaScript 来调用 WebAssembly 文件
  • hello.html - 方便开发调试,在页面上显示 WebAssembly 的调用结果

我们不能直接用浏览器打开 hello.html 文件,因为浏览器不支持 file:// 形式的 XHR 请求,所以在 HTML 中无法加载 .wasm 等相关的文件,为了看到效果,我们需要一个 Web Server,比如 Nginx、Tomcat 等,不过这些安装和配置都比较麻烦,我们还有很多其他的方法快速启动一个 Web Server。

比如通过 npm 启动一个本地 Web Server:

$ npx serve .

或者使用 Python3 的 http.server 模块:

$ python3 -m http.server

访问 hello.html 页面如下:

hello-html.png

可以看到我们在 C 语言中打印的 Hello World 成功输出到浏览器了。

另外,我们也可以将 C 语言中的函数暴露出来给 JavaScript 调用。默认情况下,Emscripten 生成的代码只会调用 main() 函数,其他函数忽略。我们可以使用 emscripten.h 中的 EMSCRIPTEN_KEEPALIVE 来暴露函数,新建一个 greet.c 文件如下:

#include <stdio.h>
#include <emscripten/emscripten.h>

int main() {
    printf("Hello World\n");
    return 0;
}

#ifdef __cplusplus
#define EXTERN extern "C"
#else
#define EXTERN
#endif

EXTERN EMSCRIPTEN_KEEPALIVE void greet(char* name) {
    printf("Hello, %s!\n", name);
}

上面的代码定义了一个 void greet(char* name) 函数,为了让这个函数可以在 JavaScript 中调用,编译时还需要指定 NO_EXIT_RUNTIMEEXPORTED_RUNTIME_METHODS 参数,将 ccall 导出来:

$ emcc -o greet.html greet.c -s NO_EXIT_RUNTIME=1 -s EXPORTED_RUNTIME_METHODS=ccall

greet.html 文件和上面的 hello.html 几乎是一样的,我们在该文件中加几行代码来测试我们的 greet() 函数,首先加一个按钮:

<button id="mybutton">Click me!</button>

然后为它添加点击事件,可以看到 JavaScript 就是通过上面导出的 ccall 来调用 greet() 函数的:

document.getElementById("mybutton").addEventListener("click", () => {
  const result = Module.ccall(
    "greet",         // name of C function
    null,            // return type
    ["string"],      // argument types
    ["WebAssembly"]  // arguments
  );
});

除了 ccall,我们还可以使用 -s EXPORTED_RUNTIME_METHODS=ccall,cwrap 同时导出 ccallcwrap 函数。ccall 的作用是直接调用某个 C 函数,而 cwrap 是将 C 函数编译为一个 JavaScript 函数,并可以反复调用,这在正式项目中更实用。

点击这个按钮,可以在页面和控制台上都看到 greet() 函数打印的内容:

greet-html.png

将 Rust 程序编译成 WebAssembly

首先按照官方文档 安装 Rust,安装包含了一系列常用的命令行工具,包括 rustuprustccargo 等,其中 cargo 是 Rust 的包管理器,可以使用它安装 wasm-pack

$ cargo install wasm-pack

wasm-pack 用于将 Rust 代码编译成 WebAssembly 格式,不过要注意它不支持 bin 项目,只支持 lib 项目,所以我们通过 --lib 来创建项目:

$ cargo new --lib rust-demo
     Created library `rust-demo` package

打开 ./src/lib.rs,输入以下代码:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
    pub fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

在上面的代码中我们使用了 wasm-bindgen 这个工具,它实现了 JavaScript 和 Rust 之间的相互通信,关于它的详细说明可以参考 《The wasm-bindgen Guide》 这本电子书。我们首先通过 extern 声明了一个 JavaScript 中的 alert() 函数,然后我们就可以像调用正常的 Rust 函数一样调用这个外部函数。下面再通过 pub fngreet() 函数暴露出来,这样我们也可以从 JavaScript 中调用这个 Rust 函数。

接着修改 ./Cargo.toml 文件,添加如下内容:

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

其中 crate-type = ["cdylib"] 表示生成一个 动态系统库。使用 wasm-pack 进行构建:

$ wasm-pack build --target web

这个命令会生成一个 pkg 目录,里面包含了 wasm 文件和对应的 JavaScript 胶水代码,这和上面的 emcc 结果类似,不过并没有生成相应的测试 HTML 文件。我们手工创建一个 index.html 文件,内容如下:

<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>hello-wasm example</title>
  </head>
  <body>
    <script type="module">
      import init, { greet } from "./pkg/rust_demo.js";
      init().then(() => {
        greet("WebAssembly");
      });
    </script>
  </body>
</html>

然后启动一个 Web Server,并在浏览器中打开测试页面:

rust-demo-html.png

我们成功在浏览器中调用了使用 Rust 编写的 greet() 函数!

将 Go 程序编译成 WebAssembly

首先确保你已经 安装了 Go

$ go version
go version go1.19 linux/amd64

使用 go mod init 初始化模块:

$ mkdir go-demo && cd go-demo
$ go mod init com.example

新建一个 main.go 文件:

package main

import "fmt"

func main() {
    fmt.Println("Hello, WebAssembly!")
}

使用 go build 可以将它编译成可执行文件,通过在命令之前指定 GOOS=js GOARCH=wasm 可以将它编译成 WebAssembly 文件:

$ GOOS=js GOARCH=wasm go build -o main.wasm

和上面的 C 语言或 Rust 语言的例子一样,为了测试这个 main.wasm 文件,我们还需要 JavaScript 胶水代码和一个测试 HTML 文件。Go 的安装目录下自带了一个 wasm_exec.js 文件,我们将其拷贝到当前目录:

$ cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

然后创建一个 index.html 文件(也可以直接使用 Go 自带的 wasm_exec.html 文件):

<html>
  <head>
    <meta charset="utf-8"/>
      <script src="wasm_exec.js"></script>
      <script>
        const go = new Go();
        WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
          go.run(result.instance);
        });
      </script>
  </head>
  <body></body>
</html>

启动 Web Server 后在浏览器中打开该页面:

go-demo-html.png

在控制台中我们就可以看到程序运行的结果了。

除了在浏览器中测试 WebAssembly 文件,也可以使用 Go 安装目录自带的 go_js_wasm_exec 工具来运行它:

$ $(go env GOROOT)/misc/wasm/go_js_wasm_exec ./main.wasm
Hello, WebAssembly!

或者 go run 时带上 -exec 参数来运行:

$ GOOS=js GOARCH=wasm go run -exec="$(go env GOROOT)/misc/wasm/go_js_wasm_exec" .
Hello, WebAssembly!

运行这个命令需要安装 Node.js v12 以上的版本,打开 go_js_wasm_exec 文件可以看到它实际上就是执行 node wasm_exec_node.js 这个命令。

上面的例子是直接在 JavaScript 中执行 Go 程序,如果我们需要将 Go 中的函数导出给 JavaScript 调用,可以通过 syscall/js 来实现:

package main

import (
    "syscall/js"
)

func addFunction(this js.Value, p []js.Value) interface{} {
    sum := p[0].Int() + p[1].Int()
    return js.ValueOf(sum)
}

func main() {
    js.Global().Set("add", js.FuncOf(addFunction))
    select {} // block the main thread forever
}

注意在 main() 函数中我们使用 select {} 将程序阻塞住,防止程序退出,否则 JavaScript 在调用 Go 函数时会报下面这样的错误:

wasm_exec.js:536 Uncaught Error: Go program has already exited
    at globalThis.Go._resume (wasm_exec.js:536:11)
    at wasm_exec.js:549:8
    at <anonymous>:1:1

由于 add 函数是直接添加到 js.Global() 中的,我们可以直接通过 window.add 来访问它:

go-add-html.png

js.Global() 为我们提供了一个 Go 和 JavaScript 之间的桥梁,我们不仅可以将 Go 函数暴露给 JavaScript 调用,甚至可以通过 js.Global() 来操作 DOM:

func hello(this js.Value, args []js.Value) interface{} {
    doc := js.Global().Get("document")
    h1 := doc.Call("createElement", "h1")
    h1.Set("innerText", "Hello World")
    doc.Get("body").Call("append", h1)
    return nil
}

除了官方的 go build 可以将 Go 程序编译成 WebAssembly 文件,你也可以尝试使用 TinyGo,这是 Go 语言的一个子集实现,它对 Go 规范做了适当的裁剪,只保留了一些比较重要的库,这让它成为了一种更加强大和高效的语言,你可以在意想不到的地方运行它(比如很多物联网设备)。另外,使用 TinyGo 编译 WebAssembly 还有一个很大的优势,它编译出来的文件比 Go 官方编译出来的文件小得多(上面的例子中 C/C++ 或 Rust 编译出来的 wasm 文件只有 100~200K,而 Go 编译出来的 wasm 文件竟然有 2M 多)。

WebAssembly 文本格式

上面我们使用了三种不同的编程语言来体验 WebAssembly,学习了如何编译,以及如何在浏览器中使用 JavaScript 调用它。不过这里有一个问题,由于 wasm 文件是二进制格式,对我们来说是完全黑盒的,不像 JavaScript 是纯文本的,我们可以方便地通过浏览器自带的开发者工具对其进行调试,而 wasm 如果调用出问题,我们将很难排查。实际上,WebAssembly 在设计之初就已经考虑了这样的问题,所以它不仅具有 二进制格式,而且还有一种类似于汇编语言的 文本格式,方便用户查看、编辑和调试。

下面是 WebAssembly 文本格式的一个简单例子:

(module
  (func $add (param $lhs i32) (param $rhs i32) (result i32)
    local.get $lhs
    local.get $rhs
    i32.add)
  (export "add" (func $add))
)

WebAssembly 代码中的基本单元是一个模块,每个模块通过一个大的 S-表达式 来表示,S-表达式是一种嵌套结构,实际上它是树的一种表示形式。上面的代码首先通过 (module) 定义了一个模块,然后模块中使用 (func $add (param $lhs i32) (param $rhs i32) (result i32)) 定义了一个 add() 函数,这个 S-表达式转换为比较好理解的形式就是 i32 add(i32 lhs, i32 rhs),最后通过 (export "add" (func $add)) 将该函数暴露出来,关于这段代码的详细解释可以参考 Mozilla 官方文档中的 Understanding WebAssembly text format

我们将上面的代码保存到 add.wat 文件中,并通过 WABT 工具包(The WebAssembly Binary Toolkit)中的 wat2wasm 将其转换为 wasm 格式:

$ wat2wasm add.wat -o add.wasm

使用下面的 JavaScript 脚本加载 wasm 并调用 add() 函数:

fetchAndInstantiate('add.wasm').then(function(instance) {
    console.log(instance.exports.add(1, 2));  // "3"
});

// fetchAndInstantiate() found in wasm-utils.js
function fetchAndInstantiate(url, importObject) {
    return fetch(url).then(response =>
    response.arrayBuffer()
    ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
    ).then(results =>
    results.instance
    );
}

将这段 JavaScript 脚本放在一个 HTML 文件中,然后启动 Web Server 访问,可以看到控制台输出了 3,也就是 add(1, 2) 的结果,并且我们还可以通过 Chrome 提供的 开发者工具对 wasm 文件进行调试

wasm-debug.png

参考

  1. WebAssembly 官网
  2. WebAssembly | MDN
  3. WebAssembly 中文网
  4. WebAssembly Design Documents
  5. WebAssembly Specification
  6. WebAssembly - 维基百科
  7. asm.js 和 Emscripten 入门教程
  8. 浏览器是如何工作的:Chrome V8 让你更懂JavaScript
  9. WebAssembly完全入门——了解wasm的前世今身
  10. 浅谈WebAssembly历史
  11. A cartoon intro to WebAssembly Articles
  12. 一个白学家眼里的 WebAssembly
  13. 使用 Docker 和 Golang 快速上手 WebAssembly
  14. 如何评论浏览器最新的 WebAssembly 字节码技术?
  15. 如何看待 WebAssembly 这门技术?
  16. 系统学习WebAssembly(1) —— 理论篇
  17. 快 11K Star 的 WebAssembly,你应该这样学
  18. WebAssembly 与 JIT
  19. WebAssembly 初步探索
  20. WebAssembly 實戰 – 讓 Go 與 JS 在瀏覽器上共舞

更多

在非浏览器下运行 WebAssembly

WebAssembly 最早只应用于 Web 浏览器中,但鉴于它所拥有 可移植、安全及高效 等特性,WebAssembly 也被逐渐应用在 Web 领域之外的一些其他场景中,并为此提出了一项新的接口标准 —— WASI(WebAssembly System Interface)

要让 WebAssembly 跑在非 Web 环境下,我们必须有一款支持 WASI 接口的运行时(WASI runtime),目前比较流行的有:wasttimewasmerWasmEdge 等,这些运行时提供了不同编程语言的 SDK,可以使得我们在各种不同的语言中调用 WebAssembly 模块。

使用 WABT 工具包

WABT 工具包中除了上文所使用的 wat2wasm 之外,还提供了很多其他有用的工具:

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

搭建自己的镜像仓库

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

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

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

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

首先下载 Docker Registry 镜像:

$ docker pull registry:latest

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

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

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

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

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

使用 Docker Registry API 访问仓库

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

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

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

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

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

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

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

使用 crane 工具操作仓库

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

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

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

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

使用 crane catalog 列出所有镜像:

$ crane catalog localhost:5000 --insecure

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

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

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

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

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

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

删除镜像

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

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

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

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

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

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

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

registry:
  storage:
    delete:
      enabled: true

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

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

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

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

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

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

进入 registry 容器:

$ docker exec -it registry /bin/sh

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

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

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

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

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

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

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

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

开启 TLS 和安全认证

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

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

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

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

然后重启 Docker 后即可。

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

开启 TLS 安全

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

创建一个 certs 目录:

$ mkdir -p certs

使用 openssl req 生成证书:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

windows-cert-install.png

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

windows-cert.png

需要重启 Docker 服务后生效。

开启用户认证

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

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

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

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

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

admin:$2y$05$mzoaYiDmF7Bm2p/JWf4kje7naTzkyYpqgg5v8mZPq0HdDuSXZ1d0i

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

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

这时访问镜像仓库报错:

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

需要先 docker login 登录:

$ docker login 192.168.1.39:5000

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

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

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

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

参考

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

更多

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

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

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

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

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

etcd 学习笔记

etcd 是一个使用 Go 语言编写的用于存储分布式系统中的数据的高可用键值数据库(key-value store),它是 CoreOS 团队在 2013 年 6 月发起的开源项目,并在 2018 年 12 月正式加入 CNCF。我们知道在 Linux 操作系统中有一个目录叫 /etc,它是专门用来存储操作系统配置的地方,etcd 这个名词就是源自于此,etcd = etc + distibuted,所以它的目的就是用来存储分布式系统中的关键数据。

etcd.png

etcd 内部采用 Raft 一致性算法,以一致和容错的方式存储元数据。利用 etcd 可以实现包括配置管理、服务发现和协调分布式任务这些功能,另外 etcd 还提供了一些常用的分布式模式,包括领导选举,分布式锁和监控机器活动等。

etcd 已经被各大公司和开源项目广泛使用,最著名的莫过于 Kubernetes 就是使用 etcd 来存储配置数据的,etcd 的一致性对于正确安排和运行服务至关重要,Kubernetes 的 API Server 将集群状态持久化在 etcd 中,通过 etcd 的 Watch API 监听集群,并发布关键的配置更改。

k8s-apiserver-etcd.png

快速开始

这一节我们将学习如何在本地快速启动一个单节点的 etcd 服务,并学习 etcd 的基本使用。

安装

首先从 GitHub Releases 页面下载和你的操作系统对应的最新版本:

$ curl -LO https://github.com/etcd-io/etcd/releases/download/v3.4.20/etcd-v3.4.20-linux-amd64.tar.gz

解压并安装到 /usr/local/etcd 目录:

$ tar xzvf etcd-v3.4.20-linux-amd64.tar.gz -C /usr/local/etcd --strip-components=1

目录下包含了 etcdetcdctl 两个可执行文件,etcdctletcd 的命令行客户端。另外,还包含一些 Markdown 文档:

$ ls /usr/local/etcd
Documentation  README-etcdctl.md  README.md  READMEv2-etcdctl.md  etcd  etcdctl

然后将 /usr/local/etcd 目录添加到 PATH 环境变量(如果要让配置永远生效,可以将下面一行添加到 ~/.profile 文件中):

export PATH=$PATH:/usr/local/etcd

至此,etcd 就安装好了,使用 etcd --version 检查版本:

$ etcd --version
etcd Version: 3.4.20
Git SHA: 1e26823
Go Version: go1.16.15
Go OS/Arch: linux/amd64

启动

直接不带参数运行 etcd 命令,即可(a single-member cluster of etcd):

$ etcd
[WARNING] Deprecated '--logger=capnslog' flag is set; use '--logger=zap' flag instead
2022-09-06 07:21:49.244548 I | etcdmain: etcd Version: 3.4.20
2022-09-06 07:21:49.253270 I | etcdmain: Git SHA: 1e26823
2022-09-06 07:21:49.253486 I | etcdmain: Go Version: go1.16.15
2022-09-06 07:21:49.253678 I | etcdmain: Go OS/Arch: linux/amd64
2022-09-06 07:21:49.253846 I | etcdmain: setting maximum number of CPUs to 8, total number of available CPUs is 8
2022-09-06 07:21:49.253981 W | etcdmain: no data-dir provided, using default data-dir ./default.etcd
2022-09-06 07:21:49.254907 N | etcdmain: the server is already initialized as member before, starting as etcd member...
[WARNING] Deprecated '--logger=capnslog' flag is set; use '--logger=zap' flag instead
2022-09-06 07:21:49.264084 I | embed: name = default
2022-09-06 07:21:49.264296 I | embed: data dir = default.etcd
2022-09-06 07:21:49.264439 I | embed: member dir = default.etcd/member
2022-09-06 07:21:49.264667 I | embed: heartbeat = 100ms
2022-09-06 07:21:49.264885 I | embed: election = 1000ms
2022-09-06 07:21:49.265079 I | embed: snapshot count = 100000
2022-09-06 07:21:49.265244 I | embed: advertise client URLs = http://localhost:2379
2022-09-06 07:21:49.265389 I | embed: initial advertise peer URLs = http://localhost:2380
2022-09-06 07:21:49.265681 I | embed: initial cluster =
2022-09-06 07:21:49.302456 I | etcdserver: restarting member 8e9e05c52164694d in cluster cdf818194e3a8c32 at commit index 5
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d switched to configuration voters=()
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d became follower at term 2
raft2022/09/06 07:21:49 INFO: newRaft 8e9e05c52164694d [peers: [], term: 2, commit: 5, applied: 0, lastindex: 5, lastterm: 2]
2022-09-06 07:21:49.312871 W | auth: simple token is not cryptographically signed
2022-09-06 07:21:49.319814 I | etcdserver: starting server... [version: 3.4.20, cluster version: to_be_decided]
raft2022/09/06 07:21:49 INFO: 8e9e05c52164694d switched to configuration voters=(10276657743932975437)
2022-09-06 07:21:49.329816 I | etcdserver/membership: added member 8e9e05c52164694d [http://localhost:2380] to cluster cdf818194e3a8c32
2022-09-06 07:21:49.331278 N | etcdserver/membership: set the initial cluster version to 3.4
2022-09-06 07:21:49.331489 I | etcdserver/api: enabled capabilities for version 3.4
2022-09-06 07:21:49.333146 I | embed: listening for peers on 127.0.0.1:2380
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d is starting a new election at term 2
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d became candidate at term 3
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d received MsgVoteResp from 8e9e05c52164694d at term 3
raft2022/09/06 07:21:50 INFO: 8e9e05c52164694d became leader at term 3
raft2022/09/06 07:21:50 INFO: raft.node: 8e9e05c52164694d elected leader 8e9e05c52164694d at term 3
2022-09-06 07:21:50.419379 I | etcdserver: published {Name:default ClientURLs:[http://localhost:2379]} to cluster cdf818194e3a8c32
2022-09-06 07:21:50.419988 I | embed: ready to serve client requests
2022-09-06 07:21:50.427600 N | embed: serving insecure client requests on 127.0.0.1:2379, this is strongly discouraged!

该命令会在当前位置创建一个 ./default.etcd 目录作为数据目录,并监听 2379 和 2380 两个端口,http://localhost:2379advertise-client-urls,表示建议使用的客户端通信 url,可以配置多个,etcdctl 就是通过这个 url 来访问 etcd 的;http://localhost:2380advertise-peer-urls,表示用于节点之间通信的 url,也可以配置多个,集群内部通过这些 url 进行数据交互(如选举,数据同步等)。

测试

打开另一个终端,输入 etcdctl put 命令可以向 etcd 中以键值对的形式写入数据:

$ etcdctl put hello world
OK

然后使用 etcdctl get 命令可以根据键值读取数据:

$ etcdctl get hello
hello
world

搭建 etcd 集群

上面的例子中,我们在本地启动了一个单节点的 etcd 服务,一般用于开发和测试。在生产环境中,我们需要搭建高可用的 etcd 集群。

启动一个 etcd 集群要求集群中的每个成员都要知道集群中的其他成员,最简单的做法是在启动 etcd 服务时加上 --initial-cluster 参数告诉 etcd 初始集群中有哪些成员,这种方法也被称为 静态配置

我们在本地打开三个终端,并在三个终端中分别运行下面三个命令:

$ etcd --name infra1 \
    --listen-client-urls http://127.0.0.1:12379 \
    --advertise-client-urls http://127.0.0.1:12379 \
    --listen-peer-urls http://127.0.0.1:12380 \
    --initial-advertise-peer-urls http://127.0.0.1:12380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new
$ etcd --name infra2 \
    --listen-client-urls http://127.0.0.1:22379 \
    --advertise-client-urls http://127.0.0.1:22379 \
    --listen-peer-urls http://127.0.0.1:22380 \
    --initial-advertise-peer-urls http://127.0.0.1:22380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new
$ etcd --name infra3 \
    --listen-client-urls http://127.0.0.1:32379 \
    --advertise-client-urls http://127.0.0.1:32379 \
    --listen-peer-urls http://127.0.0.1:32380 \
    --initial-advertise-peer-urls http://127.0.0.1:32380 \
    --initial-cluster-token etcd-cluster-demo \
    --initial-cluster 'infra1=http://127.0.0.1:12380,infra2=http://127.0.0.1:22380,infra3=http://127.0.0.1:32380' \
    --initial-cluster-state new

我们可以使用 Foreman 这个小工具来简化上面的过程,Foreman 通过一个 Procfile 文件在本地启动和管理多个进程。

我们随便选择一个节点,通过 member list 都可以查询集群中的所有成员:

$ etcdctl --write-out=table --endpoints=localhost:12379 member list
+------------------+---------+--------+------------------------+------------------------+------------+
|        ID        | STATUS  |  NAME  |       PEER ADDRS       |      CLIENT ADDRS      | IS LEARNER |
+------------------+---------+--------+------------------------+------------------------+------------+
| b217c7a319e4e4f8 | started | infra2 | http://127.0.0.1:22380 | http://127.0.0.1:22379 |      false |
| d35bfbeb1c7fbfcf | started | infra1 | http://127.0.0.1:12380 | http://127.0.0.1:12379 |      false |
| d425e5b1e0d8a751 | started | infra3 | http://127.0.0.1:32380 | http://127.0.0.1:32379 |      false |
+------------------+---------+--------+------------------------+------------------------+------------+

测试往集群中写入数据:

$ etcdctl --endpoints=localhost:12379 put hello world
OK

换一个节点也可以查出数据:

$ etcdctl --endpoints=localhost:22379 get hello
hello
world

但是一般为了保证高可用,我们会在 --endpoints 里指定集群中的所有成员:

$ etcdctl --endpoints=localhost:12379,localhost:22379,localhost:32379 get hello
hello
world

然后我们停掉一个 etcd 服务,可以发现集群还可以正常查询,说明高可用生效了,然后我们再停掉一个 etcd 服务,此时集群就不可用了,这是因为 Raft 协议必须要保证集群中一半以上的节点存活才能正常工作,可以看到集群中的唯一节点也异常退出了:

panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x75745b]

goroutine 188 [running]:
go.uber.org/zap.(*Logger).check(0x0, 0x1, 0x10a3ea5, 0x36, 0xc002068900)
        /root/go/pkg/mod/go.uber.org/zap@v1.10.0/logger.go:264 +0x9b
go.uber.org/zap.(*Logger).Warn(0x0, 0x10a3ea5, 0x36, 0xc002068900, 0x2, 0x2)
        /root/go/pkg/mod/go.uber.org/zap@v1.10.0/logger.go:194 +0x45
go.etcd.io/etcd/etcdserver.(*EtcdServer).requestCurrentIndex(0xc0002b4000, 0xc001fef200, 0xbfcf831fa9e8b606, 0x0, 0x0, 0x0)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/v3_server.go:805 +0x873
go.etcd.io/etcd/etcdserver.(*EtcdServer).linearizableReadLoop(0xc0002b4000)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/v3_server.go:721 +0x2d6
go.etcd.io/etcd/etcdserver.(*EtcdServer).goAttach.func1(0xc0002b4000, 0xc00011c4a0)
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/server.go:2698 +0x57
created by go.etcd.io/etcd/etcdserver.(*EtcdServer).goAttach
        /tmp/etcd-release-3.4.20/etcd/release/etcd/etcdserver/server.go:2696 +0x1b1

使用静态的方法运行 etcd 集群虽然简单,但是这种方法必须提前规划好集群中所有成员的 IP 和端口,在有些场景下成员的地址是无法提前知道的,这时我们可以使用 动态配置 的方法来初始化集群,etcd 提供了两种动态配置的机制:etcd DiscoveryDNS Discovery,具体的内容可以参考 Clustering GuideDiscovery service protocol

操作 etcd

有多种不同的途径来操作 etcd,比如使用 etcdctl 命令行,使用 API 接口,或者使用 etcd 提供的不同语言的 SDK。

使用 etcdctl 命令行操作 etcd

在快速开始一节,我们使用 etcdctl putetcdctl get 来测试 etcd 是否可以正常写入和读取数据,这是 etcdctl 最常用的命令。

查看版本

通过 etcdctl version 查看 etcdctl 客户端和 etcd API 的版本:

$ etcdctl version
etcdctl version: 3.4.20
API version: 3.4

写入数据

通过 etcdctl put 将数据存储在 etcd 的某个键中,每个存储的键通过 Raft 协议复制到 etcd 集群的所有成员来实现一致性和可靠性。下面的命令将键 hello 的值设置为 world:

$ etcdctl put hello world
OK

etcd 支持为每个键设置 TTL 过期时间,这是通过租约(--lease)实现的。首先我们创建一个 100s 的租约:

$ etcdctl lease grant 100
lease 694d8324b408010a granted with TTL(100s)

然后写入键值时带上这个租约:

$ etcdctl put foo bar --lease=694d8324b408010a
OK

foo 这个键将在 100s 之后自动过期。

读取数据

我们提前往 etcd 中写入如下数据:

$ etcdctl put foo bar
$ etcdctl put foo1 bar1
$ etcdctl put foo2 bar2
$ etcdctl put foo3 bar3

读取某个键的值:

$ etcdctl get foo
foo
bar

默认情况下会将键和值都打印出来,使用 --print-value-only 可以只打印值:

$ etcdctl get foo --print-value-only
bar

或者使用 --keys-only 只打印键。

我们也可以使用两个键进行范围查询:

$ etcdctl get foo foo3
foo
bar
foo1
bar1
foo2
bar2

可以看出查询的范围为半开区间 [foo, foo3)

或者使用 --prefix 参数进行前缀查询,比如查询所有 foo 开头的键:

$ etcdctl get foo --prefix
foo
bar
foo1
bar1
foo2
bar2
foo3
bar3

我们甚至可以使用 etcdctl get "" --prefix 查询出所有的键。使用 --limit 限制查询数量:

$ etcdctl get "" --prefix --limit=2
foo
bar
foo1
bar1

删除数据

etcdctl get 一样,我们可以从 etcd 中删除一个键:

$ etcdctl del foo
1

也可以范围删除:

$ etcdctl del foo foo3
2

或者根据前缀删除:

$ etcdctl del --prefix foo
1

历史版本

etcd 会记录所有键的修改历史版本,每次对 etcd 进行修改操作时,版本号就会加一。使用 etcdctl get foo 查看键值时,可以通过 --write-out=json 参数看到版本号:

$ etcdctl get foo --write-out=json | jq
{
    "header": {
        "cluster_id": 14841639068965178418,
        "member_id": 10276657743932975437,
        "revision": 9,
        "raft_term": 2
    },
    "kvs": [
        {
            "key": "Zm9v",
            "create_revision": 5,
            "mod_revision": 5,
            "version": 1,
            "value": "YmFy"
        }
    ],
    "count": 1
}

其中 "revision": 9 就是当前的版本号,注意 JSON 格式输出中 key 和 value 是 BASE64 编码的。

修改 foo 的值:

$ etcdctl put foo bar_new
OK

查看最新的键值和版本:

$ etcdctl get foo --write-out=json | jq
{
    "header": {
        "cluster_id": 14841639068965178418,
        "member_id": 10276657743932975437,
        "revision": 10,
        "raft_term": 2
    },
    "kvs": [
        {
            "key": "Zm9v",
            "create_revision": 5,
            "mod_revision": 10,
            "version": 2,
            "value": "YmFyX25ldw=="
        }
    ],
    "count": 1
}

发现版本号已经加一变成了 10,此时如果使用 etcdctl get foo 查看的当前最新版本的值,也可以通过 --rev 参数查看历史版本的值:

$ etcdctl get foo
foo
bar_new

$ etcdctl get foo --rev=9
foo
bar

过多的历史版本会占用 etcd 的资源,我们可以将不要的历史记录清除掉:

$ etcdctl compact 10
compacted revision 10

上面的命令将版本号为 10 之前的历史记录删除掉,此时再查询 10 之前版本就会报错:

$ etcdctl get foo --rev=9
{"level":"warn","ts":"2022-09-10T09:48:41.021+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-ae27c266-06c4-4be8-9659-47b9318ec8f4/127.0.0.1:2379","attempt":0,"error":"rpc error: code = OutOfRange desc = etcdserver: mvcc: required revision has been compacted"}
Error: etcdserver: mvcc: required revision has been compacted

监听键值变化

使用 etcdctl watch 可以监听某个键的变动情况:

$ etcdctl watch foo

打开另一个终端修改 foo 的值,然后将键删除:

$ etcdctl put foo bar_new
OK
$ etcdctl del foo
1

在监听窗口我们可以实时看到键值的变化:

$ etcdctl watch foo
PUT
foo
bar_new
DELETE
foo

也可以监听某个范围内的键:

$ etcdctl watch foo foo3

或以监听指定前缀的所有键:

$ etcdctl watch --prefix foo

如果要监听的键没有相同的前缀,也不是在某个范围内,可以通过 watch -i 以交互的形式手工设置监听多个键:

$ etcdctl watch -i
watch foo
watch hello

另外通过 watch 命令还可以查看某个键的所有历史修改记录:

$ etcdctl watch --rev=1 foo
PUT
foo
bar
DELETE
foo

PUT
foo
bar
PUT
foo
bar_new

租约

在上面使用 etcdctl put 写入数据时,已经介绍了可以通过租约实现 TTL 功能,每个租约都带有一个存活时间,一旦租约到期,它绑定的所有键都将被删除。

创建一个租约:

$ etcdctl lease grant 100
lease 694d8324b4080150 granted with TTL(100s)

查询租约信息:

$ etcdctl lease timetolive 694d8324b4080150
lease 694d8324b4080150 granted with TTL(100s), remaining(96s)

查询租约信息时,也可以加上 --keys 参数查询该租约关联的键:

$ etcdctl lease timetolive --keys 694d8324b4080150
lease 694d8324b4080150 granted with TTL(100s), remaining(93s), attached keys([foo])

还可以通过 keep-alive 命令自动维持租约:

$ etcdctl lease keep-alive 694d8324b4080150

etcd 会每隔一段时间刷新该租约的到期时间,保证该租约一直处于存活状态。

最后我们通过 revoke 命令撤销租约:

$ etcdctl lease revoke 694d8324b4080150
lease 694d8324b4080150 revoked

租约撤销后,和该租约关联的键也会一并被删除掉。

其他命令

使用 etcdctl --help 查看支持的其他命令。

使用 etcd API 操作 etcd

etcd 支持的大多数基础 API 都定义在 api/etcdserverpb/rpc.proto 文件中,官方的 API reference 就是根据这个文件生成的。

etcd 将这些 API 分为 6 大类:

  • service KV

    • Range
    • Put
    • DeleteRange
    • Txn
    • Compact
  • service Watch

    • Watch
  • service Lease

    • LeaseGrant
    • LeaseRevoke
    • LeaseKeepAlive
    • LeaseTimeToLive
    • LeaseLeases
  • service Cluster

    • MemberAdd
    • MemberRemove
    • MemberUpdate
    • MemberList
    • MemberPromote
  • service Maintenance

    • Alarm
    • Status
    • Defragment
    • Hash
    • HashKV
    • Snapshot
    • MoveLeader
    • Downgrade
  • service Auth

    • AuthEnable
    • AuthDisable
    • AuthStatus
    • Authenticate
    • UserAdd
    • UserGet
    • UserList
    • UserDelete
    • UserChangePassword
    • UserGrantRole
    • UserRevokeRole
    • RoleAdd
    • RoleGet
    • RoleList
    • RoleDelete
    • RoleGrantPermission
    • RoleRevokePermission

实际上,每个接口都对应一个 HTTP 请求,比如上面执行的 etcdctl put 命令就是调用 KV.Put 接口,而 etcdctl get 命令就是调用 KV.Range 接口。

调用 KV.Put 写入数据(注意接口中键值对使用 BASE64 编码,这里的键为 key,值为 value):

$ curl -L http://localhost:2379/v3/kv/put \
  -X POST -d '{"key": "a2V5", "value": "dmFsdWU="}'
{"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"24","raft_term":"3"}}

调用 KV.Range 查询数据:

curl -L http://localhost:2379/v3/kv/range \
  -X POST -d '{"key": "a2V5"}'
{"header":{"cluster_id":"14841639068965178418","member_id":"10276657743932975437","revision":"24","raft_term":"3"},"kvs":[{"key":"a2V5","create_revision":"24","mod_revision":"24","version":"1","value":"dmFsdWU="}],"count":"1"}

这和使用 etcdctl 命令行查询效果是一样的:

$ etcdctl get key
key
value

除了这 6 类基础 API,etcd 还提供了两个并发类 API,包括分布式锁和集群选主:

使用 Go 语言操作 etcd

etcd 提供了各个语言的 SDK 用于操作 etcd,比如 Go 的 etcd/client/v3、Java 的 coreos/jetcd、Python 的 kragniz/python-etcd3 等,官方文档 Libraries and tools 列出了更多其他语言的 SDK。这一节通过一个简单的 Go 语言示例来学习 SDK 的用法。

首先安装依赖:

$ go get go.etcd.io/etcd/client/v3

然后通过 clientv3.New 创建一个 etcd 连接:

cli, err := clientv3.New(clientv3.Config{
    Endpoints:   []string{"localhost:2379"},
    DialTimeout: 5 * time.Second,
})
if err != nil {
    panic("Connect etcd server error")
}
defer cli.Close()

然后就可以通过 cli 来对 etcd 操作了,比如调用 cli.Put 写入数据:

_, err = cli.Put(ctx, "hello", "world")
if err != nil {
    panic("Put kv error")
}

调用 cli.Get 查询数据:

resp, err := cli.Get(ctx, "hello")
if err != nil {
    panic("Get kv error")
}
for _, kv := range resp.Kvs {
    fmt.Printf("%s: %s\n", kv.Key, kv.Value)
}

不过在运行的时候,报了下面这样一个奇怪的错:

go run .\main.go
# github.com/coreos/etcd/clientv3/balancer/resolver/endpoint
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\resolver\endpoint\endpoint.go:114:87: undefined: resolver.BuildOption
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\resolver\endpoint\endpoint.go:182:40: undefined: resolver.ResolveNowOption
# github.com/coreos/etcd/clientv3/balancer/picker
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\picker\err.go:37:53: undefined: balancer.PickOptions
C:\Users\aneasystone\go\pkg\mod\github.com\coreos\etcd@v3.3.27+incompatible\clientv3\balancer\picker\roundrobin_balanced.go:55:63: undefined: balancer.PickOptions

这里 有个解决方案,说是 grpc 版本较高导致的,需要降低 grpc 的版本:

$ go get google.golang.org/grpc@v1.26.0

etcd 和 其他键值存储的区别

etcd vs. Redis

Redis 和 etcd 一样,支持键值存储,而且也支持分布式特性,他们之间的差异如下:

  • Redis 支持的数据类型比 etcd 更丰富
  • Redis 在分布式环境下不是强一致的,可能会丢数据或读取不到最新数据
  • Redis 的数据监听机制没有 etcd 完善
  • etcd 为了保证强一致性,性能要低于 Redis

综上考虑,Redis 适用于缓存,需要频繁读写,但对系统没有强一致性的要求,etcd 适用于系统读写较少,但是对系统有强一致性要求的场景,比如存储分布式系统的元数据。

etcd vs. ZooKeeper

ZooKeeper 和 etcd 的定位都是分布式协调系统,ZooKeeper 起源于 Hadoop 生态系统,etcd 则是跟着 Kubernetes 的流行而流行。他们都是顺序一致性的(满足 CAP 的 CP),意味着无论你访问任意节点,都将获得最终一致的数据。他们之间的差异如下:

  • ZooKeeper 从逻辑上来看是一种目录结构,而 etcd 从逻辑上来看就是一个 KV 结构,不过 etcd 的 Key 可以是任意字符串,所以也可以模拟出目录结构
  • etcd 使用 Raft 算法实现一致性,比 ZooKeeper 的 ZAB 算法更简单
  • ZooKeeper 采用 Java 编写,etcd 采用 Go 编写,相比而言 ZooKeeper 的部署复杂度和维护成本要高一点,而且 ZooKeeper 的官方只提供了 Java 和 C 的客户端,对其他编程语言不是很友好
  • ZooKeeper 属于 Apache 基金会顶级项目,发展较缓慢,而 etcd 得益于云原生,近几年发展势头迅猛
  • etcd 提供了 gRPC 或 HTTP 接口使用起来更简单
  • ZooKeeper 使用 SASL 进行安全认证,而 etcd 支持 TLS 客户端安全认证,更容易使用

总体来说,ZooKeeper 和 etcd 还是很相似的,在 新技术学习笔记:ZooKeeper 这篇文章中介绍了一些 ZooKeeper 的使用场景,我们使用 etcd 同样也都可以实现。在具体选型上,我们应该更关注是否契合自己所使用的技术栈。

官方有一个比较完整的表格 Comparison chart,从不同的维度对比了 etcd 和 ZooKeeper、Consul 以及一些 NewSQL(Cloud Spanner、CockroachDB、TiDB)的区别。

参考

  1. Etcd Quickstart - Get etcd up and running in less than 5 minutes!
  2. Etcd 中文文档
  3. Etcd 官方文档中文版
  4. Etcd 教程 | 编程宝库
  5. etcd 教程 | 梯子教程
  6. 七张图了解Kubernetes内部的架构

更多

etcd 的安全性

开启用户角色认证

etcd 支持开启 RBAC 认证,但是开启认证之前,我们需要提前创建 root 用户和 root 角色。

创建 root 用户:

$ etcdctl user add root
Password of root:
Type password of root again for confirmation:
User root created

创建 root 角色:

$ etcdctl role add root
Role root created

root 用户赋权 root 角色:

$ etcdctl user grant-role root root
Role root is granted to user root

开启认证:

$ etcdctl auth enable
Authentication Enabled

此时访问 etcd 就必须带上用户名和密码认证了,否则会报错:

$ etcdctl put hello world
{"level":"warn","ts":"2022-09-11T09:59:07.093+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-9b824d27-c8d4-4195-a17e-31eb0cf70a1c/127.0.0.1:2379","attempt":0,"error":"rpc error: code = InvalidArgument desc = etcdserver: user name is empty"}
Error: etcdserver: user name is empty

使用 --user 带上用户名和密码访问:

$ etcdctl --user root:123456 put hello world
OK

开启 TLS 证书认证

etcd 还支持开启 TLS 安全模式,使用 TLS 可以从多个维度保证 etcd 通信的安全性,包括客户端和服务器之间的安全通信,客户端认证,Peer 之间的安全通信和认证等,官方文档 Transport security model 中列举了四种使用 TLS 的常用场景:

  • Client-to-server transport security with HTTPS
  • Client-to-server authentication with HTTPS client certificates
  • Transport security & client certificates in a cluster
  • Automatic self-signed transport security

首先我们需要创建一个自签名的证书(self-signed certificate),CFSSL 是 CDN 服务商 Cloudflare 开源的一套 PKI/TLS 工具集,可以非常方便的创建 TLS 证书。从 GitHub Release 页面下载并安装 CFSSL:

$ curl -LO https://github.com/cloudflare/cfssl/releases/download/v1.6.2/cfssl_1.6.2_linux_amd64
$ chmod +x cfssl_1.6.2_linux_amd64
$ cp cfssl_1.6.2_linux_amd64 /usr/local/bin/cfssl


$ curl -LO https://github.com/cloudflare/cfssl/releases/download/v1.6.2/cfssljson_1.6.2_linux_amd64
$ chmod +x cfssljson_1.6.2_linux_amd64
$ cp cfssljson_1.6.2_linux_amd64 /usr/local/bin/cfssljson

然后准备一个配置文件 ca-csr.json,内容如下:

{
  "CN": "etcd",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ]
}

再通过 cfssl gencert 命令生成 CA 证书:

$ mkdir -p certs
$ cfssl gencert -initca ca-csr.json | cfssljson -bare certs/ca

cfssl gencert 命令输出结果为 JSON 格式,需要通过 cfssljson 转换为证书文件,上面的命令会在 certs 目录下生成三个文件:

  • ca-key.pem
  • ca.csr
  • ca.pem

其中 ca.csr证书签名请求(CSR,Certificate Signing Request) 文件,我们这里用不上,ca.pemca-key.pem 两个文件需要妥善保管,后面就是使用它们来生成其他证书的。ca.pem 文件可以公开发送给任意人,客户端可以将其添加到可信机构列表中,ca-key.pem 是私钥文件,绝对不能泄露,要不然别人就可以通过该文件冒充你生成任意证书了。

有了 CA 证书后,我们就可以签发其他证书了。接着创建一个 ca-config.json 文件:

{
  "signing": {
    "default": {
        "usages": [
          "signing",
          "key encipherment",
          "server auth",
          "client auth"
        ],
        "expiry": "876000h"
    }
  }
}

以及一个 req-csr.json 文件:

{
  "CN": "etcd",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ],
  "hosts": [
    "localhost",
    "127.0.0.1"
  ]
}

ca-config.json 文件包含一些证书签名的信息,比如证书有效时间和证书用途等,req-csr.json 文件用于生成证书,和上面的 ca-csr.json 文件类似,只是多了 hosts 字段,用于表示这个证书只能用于 localhost127.0.0.1,也可以在这里添加其他 IP 地址。

使用 cfssl gencert 生成 etcd 服务端证书:

$ cfssl gencert \
    -ca certs/ca.pem \
    -ca-key certs/ca-key.pem \
    -config ca-config.json \
    req-csr.json | cfssljson -bare certs/etcd

上面的命令在 certs 目录下也生成了三个文件:

  • etcd-key.pem
  • etcd.csr
  • etcd.pem

接下来开启 TLS 功能了:

$ etcd --name infra0 --data-dir infra0 \
    --cert-file=./certs/etcd.pem --key-file=./certs/etcd-key.pem \
    --advertise-client-urls=https://127.0.0.1:2379 \
    --listen-client-urls=https://127.0.0.1:2379

注意此时 advertise-client-urlslisten-client-urls 都是 HTTPS 地址,所以执行 etcdctl 命令时需要指定 --endpoints=https://localhost:2379

$ etcdctl --endpoints=https://localhost:2379 get hello
{"level":"warn","ts":"2022-09-12T13:43:38.384+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-5e888f67-4ad6-4893-a0b4-954aba00f984/localhost:2379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = latest balancer error: all SubConns are in TransientFailure, latest connection error: connection error: desc = \"transport: authentication handshake failed: x509: certificate signed by unknown authority\""}
Error: context deadline exceeded

不过由于证书是我们自己签发的,所以是不可信的,命令会报 certificate signed by unknown authority 这样的错,我们可以通过 --cacert 参数指定 CA 证书:

$ etcdctl --endpoints=https://localhost:2379 get hello \
    --cacert=./certs/ca.pem

这时客户端和服务端之间都是通过 TLS 加密通信的,保证了客户端和服务端之间的安全性,此外,我们还可以在启动 etcd 时加上 --client-cert-auth 参数开启客户端认证功能进一步加强 etcd 服务的安全,同时还需要加上 --trusted-ca-file 参数用于指定 CA 证书:

$ etcd --name infra0 --data-dir infra0 \
    --client-cert-auth --trusted-ca-file=./certs/ca.pem \
    --cert-file=./certs/etcd.pem --key-file=./certs/etcd-key.pem \
    --advertise-client-urls=https://127.0.0.1:2379 \
    --listen-client-urls=https://127.0.0.1:2379  

再和上面一样执行 etcdctl 命令,发现就算带上了 --cacert 参数,也一样会报错:

$ etcdctl --endpoints=https://localhost:2379 get hello \
    --cacert=./cfssl/certs/ca.pem
{"level":"warn","ts":"2022-09-12T14:00:16.057+0800","caller":"clientv3/retry_interceptor.go:62","msg":"retrying of unary invoker failed","target":"endpoint://client-9c6cf82a-7cf1-488e-b1fa-8713a1b036a9/localhost:2379","attempt":0,"error":"rpc error: code = DeadlineExceeded desc = latest balancer error: all SubConns are in TransientFailure, latest connection error: connection closed"}
Error: context deadline exceeded

这是因为 etcd 服务端接收到请求后,会对客户端的证书进行校验,而我们的请求中并没有带上证书。接下来我们就通过 CA 为客户端生成一个证书,首先创建一个 client-csr.json 文件:

{
  "CN": "etcd client",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
    {
      "O": "autogenerated",
      "OU": "etcd",
      "L": "internet"
    }
  ]
}

使用 cfssl gencert 生成客户端证书:

$ cfssl gencert \
    -ca certs/ca.pem \
    -ca-key certs/ca-key.pem \
    -config ca-config.json \
    client-csr.json | cfssljson -bare certs/client

最后使用生成的客户端证书就可以连接 etcd 了:

$ etcdctl --endpoints=https://localhost:2379 get hello\
    --cacert=./certs/ca.pem \
    --cert=./certs/client.pem \
    --key=./certs/client-key.pem

由于客户端和服务端用的是同一个 CA 生成的证书,所以客户端认证通过,服务端认为该客户端是合法的。

使用 etcd 实现服务注册和发现

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

写一个简单的 Kubernetes Operator

Kubernetes Operator 这一概念是由 CoreOS 的工程师于 2016 年提出的,它是一种通过 自定义资源custom resourceCR)来包装、运行和管理 Kubernetes 应用的方式。Kubernetes 1.7 版本以来就引入了自定义资源的概念,该功能可以让开发人员扩展新功能或更新现有功能,并且可以自动执行一些管理任务,这些自定义资源就像 Kubernetes 的原生组件一样。

通过自定义资源,我们可以将应用抽象为一个整体,而不用去关心该应用是由哪些 Kubernetes 原生组件构成的,什么 Pods、Deployments、Services 或 ConfigMaps 统统交给 Operator 来管理。创建 Operator 的关键是自定义资源的设计,通过直接调用 Kubernetes API,编写自定义规则自动管理和维护 Kubernetes 集群中的应用,包括自动化安装、配置、更新、故障转移、备份恢复等等。这样的应用也被称为 Kubernetes 原生应用(Kubernetes-native application)。可以把 Operator 当做是一个运维人员,它以软件的形式帮助我们管理 Kubernetes 中运行的应用。Operator 可以帮我们实现下面这些运维工作:

operator-capabilitiy-model.png

这个图也被称为 Operator 的能力模型,将 Operator 的能力由低到高分成了 5 个等级。

控制器循环

Kubernetes Operator 遵循 control loop 原则,这是 Kubernetes 的核心原则之一,也是机器人和自动化领域中一种常见的持续运行动态系统的机制。它依赖于一种快速调整工作负载需求的能力,进而能够尽可能准确地适应现有资源。

reconciliation-loop.png

在 Kubernetes 中,这个循环被称为 reconciliation loop。在这个循环中,有一个非常重要的角色:控制器(Controller),它可以对集群的变化做出响应,并执行相应的动作。控制器首先观察 Kubernetes 对象的当前状态,然后通过 Kubernetes API 进行持续调整,直到将对象的当前状态变成所需状态为止。

第一个 Kubernetes Controller 是 kube-controller-manager,它被认为是所有 Operator 的鼻祖。

使用 Operator Framework 开发 Operator

Operator Framework 是 CoreOS 开源的一个用于快速开发或管理 Operator 的工具包,主要分为三大部分:

  • Operator SDKBuild, test, iterate. 你无需了解复杂的 Kubernetes API 特性,就可以根据你自己的专业知识构建一个 Operator 应用。
  • Operator Lifecycle Managerinstall, manage, update. OLM 是一款帮助你安装、更新和管理 Kubernetes Operator 的工具。
  • OperatorHub.ioPublish & share. OperatorHub 是一个类似 DockerHub 的仓库,你可以在这里搜索你想要的 Operator,或者将你的 Operator 发布并分享给其他人。

通过 Operator SDK 我们可以快速开发一个 Kubernetes Operator,它不仅提供了一套 High level API 来方便我们处理业务逻辑,还提供了一个命令行工具用于快速生成一个 Operator 的脚手架项目。

安装 operator-sdk

在开发 Operator 之前,先确保你已经有一个能访问的 Kubernetes 集群环境,Kubernetes 的安装可以参考 Kubernetes 安装小记。查看 Kubernetes 集群信息:

$ kubectl cluster-info
Kubernetes control plane is running at https://kubernetes.docker.internal:6443
CoreDNS is running at https://kubernetes.docker.internal:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

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

另外,Go 的开发环境也是必不可少的,可以参考 Go 的 官方文档 下载并安装。

$ curl -LO https://go.dev/dl/go1.19.linux-amd64.tar.gz
$ sudo tar -C /usr/local -xzf go1.19.linux-amd64.tar.gz

将路径 /usr/local/go/bin 添加到 PATH 环境变量,或者将下面这行添加到 ~/.profile 文件中:

$ export PATH=$PATH:/usr/local/go/bin

查看 Go 版本:

$ go version
go version go1.19 linux/amd64

接下来,我们继续安装 Operator SDK。我们在 Operator SDK 的 Releases 页面 找到合适的版本并下载:

$ curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.23.0/operator-sdk_linux_amd64

将其移动到 /usr/local/bin/ 目录即可完成安装:

$ chmod +x operator-sdk_linux_amd64 && sudo mv operator-sdk_linux_amd64 /usr/local/bin/operator-sdk

查看已安装的 operator-sdk 版本:

$ operator-sdk version
operator-sdk version: "v1.23.0", commit: "1eaeb5adb56be05fe8cc6dd70517e441696846a4", kubernetes version: "1.24.2", go version: "go1.18.5", GOOS: "linux", GOARCH: "amd64"

另外,operator-sdk 依赖于 makegcc,确保系统上已经安装了 makegcc 工具。

使用 operator-sdk 初始化 Operator 项目

Operator SDK 提供了三种方式开发 Operator:

我们这里将使用 Go 来开发 Operator,这种方式也是最灵活的,你可以使用 client-go 调用 Kubernetes API 来对 Kubernetes 对象进行操作。首先使用 operator-sdk init 初始化项目结构:

$ operator-sdk init --domain example.com --project-name memcached-operator --repo github.com/example/memcached-operator
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.12.2
Update dependencies:
$ go mod tidy
Next: define a resource with:
$ operator-sdk create api

其中 --project-name 参数可以省略,默认项目名称就是目录名。--domain--project-name 两个参数用于组成 Operator 的镜像名称 example.com/memcached-operator,而 --repo 参数用于定义 Go 模块名:

module github.com/example/memcached-operator

初始化后的完整项目结构如下:

$ tree .
.
├── Dockerfile
├── Makefile
├── PROJECT
├── README.md
├── config
│   ├── default
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   └── manager_config_patch.yaml
│   ├── manager
│   │   ├── controller_manager_config.yaml
│   │   ├── kustomization.yaml
│   │   └── manager.yaml
│   ├── manifests
│   │   └── kustomization.yaml
│   ├── prometheus
│   │   ├── kustomization.yaml
│   │   └── monitor.yaml
│   ├── rbac
│   │   ├── auth_proxy_client_clusterrole.yaml
│   │   ├── auth_proxy_role.yaml
│   │   ├── auth_proxy_role_binding.yaml
│   │   ├── auth_proxy_service.yaml
│   │   ├── kustomization.yaml
│   │   ├── leader_election_role.yaml
│   │   ├── leader_election_role_binding.yaml
│   │   ├── role_binding.yaml
│   │   └── service_account.yaml
│   └── scorecard
│       ├── bases
│       │   └── config.yaml
│       ├── kustomization.yaml
│       └── patches
│           ├── basic.config.yaml
│           └── olm.config.yaml
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
└── main.go

主要包括以下几个文件:

  • go.mod - 用于定义 Go 项目的依赖信息
  • PROJECT - 用于保存项目的配置信息
  • Makefile - 包含一些有用的项目构建目标(make targets
  • config - 该目录下包含一些用于项目部署的 YAML 文件
  • main.go - Operator 的主程序入口

创建 API

初始化项目之后,接着就可以使用 operator-sdk create api 命令创建 API 了:

$ operator-sdk create api --group cache --version v1alpha1 --kind Memcached --resource --controller
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1alpha1/memcached_types.go
controllers/memcached_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests

使用 operator-sdk create 命令可以生成 apiwebhook 的脚手架代码,我们这里生成的是 api,包括两部分内容:自定义资源(--resource)和控制器相关的逻辑代码(--controller),其中 --group--version--kind 分别用来设置资源的分组、版本和类型。

接下来可以从这些文件开始入手:

  • api/v1beta1/memcached_types.go
  • controllers/memcached_controller.go
  • controllers/suite_test.go

memcached_types.go 文件用于定义资源的接口规范,我们在 MemcachedSpec 中添加一个新字段 Size 如下(默认已经生成了一个 Foo 字段):

// MemcachedSpec defines the desired state of Memcached
type MemcachedSpec struct {
    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // Foo is an example field of Memcached. Edit memcached_types.go to remove/update
    Foo string `json:"foo,omitempty"`
    Size int32 `json:"size"`
}

接着打开 memcached_controller.go 文件,其中 Reconcile 方法就是上面所介绍的 reconciliation loop 的核心代码,可以在这里实现自己的业务逻辑,比如调用 Kubernetes API 创建、删除或更新各种 Kubernetes 资源。我们这里只是简单地将资源的属性值打印出来(官方对 memcached-operator 有完整的示例代码,可以 参考这里):

func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    _ = log.FromContext(ctx)

    instance := &cachev1alpha1.Memcached{}
    err := r.Get(context.TODO(), req.NamespacedName, instance)
    if err != nil {
        fmt.Println("Get instance err")
        return ctrl.Result{}, err
    }

    fmt.Printf("Foo = %s, Size = %d\n", instance.Spec.Foo, instance.Spec.Size)

    return ctrl.Result{}, nil
}

然后执行下面的命令生成自定义资源文件:

$ make manifests
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases

生成的自定义资源文件位于 config/crd/bases/cache.example.com_memcacheds.yaml,文件内容如下:

---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  annotations:
    controller-gen.kubebuilder.io/version: v0.9.2
  creationTimestamp: null
  name: memcacheds.cache.example.com
spec:
  group: cache.example.com
  names:
    kind: Memcached
    listKind: MemcachedList
    plural: memcacheds
    singular: memcached
  scope: Namespaced
  versions:
  - name: v1alpha1
    schema:
      openAPIV3Schema:
        description: Memcached is the Schema for the memcacheds API
        properties:
          apiVersion:
            description: 'APIVersion defines the versioned schema of this representation
              of an object. Servers should convert recognized schemas to the latest
              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
            type: string
          kind:
            description: 'Kind is a string value representing the REST resource this
              object represents. Servers may infer this from the endpoint the client
              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
            type: string
          metadata:
            type: object
          spec:
            description: MemcachedSpec defines the desired state of Memcached
            properties:
              foo:
                description: Foo is an example field of Memcached. Edit memcached_types.go
                  to remove/update
                type: string
              size:
                format: int32
                type: integer
            required:
            - size
            type: object
          status:
            description: MemcachedStatus defines the observed state of Memcached
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}

在这个文件中,我们定义了一个名为 Memcached 的自定义资源(Custom Resource Definition,简称 CRD),并定义了 foosize 两个属性,且 size 属性为必填项。

本地调试 Operator

至此,一个简单的 Operator 就开发好了,接下来我们运行 make install 命令,该命令使用 kustomize build 生成 CRD 配置文件并执行 kubectl apply 将 CRD 安装到 Kubernetes 集群中:

$ make install
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com created

通过 kubectl get crds 可以查看集群中的自定义资源是否创建成功:

$ kubectl get crds
NAME                           CREATED AT
memcacheds.cache.example.com   2022-08-26T09:24:19Z

可以看到集群中多了一个自定义资源 memcacheds.cache.example.com。然后运行 make run 命令在本地启动控制器:

$ make run
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
api/v1alpha1/groupversion_info.go
go vet ./...
go run ./main.go
1.6615063195978441e+09  INFO    controller-runtime.metrics      Metrics server is starting to listen    {"addr": ":8080"}
1.6615063195986106e+09  INFO    setup   starting manager
1.6615063195992978e+09  INFO    Starting server {"path": "/metrics", "kind": "metrics", "addr": "[::]:8080"}
1.6615063195993063e+09  INFO    Starting server {"kind": "health probe", "addr": "[::]:8081"}
1.661506319599374e+09   INFO    Starting EventSource    {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached", "source": "kind source: *v1alpha1.Memcached"}
1.6615063196000834e+09  INFO    Starting Controller     {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached"}
1.6615063197010505e+09  INFO    Starting workers        {"controller": "memcached", "controllerGroup": "cache.example.com", "controllerKind": "Memcached", "worker count": 1}

接下来我们就可以创建一个自定义资源实例测试一下。首先修改 config/samples/cache_v1alpha1_memcached.yaml 文件,填入 foosize 两个属性:

apiVersion: cache.example.com/v1alpha1
kind: Memcached
metadata:
  name: memcached-sample
spec:
  foo: Hello World
  size: 10

然后执行 kubectl apply 命令创建自定义资源实例:

$ kubectl apply -f config/samples/cache_v1alpha1_memcached.yaml
memcached.cache.example.com/memcached-sample created

此时查看控制器的输出如下:

Foo = Hello World, Size = 10

说明控制器监听到了自定义资源的创建,并输出了它的属性值。使用 kubectl get 查看刚刚创建的自定义资源:

$ kubectl get memcached.cache.example.com/memcached-sample
NAME               AGE
memcached-sample   13m

然后我们测试下自定义资源更新时的情况,修改 cache_v1alpha1_memcached.yaml 文件,比如将 size 改为 9,重新执行 kubectl apply 命令,控制器会立即监听到该修改,并输出新的属性值:

Foo = Hello World, Size = 9

部署 Operator

Operator 开发完成后,我们需要将它部署到 Kubernetes 集群中。首先我们将其构建成 Docker 镜像,可以使用下面的命令构建,并将镜像推送到镜像仓库:

$ make docker-build docker-push IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
./memcached-operator/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
KUBEBUILDER_ASSETS="/home/aneasystone/.local/share/kubebuilder-envtest/k8s/1.24.2-linux-amd64" go test ./... -coverprofile cover.out
?       github.com/example/memcached-operator   [no test files]
?       github.com/example/memcached-operator/api/v1alpha1      [no test files]
ok      github.com/example/memcached-operator/controllers       8.935s  coverage: 0.0% of statements
docker build -t aneasystone/memcached-operator:v0.0.1 .
[+] Building 3.3s (18/18) FINISHED                                                                                                                                
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 38B                                                         0.0s
 => [internal] load .dockerignore                                                           0.0s
 => => transferring context: 35B                                                            0.0s
 => [internal] load metadata for gcr.io/distroless/static:nonroot                           0.7s
 => [internal] load metadata for docker.io/library/golang:1.18                              3.0s
 => [auth] library/golang:pull token for registry-1.docker.io0.0s
 => [builder 1/9] FROM docker.io/library/golang:1.18@sha256:5540a6a6b3b612c382accc545b3f6702de21e77b15d89ad947116c94b5f42993        0.0s
 => [internal] load build context                                                           0.1s
 => => transferring context: 3.84kB                                                         0.0s
 => [stage-1 1/3] FROM gcr.io/distroless/static:nonroot@sha256:1f580b0a1922c3e54ae15b0758b5747b260bd99d39d40c2edb3e7f6e2452298b     0.0s
 => CACHED [builder 2/9] WORKDIR /workspace                                                 0.0s
 => CACHED [builder 3/9] COPY go.mod go.mod                                                 0.0s
 => CACHED [builder 4/9] COPY go.sum go.sum                                                 0.0s
 => CACHED [builder 5/9] RUN go mod download                                                0.0s
 => CACHED [builder 6/9] COPY main.go main.go                                               0.0s
 => CACHED [builder 7/9] COPY api/ api/                                                     0.0s
 => CACHED [builder 8/9] COPY controllers/ controllers/                                     0.0s
 => CACHED [builder 9/9] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager main.go                                   0.0s
 => CACHED [stage-1 2/3] COPY --from=builder /workspace/manager .                           0.0s
 => exporting to image                                                                      0.0s
 => => exporting layers                                                                     0.0s
 => => writing image sha256:84df51146080fec45fb74d5be29705f41c27de062e1192cb7c43a3a80c22977e                                        0.0s
 => => naming to docker.io/aneasystone/memcached-operator:v0.0.1                            0.0s
docker push aneasystone/memcached-operator:v0.0.1
The push refers to repository [docker.io/aneasystone/memcached-operator]
b399109810db: Pushed 
c456571abc85: Pushed 
v0.0.1: digest: sha256:60822319ac3578e3f62a73530c5ca08472014bf7861b75de6dd88502ee11d088 size: 739

上面我将镜像推送到 Docker 官方镜像仓库 docker.io,你也可以配置成自己的镜像仓库地址。

然后就可以将镜像部署到 Kubernetes 集群中了,官方提供了两种部署方式:直接部署 或 使用 OLM 部署。

直接部署

运行下面的 make deploy 命令:

$ make deploy IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s ./memcached-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 ./memcached-operator/bin; }
cd config/manager && ./memcached-operator/bin/kustomize edit set image controller=aneasystone/memcached-operator:v0.0.1
./memcached-operator/bin/kustomize build config/default | kubectl apply -f -
namespace/memcached-operator-system created
customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com unchanged
serviceaccount/memcached-operator-controller-manager created
role.rbac.authorization.k8s.io/memcached-operator-leader-election-role created
clusterrole.rbac.authorization.k8s.io/memcached-operator-manager-role created
clusterrole.rbac.authorization.k8s.io/memcached-operator-metrics-reader created
clusterrole.rbac.authorization.k8s.io/memcached-operator-proxy-role created
rolebinding.rbac.authorization.k8s.io/memcached-operator-leader-election-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-manager-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-proxy-rolebinding created
configmap/memcached-operator-manager-config created
service/memcached-operator-controller-manager-metrics-service created
deployment.apps/memcached-operator-controller-manager created

从日志可以看到部署了一堆的东西,包括一个名字空间:

  • namespace/memcached-operator-system created

一个自定义资源:

  • customresourcedefinition.apiextensions.k8s.io/memcacheds.cache.example.com unchanged

一个 ConfigMap、Service 和 Deployment(这就是我们的 Operator):

  • configmap/memcached-operator-manager-config created
  • service/memcached-operator-controller-manager-metrics-service created
  • deployment.apps/memcached-operator-controller-manager created

还有一堆账户角色这些和安全相关的资源:

  • serviceaccount/memcached-operator-controller-manager created
  • role.rbac.authorization.k8s.io/memcached-operator-leader-election-role created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-manager-role created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-metrics-reader created
  • clusterrole.rbac.authorization.k8s.io/memcached-operator-proxy-role created
  • rolebinding.rbac.authorization.k8s.io/memcached-operator-leader-election-rolebinding created
  • clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-manager-rolebinding created
  • clusterrolebinding.rbac.authorization.k8s.io/memcached-operator-proxy-rolebinding created

这些和正常的 Kubernetes 资源是完全一样的,我们可以使用 kubectl get 查询各个资源的详情,注意指定名字空间(-n memcached-operator-system):

$ kubectl get deployment -n memcached-operator-system
NAME                                    READY   UP-TO-DATE   AVAILABLE   AGE
memcached-operator-controller-manager   1/1     1            1           9m6s
$ kubectl get pods -n memcached-operator-system
NAME                                                     READY   STATUS    RESTARTS   AGE
memcached-operator-controller-manager-689d94c9bf-bqv2q   2/2     Running   0          8m54s
$ kubectl get service -n memcached-operator-system
NAME                                                    TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
memcached-operator-controller-manager-metrics-service   ClusterIP   10.96.197.28   <none>        8443/TCP   11m

同样的,也可以使用 kubectl logs 查看 Operator 的日志:

$ kubectl logs -f memcached-operator-controller-manager-689d94c9bf-bqv2q -n memcached-operator-system

如果要卸载 Operator,执行 make undeploy 命令即可:

$ make undeploy
./memcached-operator/bin/kustomize build config/default | kubectl delete --ignore-not-found=false -f -
namespace "memcached-operator-system" deleted
customresourcedefinition.apiextensions.k8s.io "memcacheds.cache.example.com" deleted
serviceaccount "memcached-operator-controller-manager" deleted
role.rbac.authorization.k8s.io "memcached-operator-leader-election-role" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-manager-role" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-metrics-reader" deleted
clusterrole.rbac.authorization.k8s.io "memcached-operator-proxy-role" deleted
rolebinding.rbac.authorization.k8s.io "memcached-operator-leader-election-rolebinding" deleted
clusterrolebinding.rbac.authorization.k8s.io "memcached-operator-manager-rolebinding" deleted
clusterrolebinding.rbac.authorization.k8s.io "memcached-operator-proxy-rolebinding" deleted
configmap "memcached-operator-manager-config" deleted
service "memcached-operator-controller-manager-metrics-service" deleted
deployment.apps "memcached-operator-controller-manager" deleted

使用 OLM 部署

OLM 的全称为 Operator Lifecycle Manager,是一款用于 Operator 的管理工具,可以使用 OLM 来帮你安装或更新 Kubernetes Operator。我们首先通过 operator-sdk 安装 OLM:

$ operator-sdk olm install
INFO[0001] Fetching CRDs for version "latest"
INFO[0001] Fetching resources for resolved version "latest"
I0827 15:01:42.199954   12688 request.go:601] Waited for 1.0471208s due to client-side throttling, not priority and fairness, request: GET:https://kubernetes.docker.internal:6443/apis/autoscaling/v1?timeout=32s
INFO[0012] Creating CRDs and resources
INFO[0012]   Creating CustomResourceDefinition "catalogsources.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "clusterserviceversions.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "installplans.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "olmconfigs.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operatorconditions.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operatorgroups.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "operators.operators.coreos.com"
INFO[0012]   Creating CustomResourceDefinition "subscriptions.operators.coreos.com"
INFO[0012]   Creating Namespace "olm"
INFO[0012]   Creating Namespace "operators"
INFO[0012]   Creating ServiceAccount "olm/olm-operator-serviceaccount"
INFO[0012]   Creating ClusterRole "system:controller:operator-lifecycle-manager"
INFO[0012]   Creating ClusterRoleBinding "olm-operator-binding-olm"
INFO[0012]   Creating OLMConfig "cluster"
INFO[0015]   Creating Deployment "olm/olm-operator"
INFO[0015]   Creating Deployment "olm/catalog-operator"
INFO[0015]   Creating ClusterRole "aggregate-olm-edit"
INFO[0015]   Creating ClusterRole "aggregate-olm-view"
INFO[0015]   Creating OperatorGroup "operators/global-operators"
INFO[0015]   Creating OperatorGroup "olm/olm-operators"
INFO[0015]   Creating ClusterServiceVersion "olm/packageserver"
INFO[0015]   Creating CatalogSource "olm/operatorhubio-catalog"
INFO[0016] Waiting for deployment/olm-operator rollout to complete
INFO[0016]   Waiting for Deployment "olm/olm-operator" to rollout: 0 of 1 updated replicas are available
INFO[0019]   Deployment "olm/olm-operator" successfully rolled out
INFO[0019] Waiting for deployment/catalog-operator rollout to complete
INFO[0019]   Deployment "olm/catalog-operator" successfully rolled out
INFO[0019] Waiting for deployment/packageserver rollout to complete
INFO[0019]   Waiting for Deployment "olm/packageserver" to rollout: 0 of 2 updated replicas are available
INFO[0033]   Deployment "olm/packageserver" successfully rolled out
INFO[0033] Successfully installed OLM version "latest"

NAME                                            NAMESPACE    KIND                        STATUS
catalogsources.operators.coreos.com                          CustomResourceDefinition    Installed
clusterserviceversions.operators.coreos.com                  CustomResourceDefinition    Installed
installplans.operators.coreos.com                            CustomResourceDefinition    Installed
olmconfigs.operators.coreos.com                              CustomResourceDefinition    Installed
operatorconditions.operators.coreos.com                      CustomResourceDefinition    Installed
operatorgroups.operators.coreos.com                          CustomResourceDefinition    Installed
operators.operators.coreos.com                               CustomResourceDefinition    Installed
subscriptions.operators.coreos.com                           CustomResourceDefinition    Installed
olm                                                          Namespace                   Installed
operators                                                    Namespace                   Installed
olm-operator-serviceaccount                     olm          ServiceAccount              Installed
system:controller:operator-lifecycle-manager                 ClusterRole                 Installed
olm-operator-binding-olm                                     ClusterRoleBinding          Installed
cluster                                                      OLMConfig                   Installed
olm-operator                                    olm          Deployment                  Installed
catalog-operator                                olm          Deployment                  Installed
aggregate-olm-edit                                           ClusterRole                 Installed
aggregate-olm-view                                           ClusterRole                 Installed
global-operators                                operators    OperatorGroup               Installed
olm-operators                                   olm          OperatorGroup               Installed
packageserver                                   olm          ClusterServiceVersion       Installed
operatorhubio-catalog                           olm          CatalogSource               Installed

如上所示,OLM 会在 Kubernetes 集群中安装一堆的资源,可以看到 OLM 本身也包含了两个 Operator:OLM Operator 和 Catalog Operator。关于他们的作用可以参考 《如何管理越来越多的 operator?OLM 给你答案》 这篇文章。

OLM 通过 Bundle 形式来组织和管理 Operator,使用 make bundle 生成 Bundle 相关的配置文件:

$ make bundle IMG="aneasystone/memcached-operator:v0.0.1"
test -s ./memcached-operator/bin/controller-gen || GOBIN=./memcached-operator/bin go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.9.2
./memcached-operator/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
test -s ./memcached-operator/bin/kustomize || { curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash -s -- 3.8.7 ./memcached-operator/bin; }
operator-sdk generate kustomize manifests -q

Display name for the operator (required):
> memcached-operator

Description for the operator (required):
> memcached operator

Provider's name for the operator (required):
> aneasystone

Any relevant URL for the provider name (optional):
> https://www.aneasystone.com

Comma-separated list of keywords for your operator (required):
> memcached

Comma-separated list of maintainers and their emails (e.g. 'name1:email1, name2:email2') (required):
> aneasystone@gmail.com
cd config/manager && ./memcached-operator/bin/kustomize edit set image controller=aneasystone/memcached-operator:v0.0.1
./memcached-operator/bin/kustomize build config/manifests | operator-sdk generate bundle -q --overwrite --version 0.0.1
INFO[0001] Creating bundle.Dockerfile
INFO[0001] Creating bundle/metadata/annotations.yaml
INFO[0001] Bundle metadata generated suceessfully
operator-sdk bundle validate ./bundle
INFO[0001] All validation tests have completed successfully

然后将 Bundle 构建成镜像并推送到镜像仓库:

$ make bundle-build bundle-push BUNDLE_IMG="aneasystone/memcached-operator-bundle:v0.0.1"
docker build -f bundle.Dockerfile -t aneasystone/memcached-operator-bundle:v0.0.1 .
[+] Building 0.6s (7/7) FINISHED
 => [internal] load build definition from bundle.Dockerfile                                                            0.1s
 => => transferring dockerfile: 971B                                                                                   0.0s
 => [internal] load .dockerignore                                                                                      0.1s
 => => transferring context: 35B                                                                                       0.0s
 => [internal] load build context                                                                                      0.0s
 => => transferring context: 12.72kB                                                                                   0.0s
 => [1/3] COPY bundle/manifests /manifests/                                                                            0.0s
 => [2/3] COPY bundle/metadata /metadata/                                                                              0.1s
 => [3/3] COPY bundle/tests/scorecard /tests/scorecard/                                                                0.1s
 => exporting to image                                                                                                 0.1s
 => => exporting layers                                                                                                0.1s
 => => writing image sha256:849fde8bbc55db7a1cd884ccdc7c61bfdca343650f72eb65e616c98c17193bca                           0.0s
 => => naming to docker.io/aneasystone/memcached-operator-bundle:v0.0.1                                                0.0s
make docker-push IMG=aneasystone/memcached-operator-bundle:v0.0.1
make[1]: Entering directory './memcached-operator'
docker push aneasystone/memcached-operator-bundle:v0.0.1
The push refers to repository [docker.io/aneasystone/memcached-operator-bundle]
ee3ff18c6586: Pushed
1cca854eb4c8: Pushed
2fa3c5f0ef35: Pushed
v0.0.1: digest: sha256:c42ec3c4f9d461128c640f5568886b006e0332ea0d4a173008e97addefbfd3f9 size: 939
make[1]: Leaving directory './memcached-operator'

运行 Bundle 将我们的 Operator 部署到 Kubernetes 集群中:

$ operator-sdk run bundle docker.io/aneasystone/memcached-operator-bundle:v0.0.1
INFO[0023] Creating a File-Based Catalog of the bundle "docker.io/aneasystone/memcached-operator-bundle:v0.0.1"
INFO[0028] Generated a valid File-Based Catalog
INFO[0033] Created registry pod: docker-io-aneasystone-memcached-operator-bundle-v0-0-1
INFO[0033] Created CatalogSource: memcached-operator-catalog
INFO[0033] OperatorGroup "operator-sdk-og" created
INFO[0033] Created Subscription: memcached-operator-v0-0-1-sub
INFO[0037] Approved InstallPlan install-z264c for the Subscription: memcached-operator-v0-0-1-sub
INFO[0037] Waiting for ClusterServiceVersion "default/memcached-operator.v0.0.1" to reach 'Succeeded' phase
INFO[0037]   Waiting for ClusterServiceVersion "default/memcached-operator.v0.0.1" to appear
INFO[0056]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Pending
INFO[0058]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Installing
INFO[0069]   Found ClusterServiceVersion "default/memcached-operator.v0.0.1" phase: Succeeded
INFO[0069] OLM has successfully installed "memcached-operator.v0.0.1"

可以使用 kubectl get 检查 Operator 运行的状态,和上一节直接部署不一样的是,Operator 被安装在默认的 default 名字空间里了,其他的几乎没啥区别。可以更新 config/samples/cache_v1alpha1_memcached.yaml 文件来对 Operator 进行测试。

如果要卸载 Operator,执行下面的命令:

$ operator-sdk cleanup memcached-operator

卸载 OLM:

$ operator-sdk olm uninstall

使用 kubernetes-sigs/kubebuilder 开发 Operator

operator-sdkkubebuilder 都是为了方便用户创建和管理 Operator 而生的脚手架项目,其实 operator-sdk 在底层也使用了 kubebuilder,比如 operator-sdk 的命令行工具就是直接调用 kubebuilder 的命令行工具。无论由 operator-sdk 还是 kubebuilder 创建的 Operator 项目都是调用的 controller-runtime 接口,具有相同的项目目录结构。

参考

  1. Kubernetes 文档 / 概念 / 扩展 Kubernetes / Operator 模式
  2. Kubernetes Operator 基础入门
  3. Kubernetes Operator 快速入门教程
  4. Kubernetes Operators 入门笔记
  5. 亲历者说:Kubernetes API 与 Operator,不为人知的开发者战争
  6. 《Kubernetes Operators eBook》By Jason Dobies & Joshua Wood
  7. Quickstart for Go-based Operators
  8. What is a Kubernetes operator?
  9. Introducing Operators: Putting Operational Knowledge into Software
  10. Kubernetes Operators 101, Part 1: Overview and key features
  11. Kubernetes Operators 101, Part 2: How operators work
  12. 如何管理越来越多的 operator?OLM 给你答案

更多

1. 安装 gcc 报 404 Not Found 错

在 Ubuntu 上使用 sudo apt install gcc 安装 gcc 时,报如下错误:

E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_4.15.0-189.200_amd64.deb  404  Not Found [IP: 2001:67c:1562::15 80]
E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?

解决方法很简单,执行 sudo apt update 更新软件源中的所有软件列表即可。

2. 使用 operator-sdk 创建 API 报错

执行 operator-sdk create api 命令创建 API 时,报如下错误:

/usr/local/go/src/net/cgo_linux.go:12:8: no such package located
Error: not all generators ran successfully
run `controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... -w` to see all available markers, or `controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... -h` for usage
Makefile:94: recipe for target 'generate' failed
make: *** [generate] Error 1
Error: failed to create API: unable to run post-scaffold tasks of "base.go.kubebuilder.io/v3": exit status 2

没有安装 gcc 工具,使用 sudo apt install gcc 安装 gcc 即可。

3. make buildmake test 时报错

在公司电脑开发 Operator 时遇到了这个问题,执行 make buildmake test 时报下面这样的错:

STEP: bootstrapping test environment
1.6621765789962418e+09  DEBUG   controller-runtime.test-env     starting control plane

1.6621765802518039e+09  ERROR   controller-runtime.test-env     unable to start the controlplane        {"tries": 0, "error": "timeout waiting for process etcd to start successfully (it may have failed to start, or stopped unexpectedly before becoming ready)"}

看报错信息猜测可能是和 etcd 有关,使用 ps aux | grep etcd 确实可以看到在执行测试时启动了一个 etcd 的进程:

$ ps aux | grep etcd
aneasystone  2609 23.0  0.1 737148 22560 pts/0    Sl   13:34   0:00 /home/aneasystone/.local/share/kubebuilder-envtest/k8s/1.24.1-linux-amd64/etcd --advertise-client-urls=http://127.0.0.1:52467 --data-dir=/tmp/k8s_test_framework_3831360890 --listen-client-urls=http://127.0.0.1:52467 --listen-peer-urls=http://127.0.0.1:52468 --unsafe-no-fsync=true

于是我试着手工运行这个命令,发现 etcd 服务启动时报错了:

2022-09-03 11:42:28.499748 E | etcdserver: publish error: etcdserver: request timed out
2022-09-03 11:42:35.501458 E | etcdserver: publish error: etcdserver: request timed out

使用 etcdctl 也连不上该 etcd 服务。一开始我以为是 kubebuilder 自带的 etcd 文件有问题,于是就自己安装了一个 etcd,直接运行时也是报错,只不过报错信息有点不一样:

panic: invalid page type: 0: 4

goroutine 1 [running]:

github.com/etcd-io/bbolt.(*Cursor).search(0xc00005be18, {0xc00005be70, 0x8, 0x8}, 0xc00005bdb8?)

看报错是位于 etcd-io/bbolt 这个包,BoltDB 是 etcd 使用的内存 KV 数据库。使用 boltpanic: invalid page type 为关键字,很快就在 microsoft/WSL 里找到了一个相关的 Issue:BoltDB panics on cursor search since April update,根据 Issue 里的描述,写了一个 BoltDB 的简单示例:

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/boltdb/bolt"
)

func main() {
    os.Remove("test.db")
    db, err := bolt.Open("test.db", 0600, nil)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    db.Update(func(tx *bolt.Tx) error {
        _, err := tx.CreateBucket([]byte("MyBucket"))
        if err != nil {
            return fmt.Errorf("create bucket: %s", err)
        }
        return err
    })

    db.View(func(tx *bolt.Tx) error {
        b := tx.Bucket([]byte("MyBucket"))
        c := b.Cursor()
        c.Seek([]byte("test"))
        return nil
    })
    os.Remove("test.db")
}

运行代码后也是和上面几乎一模一样的报错:

$ go run main.go
panic: invalid page type: 0: 4

goroutine 1 [running]:
github.com/boltdb/bolt.(*Cursor).search(0xc00005be18, {0xc00005be70, 0x8, 0x8}, 0xc00005bdb8?)

至此大概可以推断这应该是 WSL 的问题,WSL 目前最新版本是 WSL 2,不过要注意的是,根据 Microsoft 官方的升级指南,WSL 2 只支持 Windows 10 Build 18362 之后的版本:

Builds lower than 18362 do not support WSL 2. Use the Windows Update Assistant to update your version of Windows.

打开 Windows 更新,更新完成后重启,问题解决。

4. Operator 示例

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

使用 GitHub Actions 跟踪 GitHub 趋势项目

GitHub Actions 是 GitHub 于 2018 年 10 月推出的一款 CI/CD 服务。一个标准的 CI/CD 流程通常是一个工作流(workflow),工作流中包含了一个或多个作业(job),而每个作业都是由多个执行步骤(step)组成。

GitHub Actions 的创新之处在于它将 CI/CD 中的每个执行步骤划分成一个个原子的操作(action),这些操作可以是编译代码、调用某个接口、执行代码检查或是部署服务等。很显然这些原子操作是可以在不同的 CI/CD 流程中复用的,于是 GitHub 允许开发者将这些操作编写成脚本存在放 GitHub 仓库里,供其他人使用。GitHub 提供了一些 官方的 actions,比如 actions/setup-python 用于初始化 Python 环境,actions/checkout 用于签出某个代码仓库。由于每个 action 都对应一个 GitHub 仓库,所以也可以像下面这样引用 action 的某个分支、某个标签甚至某个提交记录:

actions/setup-node@master  # 指向一个分支
actions/setup-node@v1.0    # 指向一个标签
actions/setup-node@74bc508 # 指向一个 commit

你可以在 GitHub Marketplace 中搜索你想使用的 action,另外,还有一份关于 GitHub Actions 的 awesome 清单 sdras/awesome-actions,也可以找到不少的 action。

GitHub Actions 入门示例

这一节我们将通过一个最简单的入门示例了解 GitHub Actions 的基本概念。首先我们在 GitHub 上创建一个 demo 项目 aneasystone/github-actions-demo(也可以直接使用已有的项目),然后打开 Actions 选项卡:

get-started-with-github-actions.png

我们可以在这里手工创建工作流(workflow),也可以直接使用 GitHub Actions 提供的入门工作流,GitHub Actions 提供的工作流大体分为四种类型:

  • Continuous integration - 包含了各种编程语言的编译、打包、测试等流程
  • Deployment - 支持将应用部署到各种不同的云平台
  • Security - 对仓库进行代码规范检查或安全扫描
  • Automation - 一些自动化脚本

这些工作流的源码都可以在 actions/starter-workflows 这里找到。

GitHub 会自动分析代码并显示出可能适用于你的项目的工作流。由于是示例项目,这里我们直接使用一个最简单的工作流来进行测试,选择 Simple workflow 这个工作流,会在 .github/workflows 目录下创建一个 blank.yml 文件,文件内容如下:

# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the workflow will run
on:
  # Triggers the workflow on push or pull request events but only for the "main" branch
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

  # Allows you to run this workflow manually from the Actions tab
  workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
  # This workflow contains a single job called "build"
  build:
    # The type of runner that the job will run on
    runs-on: ubuntu-latest

    # Steps represent a sequence of tasks that will be executed as part of the job
    steps:
      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
      - uses: actions/checkout@v3

      # Runs a single command using the runners shell
      - name: Run a one-line script
        run: echo Hello, world!

      # Runs a set of commands using the runners shell
      - name: Run a multi-line script
        run: |
          echo Add other actions to build,
          echo test, and deploy your project.

这个工作流没有任何用处,只是使用 echo 命令输出一行 Hello, world! 以及其他几行日志而已。

然后点击 Start commit 按钮提交文件即可:

simple-workflow.png

由于这里我们指定了工作流在 push 的时候触发,所以提交完文件之后,这个工作流应该就开始执行了。重新打开 Actions 选项卡:

all-workflows.png

这里显示了项目中所有的工作流列表,我们可以在一个项目中创建多个工作流。可以看到我们已经成功创建了一个名为 CI 的工作流,并在右侧显示了该工作流的运行情况。点击查看详细信息:

all-workflow-jobs.png

这里是工作流包含的所有作业(job)的执行情况,我们这个示例中只使用了一个名为 build 的作业。点击作业,可以查看作业的执行日志:

all-workflow-job-logs.png

详解 workflow 文件

在上一节中,我们通过在 .github/workflows 目录下新建一个 YAML 文件,创建了一个最简单的 GitHub Actions 工作流。这个 YAML 的文件名可以任意,但文件内容必须符合 GitHub Actions 的工作流程语法。下面是一些基本字段的解释。

name

出现在 GitHub 仓库的 Actions 选项卡中的工作流程名称。如果省略该字段,默认为当前 workflow 的文件名。

on

指定此工作流程的触发器。GitHub 支持多种触发事件,您可以配置工作流程在 GitHub 上发生特定活动时运行、在预定的时间运行,或者在 GitHub 外部的事件发生时运行。参见 官方文档 了解触发工作流程的所有事件。

在示例项目中,我们使用了几个最常用的触发事件。比如当 main 分支有 pushpull_request 时触发:

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

或者开启手工触发工作流:

on:
  workflow_dispatch:

这时会在工作流页面出现一个手工执行的按钮:

run-workflow-manually.png

也可以使用定时任务来触发工作流:

on:
  schedule:
    - cron: "0 2 * * *"

jobs

一个工作流可以包含一个或多个作业,这些作业可以顺序执行或并发执行。下面定义了一个 ID 为 build 的作业:

jobs:
  build:
    ...

jobs.<job-id>.runs-on

为作业指定运行器(runner),运行器可以使用 GitHub 托管的(GitHub-hosted runners),也可以是 自托管的(self-hosted runners)。GitHub 托管的运行器包括 Windows Server、Ubuntu、macOS 等操作系统,下面的例子将作业配置为在最新版本的 Ubuntu Linux 运行器上运行:

runs-on: ubuntu-latest

jobs.<job-id>.steps

作业中运行的所有步骤,步骤可以是一个 Shell 脚本,也可以是一个操作(action)。在我们的示例中一共包含了三个步骤,第一步使用了一个官方的操作 actions/checkout@v3

# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v3

这个操作将代码仓库签出到运行器上,这样你就可以对代码运行脚本或其他操作,比如编译、测试或构建打包等。

第二步,使用 echo 命令输出一句 Hello, world!

# Runs a single command using the runners shell
- name: Run a one-line script
  run: echo Hello, world!

第三步,继续执行多条 echo 命令:

# Runs a set of commands using the runners shell
- name: Run a multi-line script
  run: |
    echo Add other actions to build,
    echo test, and deploy your project.

跟踪 GitHub 趋势项目

学习了 GitHub Actions 的基本知识后,我们就可以开始使用它了。除了常见的 CI/CD 任务,如 自动构建和测试打包和发布部署 等,还可以使用它来做很多有趣的事情。

GitHub 有一个 Trending 页面,可以在这里发现 GitHub 上每天、每周或每月最热门的项目,不过这个页面没有归档功能,无法追溯历史。如果我们能用爬虫每天自动爬取这个页面上的内容,并将结果保存下来,那么查阅起来就更方便了。要实现这个功能,必须满足三个条件:

  1. 能定时执行:可以使用 on:schedule 定时触发 GitHub Actions 工作流;
  2. 爬虫脚本:在工作流中可以执行任意的脚本,另外还可以通过 actions 安装各种语言的环境,比如使用 actions/setup-python 安装 Python 环境,使用 Python 来写爬虫最适合不过;
  3. 能将结果保存下来:GitHub 仓库天生就是一个数据库,可以用来存储数据,我们可以将爬虫爬下来的数据提交并保存到 GitHub 仓库。

可以看到,使用 GitHub Actions 完全可以实现这个功能,这个想法的灵感来自 bonfy/github-trending 项目,不过我在这个项目的基础上做了一些改进,比如将每天爬取的结果合并在同一个文件里,并且对重复的结果进行去重。

首先我们创建一个仓库 aneasystone/github-trending,然后和之前的示例项目一样,在 .github/workflows 目录下创建一个流水线文件,内容如下:

# This workflow will scrap GitHub trending projects daily.

name: Daily Github Trending

on:
  schedule:
    - cron: "0 2 * * *"

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - name: Checkout
      uses: actions/checkout@v2
      
    - name: Set up Python 3.8
      uses: actions/setup-python@v2
      with:
        python-version: 3.8
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        
    - name: Run Scraper
      run: |
        python scraper.py
    # Runs a set of commands using the runners shell
    - name: Push to origin master
      run: |
        echo start push
        git config --global user.name "aneasystone"
        git config --global user.email "aneasystone@gmail.com"
        
        git add -A
        git commit -m $(date '+%Y-%m-%d')
        git push

在这里我们使用了 on.schedule.cron: "0 2 * * *" 来定时触发工作流,这个 cron 表达式需符合 POSIX cron 语法,可以在 crontab guru 页面上对 cron 表达式进行调试。不过要注意的是,这里的时间为 UTC 时间,所以 0 2 * * * 对应的是北京时间 10 点整。

注:在实际运行的时候,我发现工作流并不是每天早上 10 点执行,而是到 11 点才执行,起初我以为是定时任务出现了延迟,但是后来我才意识到,现在正好是夏天,大多数北美洲、欧洲以及部分中东地区都在实施 夏令时,所以他们的时间要比我们早一个小时。

工作流的各个步骤是比较清晰的,首先通过 actions/checkout@v2 签出仓库代码,然后使用 actions/setup-python@v2 安装 Python 环境,然后执行 pip install 安装 Python 依赖。环境准备就绪后,执行 python scraper.py,这就是我们的爬虫脚本,它会将 GitHub Trending 页面的内容爬取下来并更新到 README.md 文件中,我们可以根据参数爬取不同编程语言的项目清单:

languages = ['', 'java', 'python', 'javascript', 'go', 'c', 'c++', 'c#', 'html', 'css', 'unknown']
for lang in languages:
    results = scrape_lang(lang)
    write_markdown(lang, results)

数据爬取成功后,我们在工作流的最后通过 git commit & git push 将代码提交到 GitHub 仓库保存下来。你可以在这里 aneasystone/github-trending 查看完整的代码。

参考

更多

其他示例

结合 GitHub Actions 的自动化功能,我们可以做很多有趣的事情。比如官方文档中还提供了 其他几个示例,用于检测仓库中失效的链接。

另外,阮一峰在他的 入门教程 中介绍了一个示例,用于将 React 应用发布到 GitHub Pages。

在本地运行 GitHub Actions

https://github.com/nektos/act

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

实战 ELK 搭建日志系统

ELKElasticsearch + Logstash + Kibana 的简称。Elasticsearch 是一个基于 Lucene 的分布式全文搜索引擎,提供 RESTful API 进行数据读写;Logstash 是一个收集,处理和转发事件和日志消息的工具;而 Kibana 是 Elasticsearch 的开源数据可视化插件,为查看存储在 Elasticsearch 提供了友好的 Web 界面,并提供了条形图,线条和散点图,饼图和地图等分析工具。

总的来说,Elasticsearch 负责存储数据,Logstash 负责收集日志,并将日志格式化后写入 Elasticsearch,Kibana 提供可视化访问 Elasticsearch 数据的功能。

安装 Elasticsearch

使用下面的 Docker 命令启动一个单机版 Elasticsearch 实例:

> docker run --name es \
  -p 9200:9200 -p 9300:9300 \
  -e ELASTIC_PASSWORD=123456 \
  -d docker.elastic.co/elasticsearch/elasticsearch:8.3.2

从 Elasticsearch 8.0 开始,默认会开启安全特性,我们通过 ELASTIC_PASSWORD 环境变量设置访问密码,如果不设置,Elasticsearch 会在第一次启动时随机生成密码,查看启动日志可以发现类似下面这样的信息:

-------------------------------------------------------------------------------------------------------------------------------------
-> Elasticsearch security features have been automatically configured!
-> Authentication is enabled and cluster connections are encrypted.

->  Password for the elastic user (reset with `bin/elasticsearch-reset-password -u elastic`):
  dV0dN=eiH7CDtoe1IVS0

->  HTTP CA certificate SHA-256 fingerprint:
  4032719061cbafe64d5df5ef29157572a98aff6dae5cab99afb84220799556ff

->  Configure Kibana to use this cluster:
* Run Kibana and click the configuration link in the terminal when Kibana starts.
* Copy the following enrollment token and paste it into Kibana in your browser (valid for the next 30 minutes):
  eyJ2ZXIiOiI4LjMuMiIsImFkciI6WyIxNzIuMTcuMC4zOjkyMDAiXSwiZmdyIjoiNDAzMjcxOTA2MWNiYWZlNjRkNWRmNWVmMjkxNTc1NzJhOThhZmY2ZGFlNWNhYjk5YWZiODQyMjA3OTk1NTZmZiIsImtleSI6InNLUzdCSUlCU1dmOFp0TUg4M0VKOnVTaTRZYURlUk1LMXU3SUtaQ3ZzbmcifQ==

-> Configure other nodes to join this cluster:
* Copy the following enrollment token and start new Elasticsearch nodes with `bin/elasticsearch --enrollment-token <token>` (valid for the next 30 minutes):
  eyJ2ZXIiOiI4LjMuMiIsImFkciI6WyIxNzIuMTcuMC4zOjkyMDAiXSwiZmdyIjoiNDAzMjcxOTA2MWNiYWZlNjRkNWRmNWVmMjkxNTc1NzJhOThhZmY2ZGFlNWNhYjk5YWZiODQyMjA3OTk1NTZmZiIsImtleSI6InJxUzdCSUlCU1dmOFp0TUg4bkg3OmlLMU1tMUM3VFBhV2V1OURGWEFsWHcifQ==

  If you're running in Docker, copy the enrollment token and run:
  `docker run -e "ENROLLMENT_TOKEN=<token>" docker.elastic.co/elasticsearch/elasticsearch:8.3.2`
-------------------------------------------------------------------------------------------------------------------------------------

另外 Elasticsearch 使用了 HTTPS 通信,不过这个证书是不可信的,在浏览器里访问会有不安全的警告,使用 curl 访问时注意使用 -k--insecure 忽略证书校验:

$ curl -X GET -s -k -u elastic:123456 https://localhost:9200 | jq
{
  "name": "2460ab74bdf6",
  "cluster_name": "docker-cluster",
  "cluster_uuid": "Yip76XCuQHq9ncLfzt_I1A",
  "version": {
    "number": "8.3.2",
    "build_type": "docker",
    "build_hash": "8b0b1f23fbebecc3c88e4464319dea8989f374fd",
    "build_date": "2022-07-06T15:15:15.901688194Z",
    "build_snapshot": false,
    "lucene_version": "9.2.0",
    "minimum_wire_compatibility_version": "7.17.0",
    "minimum_index_compatibility_version": "7.0.0"
  },
  "tagline": "You Know, for Search"
}

不过更安全的做法是将证书文件拷贝出来:

$ docker cp es:/usr/share/elasticsearch/config/certs/http_ca.crt .

然后使用证书访问 Elasticsearch:

$ curl --cacert http_ca.crt -u elastic https://localhost:9200

安装 Logstash

使用下面的 Docker 命令启动一个最简单的 Logstash 实例:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2 \
  -e 'input { stdin { } } output { stdout {} }'

默认情况下,Logstash 会开启 X-Pack 监控,使用环境变量 XPACK_MONITORING_ENABLED=false 可以禁用它。另外,我们使用了 -e 'input { stdin { } } output { stdout {} }' 参数,表示让 Logstash 从标准输入 stdin 读取输入,并将结果输出到标注输出 stdout

2022/07/17 05:34:19 Setting 'xpack.monitoring.enabled' from environment.
Using bundled JDK: /usr/share/logstash/jdk
OpenJDK 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release.
Sending Logstash logs to /usr/share/logstash/logs which is now configured via log4j2.properties
[2022-07-17T05:34:34,231][INFO ][logstash.runner          ] Log4j configuration path used is: /usr/share/logstash/config/log4j2.properties
[2022-07-17T05:34:34,257][INFO ][logstash.runner          ] Starting Logstash {"logstash.version"=>"8.3.2", "jruby.version"=>"jruby 9.2.20.1 (2.5.8) 2021-11-30 2a2962fbd1 OpenJDK 64-Bit Server VM 11.0.15+10 on 11.0.15+10 +indy +jit [linux-x86_64]"}
[2022-07-17T05:34:34,261][INFO ][logstash.runner          ] JVM bootstrap flags: [-Xms1g, -Xmx1g, -XX:+UseConcMarkSweepGC, -XX:CMSInitiatingOccupancyFraction=75, -XX:+UseCMSInitiatingOccupancyOnly, -Djava.awt.headless=true, -Dfile.encoding=UTF-8, -Djruby.compile.invokedynamic=true, -Djruby.jit.threshold=0, -XX:+HeapDumpOnOutOfMemoryError, -Djava.security.egd=file:/dev/urandom, -Dlog4j2.isThreadContextMapInheritable=true, -Dls.cgroup.cpuacct.path.override=/, -Dls.cgroup.cpu.path.override=/, -Djruby.regexp.interruptible=true, -Djdk.io.File.enableADS=true, --add-opens=java.base/java.security=ALL-UNNAMED, --add-opens=java.base/java.io=ALL-UNNAMED, --add-opens=java.base/java.nio.channels=ALL-UNNAMED, --add-opens=java.base/sun.nio.ch=ALL-UNNAMED, --add-opens=java.management/sun.management=ALL-UNNAMED]
[2022-07-17T05:34:34,317][INFO ][logstash.settings        ] Creating directory {:setting=>"path.queue", :path=>"/usr/share/logstash/data/queue"}
[2022-07-17T05:34:34,347][INFO ][logstash.settings        ] Creating directory {:setting=>"path.dead_letter_queue", :path=>"/usr/share/logstash/data/dead_letter_queue"}
[2022-07-17T05:34:34,917][WARN ][logstash.config.source.multilocal] Ignoring the 'pipelines.yml' file because modules or command line options are specified
[2022-07-17T05:34:34,942][INFO ][logstash.agent           ] No persistent UUID file found. Generating new UUID {:uuid=>"b1e18429-eb7f-4669-9271-0d75fed547c1", :path=>"/usr/share/logstash/data/uuid"}
[2022-07-17T05:34:36,221][INFO ][logstash.agent           ] Successfully started Logstash API endpoint {:port=>9600, :ssl_enabled=>false}
[2022-07-17T05:34:36,474][INFO ][org.reflections.Reflections] Reflections took 67 ms to scan 1 urls, producing 124 keys and 408 values
[2022-07-17T05:34:36,882][INFO ][logstash.javapipeline    ] Pipeline `main` is configured with `pipeline.ecs_compatibility: v8` setting. All plugins in this pipeline will default to `ecs_compatibility => v8` unless explicitly configured otherwise.
[2022-07-17T05:34:36,995][INFO ][logstash.javapipeline    ][main] Starting pipeline {:pipeline_id=>"main", "pipeline.workers"=>2, "pipeline.batch.size"=>125, "pipeline.batch.delay"=>50, "pipeline.max_inflight"=>250, "pipeline.sources"=>["config string"], :thread=>"#<Thread:0x44c28a87 run>"}
[2022-07-17T05:34:37,452][INFO ][logstash.javapipeline    ][main] Pipeline Java execution initialization time {"seconds"=>0.45}
[2022-07-17T05:34:37,521][INFO ][logstash.javapipeline    ][main] Pipeline started {"pipeline.id"=>"main"}
The stdin plugin is now waiting for input:
[2022-07-17T05:34:37,603][INFO ][logstash.agent           ] Pipelines running {:count=>1, :running_pipelines=>[:main], :non_running_pipelines=>[]}

等 Logstash 启动完毕,在控制台随便输入文本,然后回车,就可以看到 Logstash 将其转换为输出结果:

hello world
{
       "message" => "hello world",
      "@version" => "1",
    "@timestamp" => 2022-07-17T05:46:57.976318Z,
          "host" => {
        "hostname" => "6573ef0db968"
    },
         "event" => {
        "original" => "hello world"
    }
}

在上面的例子中,我们使用了 -e 参数来指定 Logstash 的 pipeline 配置,这个参数一般是用来调试 Logstash 的,真实场景下我们会将配置写在配置文件中,默认情况下,Logstash 的 pipeline 配置文件位于 /usr/share/logstash/pipeline/logstash.conf,内容如下:

input {
  beats {
    port => 5044
  }
}

output {
  stdout {
    codec => rubydebug
  }
}

其中 input 表示通过 5044 端口接收从 Filebeat 发送过来的数据,output 表示将结果输出到标准输出 stdout,并指定编码方式为 rubydebug,它会以格式化的 JSON 输出结果。接下来,我们要将我们的输出保存到 Elasticsearch,将配置文件修改如下:

input {
  stdin {
  }
}

output {
  stdout {
    codec => json_lines
  }
  elasticsearch {
    hosts => ["https://172.17.0.4:9200"]
    user => "elastic"
    password => "123456"
    ssl => true
    cacert => "/usr/share/logstash/http_ca.crt"
    index => "logstash-%{+YYYY.MM.dd}"
  }
}

然后重新启动 Logstash:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

其中 http_ca.crt 就是上面我们从 es 容器中复制出来的证书文件。这里有一点要注意的是,hosts 必须是容器里的 IP 地址,这是因为这个证书带有 SAN(Subject Alternative Name),只能通过 localhost 或 容器中的地址来访问 Elasticsearch,如果使用其他地址会报错:

[2022-07-17T07:23:48,228][WARN ][logstash.outputs.elasticsearch][main] Attempted to resurrect connection to dead ES instance, but got an error {:url=>"https://elastic:xxxxxx@192.168.1.35:9200/", :exception=>LogStash::Outputs::ElasticSearch::HttpClient::Pool::HostUnreachableError, :message=>"Elasticsearch Unreachable: [https://192.168.1.35:9200/][Manticore::UnknownException] Certificate for <192.168.1.35> doesn't match any of the subject alternative names: [localhost, 876647d76274, 172.17.0.4, 127.0.0.1]"}

启动完成后,我们在控制台中随便输入文本,文本内容会自动输出到 Elasticsearch:

hello
{"@timestamp":"2022-07-17T07:40:23.556377Z","message":"hello","@version":"1","host":{"hostname":"988f1263897d"},"event":{"original":"hello"}}
world
{"@timestamp":"2022-07-17T07:50:00.327637Z","message":"world","@version":"1","host":{"hostname":"988f1263897d"},"event":{"original":"world"}}
hello world
{"@timestamp":"2022-07-17T07:50:04.285282Z","message":"hello world","@version":"1","host":{"hostname":"988f1263897d"},"event":{"original":"hello world"}}

我们通过 HTTP 接口查看 Elasticsearch 的索引:

$ curl -k -u elastic:123456 https://localhost:9200/_cat/indices
yellow open logstash-2022.07.17 9Anz9bHjSay-GEnANNluBA 1 1 3 0 18.3kb 18.3kb

可以看到自动为我们创建了一个 logstash-2022.07.17 索引,我们可以通过 HTTP 接口检索:

$ curl -s -k -u elastic:123456 https://localhost:9200/_search?q=hello | jq
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 2,
      "relation": "eq"
    },
    "max_score": 0.9808291,
    "hits": [
      {
        "_index": "logstash-2022.07.17",
        "_id": "rGMZC4IBQ5UjjhVY6pAZ",
        "_score": 0.9808291,
        "_source": {
          "@timestamp": "2022-07-17T07:40:23.556377Z",
          "message": "hello",
          "@version": "1",
          "host": {
            "hostname": "988f1263897d"
          },
          "event": {
            "original": "hello"
          }
        }
      },
      {
        "_index": "logstash-2022.07.17",
        "_id": "rmMiC4IBQ5UjjhVYxZBt",
        "_score": 0.39019167,
        "_source": {
          "@timestamp": "2022-07-17T07:50:04.285282Z",
          "message": "hello world",
          "@version": "1",
          "host": {
            "hostname": "988f1263897d"
          },
          "event": {
            "original": "hello world"
          }
        }
      }
    ]
  }
}

安装 Kibana

使用下面的 Docker 命令启动 Kibana 服务:

$ docker run --name kibana \
  -p 5601:5601 \
  -d docker.elastic.co/kibana/kibana:8.3.2

等待 Kibana 启动完成:

[2022-07-17T22:57:20.266+00:00][INFO ][plugins-service] Plugin "cloudSecurityPosture" is disabled.
[2022-07-17T22:57:20.403+00:00][INFO ][http.server.Preboot] http server running at http://0.0.0.0:5601
[2022-07-17T22:57:20.446+00:00][INFO ][plugins-system.preboot] Setting up [1] plugins: [interactiveSetup]
[2022-07-17T22:57:20.448+00:00][INFO ][preboot] "interactiveSetup" plugin is holding setup: Validating Elasticsearch connection configuration…
[2022-07-17T22:57:20.484+00:00][INFO ][root] Holding setup until preboot stage is completed.


i Kibana has not been configured.

Go to http://0.0.0.0:5601/?code=395267 to get started.

启动完成后,在浏览器输入 http://localhost:5601/?code=395267 访问 Kibana,第一次访问 Kibana 时需要配置 Elasticsearch 的 Enrollment token

kibana-enrollment-token.png

这个 Enrollment token 的值可以从 Elasticsearch 的启动日志中找到,也可以使用下面的命令生成:

$ docker exec -it es /usr/share/elasticsearch/bin/elasticsearch-create-enrollment-token -s kibana

注意访问 Kibana 的链接中带有一个 code 参数,这个参数为第一次访问 Kibana 时的验证码,如果不带参数访问,Kibana 会要求你填写:

kibana-verification.png

这个验证码可以从 Kibana 的启动日志中找到,也可以使用下面的命令得到:

$ docker exec -it kibana /usr/share/kibana/bin/kibana-verification-code
Your verification code is:  395 267

填写验证码之后,Kibana 会自动完成 Elasticsearch 的配置,然后进入登录页面:

kibana-login.png

我们输入用户名和密码 elastic/123456 即可进入 Kibana 首页:

kibana-home.png

打开 Discover 页面 http://localhost:5601/app/discover#/

kibana-discover.png

第一次访问需要创建一个数据视图(data view),数据视图的名称必须要和索引相匹配,我们这里填入 logstash-*

kibana-create-data-view.png

然后就可以输入关键字对 Elasticsearch 进行检索了:

kibana-search.png

这里的检索语法被称为 KQL(Kibana Query Language),具体内容可 参考官方文档

配置 Logstash 采集日志文件

在前面的例子中,我们配置了 Logstash 从标准输入获取数据,并转发到标准输出或 Elasticsearch 中。一个完整的 Logstash 配置文件包含三个部分:inputfilteroutput,并且每个部分都是插件:

这一节我们将改用 file 作为输入,从文件系统中采集日志文件内容,并转发到 Elasticsearch。首先修改 logstash.conf 中的 input 配置:

input {
  file {
    type => "log"
    path => ["/app/logs/*.log"]
  }
}

然后重新启动 Logstash 容器,并将日志目录挂载到 /app/logs

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash-file.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -v "/home/aneasystone/logs":/app/logs \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

我们在 logs 目录下写入一点日志:

$ cd ~/logs
$ echo 'hello' > hello.log
$ echo 'hello world' >> hello.log

稍等片刻,就可以看到 Logstash 从日志文件中读取数据了:

{"event":{"original":"hello"},"@timestamp":"2022-07-18T23:48:27.709472Z","message":"hello","type":"log","@version":"1","host":{"name":"48bdb8490d22"},"log":{"file":{"path":"/app/logs/hello.log"}}}
{"event":{"original":"hello world"},"@timestamp":"2022-07-18T23:52:07.345525Z","message":"hello world","type":"log","@version":"1","host":{"name":"48bdb8490d22"},"log":{"file":{"path":"/app/logs/hello.log"}}}

使用 Filebeat 采集日志文件

尽管 Logstash 提供了 file 插件用于采集日志文件,但是一般在生产环境我们很少这样去用,因为 Logstash 相对来说还是太重了,它依赖于 JVM,当配置多个 pipeline 以及 filter 时,Logstash 会非常占内存。而采集日志的工作需要在每一台服务器上运行,我们希望采集日志消耗的资源越少越好。

于是 Filebeat 就出现了。它采用 Go 编写,非常轻量,只专注于采集日志,并将日志转发给 Logstash 甚至直接转发给 Elasticsearch。如下图所示:

efk.jpg

那么有人可能会问,既然可以直接从 Filebeat 将日志转发给 Elasticsearch,那么 Logstash 是不是就没用了?其实这取决于你的使用场景,如果你只是想将原始的日志收集到 Elasticsearch 而不做任何处理,确实可以不用 Logstash,但是如果你要对日志进行过滤和转换处理,Logstash 就很有用了。不过 Filebeat 也提供了 processors 功能,可以对日志做一些简单的转换处理。

下面我们就来实践下这种场景。首先修改 Logstash 配置文件中的 input,让 Logstash 可以接收 Filebeat 的请求:

input {
  beats {
    port => 5044
  }
}

然后重新启动 Logstash 容器,注意将 5044 端口暴露出来:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash-beat.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -p 5044:5044 \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

然后新建一个 Filebeat 的配置文件 filebeat.yml,内容如下:

filebeat.inputs:
- type: log
  paths:
    - /app/logs/*.log

output.logstash:
  hosts: ["192.168.1.35:5044"]

然后使用 Docker 启动 Filebeat 容器:

$ docker run --name filebeat \
  -v "/home/aneasystone/filebeat/filebeat.yml":/usr/share/filebeat/filebeat.yml \
  -v "/home/aneasystone/logs":/app/logs \
  -it --rm docker.elastic.co/beats/filebeat:8.3.2

这时只要我们向 logs 目录写入一点日志:

$ echo "This is a filebeat log" >> filebeat.log

Logstash 就可以收到 Filebeat 采集过来的日志了:

{"message":"This is a filebeat log","host":{"name":"8a7849b5c331"},"log":{"offset":0,"file":{"path":"/app/logs/filebeat.log"}},"event":{"original":"This is a filebeat log"},"input":{"type":"log"},"ecs":{"version":"8.0.0"},"@version":"1","agent":{"id":"3bd9b289-599f-4899-945a-94692bdaa690","name":"8a7849b5c331","ephemeral_id":"268a3170-0feb-4a86-ad96-7cce6a9643ec","version":"8.3.2","type":"filebeat"},"tags":["beats_input_codec_plain_applied"],"@timestamp":"2022-07-19T23:41:22.255Z"}

Filebeat 除了可以将日志推送给 Logstash,还支持很多其他的 output 配置,比如 Elasticsearch、Kafka、Redis 或者写入文件等等。下面是将日志推送给 Elasticsearch 的例子,具体参数请参考 Configure the Elasticsearch output

output.elasticsearch:
  hosts: ["https://localhost:9200"]
  username: "username"
  password: "password" 
  ssl:
    enabled: true
    ca_trusted_fingerprint: "xxx"

日志格式转换

在实际的使用场景中,我们往往要对各个地方采集来的日志做一些格式转换,而不是直接将原始的日志写入 Elasticsearch 中,因为结构化的日志更方便检索和统计。比如在采集 Nginx 的 access log 时,原始的日志内容如下:

172.17.0.1 - - [20/Jul/2022:23:34:23 +0000] "GET / HTTP/1.1" 200 615 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" "-"
172.17.0.1 - - [20/Jul/2022:23:34:23 +0000] "GET /favicon.ico HTTP/1.1" 404 555 "http://localhost/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" "-"

我们查看 Nginx 配置文件中的 log_format 可以知道,其实 access log 中的每一行都是由 remote_addrremote_usertime_localrequest 等字段构成的:

http {
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;
}

下面我们就通过 Logstash 的 filter 插件Filebeat 的 processors 功能 来将这个日志转换为结构化的字段。

Logstash 的 filter 插件

Logstash 自带大量的 filter 插件用于对日志做各种各样的处理,如果要从任意文本中抽取出结构化的字段,其中最常用的 filter 插件就是 DissectGrok,其中 Dissect 使用固定的分割符来提取字段,没有使用正则表达式,所以处理速度非常快,不过它只对固定格式的日志有效,对于不固定的文本格式就无能为力了。而 Grok 要强大的多,它使用正则表达式,几乎可以处理任意文本。

这里我们将使用 Grok 来处理 Nginx 的 access log。

Grok 通过一堆的模式来匹配你的日志,一个 Grok 模式的语法如下:

%{SYNTAX:SEMANTIC}

其中,SYNTAX 为模式名称,比如 NUMBER 表示数字,IP 表示 IP 地址,等等,Grok 内置了很多可以直接使用的模式,这里有一份完整列表SEMANTIC 为匹配模式的文本创建一个唯一标识。比如我们有下面这样一行日志:

55.3.244.1 GET /index.html 15824 0.043

可以使用下面的 Grok 模式来匹配:

%{IP:client} %{WORD:method} %{URIPATHPARAM:request} %{NUMBER:bytes} %{NUMBER:duration}

Logstash 配置类似如下:

filter {
  grok {
    match => { "message" => "%{IP:client} %{WORD:method} %{URIPATHPARAM:request} %{NUMBER:bytes} %{NUMBER:duration}" }
  }
}

其中 match => { "message" => "..." } 表示处理的是 message 字段。处理得到的结构化结果如下:

{
  "client": "55.3.244.1",
  "method": "GET",
  "request": "/index.html",
  "bytes": "15824",
  "duration": "0.043"
}

你可以使用 Grok DebuggerGrok Constructor 对 Grok 模式在线进行调试。

自定义 Grok 模式

另外,Logstash 内置的 Grok 模式可能不能满足你的需要,这时,你也可以自定义 Grok 模式。Grok 是基于正则表达式库 Oniguruma 实现的,所以我们可以使用 Oniguruma 的语法 来创建自定义的 Grok 模式。

自定义 Grok 模式的方式有以下三种:

  • 第一种是直接使用正则来定义 Grok 模式
filter {
  grok {
    match => { "message" => "(?<queue_id>[0-9A-F]{10,11})" }
  }
}
  • 第二种是将 Grok 模式定义在模式文件中,比如创建一个名为 patterns 的目录,然后在目录下新建一个文件(文件名任意),文件内容如下:
# commnets
POSTFIX_QUEUEID [0-9A-F]{10,11}

然后,在 Logstash 配置文件中,通过配置参数 patterns_dir 指定刚刚创建的那个模式目录,这样就可以和内置 Grok 模式一样的使用我们自定义的模式了:

filter {
  grok {
    patterns_dir => ["./patterns"]
    match => { "message" => "%{POSTFIX_QUEUEID:queue_id}" }
  }
}
  • 第三种是将 Grok 模式定义在 pattern_definitions 参数中,这种定义方式非常方便,不过这个模式只能在当前 grok 配置块内生效
filter {
  grok {
    pattern_definitions => {
      "POSTFIX_QUEUEID" => "[0-9A-F]{10,11}"
    }
    match => { "message" => "%{POSTFIX_QUEUEID:queue_id}" }
  }
}

处理 Nginx access log

回到上面一开始的问题,我们希望将 Nginx access log 转换为结构化的格式。学习了 Grok 模式的知识之后,我们完全可以自己写出匹配 Nginx access log 的 Grok 模式,不过这样做有点繁琐。实际上,Logstash 已经内置了很多常用的系统日志格式,比如:Apache httpd、Redis、Mongo、Java、Maven 等,参见这里

虽然没有专门的 Nginx 格式,但是 Nginx 默认的 access log 是符合 Apache httpd 日志规范的,只要你在 Nginx 配置文件中没有随便修改 log_format 配置,我们都可以直接使用 Apache httpd 的 Grok 模式来匹配 Nginx 的日志。

Apache httpd 的 Grok 模式 定义如下:

# Log formats
HTTPD_COMMONLOG %{IPORHOST:clientip} %{HTTPDUSER:ident} %{HTTPDUSER:auth} \[%{HTTPDATE:timestamp}\] "(?:%{WORD:verb} %{NOTSPACE:request}(?: HTTP/%{NUMBER:httpversion})?|%{DATA:rawrequest})" (?:-|%{NUMBER:response}) (?:-|%{NUMBER:bytes})
HTTPD_COMBINEDLOG %{HTTPD_COMMONLOG} %{QS:referrer} %{QS:agent}

# Deprecated
COMMONAPACHELOG %{HTTPD_COMMONLOG}
COMBINEDAPACHELOG %{HTTPD_COMBINEDLOG}

其中 HTTPD_COMBINEDLOG 就是 Apache httpd 的日志格式,在老的版本中通常使用 COMBINEDAPACHELOGGrok Debugger 就还是使用这个老的格式。

对比发现,Nginx 的日志比 Apache httpd 的日志就多了一个 $http_x_forwarded_for 字段,所以我们可以通过下面的配置来处理 Nginx access log:

filter {
  grok {
    match => { "message" => "%{HTTPD_COMBINEDLOG} %{QS:x_forwarded_for}" }
  }
}

Filebeat 的 processors 功能

Filebeat 提供了 很多的 processors 可以对日志进行一些简单的处理,比如 drop_event 用于过滤日志,convert 用于转换数据类型,rename 用于重命名字段等。但是 Filebeat 的定位是轻量级日志采集工具,最大的理念在于 轻量,所以只能做些简单的处理,上面所说的 Logstash 的 Grok 就不支持。

如果你的日志是固定格式的,可以使用 dissect 处理器 来进行处理,下面是一个 dissect 配置的例子:

processors:
  - dissect:
      tokenizer: '"%{pid|integer} - %{name} - %{status}"'
      field: "message"
      target_prefix: "service"

其中 tokenizer 用于定义日志的格式,上面的配置表示从日志中提取 pidnamestatus 三个字段,target_prefix 表示将这三个字段放在 service 下面,默认情况是放在 dissect 下面。

这个处理器可以处理下面这种格式的日志:

"321 - App01 - WebServer is starting"
"321 - App01 - WebServer is up and running"
"321 - App01 - WebServer is scaling 2 pods"
"789 - App02 - Database is will be restarted in 5 minutes"
"789 - App02 - Database is up and running"
"789 - App02 - Database is refreshing tables"

处理之后的日志格式如下:

{
  "@timestamp": "2022-07-23T01:01:54.310Z",
  "@metadata": {
    "beat": "filebeat",
    "type": "_doc",
    "version": "8.3.2"
  },
  "log": {
    "offset": 0,
    "file": {
      "path": ""
    }
  },
  "message": "\"789 - App02 - Database is will be restarted in 5 minutes\"",
  "input": {
    "type": "stdin"
  },
  "host": {
    "name": "04bd0b21796b"
  },
  "agent": {
    "name": "04bd0b21796b",
    "type": "filebeat",
    "version": "8.3.2",
    "ephemeral_id": "64187690-291c-4647-b935-93c249e85d29",
    "id": "43a89e87-0d74-4053-8301-0e6e73a00e77"
  },
  "ecs": {
    "version": "8.0.0"
  },
  "service": {
    "name": "App02",
    "status": "Database is will be restarted in 5 minutes",
    "pid": 789
  }
}

使用 script 处理器

虽然 Filebeat 不支持 Grok 处理器,但是它也提供了一种动态扩展的解决方案,那就是 script 处理器。在 script 处理器里,我们需要定义这样一个 process 方法:

function process(event) {
    // Put your codes here
}

Filebeat 在处理日志时,会将每一行日志都转换为一个 event 对象,然后调用这个 process 方法进行处理,event 对象提供了下面这些 API 用于处理日志:

  • Get(string)
  • Put(string, value)
  • Rename(string, string)
  • Delete(string)
  • Cancel()
  • Tag(string)
  • AppendTo(string, string)

具体的使用方法可以参考官方文档。下面我们写一个简单的 script 处理器来处理上面例子中的日志格式:

processors:
  - script:
      lang: javascript
      source: >
        function process(event) {
            var message = event.Get('message');
            message = message.substring(1, message.length-1);
            var s = message.split(' - ');
            event.Put('service.pid', s[0]);
            event.Put('service.name', s[1]);
            event.Put('service.status', s[2]);
        }

处理之后的日志格式和上面使用 dissect 处理器是一样的。

我们知道 Grok 是基于正则表达式来处理日志的,当然也可以在 Javascript 脚本中使用正则表达式:

processors:
  - script:
      lang: javascript
      source: >
        function process(event) {
            var message = event.Get('message');
            var match = message.match(/"(.+) - (.+) - (.+)"/);
            event.Put('service.pid', match[1]);
            event.Put('service.name', match[2]);
            event.Put('service.status', match[3]);
        }

不过要注意的是,Filebeat 使用 dop251/goja 这个库来解析 Javascript 脚本,目前只支持 ECMA 5.1 的语法规范(也就是 ECMAScript 2009),如果使用了最新的 Javascript 语法,可能会导致 Filebeat 异常退出。

比如我们使用正则表达式的命名捕获分组(named capturing groups)来优化上面的代码:

processors:
  - script:
      lang: javascript
      source: >
        function process(event) {
            var message = event.Get('message');
            var match = message.match(/"(?<pid>.+) - (?<name>.+) - (?<status>.+)"/);
            event.Put('service', match.groups);
        }

Filebeat 直接启动就报错了:

Exiting: error initializing processors: SyntaxError: Invalid regular expression (re2): "(?<pid>[^\r\n]+) - (?<name>[^\r\n]+) - (?<status>[^\r\n]+)" (error parsing regexp: invalid or unsupported Perl syntax: `(?<`) at 3:31

这是因为命名捕获分组这个特性,是在 ECMA 9(也就是 ECMAScript 2018)中才引入的,所以在 script 处理器中编写 Javascript 代码时需要特别注意语法的版本问题。

使用 Elasticsearch 的 Ingest pipelines 功能

如果你的 Filebeat 采集的日志是直接推送给 Elasticsearch 的,那么还可以使用 Elasticsearch 的 Ingest pipelines 功能 对日志进行处理。Ingest pipelines 可以让数据在进入 Elasticsearch 索引之前进行转换,它通过一系列的 processors 对接收到的数据进行预处理,这和 Logstash 的 filter 或 Filebeat 的 processors 功能几乎是一样的。

Ingest pipelines 是支持 Grok 处理器的,所以处理 Nginx 日志就非常简单了,可以参考上面 Logstash 的配置,直接使用 Grok 来处理。首先我们需要在 Elasticsearch 里创建一个 Ingest pipeline。你可以参考 这里 通过 Elasticsearch 的接口来创建,也可以在 Kibana 通过可视化界面进行配置,这里我们选择 Kibana 来创建 Ingest pipeline。

首先访问 Kibana 页面,进入 Stack Management -> Ingest Pipelines

kibana-ingest-pipelines.png

点击 Create pipeline 进入创建页面:

kibana-create-pipeline.png

填写 pipeline 的名称和描述,然后点击 Add a processor 添加一个处理器:

kibana-add-a-processor.png

我们在 Processor 下拉列表中选择 Grok,字段 Field 中填写 message,然后在 Patterns 里填写 Grok 表达式:

%{HTTPD_COMMONLOG} %{QS:referrer} %{QS:http_user_agent} %{QS:x_forwarded_for}

注意这里不能填上面配置 Logstash 那个 Grok 表达式,%{HTTPD_COMBINEDLOG} %{QS:x_forwarded_for},因为 HTTPD_COMBINEDLOG 里包含了一个 agent 字段和 Filebeat 采集的日志里的 agent 字段冲突了,所以这里我们将其重新命名为 http_user_agent

最后点击 Add 完成添加。

创建 Ingest pipeline 之后,可以通过 Kibana 提供的 Test pipeline 功能对文档进行测试:

kibana-test-pipeline.png

我们可以填入一个测试文档:

[
  {
    "_source": {
      "message": "172.17.0.1 - - [20/Jul/2022:23:34:23 +0000] \"GET /favicon.ico HTTP/1.1\" 404 555 \"http://localhost/\" \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36\" \"-\""
    }
  }
]

然后点击 Run the pipeline,就可以得到处理后的结果。

至此,对 Elasticsearch 的配置就完成了。接下来我们配置 Filebeat,将日志直接采集到 Elasticsearch:

output.elasticsearch:
  hosts: ["https://172.17.0.3:9200"]
  username: "elastic"
  password: "123456"
  pipeline: "nginx-accesslog-pipeline"
  ssl:
    enabled: true
    ca_trusted_fingerprint: "6E597C77AB87235D381A82D961C46F2AEEC16A64C2042BF30925E097E0292069"

其中 pipeline 就是我们上面创建的 Ingest pipeline,日志推送到 Elasticsearch 之后会经过该 pipeline 处理后再存入索引,可以使用 pipelines 配置多个 pipeline,默认存入的索引为 filebeat-%{[agent.version]}-%{+yyyy.MM.dd},可以通过 index 配置进行修改,不过注意的是,index 修改之后,要记得 同时修改 setup.template 配置

另外,ca_trusted_fingerprint 表示证书的指纹信息,我们可以从 http_ca.crt 文件中得到:

$ openssl x509 -fingerprint -sha256 -in http_ca.crt
SHA256 Fingerprint=6E:59:7C:77:AB:87:23:5D:38:1A:82:D9:61:C4:6F:2A:EE:C1:6A:64:C2:04:2B:F3:09:25:E0:97:E0:29:20:69
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----

Kibana -> Discover 查询采集后的日志,确认我们配置的字段已经生效:

kibana-discover-filebeat.png

另外,为了简化 Ingest pipeline 的配置,Filebeat 还内置了 大量的 Modules。一个 Module 包括了下面这些配置:

  • Filebeat 的 input 配置,已经将常用组件的默认日志路径配置好了
  • Elasticsearch 的 Ingest pipeline 配置,用于对日志进行处理
  • 定义了各个字段的类型和描述
  • 自带一个默认的 Kibana 面板

通过这种方式,常用组件的日志都可以直接通过内置的 Modules 来配置,比如采集 Nginx 的日志可以参考 Nginx module 页面。

参考

  1. Install Elasticsearch with Docker
  2. Running Logstash on Docker
  3. Install Kibana with Docker
  4. ELK6.0部署:Elasticsearch+Logstash+Kibana搭建分布式日志平台
  5. Filebeat vs. Logstash: The Evolution of a Log Shipper
  6. https://blog.csdn.net/mawming/article/details/78344939
  7. https://www.feiyiblog.com/2020/03/06/ELK%E6%97%A5%E5%BF%97%E5%88%86%E6%9E%90%E7%B3%BB%E7%BB%9F/
  8. 如何在 Filebeat 端进行日志处理
  9. Elastic:开发者上手指南

更多

在 WSL Ubuntu 中访问 Docker Desktop

在 WSL Ubuntu 中安装 docker 客户端:

$ curl -O https://download.docker.com/linux/static/stable/x86_64/docker-20.10.9.tgz
$ sudo tar xzvf docker-20.10.9.tgz --strip=1 -C /usr/local/bin docker/docker

然后确保 Docker Desktop 开启了 2375 端口:

tcp-2375.png

设置环境变量 DOCKER_HOST

$ export DOCKER_HOST=tcp://localhost:2375

这样就可以在 WSL Ubuntu 中访问 Docker Desktop 了:

$ docker ps

如果想每次打开 WSL Ubuntu 时,环境变量都生效,可以将上面的 export 命令加到 ~/.profile 文件中。

另外有一点要注意的是,使用这种方式在 WSL Ubuntu 下运行 docker -v 挂载文件时,需要使用真实的 Windows 下的路径,而不是 Linux 下的路径。譬如下面的命令:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "/home/aneasystone/logstash/pipeline/logstash.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

要改成这样:

$ docker run --name logstash \
  -e XPACK_MONITORING_ENABLED=false \
  -v "C:/Users/aneasystone/AppData/Local/Packages/CanonicalGroupLimited.Ubuntu18.04LTS_79rhkp1fndgsc/LocalState/rootfs/home/aneasystone/logstash/pipeline/logstash.conf":/usr/share/logstash/pipeline/logstash.conf \
  -v "C:/Users/aneasystone/AppData/Local/Packages/CanonicalGroupLimited.Ubuntu18.04LTS_79rhkp1fndgsc/LocalState/rootfs/home/aneasystone/logstash/http_ca.crt":/usr/share/logstash/http_ca.crt \
  -it --rm docker.elastic.co/logstash/logstash:8.3.2

其中,C:/Users/aneasystone/AppData/Local/Packages/CanonicalGroupLimited.Ubuntu18.04LTS_79rhkp1fndgsc/LocalState/rootfs 是 WSL Ubuntu 在 Windows 下的根路径。

Elastic Beats

除了 Filebeat,Elastic 还提供了其他一些轻量型的数据采集器:

使用 Logback 对接 Logstash

https://github.com/logfellow/logstash-logback-encoder

其他学习资料

  1. Logstash 最佳实践
  2. Elasticsearch Guide
  3. Logstash Guide
  4. Filebeat Guide
  5. Kibana Guide
扫描二维码,在手机上阅读!

Kubernetes 使用小记

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

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

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

使用 Minikube 创建集群

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

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

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

  • Node

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

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

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

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

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

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

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

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

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

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

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

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

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

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

使用 kubectl 创建 Deployment

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

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

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

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

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

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

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

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

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

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

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

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

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

$ kubectl proxy
Starting to serve on 127.0.0.1:8001

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

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

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

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

查看 Pod 和工作节点

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

使用 Service 暴露你的应用

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

Service 有如下几种类型:

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

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

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

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

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

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

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

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

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

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

从集群外访问服务:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

使用新标签查询 Pod 列表:

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

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

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

确认下 Service 已经删除:

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

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

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

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

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

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

运行应用程序的多个实例

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

执行滚动更新

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

参考

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

更多

了解 Kubernetes 基本概念

Cluster

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

Master

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

Node

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

Pod

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

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

Controller

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

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

Service

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

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

Namespace

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

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

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

Kubernetes 架构

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

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

components.png

Kubernetes 其他教程

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

Kubernetes 任务

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

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