はじめに こんにちは。株式会社ZOZOのSRE部プラットフォームSREチームに所属している はっちー と申します。 本記事では、Kubernetesクラスター上にモックリソースをサクッと構築する「モック構築ツール」を紹介します。ZOZOの事例をもとにした説明となりますが、Kubernetesクラスター上での負荷試験やフロントエンド開発などの効率化において広く一般的に活用できるツールのため、OSSとして公開しています。GitHubリポジトリは以下です。 github.com 本ツールは、私個人のOSSとして管理しています。ZOZOでは、社員がOSS活動しやすいように、「業務時間中に指示があって書いたソフトウェアでも著作権譲渡の許諾によって個人のものにできる」というOSSポリシーがあります。ありがたいです。 techblog.zozo.com 目次 はじめに 目次 モック構築ツールとは 開発のきっかけ ZOZOでモック構築ツールが役立つ場面 負荷試験で依存先マイクロサービスに多くの負荷がかかる場面 stg環境の競合回避 APIクライアント側の開発 使い方 前提(ツール実行側) Step1. OpenAPI仕様書のコピー Step2. AWSとKubernetesの接続設定 Step3. パラメーター設定 Step4. モックリソースの構築 Step5. (任意)レイテンシーの設定 Step6. (任意)テストリクエスト Step7. 負荷試験 Step8. モックリソースの削除 実装紹介 なぜGoで実装したか ディレクトリ構成 Goコード以外 Goコード パラメーター処理と初期化処理 main関数 DockerイメージとECRの構築と削除 Kubernetesリソースの構築と削除 Istioリソースの構築と削除 今後の展望 追加開発 使ってもらう まとめ We are hiring モック構築ツールとは モック構築ツールは、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の接続設定をします。たとえば、 awsp と kubie を使っている場合は以下です。 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レイテンシーの値を簡単に確認できるようになっているため、それを利用します。 正直なところ、この設定作業は負担が大きいので、一部自動化する予定です。なお、試験的に問題なければレイテンシーを設定しなくても構いませんし、全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.go 、 ecr.go 、 k8s.go 、 istio.go 、 params.go 、 action_type.go 、 lib.go の7つです。その他には、 Makefile 、 openapi.yaml 、 Dockerfile.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関数の処理の流れを図にすると以下の通りです。 DockerイメージとECRの構築と削除 ecr.go ではDockerイメージのビルドやECRの構築と削除をします。buildAndPushECR関数は、Dockerイメージをビルドして、ECRを構築してプッシュします。deleteECR関数では、ECRを削除します。なお、現状はECRログインの箇所で aws-cli を実行してしまっているので、 aws-sdk-go を使って改修する予定です。また、ECRだけでなく他のコンテナレジストリにも対応する予定です。 すでに同名のリソースが存在する状態で構築リクエストをするとWARNログを出力しますがエラーにはなりません。同様に、指定リソースが存在しない状態で削除リクエストをするとWARNログを出力しますがエラーにはなりません。これは、 ecr.go 、 k8s.go 、 istio.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