Eisen's Blog

© 2024. All rights reserved.

Kuberhealthy 一个还不错的健康检查 operator

2021 March-01

几年前 k8s 就是 openbayes 部署的基础平台了,它算是一个相当不错的 PaaS 了。这里介绍一个最近发现的做 healthcheck 的 operator(我不知道这东西用中文怎么说)。

需求

当我们的子系统越来越多的时候,我们希望每个系统有一些监控用于检测其核心的功能是否跑起来了,并希望知晓每个服务的宕机事件。uptimerobot 就是在做类似的事情。我们可以给它一个 http 访问地址,当访问成功的之后就标记为「健康」,当访问失败的时候就标记为「不健康」。这个检测在指定的周期执行,日积月累就能知道一个服务的稳定情况了。

不过以上所述的健康检查就像是一个 curl https://targeturl 似乎在假设所有的子系统都需要有一个可以方便访问的 http path。不过有些时候,我们会需要一些稍微复杂的健康检查:

  • 希望知道一些非 http 请求访问的服务是否健康
  • 希望知道一些以灵活的请求内容访问的服务是否健康
  • 希望有一些端到端的健康检查,以联通多个系统

这里就推荐一下 kuberhealthy 这个项目。首先它是一个 kubernetes 的 operator 那么只有你在使用 k8s 的时候这个东西才算是有意义的。它有两个我很看中的地方:

  1. kuberhealthy 提供了一个健康检查的框架,用于让你构建任意形式的健康检查,并且这个框架我觉得很清晰,构建起来也很容易,理解起来一点都不晦涩
  2. 它已经包含了不少预置健康检查了,尤其是与 k8s 集成的健康检查,算是做到了一定程度的开箱即用

几个不错的预置健康检查

  1. pod-restarts-check 用于检查是否存在 pod 在不断的重启,可以快速的发现是不是有服务出现了些导致其不断重启的行为。
  2. pod-status-check 用于检查是否存在 pod 一直处于失败的状态。
  3. storage-check 用于判断是否可以使用某一个 storageclass 成功创建 pvc。每个集群的分布式存储的健康基本就是各种服务不出问题的基石,即使发现存储出现问题太重要了。

更多的可以在这里找到。

自定义 healthcheck

文档 里做了介绍,简单的说就是这样一个东西:

  • 做一件事情去检查你想要测试的东西是否「健康」,总之这个行为可以很灵活,你想怎么测试就怎么测试
  • 如果健康就把 {"OK": true} 以 POST 发送到 http://$KH_REPORTING_URL
  • 如果不健康就发以下的内容,其中 Errors 自然就是具体的出错原因了。
{
  "Errors": [
    "Error 1 here",
    "Error 2 here"
  ],
  "OK": false
}

这里我写了一个用来测试 openbayes 的服务是否可用的端到端的健康检查,它主要做如下的事情:

  1. 用 openbayes 的命令行工具登录到预置的账号
  2. 创建一个脚步任务提交到 openbayes
  3. 如果任务提交成功则是「健康」的
  4. 否则系统就是「不健康」的

具体的 crd 定义如下:

apiVersion: comcast.github.io/v1
kind: KuberhealthyCheck
metadata:
  name: openbayes-gear-check
spec:
  runInterval: 6h # 指定多久时间跑一次
  timeout: 5m # 指定执行的最长时间
  podSpec:
    containers:
    - env: # 自定义的环境变量
        - name: OPENBAYES_TOKEN
          value: <THE TOKEN>
        - name: ENTRYPOINT
          value: <THE ENTRYPOINT>
        - name: RUNTIME
          value: pytorch-1.7.0
        - name: RESOURCE
          value: titan_rtx
      image: xxxx # 镜像
      name: main

容器的 Dockerfile 如下:

FROM ubuntu

RUN apt update
RUN apt install wget curl git -y
RUN wget https://gitee.com/openbayes/bayes-releases/attach_files/606636/download/bayes_amd64.deb
RUN dpkg -i bayes_amd64.deb
WORKDIR /empty
COPY run.sh .
CMD ["/bin/bash", "run.sh"]

只做了两件事:

  1. 下载 bayes 命令行工具
  2. 执行 run.sh 脚本

其中 run.sh 如下:

set -e # 任意一行错了就不再继续执行了

bayes upgrade # 更新命令行工具到最新
bayes switch test -e $ENTRYPOINT # 切换 bayes 命令行的上下文
bayes login $OPENBAYES_TOKEN # 登录
bayes gear init healthcheck -m '自动测试项目' # 创建项目
bayes gear run task --env $RUNTIME --resource $RESOURCE -f -- echo 123 # 提交 Python 脚本
curl --location --request POST $KH_REPORTING_URL \
--header 'Content-Type: application/json' \
--data-raw '{ "OK": true }' # 提交成功就告知 kuberhealthy 这次检查是成功的

下面是执行的样子,可以看到这个任务就像是 crd 设置的那个样子,每 6 个小时跑一次。

openbayes healthcheck 的执行记录
openbayes healthcheck 的执行记录

与 prometheus / grafana 集成

首先 kuberhealthy 自己有一个汇总页面,可以通过 kubectl port-forward svc/kuberhealthy 8888:80 类似的方式去访问:

{
  "OK": true,
  "Errors": [],
  "CheckDetails": {
    "kuberhealthy/dns-status-internal": {
      "OK": true,
      "Errors": [],
      "RunDuration": "3.359431829s",
      "Namespace": "kuberhealthy",
      "LastRun": "2021-03-01T06:08:09.35793049Z",
      "AuthoritativePod": "kuberhealthy-84bf9fd647-2x72f",
      "uuid": "1d309ed7-4b7a-4fd5-9706-d04f53c9ed30"
    },
    "kuberhealthy/namespace-pod-check": {
      "OK": true,
      "Errors": [],
      "RunDuration": "13.589526741s",
      "Namespace": "kuberhealthy",
      "LastRun": "2021-03-01T05:50:19.584075218Z",
      "AuthoritativePod": "kuberhealthy-84bf9fd647-2x72f",
      "uuid": "49f7bef3-f5d4-4e25-bf06-acd260d52e27"
    },
    "kuberhealthy/openbayes-gear-check": {
      "OK": true,
      "Errors": null,
      "RunDuration": "1m8.486351014s",
      "Namespace": "kuberhealthy",
      "LastRun": "2021-03-01T00:51:14.477191396Z",
      "AuthoritativePod": "kuberhealthy-84bf9fd647-2x72f",
      "uuid": "c13aea0d-52ca-4777-a6e0-551d0c59962a"
    },
    "kuberhealthy/openbayes-random-image-serving-check": {
      "OK": true,
      "Errors": null,
      "RunDuration": "9.008124976s",
      "Namespace": "kuberhealthy",
      "LastRun": "2021-03-01T06:08:15.000563842Z",
      "AuthoritativePod": "kuberhealthy-84bf9fd647-2x72f",
      "uuid": "eb1341d3-9bbf-44ab-9348-76715464df6b"
    },
    "kuberhealthy/pod-restarts": {
      "OK": true,
      "Errors": null,
      "RunDuration": "2.903617967s",
      "Namespace": "kuberhealthy",
      "LastRun": "2021-03-01T06:05:09.40445213Z",
      "AuthoritativePod": "kuberhealthy-84bf9fd647-2x72f",
      "uuid": "4cf2c122-95ac-4902-8ecf-ee484a54c475"
    },
    "kuberhealthy/pod-status": {
      "OK": true,
      "Errors": [],
      "RunDuration": "2.845955825s",
      "Namespace": "kuberhealthy",
      "LastRun": "2021-03-01T06:05:08.84049994Z",
      "AuthoritativePod": "kuberhealthy-84bf9fd647-2x72f",
      "uuid": "77af9108-720b-4ad4-b8cc-2fe81e751d8d"
    }
  },
  "JobDetails": {},
  "CurrentMaster": "kuberhealthy-84bf9fd647-2x72f"
}

但是谁也没空去看这么个 json 的,还是需要和 prometheus 这样的东西集成才行:

    scrape_configs:
      ...
      - job_name: kuberhealthy
        scrape_interval: 1m
        honor_labels: true
        metrics_path: /metrics
        static_configs:
          - targets:
            - kuberhealthy.kuberhealthy

然后就可以在 grafana 那边去设置具体的展示了,这里我有一个比较简单的:

grafana 的示例
grafana 的示例

具体的集成文档见K8s-KPIs-with-Kuberhealthy.md

遇到的坑

  1. kuberhealthy 的 service account 需要好好制定,对于跨 namespace 的东西,需要给 clusterRole 才可以
  2. kuberhealthy 在每次有新的 KuberhealthyCheck 创建的时候似乎都会刷新它自己的周期计时,导致一些数个小时才需要跑一次的检查会立即跑一次,这个行为对于某些场景可能无法接受

REST vs GraphQL

2021 February-20

考虑到目前 REST API 层和前端对接起来不是那么顺滑,于是有去找解决方案了,找来找去感觉也就是切 GraphQL 了,这次又来看 GraphQL 感觉它比上次看要香了。好好总觉了一些东西出来。

为什么之前对 GraphQL 不感兴趣

之前有注意到 GraphQL,毕竟也都好几年了呢,但当时一方面是没有对它有比较充分的理解,没有真切的感受到它的好处,另一方面是其存在诸多对于 REST 的一些误解。

认为 REST 的资源是独立的

这是我从一个介绍 GraphQL 的材料里找到的例子,例子中提到如果用户想要获取一个 book 列表,每个 book 中还要包含 author 的信息,就很容易出现 N+1 个请求的问题,效率非常低下。如果这也是为什么要有 GraphQL 的原因之一的话,那其实没必要有 GraphQL...

REST 也没有这么死板吧。实际上在返回的每一个 book 中包含 author 信息是非常自然的事情:

GET /books

{
  "data": [
    {
      "id": "1",
      "title": "how to graphql",
      "description": "bla",
      "author": {
        "id": "x",
        "name": "abc",
        "avatar": "http://avatar.com"
      }
    }
    ...
  ]
}

自我一篇很古老的文章里就提到了,GET 请求获取的是「读模型」,应该按照调用方的需求合理的展示相关的数据,以方便调用方的使用。如果像这里说的方法就是大家使用 REST 的方式的话,我觉得前端早就疯了。实际上,这种灵活的聚合展示早就是家常便饭了呢,当然,从标准化的角度来说,这却是也是一个巨大的问题。

过度担忧 overfetching

TolerantReader 有提到,如果你对于一个请求只关心其一部分信息,那就只处理那部分就好,至于其他的字段,请自行忽略。对于这些额外的字段所带来的额外的数据其实是完全不需要担心的:

  1. REST 请求所获取的资源通常都是区分 data 和 data-item 的。
    • data 是一个全量的信息,比如上午提到的一个完整的 book 信息,包含其详细的描述、目录、甚至热门评论,其所拥有的具体字段完全是和调用方探讨确认的,通常用于详情页面的展示
    • data-item 通常是一个资源的 summary 通常用于列表的展示
    这两种类型的区分本来就考虑了 overfetching 的问题:有些字段字段获取成本比较高,在列表里获取很容易产生 N+1 的问题,因此就只在 data 中出现,而不会出现在 data-item 中。在有了这个原则之后,额外那些字段其实不会对性能有什么非常大的影响。
  2. 每次返回的结构一致更容易做缓存,一次获取长期拥有,其实没那么大成本

目前 REST 的一些问题

不过 REST 也不是没有问题,在 REST 的一些实践心得 也做了一些吐槽。总结来说就是这么几点:

  1. 也有类似于 jsonapi 的东西出现,但不算公认的标准,也没有形成什么很好的支持
  2. 没有大厂支持,依靠社区进展比较缓慢,OpenAPI 的众多工具都有奇奇怪怪的小问题
  3. 对 websocket 没有很好的支持
  4. 没有考虑 HATEOAS 的弊端,这里我直接意译 A REST View of GraphQL 的观点,讲的太好了:

HATEOAS 在用户是最终用户的时候比较有意义,可以支持用户像是在浏览器里面做探索,每个页面有链接指引用户。即使页面发生了变化,只要链接都在就能够支持用户。

可惜对于 API Client 来说,只需要一次性去调用具体某一个接口的,如果每次都从根源去探索 API 效率是非常低下的。这种情况下类似于 OpenAPI 所生成的 SDK 是更方便的。

从目前的交互模式来看,前端就是这么一个 API Client 而已,HATEOAS 对其并没有什么意义。任何 API schema 的修改都可能导致 API Client 的崩溃。

采用 GraphQL 的一些好处

  1. Graph 的 metal model 非常适合做「读」模型的标准化
  2. 强大的生态,首先是 facebook 提出的,后面相当多的公司都有跟进(Github、Airbnb、Netflix)
  3. 工具链比较健全:
    1. GraphiQL, GraphQL Playground 这样类似于 postman 的测试调用工具
    2. Apollo 这样做前后端继承框架开发的公司,在前后端都做的不错
    3. 对微服务的支持,Apollo Federation
    4. schema-registry 甚至有这种 schema 变更追溯的东西,REST 这边想都不敢想
  4. subscription 的概念,将长链接的情况也考虑在内了

整体来说,我觉得 GraphQL 是一个工具更健全,规范更完善,生命力更持久的体系。却是克服了 rest 中的一些问题,后面会开始尝试用 GraphQL 对原有 API 做一层封装看看效果。

参考资料

  1. A REST View of GraphQL
  2. Shipping 'Belonging' with GraphQL & Apollo at Airbnb (Adam Neary)
  3. GraphQL: The Mental Model — Dhaivat Pandya
  4. Apollo GraphQL
  5. Apollo Federation
  6. schema-registry

REST 的一些实践心得

2021 February-16

这篇文章是强行插播过来的,本来是在做 GraphQL 的调研,但是发现其实 REST 这边的很多进展根本就没有说啊,那后面比较的时候很多东西就有点说不清楚了呢。所以就介绍下目前 REST API 开发过程中引入的一些不错的实践,重点是如何 OpenAPI 以及其工具链完善开发流程,降低沟通成本。

TL;DR

就是标题了呢,绝大部分是围绕 openapi 以及其工具链介绍具体解决了什么问题。

CQRS 读写分离,读数据结构要标准,写数据结构要简单

众所周知,REST API 可以按照行为分为「读」和「写」两种:

  • 读:只获取数据,不对数据造成更改,用 GET 方法
  • 写:更新数据内容,用 POST PUT DELETE PATCH

自我一篇很古老的文章里就提到了,GET 请求获取的是「读模型」(区分于文中所陈述的 DDD 的「写」模型),应该按照使用方需求合理展示相应数据。不过当时对「读模型」讲述的不够深入,实际上 GET 所返回的数据不是孤立的,而是相互关联的。例如有这么一个场景,主要包含两个资源 userblogblog 会有 user 作为其 author。有如下两个 API:

  • GET /users 返回用户列表
  • GET /blogs 返回博客列表

其中 blog 下会包含 author 的信息:

{ // blog
  "id": "xxx",
  "title": "xxx",
  "summary": "xxx",
  "tags": ["spring", "rest"],
  "author": { // author
    "name": "xxx",
    "avatar": "http://images.jpg",
    "description": "bla bla bla"
  }
}

然后可能会有一个用户的列表 GET /users,里面包含了用户的信息:

{
  "name": "xxx",
  "avatar": "http://images.jpg",
  "description": "bla bla bla"
}

blog 下的 author 和 users 下的每个 user 的结构如果没什么特殊原因应该是一致的,否则前端同学肯定一脸懵逼。这就是所谓的「结构标准化」了。就是 REST GET 的返回结果应该按照 API 服务所提供的资源之间的关系有统一的结构。 这部分和 GraphQL 的感觉有点异曲同工了。

而「写模型」数据通常是让前端去提交某种结果,其所需要的结构是在 requestBody 中。如果硬要提交的结构和返回的结构完全一致的话会有三个问题:

  1. 完全做不到,因为很多数据是提交到后端后后端去更新和关联起来的,比如上文提到提 blogauthor 字段通常都是自动生成的,怎么可能是提交上去的,这就不符合实际业务逻辑
  2. 可能会非常冗余,让 API 适用方觉得比较繁(sha)琐(bi)
  3. 容忍度不太高,使用者稍有不慎就会 400 警告

比如在 openbayes 创建 job 的 API 里面,需要用户去提交什么样子的资源绑定到什么路径上,如果是规范的提交方式应该是这个样子:

POST /jobs

{
  "data_bindings": [
    {
      "path": "/input0",
      "data": {
        "id": "abc",
        "name": "mnist",
        "type": "DATASET",
        "owner": {
          "id": "userid",
          "name": "username",
          ...
        }
        ...
      }
    }
  ]
}

其中大部分数据毫无意义,因为绝大部分信息都是在提供了 userid + data-type + data-id 之后后端直接在数据库中就查的出来的,所以合理的提交方式应该是以下的样子:

POST /jobs

{
  "data_bindings": [
    {
      "path": "/input0",
      "data": {
        "userid": "userid",
        "type": "DATASET",
        "id": "xxx"
      }
    }
  ]
}

再举个容忍度的例子,管理员创建一个新的商品:

POST /plans

{
  "name": "xxx",
  "description": "xxxx",
  "price": {
    "amount": 1.99,
    "currency": "CNY"
  }
}

其中这个 price 部分有点点小冗余了,如果可以同时支持以下方式可能会更友好:

POST /plans

{
  "name": "xxx",
  "description": "xxxx",
  "price": "CNY 1.99"
}

这部分我觉得就有点额外发挥的意思了,甚至会画蛇添足。将 API 调用 SDK 化可以极大程度上减少这种额外工作的必要。

OpenAPI 标准化

API 越写越多,接口越来越复杂,前端调用的时候总要有个文档说说到底每个 API 都返回了啥吧,那么就要用 OpenAPI 了。

petstore 的 openapi 3.0 样例
petstore 的 openapi 3.0 样例

OpenAPI 是一套标准,定义了如何用 json schema (的变种)去定义一套 REST API 的接口的。然后围绕这个标准产生了一系列的工具,这一系列的工具的组合一定程度上解决了我们 REST 开发中的诸多痛点。

redoc 用于生成文档

openapi 就是个 yaml 而且其官方提供的 swagger editor 在这个 yaml 比较大的时候会很卡。干脆就用 intellij 去编辑了,但是我总是知道自己的语法写没写对吧。这个时候我就直接用 redoc 这个东西去渲染结果了,如果展示的对就是正确的了

然后其实随着 api 的规模的增大,单个 yaml 已经承受不了了。最后分化出了多个 yaml 文档,并对 redoc 提供的 html 做了些许魔改形成了下面的这个样子:

openbayes api 文档的样子

可以看到按照边界分成了 6 个 yaml 然后 openapi 支持每个之间做了相互的数据引用。

这个文档在一定程度上解决了前后端沟通的问题。

openapi-validator 用于文档校验

openapi 文档会又撰写 api 的人同步的更新,为了保证 openapi 上线前不要各种挂,就引入了 openapi-validator 在 ci 那边跑测试,如果校验没过就报错了呢。openapi-validator 相对比较灵活,有点像是 lint(巧了,openapi-validator 的命令行工具就叫 openapi-lint)可以自动的定制一些规则,要求文档的撰写者遵循。

openapi2schema + restassured 与后端测试集成

文档写好了,单毕竟就是个 yaml 而已,如何保证实际的代码和 openapi 一致呢?这里就魔改出来一个还算凑合的方案。

首先 restassured 是我们之前就广泛使用的 api 测试框架,然后它其实又一个 JsonSchemaValidator 组件,支持用 jsonschema 去校验请求的内容。然后我有找到一个名为 openapi2schema 的东西,可以把 openapi yaml 转换为 json schema 格式。那么我就可以实现用 openapi 文档去测试实际的代码是不是符合规范了,这里就直接截取项目中的一小段代码:

public void should_get_a_workspace_with_one_dataset_binding() {
    String schema = getJsonSchema("api.json", "'/users/{userId}/jobs/{jobId}'.get.responses.200");

    // bla bla bla

    given()
        .header("Authorization", "Bearer " + token)
        .contentType(ContentType.JSON)
        .when()
        .get("/users/{uid}/jobs/{jid}", jobData.getUserId(), jobId)
        .prettyPeek()
        .then()
        .statusCode(200)
        .body(JsonSchemaValidator.matchesJsonSchema(schema));
  }

其中 api.json 就是利用 openapi2schema 将 openapi 转换为 jsonschema 的结果了。baeldung 有篇 文章 介绍了如何使用 rest assured 的 json schema validator。

openapi-generator-cli 生成前端 sdk

最后,也是最重要的一环,为了让 openapi 有灵魂,一定是给前端把它的调用做封装,而不是让前端同学按照文档自己去写调用接口。这里用了 openapi-generator-cli 可以生成众多语言的 sdk,甚至支持 typescript 的 sdk。

typescript sdk 的截图
typescript sdk 的截图

HATEOAS

这部分在 在 Spring Boot 中使用 HATEOAS 介绍过了。简单的说就是两部分:

  • 用 link 的有无反应权限
  • 一定程度上确保 link 像网页那样为用户的 api 探索提供探索,尤其是对于「分页」「列表-详情」这样的关联性极强的 API 之间。

遇到的问题

吐槽下目前 REST 生态吧。

REST 没什么标准(甚至还会时不时为 REST 是什么而争论),唯一看起来像样的就是 OpenAPI 但是 OpenAPI 的问题也挺多的。

OpenAPI 不是什么大厂,没有很强的运营推广能力,很多东西的贡献也全凭兴趣,持久性和可靠性令人怀疑。比如目前 openbayes 的 API 已经相对比较多了,统计下 openapi 文档的行数已经相当恐怖了。以下几个文件还是自己强行拆分出来的,OpenAPI 自身对于多文件的支持并不是很好,目前的方案也不是很完美,可能需要结合 How to split a large OpenAPI Specification into multiple files 这篇文章的方案做更多的尝试了。

ls *.yml | xargs wc

    8499   15129  216144 api.yml
    1543    2752   40366 finance.yml
     167     286    4375 notifications.yml
     823    1432   20210 orgs.yml
    1366    2401   35797 servings.yml
    1550    2696   37413 users.yml
   13948   24696  354305 total

redoc 并不支持完整的 openapi 语法,很多时候校验全凭效果,比如 allOf oneOf 这些继承语法。正如上文所说,「如果展示的对就是正确的了」。类似的问题在 openapi-generator-cli 也会出现,具体的问题也是千奇百怪。最终结果导致我们只能使用一个 openapi 语法的子集,并且我觉得如果 openapi 没有能力让下面的工具链与自己的标准协同更新,openapi 也就慢慢成了一纸空文。以及 openapi2schema 就更没谱了,就是个完完全全的个人作品,已经一年多没有更新了,后面还能不能用都不知道。

总的来说,上面的工具链有点像是纸糊的,摇摇欲坠,这也是我寻求 GraphQL 破局的重要原因之一吧。