K8sアプリのE2Eテストを無料でCIする。GitHub ActionsとKindで

K8sアプリ(=Kubernetes上で動くことを前提としたアプリケーション)のEnd-to-Endテスト(=E2Eテスト)をCIの無料枠で実行する方法を説明します。

テストツールの列挙

まずはK8sアプリケーションのテストに使うツールを列挙します。 代表的には次のような選択肢があります。

  • fake: client-goのモッククライアント。admission機構が無いなど、本物とは挙動が異なる点も多い
  • envtest: controller-runtimeのインテグレーションパッケージ。kube-apiserverとetcdを実際に建てる。APIリソース操作で十分に試験項目を網羅できるなら便利。
  • kind: dockerベースのスタンドアローンK8s。マルチノードをサポートしている。K8sの下回りに依存しないアプリケーションなら基本的にこれで問題なし。
  • 本物のK8s: コスト大だがどうしても必要なら。

E2Eテストには基本的にkind、あるいは本物のK8sを使うことになるでしょう。プログラムによってはenvtestだけで十分な試験を行えるかもしれません。

テストツールの選定

では選択肢の中からどれを選ぶべきでしょうか。megaconfigmapというプログラムを作ったときの具体的な話を書きます。

このツールは以下の2つで構成されています。

  • kubectl-megaconfigmap: サイズの大きなファイルを複数のConfigMapに分割保存するkubectlプラグイン
  • combiner: 分割保存されたConfigMapを収集して1つに結合するプログラム

前者のkubectl-megaconfigmapは要するにConfigMapを作るプログラムなので、kube-apiserverさえあれば十分にテストできそうです。
その一方で後者のcombinerはPodのinitContainerとして動かすことを前提としているので、E2EテストにはPodの動作環境が欲しくなります。

これらの条件を踏まえ、kindを使うという方針を決めました。

CIサービスの選定

では次にどこのCIサービスでkindを動かせばよいでしょうか。 収益性ゼロの個人趣味プロダクトなので、できれば課金されたくありません。

無料枠の大きなCIサービスはどこかというと、GitHub Actionsです。 無料でも最大20個のジョブを同時実行できます。 そしてそれぞれのジョブは仮想CPUコアx2・メモリ7GBのマシンで実行されます。

これくらいのスペックがあればkindが普通に動きそうです。 というわけでGitHub Actionsを使うことにします。

engineerd/setup-kind を利用したKind構築

では次にGitHub ActionsのCIにKindを構築する手段を考えてみましょう…としたのですが、 実はGitHub Actionsのマーケットプレイスで配布されている engineerd/setup-kindGitHub ActionのWorkflowにステップ追加するだけで完了してしまうのでした。便利すぎる…!

name: Kind
on: [push]
jobs:
  build:
    name: E2E Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-go@v2
        with:
          go-version: 1.13
        id: go
      - name: Install kubectl-megaconfigmap
        run: go install -mod=vendor ./cmd/kubectl-megaconfigmap
      - name: Set up Kind
        uses: engineerd/setup-kind@v0.3.0
        with:
          version: "v0.7.0"
      - name: Build Image
        run: make docker-build TAG=latest
      - name: Load Image to Kind
        run: kind load docker-image quay.io/dulltz/megaconfigmap-combiner:latest
      - name: Run Test
        run: make e2e

setup-kind適用後のインスタンスではkubectlも普通に叩けるようになっています。 ginkgoを使ったテストコードの例を載せます。

package e2e_test

import (
    "bytes"
    "encoding/json"
    "fmt"
    "os"
    "os/exec"
    "time"

    . "github.com/onsi/ginkgo"
    . "github.com/onsi/gomega"
    corev1 "k8s.io/api/core/v1"
)

var _ = Describe("combiner", func() {
    Describe("creating MegaConfigMap", func() {
        Context("with dummy file", func() {
            It("should be combined into one file", func() {
                By("preparing file")
                dummyFile := "data.dummy"
                f, err := os.Create(dummyFile)
                Expect(err).ShouldNot(HaveOccurred())
                defer os.Remove(dummyFile)
                Expect(f.Truncate(1e6)).ShouldNot(HaveOccurred())
                Expect(f.Close()).ShouldNot(HaveOccurred())
                stdout, stderr, err := run("kubectl", "megaconfigmap", "create", "my-conf", "--from-file=./"+dummyFile)
                Expect(err).ShouldNot(HaveOccurred(), "stdout: %s, stderr: %s", stdout.String(), stderr.String())
                By("creating a pod with combiner")
                stdout, stderr, err = run("kubectl", "apply", "-f", "../examples/pod.yaml")
                Expect(err).ShouldNot(HaveOccurred(), "stdout: %s, stderr: %s", stdout.String(), stderr.String())
                Eventually(func() error {
                    stdout, stderr, err := run("kubectl", "exec", "megaconfigmap-demo", "--", "ls", "-lh", "/demo/"+dummyFile)
                    if err != nil {
                        return fmt.Errorf("err: %s, stdout: %s, stderr: %s", err, stdout.String(), stderr.String())
                    }
                    return nil
                }, 60*time.Second).ShouldNot(HaveOccurred())

                By("delete all partial configmaps if parent have been deleted")
                stdout, stderr, err = run("kubectl", "delete", "cm", "my-conf")
                Eventually(func() error {
                    stdout, stderr, err := run("kubectl", "get", "cm", "-o=json")
                    if err != nil {
                        return fmt.Errorf("err: %s, stdout: %s, stderr: %s", err, stdout.String(), stderr.String())
                    }
                    var cml corev1.ConfigMapList
                    err = json.Unmarshal(stdout.Bytes(), &cml)
                    if err != nil {
                        return fmt.Errorf("failed to unmarshal. err: %s", err)
                    }
                    if len(cml.Items) > 0 {
                        return fmt.Errorf(string(len(cml.Items)) + " configmap remains")
                    }
                    return nil
                }, 20*time.Second).ShouldNot(HaveOccurred())

                By("clean up")
                stdout, stderr, err = run("kubectl", "delete", "-f", "../examples/pod.yaml")
                Expect(err).ShouldNot(HaveOccurred(), "stdout: %s, stderr: %s", stdout.String(), stderr.String())
            })
        })
    })
})

func run(first string, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
    cmd := exec.Command(first, args...)
    outBuf := new(bytes.Buffer)
    errBuf := new(bytes.Buffer)
    cmd.Stdout = outBuf
    cmd.Stderr = errBuf
    err := cmd.Run()
    return outBuf, errBuf, err
}

まとめ

趣味K8aアプリケーションのE2EテストにはGitHub Actions+Kindがいい感じでした。 よりよいやり方があればぜひ教えてください。