Node Affinity を使って Pod を特定のインスタンスタイプで動かす

Posted on

freee では GitHub Actions の self-hosted runners に Kubernetes (EKS) を使っています。主な用途は VPC の外では実行したくない CI やビルドです。

具体的には actions-runner-controller という Custom Contoroller を使って、Pod を Runner として登録しその中でジョブを実行しています。また、ジョブが終わると Pod が作り直されるため、前回の状態を含まないクリーンな環境が保証されます。この仕組みについては、また勉強会などで発表したいと思います。

実際に運用し始めると、特定の用途でマルチコアを使いたいケースが出てきました。 Pod の配置戦略に関する機能はいくつかありますが、今回は Node Affinity を使って Pod を特定のインスタンスタイプで動かすようにしました。

そのときの検証手順を忘れないように残しておきます。なお、検証では actions-runner-controller は使わずに、Kubernetes 標準の機能だけを使っています。

検証環境の情報

Kubernetes のバージョンは v1.15 で、eksctl を使って EKS クラスタを立てています。

今回は CPU bound な処理をする Pod を c5 系のノードに割り当てます。事前準備として eksctl で必要な nodegroup を作成しておきます。

$ eksctl get nodegroup --cluster hello-world
CLUSTER        NODEGROUP      CREATED                 MIN SIZE    MAX SIZE    DESIRED CAPACITY    INSTANCE TYPE    IMAGE ID
hello-world    nodegroup-1    2020-06-20T13:28:26Z    1           3           1                   m5.large         ami-03964931fd94c2743
hello-world    nodegroup-2    2020-06-21T04:16:49Z    1           3           1                   c5.large         ami-03964931fd94c2743

ラベルの確認

まず Node のラベルを確認します。今回欲しいインスタンスタイプの情報は beta.kubernetes.io/instance-type に入っています。

$ kubectl get no
NAME                                                STATUS   ROLES    AGE   VERSION
ip-192-168-59-152.ap-northeast-1.compute.internal   Ready    <none>   20m   v1.15.11-eks-af3caf
ip-192-168-60-145.ap-northeast-1.compute.internal   Ready    <none>   15h   v1.15.11-eks-af3caf

$ kubectl get no/ip-192-168-60-145.ap-northeast-1.compute.internal -o json | jq '.metadata.labels'
{
  "alpha.eksctl.io/cluster-name": "hello-world",
  "alpha.eksctl.io/instance-id": "i-0a177de60093fb89f",
  "alpha.eksctl.io/nodegroup-name": "nodegroup-1",
  "beta.kubernetes.io/arch": "amd64",
  "beta.kubernetes.io/instance-type": "m5.large",
  "beta.kubernetes.io/os": "linux",
  "failure-domain.beta.kubernetes.io/region": "ap-northeast-1",
  "failure-domain.beta.kubernetes.io/zone": "ap-northeast-1d",
  "kubernetes.io/arch": "amd64",
  "kubernetes.io/hostname": "ip-192-168-60-145.ap-northeast-1.compute.internal",
  "kubernetes.io/os": "linux"
}

$ kubectl get no/ip-192-168-59-152.ap-northeast-1.compute.internal -o json | jq '.metadata.labels'
{
  "alpha.eksctl.io/cluster-name": "hello-world",
  "alpha.eksctl.io/instance-id": "i-053cd19eee0021bc2",
  "alpha.eksctl.io/nodegroup-name": "nodegroup-2",
  "beta.kubernetes.io/arch": "amd64",
  "beta.kubernetes.io/instance-type": "c5.large",
  "beta.kubernetes.io/os": "linux",
  "failure-domain.beta.kubernetes.io/region": "ap-northeast-1",
  "failure-domain.beta.kubernetes.io/zone": "ap-northeast-1d",
  "kubernetes.io/arch": "amd64",
  "kubernetes.io/hostname": "ip-192-168-59-152.ap-northeast-1.compute.internal",
  "kubernetes.io/os": "linux"
}

ビルトインで付与されるラベルは次のとおりです(最新バージョンではいくつか増えているようです)。 OS や CPU の命令セットに関するラベルもあります。

  • kubernetes.io/hostname
  • failure-domain.beta.kubernetes.io/zone
  • failure-domain.beta.kubernetes.io/region
  • beta.kubernetes.io/instance-type
  • kubernetes.io/os
  • kubernetes.io/arch

Node Affinity を設定する

今回は c5.large で実行されるように Node Affinity を設定します。ちなみに、設定する前は 2 つのノードに均等に配置されているのがわかります。

$ kubectl get po -o=jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.nodeName}{"\n"}{end}'
nginx-deployment-68c7f5464c-f7r6c    ip-192-168-59-152.ap-northeast-1.compute.internal
nginx-deployment-68c7f5464c-f9d46    ip-192-168-59-152.ap-northeast-1.compute.internal
nginx-deployment-68c7f5464c-lhfh8    ip-192-168-60-145.ap-northeast-1.compute.internal
nginx-deployment-68c7f5464c-pwx7p    ip-192-168-60-145.ap-northeast-1.compute.internal

PodSpec に nodeAffinity の設定を追加します。 requiredDuringSchedulingIgnoredDuringExecution を指定すると条件に一致するノードにしか配置されなくなります。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 4
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:latest
        ports:
        - containerPort: 80
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
            - matchExpressions:
              - key: beta.kubernetes.io/instance-type
                operator: In
                values:
                - c5.large

apply すると条件に一致しなくなった 2 つの Pod がすぐに作り直され、すべての Pod が c5.large のノードに再配置されました。

$ kubectl get po
NAME                                READY   STATUS              RESTARTS   AGE
nginx-deployment-68c7f5464c-f9d46   1/1     Running             0          26m
nginx-deployment-68c7f5464c-lhfh8   1/1     Running             0          26m
nginx-deployment-68c7f5464c-pwx7p   1/1     Running             0          26m
nginx-deployment-6c5c8b94f8-b29lc   0/1     ContainerCreating   0          4s
nginx-deployment-6c5c8b94f8-vghw6   0/1     ContainerCreating   0          4s

$ kubectl get po -o=jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.nodeName}{"\n"}{end}'
nginx-deployment-6c5c8b94f8-b29lc    ip-192-168-59-152.ap-northeast-1.compute.internal
nginx-deployment-6c5c8b94f8-n9lhr    ip-192-168-59-152.ap-northeast-1.compute.internal
nginx-deployment-6c5c8b94f8-rvb6l    ip-192-168-59-152.ap-northeast-1.compute.internal
nginx-deployment-6c5c8b94f8-vghw6    ip-192-168-59-152.ap-northeast-1.compute.internal

条件に一致するノードがない場合も検証してみます。 c5.large のノードを cordon して Pod がスケジューリングされないようにします。

$ kubectl cordon ip-192-168-59-152.ap-northeast-1.compute.internal
node/ip-192-168-59-152.ap-northeast-1.compute.internal cordoned

$ kubectl get no
NAME                                                STATUS                     ROLES    AGE     VERSION
ip-192-168-59-152.ap-northeast-1.compute.internal   Ready,SchedulingDisabled   <none>   4h16m   v1.15.11-eks-af3caf
ip-192-168-60-145.ap-northeast-1.compute.internal   Ready                      <none>   19h     v1.15.11-eks-af3caf

この状態で再度 apply すると、すべての Pod が Pending になります。

$ kubectl delete deploy/nginx-deployment
deployment.extensions "nginx-deployment" deleted

$ kubectl apply -f nginx.yaml
deployment.apps/nginx-deployment created

$ kubectl get po
NAME                                READY   STATUS    RESTARTS   AGE
nginx-deployment-6c5c8b94f8-8fsq9   0/1     Pending   0          28s
nginx-deployment-6c5c8b94f8-b92x7   0/1     Pending   0          28s
nginx-deployment-6c5c8b94f8-hbv8c   0/1     Pending   0          28s
nginx-deployment-6c5c8b94f8-sbsgn   0/1     Pending   0          28s

Pod のイベントを見ると次のような Warning が出ており、空いている m5.large のノードに割り当てられなかったことがわかります。

0/2 nodes are available: 1 node(s) didn't match node selector, 1 node(s) were unschedulable.

このクラスタは Cluster Autoscaler を実行していたので、数分後に c5.large のノードが増えて Running になりました。

優先度を付けた Node Affinity を設定する

実際の運用では、c5.large に割り当てられなかったら m5.large に割り当てたほうが可用性は上がります。このように優先順位を付けるには preferredDuringSchedulingIgnoredDuringExecution を使います。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 4
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:latest
        ports:
        - containerPort: 80
      affinity:
        nodeAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 1
            preference:
              matchExpressions:
              - key: beta.kubernetes.io/instance-type
                operator: In
                values:
                - c5.large
          - weight: 2
            preference:
              matchExpressions:
              - key: beta.kubernetes.io/instance-type
                operator: In
                values:
                - m5.large

先ほどと同じように c5.large のノードを cordon して apply しても、今度はすぐに Running になります。割り当てられたノードを見ると m5.large に配置されたことがわかります。

$ k get po -o=jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.nodeName}{"\n"}{end}'
nginx-deployment-7ffc895f84-7bzdz    ip-192-168-60-145.ap-northeast-1.compute.internal
nginx-deployment-7ffc895f84-c277w    ip-192-168-60-145.ap-northeast-1.compute.internal
nginx-deployment-7ffc895f84-gb28r    ip-192-168-60-145.ap-northeast-1.compute.internal
nginx-deployment-7ffc895f84-kx7bz    ip-192-168-60-145.ap-northeast-1.compute.internal

おわりに

Node Affinity とは直接関係ないのですが、今回のように c5.large を必ず起動させたい場合はインスタンスタイプ(もしくはファミリー)ごとに nodegroup を作る必要があります。

eksctl で複数のインスタンスタイプを混ぜた nodegroup (MixedInstancesPolicy) を作ることはできますが、それぞれのインスタンスタイプの最低台数を指定できません。

nodeGroups:
  - name: nodegroup-1
    instanceType: mixed
    desiredCapacity: 3
    instancesDistribution:
      instanceTypes:
        - m5.large
        - r5.large
        - c5.large

たとえば上のような nodegroup を作っても、3 台のインスタンスタイプは起動してみるまでわかりません(それぞれ 1 台ずつ起動するわけではない)。