平时除了维护公司和私人在公有云的 Kubernetes clusters 之外,个人网络环境下还有些需要运行在本地的 workload;比如用于监控本地路由设备(~作~ XD)的 Prometheus exporters 和一些新奇玩意儿。为了能够运行这些应用,我在家组建了一套「边缘计算集群」,来看看是怎么做的吧。

硬件准备

我手头上目前有一台 Raspberry Pi 3 B+,我想使用它作为 Master 节点:

和两块 Nanopi NEO 2

搭建集群

K3s

为了能够适应较低的计算性能,我选择了使用 K3s 部署 Kubernetes 集群。K3s 是一款 Rancher 开源的轻量 Kubernetes 实现,主要目标为物联网和边缘计算等场景。

如果你是在搭建测试集群,不妨试试 Minikube 和 MicroK8s,它们能够提供更加接近生产环境集群的体验。

不同于以上两款产品,K3s 除了更加轻量外,还支持多节点,因此比较符合我的使用场景。

以上产品的详细对比可参考 这篇帖子

K3sup

K3sup 是由 OpenFaaS 的创始人 Alex Ellis 开发的一款小工具,可用于快速部署 K3s 节点,例如部署 master node:

k3sup install --ip "$MASTER" --user pi

执行以上命令,K3sup 将以用户 pi 的身份通过 SSH 连接 $MASTER(也就是作为 Master 节点的树莓派 IP 地址),在下载并安装 K3s 后,K3sup 会将生成的 Kubeconfig 从远端拉取到本地的工作目录中。

接下来部署 worker node:

k3sup join --ip "$WORKER" --server-ip "$MASTER" --user pi

随后即可通过 kubectl 管理集群了:

export KUBECONFIG="$(pwd)/kubeconfig"
kubectl get node

具体的安装过程限于篇幅不再详述,可参考 Alex Ellis 的这篇博客

使用 NodeAffinity 处理不同 CPU 架构问题

根据上图可以发现,树莓派的 CPU arch 是 arm,而 NanoPi 是 arm64。为了能够将对应其架构的容器镜像调度到正确的节点,使用 NodeAffinity 是解决方案之一。例如部署内网穿透项目 inlets

apiVersion: apps/v1
kind: Deployment
metadata:
  name: inlets-arm64
spec:
  selector:
    matchLabels:
      app: inlets
  replicas: 1
  template:
    metadata:
      labels:
        app: inlets
    spec:
      containers:
        - name: inlets
          image: inlets/inlets:2.6.4-arm64 # 镜像为 arm64 版本
          args: [server]
      affinity:
        nodeAffinity:
          # 在 Pod Scheduling 时强制要求
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:     # 节点选择器数组
              - matchExpressions:  # 匹配节点 labels
                  - key: kubernetes.io/arch  # label 名称
                    operator: In             # 要求满足以下任意值其一
                    values: [arm64]          # 可指定多个值

可以看到我们在 Pod Spec 内定义了 affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution.nodeSelectorTerms 字段,值为 NodeSelectorTerm 数组。我们定义了一条规则为:label kubernetes.io/arch 的值必须存在于数组 [arm64] 中。本例中只有单个值,因此等效于:必须等于 arm64

Docker Manifests

如果你想要部署的应用镜像是你自己构建的话,那么强烈推荐试试看 Docker image manifest v2 的特性 —— 可创建 manifest lists 包含多个不同 platforms 和 architectures 的 image manifests。

Docker Client 也提供了一个实验性的命令 <code>docker manifest</code> 来创建、推送 manifest lists。我结合实例来说说它的用法。

由于该命令目前是「实验性」的,首先需要通过环境变量开启才能够使用。我们顺便配置几个变量备用:

export DOCKER_CLI_EXPERIMENTAL=enabled # 开启 Docker CLI 的实验性功能
export IMAGE_REPO=vendor/app           # 镜像名称,请按需填写
export IMAGE_TAG=v1.0.0                # 镜像 tag,请按需填写

假设你已经分别构建好针对 arm64arm 的镜像,接下来先将它们推送到 registry:

docker push "${IMAGE_REPO}:${IMAGE_TAG}-arm64"
docker push "${IMAGE_REPO}:${IMAGE_TAG}-arm"

随后创建 manifest list 指向多个 image manifests:

docker manifest create --amend \
  "${IMAGE_REPO}:${IMAGE_TAG}" \       # manifest list 名称
  "${IMAGE_REPO}:${IMAGE_TAG}-arm64" \ # 针对 arm64 的镜像
  "${IMAGE_REPO}:${IMAGE_TAG}-arm"     # 针对 arm 的镜像

最关键的一步到了,为它们添加注解。将每个 manifest 绑定至特定的 osarch

# 注解 arm64 image manifest
docker manifest annotate \
  --os linux \                        # 系统为 Linux
  --arch arm64 \                      # 架构为 arm64
  "${IMAGE_REPO}:${IMAGE_TAG}" \      # manifest list 名称
  "${IMAGE_REPO}:${IMAGE_TAG}-arm64"  # 被注解的 manifest

# 注解 arm image manifest
docker manifest annotate \
  --os linux \                      # 系统同为 Linux
  --arch arm \                      # 架构为 arm
  "${IMAGE_REPO}:${IMAGE_TAG}" \    # manifest list 名称
  "${IMAGE_REPO}:${IMAGE_TAG}-arm"  # 被注解的 manifest

此时 manifest list 已经创建好了,可使用 docker manifest inspect 命令检查一下具体信息。确认无误后,推送到 registry 即可:

docker manifest push --purge "${IMAGE_REPO}:${IMAGE_TAG}"

需要注意的是,此处的 --purge 参数是必不可少的。因为 Docker CLI 并没有提供 docker manifest remove 或是 docker manifest purge 之类的命令。如果不随着推送直接清理,那就只能到本地的 $HOME/.docker/manifests 目录手动删除了… 虽然早在 2018 年初就有人针对此问题提出 issue,但截止发稿前仍没有仓库 member 回复。

最后,使用刚刚创建的 manifest list 名称代替有后缀的 image manifest 名称即可,甚至可以增加 replicas 的数量,通过 PodAntiAffinity 刻意将多个副本部署在不同 CPU 架构的节点上而无需区分 image

apiVersion: apps/v1
kind: Deployment
metadata:
  name: inlets
spec:
  selector:
    matchLabels:
      app: inlets
  replicas: 2
  template:
    metadata:
      labels:
        app: inlets
    spec:
      containers:
        - name: inlets
          image: inlets/inlets:2.6.4 # 后缀 `arm64` 已被移除
          args: [server]
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - topologyKey: kubernetes.io/arch
              labelSelector:
                matchLabels:
                  app: inlets

完。