GoでKubernetesクラスター上にモックリソースをサクッと構築するOSSを開発しました

GoでKubernetesクラスター上にモックリソースをサクッと構築するOSSを開発しました

はじめに

こんにちは。株式会社ZOZOのSRE部プラットフォームSREチームに所属しているはっちーと申します。

本記事では、Kubernetesクラスター上にモックリソースをサクッと構築する「モック構築ツール」を紹介します。ZOZOの事例をもとにした説明となりますが、Kubernetesクラスター上での負荷試験やフロントエンド開発などの効率化において広く一般的に活用できるツールのため、OSSとして公開しています。GitHubリポジトリは以下です。

github.com

本ツールは、私個人のOSSとして管理しています。ZOZOでは、社員がOSS活動しやすいように、「業務時間中に指示があって書いたソフトウェアでも著作権譲渡の許諾によって個人のものにできる」というOSSポリシーがあります。ありがたいです。

techblog.zozo.com

目次

モック構築ツールとは

モック構築ツールは、Kubernetesクラスター上でマイクロサービスのモックリソースを構築し、負荷試験やフロントエンド開発などを効率化するツールです。モックは、APIなどの処理を模倣するものです。OSSのPrismを利用しており、モックサーバーはOpenAPIで定義された仕様書における正常系のexample値でレスポンスを返します。本ツールは、単にPrismのPodをKubernetesクラスター上に構築するだけではありません。Amazon Elastic Container Registry(以降、ECR)や一連のKubernetesリソースをコマンド一発で構築します(事前作業あり)。詳細は後述します。また、モックは1つのマイクロサービスだけでなく、複数構築できます。

モック構築ツールの概要

開発のきっかけ

あるマイクロサービスの負荷試験で大きい負荷をかけた際に、依存先となるマイクロサービスにさらに依存するマイクロサービスや外部システムへ大量にリクエストが飛んでしまい、問題となったことがきっかけです。自チームの管轄マイクロサービスであれば影響範囲は予測できますが、他チームの管轄マイクロサービスとなると、さらにその先の影響範囲を把握するのは困難です。また、負荷試験のたびに各SREチームへ影響範囲を確認しあうのも現実的ではありません。

想定外にリクエストが飛ぶ図

そこで、負荷試験時にモックが欲しいという考えになりました。しかし、Kubernetesクラスター上にマイクロサービスのモックを用意するのは、面倒な作業です。加えて、負荷試験は開発完了後のリリース日が迫った段階で実施されるケースがほとんどで、試験期間は限られています。したがって、なるべく工数をかけずに、誰でも均一的な方法で手軽にマイクロサービスのモックを用意する仕組みが社内に必要だと感じました。そこで、モック構築ツールを開発することにしました。

ZOZOでモック構築ツールが役立つ場面

負荷試験で依存先マイクロサービスに多くの負荷がかかる場面

きっかけとなった場面になりますが、もう少し詳細に説明します。

前提として、ZOZOでは負荷試験を検証環境(以降、stg環境)で行います。stg環境では、すべてのマイクロサービスが本番環境(以降、prd環境)と同じスペックのPodで動作しています。しかし、インフラコストの観点から、Pod数はprd環境よりも大幅に少ないです。また、負荷試験の対象となるマイクロサービスのPod数は1で固定しており、オートスケーリングもしないようにしています。

負荷試験では、試験対象のマイクロサービスが短時間に大量のリクエストを実行します。試験対象のマイクロサービスが別のマイクロサービスに依存している場合、実行するAPIによってはそちらにもリクエストが流れます。流れるリクエスト量が多くない場合は、stg環境で起動している、依存先のマイクロサービスのPodにそのままリクエストが流れても支障はないです。一方、流れるリクエスト量が多い場合は、リクエストを捌ききれなくなり、負荷試験に支障がでます。依存マイクロサービスはオートスケーリングしますが、スケーリングが完了するまでに多少の時間はかかるため、正確に性能を計測できません。したがって、依存するマイクロサービスを事前にスケールアップする必要があります。

しかしながら、マイクロサービスの数が多いと事前スケーリングの対象が多くて作業が大変ですし、自チームの管轄外マイクロサービスに関しては、その担当SREチームとの調整コストが発生します。また、「1PodあたりCPU使用率50%で捌ける限界のスループットを計測する」という試験項目があるため、試験前に負荷の程度を見積もって伝えるのが難しいです。少しずつ増強依頼となると調整が大変ですし、大雑把に増強しすぎるとムダなインフラコストが発生してしまいます。さらに、ZOZOでない外部のシステムに依存している場合は、より調整が困難になります。このようなケースでモックは非常に役立ちます。

stg環境の競合回避

ZOZOでは1つのstg環境で複数のSREチームが複数のプロジェクトに関する負荷試験や障害試験を実施しています。したがって、しばしばstg環境では複数の試験タイミングが重複してしまいます。もし、同時に試験を実施してしまうと、お互いの試験が影響してしまい、正しく試験ができません。現状は担当者間の調整で回避していますが、負荷試験ではモック構築ツールでモックを用意するようになれば、調整コストを削減できます。

APIクライアント側の開発

マイクロサービスのOpenAPI仕様設計が完了すれば、Kubernetesクラスター上にモックを構築できます。したがって、フロントエンドなどのAPIクライアント側はそのマイクロサービスの開発完了を待つ必要なく、クラウド環境上で自分たちの開発や動作確認を進めることができます。

使い方

前提(ツール実行側)

  • AWSとKubernetesの認証情報が設定済み
  • 以下がインストール済み
    • Go
      • v1.22.5での動作は確認済み
    • aws-cli
      • 完全にaws-sdk-goへ移行できていないため
    • kubectl
      • VirtualServiceやテストリクエストで利用
      • 必須でない
    • Docker
      • イメージビルドなどをするため

Step1. OpenAPI仕様書のコピー

openapi.yamlにOpenAPIの仕様をコピー&ペーストします。

ZOZOでは、ほぼすべてのマイクロサービスのAPIはOpenAPIで定義されています。社内ではGiHub Pages上で公開されています。

Step2. AWSとKubernetesの接続設定

リソースを構築するAWSとKubernetesの接続設定をします。たとえば、awspkubieを使っている場合は以下です。

awsp <your_profile>
kubie ctx <your_context>

Step3. パラメーター設定

params.goでパラメーターを設定します。現状は、ハードコーディングで設定する必要があります。ツール利用者は最低限、以下のパラメーター設定が必要です。

  • microserviceName
    • モックにするマイクロサービスの名前
    • e.g. zozo-member-api
  • microserviceNamespace
    • モックにするマイクロサービスのネームスペース
    • e.g. zozo-member

Step4. モックリソースの構築

以下のコマンドを実行します。

make run-create

コマンド一発で、以下のリソースがAWSとKubernetesクラスター上に構築されます。

  • AWS
    • ECR
  • Kubernetes
    • Namespace
    • Deployment
    • Service
    • VirtualService

Step5. (任意)レイテンシーの設定

この手順は任意です。

現状のままですと、モックは即座にAPIレスポンスを返します。これでは負荷試験のモックとしては不十分な場合があります。なぜならば、実際のAPIではDBアクセスなどのさまざまな処理をした後にレスポンスを返すため、レイテンシーが少なからず発生するからです。負荷試験用のモックとしては、このレイテンシーも考慮してAPIレスポンスを返すようにした方がよいです。そこで、IstioのFault Injection機能を利用して、固定時間の遅延を発生させるようにします。具体的には、ツールで構築されたVirtualServiceリソースをeditして、spec.http.fault.delay.fixedDelayにレイテンシーの値を設定します。

kubectl edit VirtualService -n example-namespace example-vs
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: example-vs
spec:
  hosts:
  - example-service.example-namespace.svc.cluster.local
  http:
  - name: example1
    match:
    - uri:
        prefix: /example1/
      method:
        exact: GET
    fault:
      delay:
        percentage:
          value: 100.0
        fixedDelay: 0.1s # here
    route:
    - destination:
        host: example-service.example-namespace.svc.cluster.local
  - name: default
    route:
    - destination:
        host: example-service.example-namespace.svc.cluster.local

当然ながらレイテンシーはAPIごとに異なるため、負荷試験のシナリオ中で実行されるAPIごとに上記の設定をします。すでにprd環境でリリース済みのAPIであれば、prd環境のデータを分析して実際のレイテンシーを設定します。ZOZOでは、DatadogからAPIごとのp95レイテンシーの値を簡単に確認できるようになっているため、それを利用します。

Datadog上での会員基盤のp95レイテンシー例

正直なところ、この設定作業は負担が大きいので、一部自動化する予定です。なお、試験的に問題なければレイテンシーを設定しなくても構いませんし、全API一律でdefaultセクションにレイテンシーを設定しても構いません。

Step6. (任意)テストリクエスト

この手順は任意です。

モックリソースが構築できたら、テストリクエストをします。たとえば、Kubernetesクラスター内に適当なPodを起動して、モックに対してcurlでAPIリクエストを実行します。OpenAPIの仕様に即したレスポンスが返ることを確認できます。

kubectl run tmp-$(date "+%Y%m%d-%H%M%S")-hacchi --image yauritux/busybox-curl:latest -n api-gateway --annotations="sidecar.istio.io/inject=true" --rm -it -- sh -c "curl -v -m 10 http://zozo-member-api-prism-mock.zozo-member-prism-mock.svc.cluster.local/internal/members/1"

...

* Request completely sent off
< HTTP/1.1 200 OK
< access-control-allow-origin: *
< access-control-allow-headers: *
< access-control-allow-credentials: true
< access-control-expose-headers: *
< content-type: application/json
< content-length: 467
< date: Mon, 22 Jul 2024 10:09:48 GMT
< x-envoy-upstream-service-time: 6
< server: envoy
<
* Connection #0 to host zozo-member-api-prism-mock.zozo-member-prism-mock.svc.cluster.local left intact
{"id":1,"email":"taro.tanaka@zozo.com","zozo_id":"tanaka","has_password":false,"last_name":"田中","first_name":"太郎","last_name_kana":"タナカ","first_name_kana":"タロウ","gender_id":1,"birthday":"2004-12-15","zipcode":"1020094","prefecture_id":1,"address":"千代田区紀尾井町1-3","address_building":"東京ガーデンテラス紀尾井町 紀尾井タワー","phone":"0120-55-0697","zozo_employee_id":"1","registered_at":"2004-12-15T12:00:00+00:00"}pod "tmp-20240722-190922-hacchi" deleted

Step7. 負荷試験

接続先情報をモックのServiceに切り替えて、負荷試験を実施します。

Step8. モックリソースの削除

負荷試験が完了したら、モックリソースをすべて削除します。

make run-delete

以上で、使い方の説明は終了です。

実装紹介

なぜGoで実装したか

本ツールはGoで実装しました。Goを選択した理由は以下の通りです。

  • 社内推奨プログラミング言語の1つのため。
  • 開発者である私がもっとも使い慣れた言語であるため。
  • AWSやKubernetesのライブラリが豊富であり、社内でも使用実績があったため。

なお、社内のインフラ関連のツールはほとんどがシェルスクリプトで実装されているため、シェルスクリプトでの実装も選択肢にありました。しかし、上記の理由に加えて、YAMLファイルを読み取る機能を開発するにあたって、主観ではGoの方が書きやすそうと感じました。また、シェルスクリプトだと利用者にyqをインストールしてもらう必要がありそうなため、それを避けました。

ディレクトリ構成

まず、全体像としてディレクトリ構成を示します。

モック構築ツールのディレクトリ

とくに、特別な点はありません。そこまで複雑なツールではないため、今のところはmainパッケージのみにしています。Goファイルは、main.goecr.gok8s.goistio.goparams.goaction_type.golib.goの7つです。その他には、Makefileopenapi.yamlDockerfile.prismがあります。

Goコード以外

説明のしやすさから、Goコード以外の説明から始めます。

簡単にツールの実行ができるようにMakefileを用意しています。基本的には、make run-create(モックリソースの構築)およびmake run-delete(モックリソースの削除)を実行します。実行時に作成されるワンバイナリは残さないようにしています。また、開発用にmake deps(依存モジュールのダウンロード)も用意しています。

BINARY_NAME=prism-mock
GO=go

build:
    $(GO) build -o $(BINARY_NAME) .

run-create: build
    ./$(BINARY_NAME) -action create
    $(MAKE) clean

run-delete: build
    ./$(BINARY_NAME) -action delete
    $(MAKE) clean

clean:
    $(GO) clean
    rm -f $(BINARY_NAME)

deps:
    $(GO) mod download

モック対象マイクロサービスのAPI仕様をopenapi.yamlにコピー&ペーストします。Prismはこのファイルを元にモックサーバーを起動します。

PrismのDockerfile.prismは以下の通りです。イメージはstoplight/prism:5.8.2を指定しています。openapi.yamlをCOPYします。mockコマンドでPrismを起動します。

FROM stoplight/prism:5.8.2
COPY ./openapi.yaml /app/openapi.yaml
CMD ["mock", "-h", "0.0.0.0", "-p", "80", "/app/openapi.yaml"]

Goコード

パラメーター処理と初期化処理

パラメーターの管理は、params.goで処理しています。設定可能なパラメーターは以下です。microserviceName=zozo-aggregation-apiの場合、構築されるリソース名はzozo-aggregation-api-prism-mockとなります。

Parameter Name Description default required
microserviceName モックにするマイクロサービス名 "" Yes
microserviceNamespace モックにするマイクロサービスのネームスペース名 "" Yes
prismMockSuffix リソース名のサフィックス "-prism-mock" Yes
prismPort Prismコンテナーのポート番号 "80" Yes
prismCPU PrismコンテナーのCPUリクエスト "1" Yes
prismMemory Prismコンテナーのメモリリクエスト "1Gi" Yes
istioProxyCPU istioサイドカーコンテナーのCPUリクエスト "500m" Yes
istioProxyMemory istioサイドカーコンテナーのメモリリクエスト "512Mi" Yes
timeout ツールの実行タイムアウト時間 10 * time.Minute Yes
ecrTagEnv ECRのCostEnvタグの値 "stg" No

main.goのinit関数で以下の初期化処理を行います。

  • openapi.yamlのデータ取得と空チェック
  • パラメーターのバリデーションチェック
  • コマンドライン引数(アクションパラメーター)の取得
  • AWSとKubernetesの設定
  • リソース名の作成

main.goのコードを見る

var (
    action        actionType
    awsConfig     aws.Config
    awsAccountID  string
    kubeConfig    *restclient.Config
    resourceName  string
    namespaceName string
)

func init() {
    //empty check for openapi.yaml
    data, err := os.ReadFile("openapi.yaml")
    if err != nil {
        panic(err)
    }
    if len(data) == 0 {
        panic("openapi.yaml is empty")
    }

    // validation parameters
    err = validateParams()
    if err != nil {
        panic(err)
    }

    // action parameter
    var actionStr string
    flag.StringVar(&actionStr, "action", "create", "create or delete(default: create)")
    flag.Parse()
    parsedAction, err := validateActionType(actionStr)
    if err != nil {
        panic(err)
    }
    action = parsedAction

    // AWS config
    awsConfig, err = config.LoadDefaultConfig(context.Background())
    if err != nil {
        panic(fmt.Errorf("failed load AWS config: %v", err))
    }

    // get AWS account ID
    stsClient := sts.NewFromConfig(awsConfig)
    result, err := stsClient.GetCallerIdentity(context.Background(), &sts.GetCallerIdentityInput{})
    if err != nil {
        panic(fmt.Errorf("failed to get caller identity: %v", err))
    }
    awsAccountID = *result.Account

    // kube config
    kubeconfigPath := clientcmd.NewDefaultPathOptions().GetDefaultFilename()
    kubeConfig, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath)
    if err != nil {
        panic(fmt.Errorf("failed to build kubeconfig: %v", err))
    }

    // resource name
    resourceName = microserviceName + prismMockSuffix
    namespaceName = microserviceNamespace + prismMockSuffix
}

main関数

main.goのmain関数では、リソース構築と削除に関するさまざまな処理を呼び出しています。リソース構築の場合は、buildAndPushECR関数とcreateK8sResources関数とcreateIstioResources関数を順に実行します。リソース削除の場合は、deleteIstioResources関数とdeleteK8sResources関数とdeleteECR関数を順に実行します。いずれも、エラーが発生した場合はpanicします。contextパッケージでタイムアウト設定しています。

main.goのコードを見る

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()

    if action == create {
        err := buildAndPushECR(ctx)
        if err != nil {
            panic(err)
        }
        err = createK8sResources(ctx)
        if err != nil {
            panic(err)
        }
        err = createIstioResources(ctx)
        if err != nil {
            panic(err)
        }
        log.Println("[INFO] All resources for prism mock are created successfully")
    } else if action == delete {
        err := deleteIstioResources(ctx)
        if err != nil {
            panic(err)
        }
        err = deleteK8sResources(ctx)
        if err != nil {
            panic(err)
        }
        err = deleteECR(ctx)
        if err != nil {
            panic(err)
        }
        log.Println("[INFO] All resources for prism mock are deleted successfully")
    }
}

main関数の処理の流れを図にすると以下の通りです。

main関数の処理の流れ

DockerイメージとECRの構築と削除

ecr.goではDockerイメージのビルドやECRの構築と削除をします。buildAndPushECR関数は、Dockerイメージをビルドして、ECRを構築してプッシュします。deleteECR関数では、ECRを削除します。なお、現状はECRログインの箇所でaws-cliを実行してしまっているので、aws-sdk-goを使って改修する予定です。また、ECRだけでなく他のコンテナレジストリにも対応する予定です。

すでに同名のリソースが存在する状態で構築リクエストをするとWARNログを出力しますがエラーにはなりません。同様に、指定リソースが存在しない状態で削除リクエストをするとWARNログを出力しますがエラーにはなりません。これは、ecr.gok8s.goistio.goのすべてで同じ仕様です。

ecr.goのコードを見る

func buildAndPushECR(ctx context.Context) error {
    // build Docker image
    imageTag := microserviceName + ":latest"
    cmd := exec.Command("docker", "build", "-f", "Dockerfile.prism", "-t", imageTag, ".")
    if err := cmd.Run(); err != nil {
        return fmt.Errorf("failed to build docker image: %v", err)
    }
    log.Println("[INFO] Docker image is built successfully")

    // create ECR repository
    ecrClient := ecr.NewFromConfig(awsConfig)
    repositoryName := resourceName
    input := &ecr.CreateRepositoryInput{
        RepositoryName: aws.String(repositoryName),
        Tags: []types.Tag{
            {
                Key:   aws.String("CostEnv"),
                Value: aws.String(ecrTagEnv),
            },
            {
                Key:   aws.String("CostService"),
                Value: aws.String(microserviceName),
            },
        },
    }
    _, err := ecrClient.CreateRepository(ctx, input)
    if err != nil {
        var ecrExistsException *types.RepositoryAlreadyExistsException
        if !errors.As(err, &ecrExistsException) {
            return fmt.Errorf("failed to create ECR repository: %v", err)
        }
        log.Println("[WARN] The ECR already exists")
    } else {
        log.Println("[INFO] ECR is created successfully")
    }

    // tag Docker image for ECR
    ecrImageTag := fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/%s:latest", awsAccountID, awsConfig.Region, repositoryName)
    cmdTag := exec.Command("docker", "tag", imageTag, ecrImageTag)
    if err := cmdTag.Run(); err != nil {
        return fmt.Errorf("failed to tag image: %v", err)
    }
    log.Println("[INFO] Docker image tagged successfully")

    // login to ECR
    loginCommand := fmt.Sprintf("aws ecr get-login-password --region %s | docker login --username AWS --password-stdin %s.dkr.ecr.%s.amazonaws.com", awsConfig.Region, awsAccountID, awsConfig.Region)
    cmdLogin := exec.Command("bash", "-c", loginCommand)
    if err := cmdLogin.Run(); err != nil {
        return fmt.Errorf("failed to log in ECR: %v", err)
    }
    log.Println("[INFO] Logged in ECR successfully")

    // push image to ECR
    cmdPush := exec.Command("docker", "push", ecrImageTag)
    if err := cmdPush.Run(); err != nil {
        return fmt.Errorf("failed to push image to ECR: %v", err)
    }
    log.Println("[INFO] Docker image is pushed to ECR successfully")
    return nil
}

func deleteECR(ctx context.Context) error {
    // Delete ECR
    ecrClient := ecr.NewFromConfig(awsConfig)
    repositoryName := resourceName
    input := &ecr.DeleteRepositoryInput{
        RepositoryName: aws.String(repositoryName),
        Force:          true, // Force delete to remove all images
    }
    _, err := ecrClient.DeleteRepository(ctx, input)
    if err != nil {
        var ecrNotFoundException *types.RepositoryNotFoundException
        if !errors.As(err, &ecrNotFoundException) {
            return fmt.Errorf("failed to delete ECR: %v", err)
        }
        log.Println("[WARN] The ECR is not found")
    } else {
        log.Println("[INFO] ECR is deleted successfully")
    }
    return nil
}

Kubernetesリソースの構築と削除

k8s.goではKubernetesリソースの構築と削除をします。kubernetes/client-goを使用しています。createK8sResources関数では、対象KubernetesクラスターのIstioバージョンを取得し、Namespace、Deployment、Serviceを構築します。deleteK8sResources関数では、Service、Deployment、Namespaceを削除します。

Istioバージョンの取得は、istio-systemネームスペース上で動作するapp: istiodラベルの付いたPod(istiod)のistio.io/revラベル値から取得します。このラベル値は1-21-4などのハイフン繋ぎのものになります。この時、もしKubernetesクラスター上でIstioのバージョンアップグレード作業中であれば、2つのバージョンのPodが存在します。そこで、それらのうち最新バージョンを返すgetLatestVersion関数を実装しました。getLatestVersion関数は、x-y-z形式のバージョンリストを受け取り、parseVersion関数でx-y-z形式のバージョンを数値のスライスに変換します。そして、compareVersions関数でメジャーバージョンから順に大小比較し、最終的に新しい方のバージョンを返します。このように、本ツールはIstioのバージョンアップグレード作業中も問題なく動作するよう工夫しています。なお、取得したIstioバージョンは、Namespaceのistio.io/revラベルに使用します。バージョン情報をうまく取得できなかった場合はエラーにせず、空で処理を続行します。

Deploymentでは、PodにはIstioのサイドカーを注入しています。メインコンテナーのイメージは構築したECRのものを指定しています。

k8s.goのコードを見る

func createK8sResources(ctx context.Context) error {
    // create clientset using kubeconfig
    clientset, err := kubernetes.NewForConfig(kubeConfig)
    // ...

    // get the latest istio version from istiod pod considering during upgrade
    podList, err := clientset.CoreV1().Pods("istio-system").List(ctx, metav1.ListOptions{
        LabelSelector: "app=istiod",
    })
    //...

    hyphenedVersions := []string{}
    for _, item := range podList.Items {
        hyphenedVersions = append(hyphenedVersions, item.ObjectMeta.Labels["istio.io/rev"])
    }
    latestVersion, err := getLatestVersion(hyphenedVersions)
    // ...

    // Namespace
    namespace := &corev1.Namespace{
        ObjectMeta: metav1.ObjectMeta{
            Name: namespaceName,
            Labels: map[string]string{
                "istio.io/rev": latestVersion,
            },
        },
    }
    _, err = clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{})
    //...

    // Deployment
    deployment := &appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name: resourceName,
        },
        Spec: appsv1.DeploymentSpec{
            Replicas: int32Ptr(1),
            Selector: &metav1.LabelSelector{
                MatchLabels: map[string]string{
                    "app": resourceName,
                },
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: map[string]string{
                        "app": resourceName,
                    },
                    Annotations: map[string]string{
                        "sidecar.istio.io/inject":                          "true",
                        "sidecar.istio.io/proxyCPULimit":                   istioProxyCPU,
                        "sidecar.istio.io/proxyMemoryLimit":                istioProxyMemory,
                        "traffic.sidecar.istio.io/includeOutboundIPRanges": "*",
                        "proxy.istio.io/config":                            `{ "terminationDrainDuration": "30s" }`,
                    },
                },
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {
                            Name:  resourceName,
                            Image: fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/%s", awsAccountID, awsConfig.Region, resourceName),
                            Ports: []corev1.ContainerPort{
                                {
                                    ContainerPort: int32(prismPort),
                                },
                            },
                            Resources: corev1.ResourceRequirements{
                                Limits: corev1.ResourceList{
                                    corev1.ResourceCPU:    resource.MustParse(prismCPU),
                                    corev1.ResourceMemory: resource.MustParse(prismMemory),
                                },
                            },
                        },
                    },
                },
            },
        },
    }
    _, err = clientset.AppsV1().Deployments(namespaceName).Create(ctx, deployment, metav1.CreateOptions{})
    //...

    // Service
    service := &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name: resourceName,
        },
        Spec: corev1.ServiceSpec{
            Selector: map[string]string{
                "app": resourceName,
            },
            Ports: []corev1.ServicePort{
                {
                    Protocol:   corev1.ProtocolTCP,
                    Port:       80,
                    TargetPort: intstr.FromInt(80),
                },
            },
            Type: corev1.ServiceTypeClusterIP,
        },
    }
    _, err = clientset.CoreV1().Services(namespaceName).Create(ctx, service, metav1.CreateOptions{})
    //...

    return nil
}

func deleteK8sResources(ctx context.Context) error {
    // create clientset using kubeconfig
    clientset, err := kubernetes.NewForConfig(kubeConfig)
    //...

    // Service
    err = clientset.CoreV1().Services(namespaceName).Delete(ctx, resourceName, metav1.DeleteOptions{})
    //...

    // Deployment
    err = clientset.AppsV1().Deployments(namespaceName).Delete(ctx, resourceName, metav1.DeleteOptions{})
    //...

    // Namespace
    err = clientset.CoreV1().Namespaces().Delete(ctx, namespaceName, metav1.DeleteOptions{})
    //...

    return nil
}

func getLatestVersion(versions []string) (string, error) {
    if len(versions) == 0 {
        return "", fmt.Errorf("no versions provided")
    }

    // init max with the zero index element
    maxVersion := versions[0]
    maxVersionParts, err := parseVersion(maxVersion)
    if err != nil {
        return "", err
    }

    // compare all versions
    for _, version := range versions[1:] {
        versionParts, err := parseVersion(version)
        if err != nil {
            return "", err
        }

        if compareVersions(versionParts, maxVersionParts) > 0 {
            maxVersion = version
            maxVersionParts = versionParts
        }
    }

    return maxVersion, nil
}

func parseVersion(version string) ([]int, error) {
    // convert "x-y-z" to [x, y, z]
    parts := strings.Split(version, "-")
    if len(parts) != 3 {
        return nil, fmt.Errorf("invalid version format: %s", version)
    }

    intParts := make([]int, len(parts))
    for i, part := range parts {
        num, err := strconv.Atoi(part)
        if err != nil {
            return nil, fmt.Errorf("invalid number in version: %s", part)
        }
        intParts[i] = num
    }
    return intParts, nil
}

func compareVersions(v1, v2 []int) int {
    // return 1 if v1 > v2, -1 if v1 < v2, 0 if v1 == v2
    for i := 0; i < len(v1); i++ {
        // if just one part is greater, the version is greater
        if v1[i] > v2[i] {
            return 1
        } else if v1[i] < v2[i] {
            return -1
        }
    }
    // if all parts are equal, the versions are equal
    return 0
}

Istioリソースの構築と削除

istio.goではIstioリソースの構築と削除をします。istio/client-goを使用しています。createIstioResources関数では、VirtualServiceを構築します。deleteIstioResources関数では、VirtualServiceを削除します。

VirtualServiceの設定は、/example1/のGETリクエストに対して100%の確率で100msの遅延を発生させるものです。また、/example1/以外のリクエストに対しては遅延を設定していません。この設定は、サンプルのようなもので、VirtualServiceを構築してから手動で編集することを想定しています。将来的には、configファイルでAPIのパス、HTTPメソッド、遅延時間などを設定できるようにし、その設定を元にVirtualServiceを自動で構築するように改修する予定です。

istio.goのコードを見る

func createIstioResources(ctx context.Context) error {
    // Istio clientset
    istioClient, err := versioned.NewForConfig(kubeConfig)
    //...

    // VirtualService
    virtualService := &v1alpha3.VirtualService{
        ObjectMeta: metav1.ObjectMeta{
            Name: resourceName,
        },
        Spec: networkingv1alpha3.VirtualService{
            Hosts: []string{
                resourceName + "." + namespaceName + ".svc.cluster.local",
            },
            Http: []*networkingv1alpha3.HTTPRoute{
                {
                    Name: "example1",
                    Match: []*networkingv1alpha3.HTTPMatchRequest{
                        {
                            Uri: &networkingv1alpha3.StringMatch{
                                MatchType: &networkingv1alpha3.StringMatch_Prefix{
                                    Prefix: "/example1/",
                                },
                            },
                            Method: &networkingv1alpha3.StringMatch{
                                MatchType: &networkingv1alpha3.StringMatch_Exact{
                                    Exact: "GET",
                                },
                            },
                        },
                    },
                    Fault: &networkingv1alpha3.HTTPFaultInjection{
                        Delay: &networkingv1alpha3.HTTPFaultInjection_Delay{
                            Percentage: &networkingv1alpha3.Percent{
                                Value: 100.0,
                            },
                            HttpDelayType: &networkingv1alpha3.HTTPFaultInjection_Delay_FixedDelay{
                                FixedDelay: &duration.Duration{Nanos: int32(100000000)}, // 100ms
                            },
                        },
                    },
                    Route: []*networkingv1alpha3.HTTPRouteDestination{
                        {
                            Destination: &networkingv1alpha3.Destination{
                                Host: resourceName + "." + namespaceName + ".svc.cluster.local",
                            },
                        },
                    },
                },
                {
                    Name: "default",
                    Route: []*networkingv1alpha3.HTTPRouteDestination{
                        {
                            Destination: &networkingv1alpha3.Destination{
                                Host: resourceName + "." + namespaceName + ".svc.cluster.local",
                            },
                        },
                    },
                },
            },
        },
    }
    _, err = istioClient.NetworkingV1alpha3().VirtualServices(namespaceName).Create(ctx, virtualService, metav1.CreateOptions{})
    //...

    return nil
}

func deleteIstioResources(ctx context.Context) error {
    // Istio clientset
    istioClient, err := versioned.NewForConfig(kubeConfig)
    //...

    err = istioClient.NetworkingV1alpha3().VirtualServices(namespaceName).Delete(ctx, resourceName, metav1.DeleteOptions{})
    //...

    return nil
}

今後の展望

追加開発

現時点で、以下の追加開発を予定しています。

  • パラメーターをハードコーディング(params.go)以外の方法で設定できるようにする。
  • ECRログインで、aws-sdk-goを使用して、aws-cliをプログラム中で使わないようにする。
  • ECR以外のコンテナレジストリにも対応する。
  • ECRリソースタグ(CostEnvとCostService)の付与はZOZO特有なのでオプションにする。
  • PodのAffinity設定を可能にする。
    • spec.affinity.nodeAffinityのmatchExpressionsのkey/value設定ができるようにする。
  • Istioのサイドカーコンテナーインジェクションをオプションにする(不要な場合もあるため)。
  • IstioのVirtualServiceの設定をconfigファイルで設定できるようにし、その設定を元にVirtualServiceを構築する。
  • レイテンシーの設定値をDatadogから自動で取得するようにする。
    • Query Time Seriesを利用できそう。Goのクライアントもありそう。
    • openapi.yamlからAPIパスとHTTPメソッドの情報を抽出して、Datadogのクエリに利用する想定。
    • パスパラメーターの取り扱いも考慮する必要がある。
    • もう少し要調査。
  • CIを追加する。
    • テストコードを追加する。
    • golangci-lintを追加する。

使ってもらう

社内外含め、色々な負荷試験や開発で利用いただき、使用実績を積み重ねたいです。また、その中で得られたフィードバックから、必要に応じてissue化して、機能追加やバグ修正をします。

まとめ

本記事では、Kubernetesクラスター上にPrismを使って、マイクロサービスのOpenAPI仕様をそのまま返すモックリソースをサクッと構築する「モック構築ツール」を紹介しました。Goのソースコードを読んだ方はお気づきかと思いますが、モック構築ツール自体の実装は難しくありません。AWSリソースやKubernetesリソースの構築と削除をしているだけです。強いて言えば、Istioの最新バージョン取得のロジックが少し複雑かもしれない程度です。シンプルで理解しやすいツールですので、よろしければぜひ使ってみてください。また、少しでも良いなと思っていただいたら、GitHubリポジトリにスターをいただけるととても嬉しいです。ほぼはじめての自作OSSなので少し緊張しています。

なお、本ツールの中核であるPrismは素晴らしいOSSです。もし、Prismの活用がまだでしたら、ローカルの動作確認や結合試験などにも便利ですのでぜひ使ってみてください。

We are hiring

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

hrmos.co

corp.zozo.com

カテゴリー