Istioを使ってPull Requestごとにpreview環境を作る

Posted on

tl;dr

  • Virtual Serviceを使ってpreview用のルーティングを管理するようにした
  • 必要最低限のリソース(Virtual Service, Service, Deployment)だけを作って短時間でフィードバックサイクルが回るようにした

概要

共用の開発環境を使っていると、何かの変更が他に影響を与えてしまうことがあります。そしてそれが既存のリクエストを正しく処理できなくなってしまうものであれば最悪です。

Kubernetesを使ったマイクロサービスが一般的になり、各マイクロサービスは独立したデプロイサイクルを持つことが容易になりました。しかし気軽に開発環境にデプロイ出来なくなったとすれば利点を最大限に活かせません。

そこで用いられるのが開発中の機能を分離して扱えるよう環境を用意する手法です。実現方法としては

  1. dev-1, dev-2, … のように開発環境自体を複数作る
  2. Kubernetesクラスタを分離する
  3. Namespaceを分離する
  4. 特定のリクエストを分離する

などといったことが考えられます。最も多いのは1.のdev-Nを作るケースのように思います(個人の感想)。手法はいくつかありますが、分離レベルと実装コストがそれぞれ異なるのでチームの状態やシステム要件に沿って選択するのが良いと思います。

今回採用したのは4.の手法です。Istioを入れていたので、それを最大限活用してトラフィック管理を実装しました。

前提条件

  • GKE: v1.17.12-gke.2502
  • Istio: 1.7.3

アーキテクチャはざっくり以下の感じです。DBはマイクロサービス単位で閉じて、マイクロサービス間は基本的にHTTPでやり取りする一般的な構成です。

preview-サービス全体.png

preview環境の実装

改めてpreview環境の定義を書いておきましょう。ここではクラスタ内外問わず、特定のマイクロサービスに対するトラフィックを分離させることを指しています。

preview-トラフィック全体.png

また先の図から自明ですが、最終的にリクエストしたいサービスに対するトラフィックはリクエスト地点から以下の3つに分けることが出来ます。

preview-トラフィックパターン.png

これをIstioのVirtual Serviceという機能を使って実現しています。Virtual Service自体については公式ドキュメント (https://istio.io/latest/docs/reference/config/networking/virtual-service/) を読むのがオススメです。何度か読むと何となく分かった気になれるので便利です。

Virtual Serviceはリクエストの種類(URL、ヘッダなど)によってルーティング先を管理することが出来ます。先の図で表したリクエストパターン1.は以下のように定義することが出来ます。

apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: my-gateway
  namespace: istio-system
spec:
  selector:
    istio: ingressgateway
  servers:
  - hosts:
    - '*'
    port:
      name: http
      number: 80
      protocol: HTTP
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: A-gateway-virtual-service
  namespace: istio-system
spec:
  gateways:
  - my-gateway
  hosts:
  - api-A.example.com
  http:
  - route:
    - destination:
        host: A-service.default.svc.cluster.local
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: preview-123-A-gateway-virtual-service
  namespace: istio-system
spec:
  gateways:
  - my-gateway
  hosts:
  - preview-123-api-A.example.com
  http:
  - route:
    - destination:
        host: preview-123-A-service.default.svc.cluster.local

これで spec.gateways を通して spec.hosts のホスト名で入ってきたリクエストのルーティングを指定出来ます。単純化のためにマニフェストをホストごとに分けていますが、HTTPMatchRequest (https://istio.io/latest/docs/reference/config/networking/virtual-service/#HTTPMatchRequest) を使ってマニフェストをまとめることも出来ます。

上記のVirtual Serviceだけではリクエストパターン2.と3.は満たせません。 spec.gateways に指定した my-gateway はクラスタ外からのルーティングに使用され、クラスタ内のサービス間通信では使用されないからです。 サービス間通信でVirtual Serviceを適用するには mesh Gatewayを指定するか、 spec.gateways を空にする必要があります。 mesh Gatewayは予約語として特別な扱いをされており、実際にそのGatewayリソースを作る必要はありません。サービス間のリクエストは以下のVirtual Serviceで定義することが出来ます。

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: preview-A-mesh-virtual-service
  namespace: istio-system
spec:
  hosts:
  - A-service.default.svc.cluster.local
  http:
  - route:
    - destination:
        host: A-service.default.svc.cluster.local

ここまででpreview用のホスト名を使ってpreview用に作ったServiceに対してルーティングを定義することが出来ました。しかしこのままではリクエストパターン3.のときにアプリケーション側に手を入れる必要があります。つまり A Serviceからは B-service.default.svc.cluster.local に対してリクエストを送ってしまうということです。podの環境変数で変更しても良いですが、ここでもVirtual Serviceを使って実現することが出来ます。 Virtual Serviceではリクエストヘッダによってルーティングを変更することが出来ました。これをHeaders.HeaderOperations (https://istio.io/latest/docs/reference/config/networking/virtual-service/#Headers-HeaderOperations) と合わせて使うことで、特定のURLでリクエストされたときに任意のヘッダを付与することが出来ます。

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: preview-123-A-gateway-virtual-service
  namespace: istio-system
spec:
  gateways:
  - my-gateway
  hosts:
  - preview-123-api-A.example.com
  http:
  - route:
    - destination:
        host: preview-123-A-service.default.svc.cluster.local
      headers:
        request:
          add:
            X-PREVIEW: preview-123

同様に mesh Gatewayを使ったVirtual Serviceは以下のように書き換えることが出来ます。

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: preview-A-mesh-virtual-service
  namespace: istio-system
spec:
  hosts:
  - A-service.default.svc.cluster.local
  http:
  - match:
    - headers:
        X-PREVIEW:
          prefix: preview-123
    route:
    - destination:
        host: preview-123-A-service.default.svc.cluster.local
  - match:
    - headers:
        X-PREVIEW:
          prefix: preview-456
    route:
    - destination:
        host: preview-456-A-service.default.svc.cluster.local
  - route:
    - destination:
        host: A-service.default.svc.cluster.local

これで preview-123-api-A.example.com のリクエストにpreviewヘッダが付与され、mesh内ではpreviewヘッダの有無によってルーティングが行われるようになります。(ただしヘッダの伝播はアプリケーション側で対応する必要があります。IstioあるいはEnvoyで実現する方法があれば教えてください!) これまでのリクエストの流れをまとめると以下のようになります。

preview-トラフィックパターンのコピー.png

また今回はServiceリソースも個別に作成して、そこからpodを紐付けることを想定しています。ただしDestination RuleとSubsetを使って任意のpodに流すというのももちろん可能なので、どこまでリソースを分離したいかで使い分けると良いと思います。

Goでのヘッダ伝播

previewヘッダの伝播にはアプリケーション側で別途対応が必要と書きました。例えばGoではhttpハンドラで特定のヘッダをcontextに詰めて使い回すということが考えられます。 以下のようなコードをライブラリに置いておくと、各マイクロサービスで意識する必要が無くなるので便利だと思います。

https://github.com/takashabe/go-http-propagation-sample

package main

import (
	"context"
	"net/http"
)

const PreviewHeader = "X-PREVIEW"

type addedPreviewKey struct{}

func PreviewMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		h := r.Header.Get(PreviewHeader)
		if h == "" {
			next.ServeHTTP(w, r)
			return
		}
		r = r.WithContext(context.WithValue(r.Context(), addedPreviewKey{}, h))
		next.ServeHTTP(w, r)
	})
}

type PreviewTransport struct {
	Base http.RoundTripper
}

func (t *PreviewTransport) base() http.RoundTripper {
	if t.Base != nil {
		return t.Base
	}
	return http.DefaultTransport
}

func (t *PreviewTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	tr := t.base()
	r := req.Clone(req.Context())
	v, ok := r.Context().Value(addedPreviewKey{}).(string)
	if ok {
		r.Header.Add(PreviewHeader, v)
	}
	return tr.RoundTrip(r)
}

func (t *PreviewTransport) CancelRequest(req *http.Request) {
	type canceler interface {
		CancelRequest(*http.Request)
	}
	if cr, ok := t.base().(canceler); ok {
		cr.CancelRequest(req)
	}
}

func NewClient(ctx context.Context) *http.Client {
	cl := http.DefaultClient
	tr := &PreviewTransport{
		Base: cl.Transport,
	}
	cl.Transport = tr
	return cl
}

preview環境の生成

ここまでpreview環境をどう実装するかについて見てきました。これらのリソースを手動で作るのは大変なので自動化しましょう。 Kubernetesのリソース操作にはkubernetes/client-go (https://github.com/kubernetes/client-go) が使えます。CRDであるIstioのリソースはistio/client-go (https://github.com/istio/client-go) が用意されているのでこちらを使います。

社内ではpreview環境を管理するためのCLIツールを作成し、手動あるいはCI(Github Actions)から利用することでPull Requestごとにpreview環境を作成出来るようにしています。コードの詳細は割愛しますが、これまで見てきたリソースを愚直に作る+αを行っています。

Virtual Serviceを作成するときに一つだけ注意点があります。 spec.http に指定するHTTPRouteが複数存在するとき、いずれかにマッチした時点でルーティング先が決定されます。つまり mesh GatewayのVirtual Serviceではpreviewヘッダが無い既存Serviceへのルーティングは最後に置く必要があります。

まとめ

IstioのVirtual Serviceを使ってpreview環境を作る手法について紹介しました。preview環境として分離する最適な粒度はアーキテクチャによって異なるので検討が必要です。HTTP(あるいはgRPC)をベースにマイクロサービス間で通信する場合は今回の手法で大体事足りるはずです。

また今回はメッセージキューやDBなどのクラウドにありがちな共有コンポーネントは考慮していません。実運用ではこれらもKubernetesリソースと同時に作成してしまうと良いかもしれません。私自身、社内でこのpreview機構を実装してから日が浅いので適宜最適化していきたいと思います。