Mirrativ tech blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc...)によるブログです

【インフラ】 Envoy の導入と xDS API で Consul 連携やってみた話

f:id:octu0:20210427120345p:plain こんにちは ハタ です 今回はインフラ/基盤開発で導入している Envoy について紹介したいなと思っています

現在ミラティブでは Go移行 を進めているところで、
既存のWebアプリケーション(Perl で実装されてます)と、新たに Go で実装された Web アプリケーションをシームレスに導入/切り替えするために Envoy の導入を行いました

Envoy xDSとConsul によるインスタンス管理

NGINXHAProxy または Apache HTTP Server など使い慣れた && ノウハウもあるミドルウェアではなくなぜ Envoy を選定したかというと、
なんといっても魅力的なのが xDS と呼ばれる Discovery Service 群があることで柔軟に既存コンポーネントと連携を取りやすいことやAPI操作によるコントロールの柔軟性が高いことが決め手となっています
また後述しますが go-control-plane による API連携の実装が容易だったことも今後バックエンドのミドルウェアが変更した際にも連携しやすくなるというのも決め手の一つです

ミラティブでは従来より Consul による 仮想マシンインスタンスの管理およびクラスタの管理を行っており ほぼ全てのインフラ監視やアプリケーションの連携は consul 経由にて行われています

f:id:octu0:20210426211019p:plain
参加するconsulのクラスタを分けてマシンの管理をしてます

HAProxyやNGINXなどでは 設定ファイルをベースに構成を管理するため、例えば consul-template を使うことで
conuslのクラスタに応じた 設定ファイルの生成と動的なreloadを実装することができるのですが
ミラティブのオートスケーリングなどのワークフローと連携させるにはもっと細かく制御可能な方法を導入する必要がありました

Envoy xDS は下記のような種類があり、既存の consul クラスタとのマッピングを行うことで、既存機能に大きな変更することなく導入することができました

  • CDS (Cluster Discovery Service)
    • consul service のクラスタ群を定義
    • クラスタに流れるロードバランサーとヘルスチェックの定義
  • EDS (Endpoint Discovery Service)
    • エンドポイントの定義としてインスタンスのアドレス(IP/Port)の定義
    • リージョン/ゾーンの定義(Zone aware routingに使いたいため)
    • ステータスが active (内部チェックが完了したもの) になったインスタンスの定義 (LbEndpointのHealthStatusとの連携のため)
  • RDS (Route Discovery Service)
    • リクエストとクラスタのマッピング(ドメイン名とPath Prefixに応じて振り分け先クラスタ(consul service)を定義してます)
    • リクエストの振り分けは重み付けを定義(WeightedClustersを使っています)

他にも LDS だったり ALS の定義もあります

実際どのように Consul を連携を行っているのか

Envoy は 静的な設定ファイル ベースで設定することも可能なのですが v3 API を使って動的に設定を行うようにしています

Envoy 自体の冗長化も行うため Consul KV に構成の設定(後述)を記録しておき、xDS 連携するサーバが KV の内容を読み出して envoy に動的に設定するようにしています
具体的には go-control-plane による実装を行った xds-server を sidecar として、 Envoy と共に冗長化を行っています(consul kv自体は consul cluster による冗長化が行われています)

f:id:octu0:20210426191631p:plain
構成情報はConsul KVに永続化してます

ミラティブでは、サービスイン可能なインスタンス群のクラスタやインスタンスに不具合が起きた場合はエラー用のクラスタなどがあり、適切なクラスタに参加するインスタンスをEDSに設定しリクエストの振り分け対象にしています

EDS 以外のリクエスト振り分けの設定値やListenerの定義などは、次のような yaml の形式になっていてこれを consul kv に登録し、xds-server がconsul kvから読み出しながらCDSやRDS を更新しています

listener:
  listen:
    protocol: "tcp"
    address: "0.0.0.0"
    port: 8000
  ... # 他にもtimeout定義とか
route:
  domain: ["mirrativ.com", "www.mirrativ.com"]
  host-header: "www.mirrativ.com"
  cluster:
    web-api-perl:
      prefix: ["/api"]
      weight: 90
    web-api-go:
      prefix: ["/api"]
      weight: 10
    web-asset:
      prefix: ["/asset"]
      weight: 100
cluster:
  loadbalancer:
    web-api-perl:
      lb-policy: "round-robin"
      discovery: "eds"
      health-check:
        type: "http"
        host: "www.mirrativ.com"
        value: "/health_check"
        expected: ["200"]
        timeout: 3
        interval: 1
        healthy: 3
        unhealthy: 4
    web-api-go:
      lb-policy: "round-robin"
      discovery: "eds"
      health-check:
        ... # ヘルスチェックの定義
    web-asset:
      lb-policy: "round-robin"
      discovery: "eds"
      health-check:
        ... # ヘルスチェックの定義
endpoint:
  eds:
    web-api-perl:
      protocol: "tcp"
      port: 80
      balancing-policy: "locality"
    web-api-go:
      protocol: "tcp"
      port: 8080
      balancing-policy: "locality"
    web-asset:
      protocol: "tcp"
      port: 8080
      balancing-policy: "locality"

Yaml ファイルの定義なので分かりづらいのですが、CDSおよびEDSとRDSの連携は 既存の consul クラスタ(Service名)でマッピングするようにして(web-api-perlやweb-api-go という ServiceName のクラスタが存在します)、なるべく複雑にならないように Consul で使用しているものをそのまま利用するように定義にしています

このあたりの xDS 連携方法は、何か特別なミドルウェアを入れる必要はなく、既存のインフラ構成に合わせて実装しやすいのも envoy の良いところだと思います

ヘルスチェックとアクセスログ

envoy と sidecar で動作する xds-server は CLB などの LoadBalancer 配下に配置するようにしているのですが、
administration api の /ready を直接ヘルスチェックに使うのではなく、xds-server 側でヘルスチェックのエンドポイントを用意しています

これは何かしらの理由で envoy を CLB から外したい場合(多くの場合はインスタンスそのもののメンテナンスや特定ゾーンの抜き差しになると思います)や安全弁として特定の状態になった場合に、安全にリクエストを停止させる方法として用意しているエンドポイントになります

ミラティブのほぼ全てのアプリケーションレイヤでは、 メンテナンスファイル という形式で、特定のパスにファイルが存在する場合はアプリケーションを安全に停止させるように実装していて、今回の xds-server にも同じように実装しています

例えば Go の http server で実装すると下記のような実装です

http.HandleFunc("/api/foo", handleAPIFoo) // 各種 API
http.HandleFunc("/api/bar", handleAPIBar) //
...
// health_check エンドポイント
http.HandleFunc("/_my_health_check", func(w http.ResponseWriter, r *http.Request){
  if _, err := os.Stat("/opt/mirrativ/my-app/maintenance"); err == nil { // ファイルが存在する場合
    w.WriteHeader(599)              // status:200 以外を返す
    w.Write([]byte(`MAINTENANCE`))  // もちろんLBのhealth checkは 200-399 を healthy として定義する必要があります(L7の場合)
    return
  }

  // 通常時は status:200
  w.WriteHeader(http.StatusOK)
  w.Write([]byte(`OK`))
})

xds-server のヘルスチェックも同じように実装していて、メンテナンスファイルがある場合または envoy の cluster 構成が正常ではない場合にリクエストを受けないように status:200 以外を返すように実装し、CLBのヘルスチェック先に指定しています

また、アクセスログについても envoy が直接出力するのではなく、grpc によって xds-server に転送されるようにしています
これは今後リアルタイムなログ集計などを行えるるようにしたり、アクセスログの出力先を柔軟に切り替えれるようにするためです

ALS の実装は xDS を指定する時と同じように grpc server を指定することでアクセスログを受け取ることができます
例えば次のように定義して実装します

# bootstrap.yaml に追加
static_resources:
  clusters:
  # 通常のxdsのgrpc
  - name: xds_cluster
    ...
    type: STATIC
    load_assignment:
      cluster_name: xds_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { protocol: TCP, address: 127.0.0.1, port_value: 9000 }
  # alsのgrpc
  - name: als_cluster
      ...
      type: STATIC
      load_assignment:
        cluster_name: als_cluster
        endpoints:
        - lb_endpoints:
          - endpoint:
              address:
                socket_address: {protocol: TCP, address: 127.0.0.1, port_value: 9001 }

grpc.Server の定義(抜粋)

import (
  acclogv3 "github.com/envoyproxy/go-control-plane/envoy/service/accesslog/v3"
  ...
)

var (
  _ acclogv3.AccessLogServiceServer = (*myHandler)(nil)
)

type myHandler struct {}
func (h *myHandler) StreamAccessLogs(stream alsv3.AccessLogService_StreamAccessLogsServer){
  msg, err := stream.Recv()
  if err != nil {
    panic(err.Error())
  }
  for _, e := range msg.GetHttpLogs().GetLogEntry() {
    ... // いい感じにログを保存
  }
}

func main() {
  alsHandler := &myHandler{}

  svr := grpc.NewServer()
  acclogv3.RegisterAccessLogServiceServer(svr, alsHandler)
  
  listener, err := net.Listen("tcp", "[0.0.0.0]:9001")
  if err != nil {
    panic(err.Error())
  }
  svr.Serve(listener)
}

LDS の HttpConnectionManager の定義(抜粋)

import(
  "github.com/golang/protobuf/ptypes"

  accesslogv3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3"
  alsv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/access_loggers/grpc/v3"
  corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
  httpconnmgrv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3"
  wellknownv3 "github.com/envoyproxy/go-control-plane/pkg/wellknown"
  ...
)

func createHttpConnectionManager() *httpconnmgrv3.HttpConnectionManager {
  configDef := &alsv3.HttpGrpcAccessLogConfig{
    CommonConfig: &alsv3.CommonGrpcAccessLogConfig{
      ...
      GrpcService: &corev3.GrpcService{
        TargetSpecifier: &corev3.GrpcService_EnvoyGrpc_{
          EnvoyGrpc: &corev3.GrpcService_EnvoyGrpc{ClusterName: "als_cluster"}
        },
      },
    },
  }

  config, err := ptypes.MarshalAny(configDef)
  if err != nil {
    panic(err.Error())
  }

  return &httpconnmgrv3.HttpConnectionManager{
    ....
    AccessLog: []*accesslogv3.AccessLog{
      &accesslogv3.AccessLog{
        Name: wellknownv3.HTTPGRPCAccessLog,
        ConfigType: &accesslogv3.AccessLog_TypedConfig{
          TypedConfig: config,
        },
      },
    }
    ....
  }
}

ここまでの実装例として YAML 管理となってしまいますが実装例を用意してみました

github.com

v3 xDSの実装で、yamlで読み込んでいる箇所が consul のインスタンスに置き換わるとイメージがしやすいかもしれません

Envoy の導入に際して

f:id:octu0:20210426185233p:plain
既存のCLB構成

ミラティブでは既存のオートスケーリングが動作しており、既存のオートスケーリングではCLBを直接制御してサービスイン・アウトの実装がされていたり、カナリアリリースなどが実装されているため
既存の動作を保ちつつEnvoyを共存させるため、リリース順序を作りつつ導入に至りました

release.v1 CLB backend の perl 構成と同居

f:id:octu0:20210426185451p:plain
最初のreleaseではenvoyとperlの既存環境がCLB内で混在するところからスタート

LB の backend group は既存のリクエストを直接受けるものと、 envoy 経由で proxy させるものを共存させました
オートスケーリングは従来どおり CLB の backend group に直接追加する形のままとしてます
envoyからのリクエストの振り分けが正しく consul のクラスタ経由で連携できるようにしました

release.v2 CLB backend を envoy のみにする

f:id:octu0:20210426190111p:plain
CLB内で混在しているよりもだいぶすっきりしました

既存のオートスケーリングで CLB の backend group に直接追加せずに、 consul のクラスタ経由で行うように変更 ここで既存機能の一部修正が行われました

release.v3 envoy loadbalancingy/routing でアクセスの振り分けを行う

f:id:octu0:20210426205531p:plain
既存のperlのAPIの中から一部だけ別クラスタとして定義し、問題なく捌けるかを試すための構成

この段階で初めて envoy によるリクエストの振り分け(load balancing/routing)を行うようにしました
既存の perl 構成のものから、特定のエンドポイントだけを受け付ける クラスタを構成しPathベース負荷分散を行うようにしました

その後(Next Step)

f:id:octu0:20210426194141p:plain
consul 連携イメージ

その後いくつかのエンドポイントの切り出しを行いました

  • 例えば /asset/* だけを切り出した web-asset クラスタを構成し画像などを配信するだけの軽量なサーバに切り替えたり
  • 例えば 比較的アクセス数の多い /api/foobar のエンドポイントだけを切り出し、クラスタ内の構成は同じまま、特定のエンドポイントだけの負荷分散を構成したりしました
  • 他にも複数のQA環境の切り替えなども envoy と xds-server によって切り替えを行っています

また現在では、当初の目的であったGoのエンドポイントのリリースも行われており、徐々に Perl から Go の実装に切り替わっていっています

今回Envoyの導入によって全ての上りのトラフィックが Envoy経由でコントロールできるようになったため、次なるステップとして下記のようなものを実施できたらなと考えています(できるかどうかは別として)

  • 動的WeightedRoundRobin
    • 現在は同一Pathの場合にWeightedClusterによる振り分けを行っていますが、新規リリースの場合に小さなWeightからスタートさせリクエストを反映できたらいいなとか
    • カナリアリリースや暖気にも応用が効きそうです
  • バージョニングルーティング
    • IngressだけでなくEgressのトラフィックコントロールを行うことで同一のリリースバージョンは同一のリソースを参照させたりできないかなとか
    • 後方互換を伴うリクエストを受けたりするときに役立ちそう
  • GCPのメンテナンスイベントとの連携
    • ライブマイグレーション中は一時的にインスタンスのパフォーマンス劣化することが見えているため、振り分け対象から外したりできないかなとか
    • Outlierで細かく制御したりとか

この他にもMySQL Filter の活用もできたりしないかなと考えたりしています
RBAC(Role Based Access Control)が使えるので、フェイルオーバ時の挙動制御に使えたりしないかなとか、今後調べつつトライしてみたいです

We are hiring!

Goによる大規模サービスの開発が進む中で、インフラ基盤開発は常に次の一手を考えつつ新しい基盤となる技術を作っていっています 一緒に基盤開発をしてくれるエンジニアを募集しています!

www.mirrativ.co.jp