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-kindを GitHub 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がいい感じでした。 よりよいやり方があればぜひ教えてください。