Skaffold + Cloud Build で Test にパフォーマンス測定を加えた CI を回す

Koichi Watanabe
google-cloud-jp
Published in
20 min readDec 19, 2019

--

この記事は Google Cloud Japan Customer Engineer Advent Calendar 2019 の 18日目の記事です。

TL;DR

下記のような CI (継続的インテグレーション) のユースケースを Skaffold + Cloud Build + GitHub Actions + k6 で実装してみました。

  • ローカル環境で、アプリケーション、コンテナ、Kubernetes デプロイのテストを継続的に回しながら開発をする。
  • プルリク作成時、レビュー前に GCP 上でビルド & デプロイをテストして、合わせて API のパフォーマンス測定を行なう。

サンプルコードどこにまとまってるの?という方は こちら

はじめに

こんにちは、Google Cloud の Koichi です。

Kubernetes 上にデプロイするアプリケーションをローカル環境で開発するとき、アプリケーションの Unit Test だけでなく、コンテナのビルドや、Kubernetes クラスタへのデプロイを継続的にテストしてくれる CI が手元にあると開発が捗る方は多いんじゃないかと思います。

また、コードの Pull Request を送る際には、ローカル環境と同様のテスト環境を GCP 上にデプロイして、単体テストに加えて簡易なパフォーマンス測定を実行し、条件をパスしたらレビューに回す、のようなフローを CI が担ってくれるとより効率よく安心して開発できるのではないでしょうか。

本記事では、ソフトウェアのクオリティやパフォーマンス維持しながら、より迅速に開発するための CI 導入の一例として、Skaffold + Cloud Build + GitHub Actions + k6 を用いて環境を構築していきたいと思います。

※ 注意:2019/12/18 時点のバージョンをベースに記載しています。

今回の構成

※ 各コンポーネントについての説明は構成図の下のほうにあります。

ローカル環境

Skaffold がリポジトリ内のファイルをモニタリングし、変更があるたびに Unit Test と Docker イメージのビルド、そして Minikube へのデプロイを実行して更新します。開発者はログを適宜確認し、必要に応じて修正を行なうことができます。

GCP 環境

Pull Request を送ると、GitHub Actions が Cloud Build を Invoke し、Skaffold がローカル環境と同様に Unit Test と Docker イメージのビルド、そして GKE クラスタへのデプロイを GCP 上で行ないます。続いて次のステップで k6 が起動してパフォーマンス測定を実施し、設定したスコアをクリアしていたら環境を削除して GitHub Actions が成功します。

それぞれのコンポーネントについて簡単にご説明します。

Skaffold

Skaffold とは、Kubernetes 上にデプロイするアプリケーションの継続的な開発を容易にするためのコマンドラインツールです。詳しくは こちら をご参考ください。今回は Skaffold を2つの用途で使用しています。

  1. skaffold dev -p local
    ファイルが更新される度に、アプリケーションの Unit Test、Docker コンテナのビルド、Minikube クラスタへのデプロイを継続的に実行します。常に手元の変更が検証できることで開発時間を短縮することが目的です。
  2. skaffold run -p cloudbuild
    Cloud Build のステップ内で skaffold dev と同様のステップを GCP 環境上で実行します。-p で任意の Profile を活用することで、ローカル環境と GCP 環境のコンフィグ差分を最小限に抑えられます。

Google Cloud Build

GCP 上で CI/CD を実行するためのフルマネージドサービスです。詳しくは こちら。今回の構成では、ローカル環境と GCP 環境のビルド・テスト・デプロイを Skaffold に持たせているため、Cloud Build では、Skaffold への GCP アクセス権限の橋渡しやステップの管理 (後述する k6 の実行) に使用しています。

Minikube

Kubernetes クラスタを単一ノード上で動かすパッケージです。

Google Kubernetes Engine (GKE)

GCP が提供する Kubernetes クラスタのマネージドサービスです。詳しくは こちら

GitHub Actions

GitHub リポジトリに対するイベント(例:Pull Request の作成、コードのコミット)をトリガーに、任意のアクションを実行できる仕組みです。今回は、Pull Request を契機に Google Cloud Build を起動する際に使用しています。ちなみに、GitHub Actions は Pull Request のコード変更箇所によって処理を分けたりなど柔軟なことができますが、よりシンプルな方法として Cloud Build と GitHub の Integration も使うこともできます。詳しくは こちら をご参考くださいませ。

k6

Lightweight な http の負荷測定ソフトウェアです。JavaScript で任意のシナリオが書けます。今回は、コードレビュー前にアプリケーション単体の Mock レベルの API レスポンスタイムが SLO を満たしているかをチェックするユースケースを想定しており、負荷試験として性能限界やスケーラビリティを確認する目的では使用していませんのでご注意ください。

手順

Step 1. ローカル開発のセットアップ

ここでは MacOS 10.14.6 を例として記載します。

まず Skaffold をインストールします。

$ brew install skaffold

もしくは gcloud コマンドからでもインストールできます。

$ gcloud component install skaffold

続いて Minikube をインストールします。今回は VM driver に Hyperkit を使用しています。

$ brew install hyperkit docker-machine-driver-hyperkit minikube
$ sudo chmod u+s $(brew - prefix)/bin/docker-machine-driver-hyperkit

Minikube を起動します。

$ minikube start - vm-driver=hyperkit

Step2. サンプルアプリの作成

今回は Go でサンプルアプリを作成します。パッケージ管理は go mod を使用しています。

app/main.go

package mainimport (
"net/http"
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/kochan/cicd-sample/app/handler"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Get("/", handler.Hello)
http.ListenAndServe(":8080", r)
}

app/handler/hello.go

package handlerimport "net/http"func Hello(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello World"))
}

app/handler/hello_test.go

package handlerimport (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
)
func TestHello(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(Hello))
defer ts.Close()
response, err := http.Get(ts.URL)
body, err := ioutil.ReadAll(response.Body)
defer response.Body.Close()
if err != nil {
t.Errorf("Get unexpected error: %v\n", err)
}
if response.StatusCode != http.StatusOK {
t.Errorf("Status code error: %v\n", response.StatusCode)
}
if fmt.Sprintf("%s", body) != "Hello World" {
t.Errorf("Response body error: %v\n", response.StatusCode)
}
}

app/Dockerfile

FROM golang:1.13.5-alpine3.10 as builderRUN apk add --update --no-cache ca-certificates git gccRUN mkdir /app
WORKDIR /app
ADD . .
RUN go mod downloadRUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go test ./...
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o /go/bin/app
FROM scratch
COPY --from=builder /go/bin/app /go/bin/app
ENTRYPOINT ["/go/bin/app"]

k8s/app/deployment.yaml

---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cicd-sample-app
spec:
selector:
matchLabels:
app: cicd-sample-app
replicas: 1
template:
metadata:
labels:
app: cicd-sample-app
spec:
containers:
- name: cicd-sample-app
image: gcr.io/<PROJECT_ID>/cicd-sample-app
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: cicd-sample-app
spec:
type: NodePort
selector:
app: cicd-sample-app
ports:
- protocol: TCP
port: 8080
targetPort: 8080

Step3. Skaffold の定義

skaffold.yaml を作成します。

Profiles で、ローカル環境用 (local) はローカルでビルド (push なし)、Minikube にデプロイ、GCP 環境 (cloudbuild) では Cloud Build を使用するなど、環境特有のふるまいを分けることができます。

apiVersion: skaffold/v1
kind: Config
metadata:
name: cicd-sample
build:
artifacts:
- image: gcr.io/<PROJECT_ID>/cicd-sample-app
context: app
- image: gcr.io/<PROJECT_ID>/k6
context: k6
deploy:
kubectl:
manifests:
- k8s/app/deployment.yaml
profiles:
- name: local
build:
local:
push: false
deploy:
kubeContext: minikube
- name: cloudbuild
build:
googleCloudBuild:
projectId: <PROJECT_ID>

ここまでで、skaffold dev -p local を起動してローカル環境が動くようになりました。

Step4. k6 のセットアップ

サンプルアプリのパフォーマンス測定のシナリオを作成します。

今回はシンプルに Response Time が 99% percentile で 100ms 以内であることを Threshold とします。

k6/script.js

import http from "k6/http";
import { check } from "k6";
export let options = {
thresholds: {
http_req_duration: ["p(99)<100"],
}
};
export default function() {
http.get("http://cicd-sample-app.default.svc.cluster.local:8080/");
}

k6/Dockerfile

FROM loadimpact/k6:0.25.1COPY script.js .

続いて、k6 を実行するための Kubernetes Jobs を定義します。

この Job は、InitContainer にてサンプルアプリの Service エンドポイントが 200 を返すまで負荷測定が開始するのを待機します。

k8s/k6/jobs.yaml

---
apiVersion: batch/v1
kind: Job
metadata:
name: k6
spec:
backoffLimit: 0
completions: 1
parallelism: 1
template:
metadata:
name: k6
spec:
initContainers:
- name: check-app-ready
image: nicolaka/netshoot
command: ['sh', '-c',
'until curl -X GET -LI http://cicd-sample-app.default.svc.cluster.local:8080 -o /dev/null -w "%{http_code}\n" -s | grep -E "^200$";
do echo waiting for app; sleep 2; done'
]
containers:
- name: k6
image: gcr.io/<PROJECT_ID>/k6
command: ["k6"]
args: ["run", "script.js"]
restartPolicy: Never

Step5. GCP 環境のセットアップ

テスト環境用の GKE クラスタを用意します。

$ gcloud container clusters create "cicd-sample-k8s-cluster"  \
--zone "asia-northeast1-c" \
--enable-autorepair \
--no-enable-autoupgrade \
--machine-type "n1-standard-1" \
--image-type "COS" \
--scopes "https://www.googleapis.com/auth/cloud-platform" \
--enable-stackdriver-kubernetes \
--enable-ip-alias

Step6. Cloud Build の定義

cloudbuild.yaml を作成します。

最初のステップでは Skaffold ワーカーに GKE クラスタの context を渡しています。次のステップの skaffold run -p cloudbuild では、GCP 上で skaffold.yaml の定義に従って処理を実行しています。

Skaffold による GKE クラスタへのデプロイが完了したら k6 のジョブを起動します。ここで kubectl のオプションを — attach=true と — restart=Never にすることで、k6 Pod の exit code を kubectl の exit code として返して k6 のシナリオで設定した Threshould を満たしていない場合に Cloud Build がすぐに Fail するようにしています。本来であれば GitHub Actions 側でエラー時に skaffold delete を実行する処理も入れるべきですが、今回は簡略化のため Skip しています。

steps:
- name: "gcr.io/k8s-skaffold/skaffold:v1.0.1"
args:
[
"gcloud",
"container",
"clusters",
"get-credentials",
"cicd-sample-k8s-cluster",
"--zone",
"asia-northeast1-c",
"--project",
"${PROJECT_ID}",
]
- name: "gcr.io/k8s-skaffold/skaffold:v1.0.1"
args:
[
"skaffold",
"run",
"-p",
"cloudbuild"
]
- name: "gcr.io/cloud-builders/kubectl"
args:
[
"run",
"--generator=run-pod/v1",
"k6",
"--attach=true",
"--restart=Never",
"--rm",
"--image",
"gcr.io/<PROJECT_ID>/k6:dirty",
"--",
"run",
"script.js"
]
env:
- CLOUDSDK_COMPUTE_ZONE=asia-northeast1-c
- CLOUDSDK_CONTAINER_CLUSTER=cicd-sample-k8s-cluster
- name: "gcr.io/k8s-skaffold/skaffold:v1.0.1"
args:
[
"skaffold",
"delete"
]

Step8. GitHub Actions のセットアップ

まず、GitHub Actions から Cloud Build を実行するため、Cloud Build 実行用のサービスアカウントの auth.json を base64 にエンコードし、GitHub リポジトリの Secrets に登録します。参考

続いて、自身の GitHub リポジトリの .github/workflows の下に yaml ファイルを作成します。CloudBuild は、デフォルトではプロジェクト外の GCS バケットに Log の書き出しを行なうため、今回作成したサービスアカウントからは Log が直接閲覧できず、そのままだと GitHub Actions がエラーになってしまいます。そのため、Cloud Build 実行時に — gcs-log-dir オプションを渡し、指定の GCS にログを出すようにしています。 参考

name: Automated testing for pull requeston:
pull_request:
env:
PROJECT_ID: <PROJECT_ID>
jobs:
pullreq_auto_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: GoogleCloudPlatform/github-actions/setup-gcloud@master
with:
version: '272.0.0'
service_account_key: ${{ secrets.GCP_SA_KEY }}
- run: gcloud builds submit . --config cloudbuild.yaml --project ${PROJECT_ID} --gcs-log-dir=gs://<BUKECT_NAME>/logs

以上で、Pull Request を契機に GCP 上へのテスト環境のデプロイとパフォーマンス測定のテストが実行されるようになりました。

まとめ

ローカル環境

skaffold dev -p local を手元で実行すると、skaffold.yaml の定義に従ってアプリケーションのテスト、ビルド、Minikube へのデプロイが実行され、コード変更を待機する状態となります。

アプリケーションのコードや、Dockerfile、Kubernetes のマニフェストなどを手元で変更すると、Skaffold が検知して自動でローカル環境を更新してくれます。Log は stdout に出てくるため、何かミスったらすぐに気づいて修正できますね。

GCP 環境

新しい Pull Request を作成すると GitHub Actions が Cloud Build を Hook します。Cloud Build 内では Skaffold が Docker イメージをビルド、GCR にプッシュしサンプルアプリの Pod を GKE にデプロイします。その後、k6 を実行して性能基準を満たしていたら GitHub Actions が成功を返します。

詳細の Log は GitHub Actions のコンソールか GCS のログファイルを用いて確認できます。

例) 成功

例) パフォーマンス測定の Threshould を 1ms 以内して失敗させた場合

あとがき

いかがでしたでしょうか。

Kubernetes 上で開発をするときに手間になりがちなローカル環境のテストを Skaffold で効率よく回し、コードレビュー時には、同様の環境を GCP 上に再現してパフォーマンス測定も含めたテストを実行することで、クオリティの高いレビューサイクルを回すだけじゃなく、このようなテスト結果を蓄積していくことで品質管理やパフォーマンス劣化の早期発見に繋げていけるのではないかと思います。特にパフォーマンスにセンシティブなサービスでは CI がこのあたりを担うことで、開発者の負担やトラブルシューティングの労力が少しでも下がってくれるんじゃないかと期待しています。

今回のサンプルはシンプルなものですが、他にも GCP には コンテナの脆弱性スキャン の機能があったり、Skaffold で container-structure-test を回すことなどもできます。また、k6 の測定結果を Hangouts や Slack に流したり、BigQuery などに結果を蓄積して傾向を分析するなど、ニーズに応じて柔軟に処理を追加できるので、これらのプロダクトを応用してよりよい開発環境を作ることができそうですね。

一方で、実際の開発では、データベース含めたテストをどこまで CI でやるのかや、コードレビュー後の CD のパイプラインどうするかなど、悩みは尽きないところだと思います。また何か面白いネタがあればシェアや情報交換などできたら幸いです。

明日 2019–12–19 は Youhei Wakisaka さんによる “Cloud Speech-to-Text で音声認識入門 & Tips” です。お楽しみに!!

Special Thanks — Jumpei Arashi さん。

--

--