Mirrativ Tech Blog

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

EnvoyでVM内のトラフィックをコントロールしてデプロイを高速化した

こんにちは、ミラティブのインフラを担当している清水です。 ミラティブは2025年8月28日に10周年を迎え、ユーザーの皆さまに長年ご愛顧いただけて感謝の極みです。

一方で、長年の運用で刷新できないまま残ったレガシーなシステムもあり、古いものと新しいものを混在したまま運用を続けています。 過去記事でもサーバサイドの技術をPerlからGoへシステム移行することを紹介していますが、ミラティブのサーバサイドのアプリケーションは現在も継続してPerlからGoへ移行しています。 このため、GoとPerlをデプロイする必要があり、それぞれの言語特性にあったデプロイの仕組みでアプリケーションを更新しています。

しかしながら、長年の運用でGoの実行環境とPerlの実行環境では、デプロイについてそれぞれに課題が出てきており、2つの環境の差異による管理コストも増え、どうにか仕組みを統一化してそれぞれのデプロイの課題も一緒に解決できないかと考えました。

今回の記事では、ミラティブのサーバサイドで長年運用してきたGoとPerlのそれぞれのデプロイの仕組みを刷新した取り組みについて紹介します。 最終的には、仕組みを統一化したうえでデプロイ時間を短縮して安全に切り替える方法を実現できました。

元々のミラティブのデプロイ構成

ミラティブで開発しているサーバサイドのアプリケーションはGo製とPerl製のものがそれぞれ別のVMで動いており、 GoとPerlのWebサーバへ流れるサービストラフィックを Envoy を用いて それぞれ振り分けています。

GoとPerlはそれぞれ別のデプロイ方式で最新バージョンを更新していました。

  • Go環境: サービスイン済みのVMを順次新しいバージョンのVMに入れ替えていくローリングアップデート方式
  • Perl環境: rsyncでVMへコード配布した後にServer::StarterモジュールによるHotDeploy(以下、HotDeploy方式とします)

これらの方式でそれぞれの環境に応じてバージョンの更新していました。

加えて、ミラティブでは最新リリースを全展開する前に、GoとPerlそれぞれ1台ずつ最新リリースで動かすVMを用意してカナリアリリースしています。これらは

  • Go環境: ローリングアップデート方式で最新リリースのVMを1台だけ入れ替え
  • Perl環境: HotDeploy方式でVM1台だけ最新リリースを適用

として数分間のカナリアリリースを行っていました。

Go環境のデプロイ(ローリングアップデート方式)

Go環境のWebをv1からv2へデプロイする場合の流れです。

ミラティブのWebはLBで受けたリクエストをIngressEnvoyに流し、エンドポイントを判定して後段のWebのVMにリクエストを流しています。

初期状態は v1 のVMのみ動いています。

カナリアリリースが始まると v2 のVMが1台投入されて一定時間エラーが出ていないことを確認します。

カナリアリリースで投入した v2 が問題無ければ v1 と同じ数だけVMを展開します。

v1 をサービスアウトして v2 のVMのみの状態にします。

これでGo環境のデプロイは完了になります。

Perl環境のデプロイ(HotDeploy方式)

Perl環境のデプロイは配布元のdeployサーバからrsyncでWebサーバにコードを配布します。

コードの配布が完了したら、Server::StarterモジュールにHUPシグナルを送ってプロセスを再生成します。

Perl環境のデプロイはすべてのWebサーバでコード配布とHUPシグナルを送信したら完了となります。

それぞれのデプロイの課題

Go環境のデプロイ課題(ローリングアップデート方式)

Goのデプロイはローリングアップデート方式を採用していたため、Immutable Infrastructureを実現できて安全にリリースできる強みがありました。

また、構築時にVMで動かすアプリケーションのバージョンが決まるので、バージョン管理しやすく、監視を作って異なるバージョンがサービスインしていないか検知させることもできます。

一方で、都度VMを新規構築して、サービスイン済みのVMと入れ替えていくので、どうしても反映に時間が掛かってしまうデメリットも存在しました。

更新のたびに都度VMを構築するということは、障害発生時などでロールバックしたい場合もVMの再構築が必要となるので、障害によるユーザさんに与える影響が長期化してしまうのも悩みでした。

Perl環境のデプロイ課題(HotDeploy方式)

Perlのデプロイは既に起動中のVMに対してコードの差分を配布してプロセスを再生成するので、マシンリソースを効率的に利用できて適用の早い点がメリットでした。 障害が発生してロールバックしたい場合も、古いバージョンのコードを再配布してプロセスを再生成すれば良いだけなので反映が早く、ユーザさんへの影響が短く済むのも良い点でした。

一方で、運用しているとデメリットもあることに気がつきました。

1つ目は、コードを配布するために一元化された環境からrsyncしてコードを配布しているので、デプロイをするための方式とVM構成の結合度が上がって他環境へ移植しづらい点です。 rsync src/dir dst/dir という構成を再現し続ける必要があり、デプロイ元とデプロイ先のディレクトリ構成やSSH接続などを移植し続ける必要がありました。

2つ目は、オートスケールとの相性も悪く、VMの新規構築時に初回のrsyncが走るので、同期に時間がかかる点です。 あらかじめコードを含んだイメージから構築して、rsyncによる同期時間を減らす工夫もしていましたが、初回だとdiskスキャンに時間を取られがちでした。

3つ目は、オートスケール中であれば構築中にバージョンを揃える必要があるので、デプロイをロックする必要があり、高速なデプロイをそこまで実現できませんでした。

最後に、バージョン管理もやりづらく、rsyncで配布した時点のものとHotDeployで切り替えたバージョンが一致しているかを監視・管理する仕組みを用意する必要もありました。

改善したいポイント

GoとPerlのそれぞれのデプロイについて課題を紹介させていただきましたが、まとめると以下を改善していけばデプロイを快適にできるのではないかと考えました。

  • Goのデプロイに時間がかかるので短縮したい
    • リリースの反映に時間がかかると開発体験が悪くなるので短縮したい
    • ロールバックに時間がかかるとユーザさんに迷惑をかけてしまう時間も増えてしまうのでこちらも短縮したい
  • GoとPerlでデプロイの仕組みを統一したい
    • GoとPerlでデプロイの仕組みが異なりややこしい
      • PerlとGoで環境は異なるが、それぞれでVMのリソース管理を細かく行っていて、CPUバウンド/メモリバウンドするポイントもはっきりしているので、PerlとGoで起動方法を同じにして管理方法を統一できるはず
    • GoとPerlで仕組みの異なるデプロイをメンテし続けるのは管理コストがかかるので減らしたい
    • GoとPerlでデプロイの反映タイミングが異なるので統一したい
      • Goのデプロイが遅いので先にPerlのデプロイが完了する
      • 新入社員など、ミラティブのデプロイの仕組みを知らない人はPerlの反映段階でデプロイが完了したと錯覚させてしまう
  • PerlのVMの新規構築が遅いのをなんとかしたい
    • PerlはHotDeployなので反映は早いが、VM構築時にはrsyncによる初回ディスクスキャンで時間がかかって構築が遅れる
  • PerlのVMで動いているアプリケーションのバージョンをしっかり管理したい
    • PerlのHotDeployはrsyncでコードを同期する方式なのでシンプルだがバージョン管理しづらい
    • Goは指定バージョンのビルドで起動しているのでPerlも同様にしたい

どういう風に解決したか

Goのデプロイをコンテナでインプレースデプロイして高速化

まず、Goのデプロイの高速化を着手しました。 理想としてはPerlのHotDeployのようにアプリケーションの入れ替え(以下、インプレースデプロイ方式)のみで更新を反映できれば良いのですが、Goはコンパイル型言語なので、スクリプト言語であるPerlのようにコードをサーバに配布して反映させることができません。 更新を反映させるにもプロセスそのものを切り替える必要があります。

配布方法もPerlのように、push型で一元サーバからrsyncで配布すると、バイナリに加えてconfigやassetなども配布する必要があり、 ファイル数が多くなると転送に時間がかかってサーバの台数に比例して遅くなる問題もでてきます。

そこで、rsyncに変わる別の方法で効率よくバイナリを配布して、Goで作成されたアプリケーションのプロセスそのものを切り替えられないかと考えました。

ミラティブではVMのホストOSとしてコンテナの実行に最適化された Flatcar Container Linux を使っていて、Goのアプリケーションをコンテナ上で起動しています。 コンテナは構成が階層(ステージ)に分かれているため、デプロイ時は変更分のレイヤーだけをpullすればよく、効率的に更新できそうです。

ファイル数の多いconfigやassetなどもdockerイメージに収めておけば、1つのイメージを pull して差分で取得できるので効率的ですし、バージョン管理もやりやすそうだと考えました。

プロセスそのものを切り替えてバージョンアップする時にも、新しいバージョンのコンテナを起動してリクエストを切り替えることができれば可能になると考えました。

PerlもHotDeployをやめてコンテナでインプレースデプロイ方式に変更した

GoとPerlでデプロイの仕組みが異なるので統一するためにPerlのHotDeployを廃止できないか検討しました。 PerlのHotDeployは反映が早くて良いのですが、オートスケールと相性悪くVMの構築が遅かったり、ファイルの差分同期でバージョン管理しづらい点も悩みでした。

PerlもGo同様にコンテナのホストOSとして最適化された Flatcar Container Linux を使っていて、指定バージョンのイメージからServer::Starterを呼び出して起動するようにすれば、わざわざrsyncでコードを同期しなくても複数のバージョンのコンテナを同時に起動できそうです。 この方式であれば複数のバージョンのコンテナを同時に起動できるため、Goと同じくインプレースデプロイ方式に統一できそうです。

HotDeployを廃止すればrsyncも不要になりVMの構築も早くなります。 また、指定のイメージを起動するだけになるのでバージョン管理もやりやすそうです。

一方で、イメージをまるっと取得してくるので差分を反映させていたHotDeployに比べてデプロイの速度は落ちてしまいます。 ですが、インプレースデプロイ方式と同等のデプロイ時間となるはずなので、VM構築の時間が減る分だけトータルでみたときのデプロイの時間は減るはずと考えました。

コンテナを入れ替える時のダウンタイムをどうするか

GoとPerlをVMの中でコンテナを入れ替えるインプレースデプロイ方式に変更することで、構成の統一化とGoのデプロイの高速化ができそうです。 しかし、GoとPerlにトラフィックが流れている状態でコンテナを停止して入れ替えてしまうとクライアントにエラーが返ってしまいます。 そこで、VMの中でトラフィックコントロールしてクライアントにエラーを返さずコンテナの入れ替えができないか検討しました。

ミラティブでは Envoy を用いて すでに外から来るトラフィックをコントロールしている実績があり、 これまでにも 不調になる予兆があるVMへのトラフィックコントロール を実装したりだとか、サービスインしたwebサーバに徐々にトラフィックを流してすこしずつ暖気させるといったこともやっていたりと、Envoyを活用してトラフィックコントロールしてきました。 コンテナを入れ替える時のダウンタイムもEnvoyであればトラフィックコントロールでき、柔軟なチューニングもできそうなので、今回もEnvoyを活用していくことにしました。

VM内で複数コンテナ立ち上げる時のIPとポートの管理はどうするか

Envoyを活用すれば、複数のバックエンドアプリケーションのコンテナを起動して切り替えることもできそうですが、VM内で複数のコンテナ立ち上げるため、コンテナのIPとポートの管理が課題となりました。

既存のWebサーバはTCPのポートフォワードでバックエンドアプリケーションにトラフィックを流しているのですが、既存と同じ仕組みでバックエンドのコンテナにトラフィックを流した場合、すでにListen済みのプロセスが存在すると起動できないので、Listenしていないことを保証する必要がありました。

また、TIME_WAITしてるコネクションが存在すると簡単に閉じることができないので、コネクションが解放されるまで待つか別の空きのポートを探す必要もありました。

コンテナで動かすので、docker-proxyとEnvoyの組み合わせでもリクエストを振り分けられそうですが、docker-proxyはユーザランドで動くプロセスなのでパケットをカーネル空間からユーザ空間に切り替えが必要で、スループットが良くないので採用を見送りました。

reuse_portでバックエンドアプリケーションを複数起動させることも出来そうですが、実績もある ものの切り替えにはちょっとした実装が必要で、特定バージョンのみリクエストを流す・流さないといったトラフィックコントロールが難しいのも課題でした。

ほかにも世の中にはreuse_portをeBPFで拡張してリクエストを振り分けるといった実績もありそうですが、扱いやすい上位レイヤでHTTPヘッダなどの情報をもとにenvoyを制御したいとも思いました。

いろいろと検討しましたが、TCPは何かと課題が多いのでUnixDomainSocket(以下、UDS)を利用してみることにしました。

GoとPerlのコンテナが ${version}.sock を作って Envoy に読み込ませるだけになるので、TCPのようにListenしていないことを保証する必要がありません。 切り替えも簡単で、Envoyで流したいバックエンドアプリケーションのsocketを指定すれば良いだけなので、切り替えるときに古いバージョンへトラフィックを流さないみたいなことが手軽にできそうです。 また、UDSによる性能が出せる点もメリットにあげられます。(PerlとGoのUDS連携 も参照ください)

Envoyによるアプリケーションの切り替えは扱いやすいUDSを利用してみることにしました。

deploy時のダウンタイムをどのように無くすのか

EnvoyとUDSの組み合わせでダウンタイム無く切り替えられそうですが、リクエストを安全に切り替えできるように、またサービス影響無く切り替えるための工夫も検討しました。

まず、ヘルスチェックを多重に実装することとしました。 切り替え前にGoとPerlのバックエンドアプリケーションのコンテナそのものが生きているかと、Envoyを通してリクエストルーティング出来ているか両方チェックすることで安全に切り替えられそうです。

さらに、何らかの実装ミスでどこにもリクエストを流せない状況を無くすため、header match を使った分岐ではfallbackにdefaultを必ず通すように検討しました。

リクエストの切り替え完了も安全に行えるように、古いバージョンのバックエンドアプリケーションへリクエストが流れなくなったのを確認してからupstreamの古いバージョンのルーティングを消して切り替えを完了させるようにしました。

deployをどうするのか

PerlのHotDeployでは中央集権型のデプロイとなっていて、デプロイ用のVMを用意してssh越しにrsyncでコードを同期し、Server::StarterモジュールにHUPシグナルを送って更新を反映させていました。

新しく作るインプレースデプロイ方式も、デプロイ用のVMを用意してssh越しにコマンドを実行したりdocker-apiを叩いて実現できそうですが、デプロイ対象となるVMの台数が増えてくるとデプロイ完了までの時間が増加していくだろうと予想していました。

並列で処理させれば時間短縮できますが、デプロイ対象が増えればデプロイ用のVMのCPUやメモリといったリソースも増加していきます。 加えて、既存のデプロイはポーリング式になっていて、一定間隔でデプロイ対象が存在しているか確認しているので、リアルタイム性が無いのも悩みでした。

そこで、新しいデプロイは中央集権型を廃止して個々のVMが自律的にデプロイできるようにデプロイ時のトリガーをイベント式にし、リアルタイムにインプレースデプロイできるように変更してみることにしました。

ミラティブでは既にサーバのクラスタ管理にconsulを利用していて、consul-event というイベント処理の仕組みを備えていたので、工数少なく投入できそうなので利用してみることにしました。

consul-eventであれば特定のイベントをwatchして docker pull & docker run するだけで実現できるので複雑な仕組みもいらないですし、 今後ミラティブを動かすDCが増えていってconsul-clusterが分割していったときに、特定のDCだけイベントを発行してデプロイするといった拡張もやりやすそうです。 consul-eventは直近のイベントを記録してくれる(ただし、順位づけは行われない)ので、どんなイベントが発行されていたのかを後からデバッグしやすいのも採用理由でした。

Envoyを活用したインプレースデプロイ方式の構成図

最終的に完成した構成図が以下となります。 初期状態ではGo環境とPerl環境は v1 で動いています。

deployサーバがconsulに v2 に変更するようイベントを発行すると、consulを介してイベントが伝播し、Go環境とPerl環境のVMで動いているwatch-eventプロセスがイベントを検知します。

watch-eventプロセスが v2 イベントを検知すると、v2 コンテナのイメージをPullして起動します。 そして、SidecarEnvoyのapiで v2 コンテナをバックエンドに追加します。 このとき、リクエストの流し先は v1 のままにしておきます。

カナリアリリースするため、LBと接続されたIngressEnvoyが数%だけリクエストに X-SERVER_VERION:v2 ヘッダを付与してバックエンドのVMにリクエストを流します。

リクエストを受け取ったGo環境とPerl環境のVMではEnvoyのHeader MatchでX-SERVER_VERION:v2 の付与されたリクエストだけ v2 にリクエストを流します。

カナリアリリースでエラーが出ていないことを確認できたら、 v1 から v2 へ完全に切り替えます。 deployサーバが v1 をpurgeするイベントを発行し、Go環境とPerl環境のVMで動くwatch-eventプロセスが検知します。 watch-eventプロセスがSidecarEnvoyのapiを実行して v1 をバックエンドから外し、 v1 にリクエストが流れなくなったらコンテナを停止して削除します。

最終的にはバックエンドが v2 のみとなり、コンテナを利用したインプレースデプロイが完了となります。

Envoyによるインプレースデプロイの実装例

SidecarEnvoyの実装を紹介します。

Sidecarとは、メインとなるアプリケーションコンテナに対し、その機能を拡張・補助する別コンテナです。 Dockerの原則では通常1つのコンテナにすべてを詰め込みますが、ログの収集や通信の暗号化、設定ファイルの同期など、ビジネスロジック以外の周辺機能を詰め込んでしまうと、 アプリが肥大化して管理が難しくなります。 そこで、関心を分離するため、ビジネスロジック以外の機能を持ったメインコンテナに寄り添う形で動くコンテナがSidecarとなります。

SidecarEnvoyはGoで作られた内製のアプリケーションとなっていて、Envoyの設定ファイルとログを生成してくれます。

今回は実際にサービスで利用しているコードを簡略化して必要最低限動くものをサンプルとして紹介させていただきます。

各コードの説明

SidecarEnvoyはEnvoyの設定ファイルテンプレートとGoのコードで構成されています。

Envoyの設定ファイルテンプレート

Envoyの設定ファイルにはxds(Extensible Discovery Service)を操作するための命令が記述されています。 xdsとは、Envoyのデータプレーンを動的に設定・制御するためのAPI群の総称です。 SidecarEnvoyがEnvoyの設定ファイルを生成し、Envoyが生成された設定ファイルを読み取ることで、動的なバックエンドアプリケーションの切り替えを実現しています。

まずはEnvoyの設定テンプレートから紹介させていただきます。

envoy.yaml.tmpl はenvoy.yamlを生成するためのテンプレートです。

lds.yamlとcds.yamlをincludeしていて、Envoyを起動するときはenvoy.yamlをconfgに指定します。

{{ $root := . }}
node:
  cluster: ingress-switcher
  id: my-host

dynamic_resources:
  cds_config:
    path: "{{ $root.CDSYamlPath }}"
    resource_api_version: "V3"

  lds_config:
    path: "{{ $root.LDSYamlPath }}"
    resource_api_version: "V3"

lds.yaml.tmpl はxdsのlds(Listener discovery service)を記述したlds.yamlを生成するためのテンプレートです。

Envoyをどのip:portでlistenさせ、受け取ったリクエストを条件判定させてどのバックエンドのclusterに引き渡すかを定義しています。 header match を記述していて、トラフィックの流し先をdefault、バージョン付き、ヘルスチェックに分岐させています。

{{ $root := . }}
version_info: "{{ $root.ConfigVersion }}"
resources:
- "@type": type.googleapis.com/envoy.config.listener.v3.Listener
  name: listener_0
  address:
    socket_address:
      protocol: TCP
      address: 0.0.0.0
      port_value: 8080
  filter_chains:
  - filters:
    - name: envoy.filters.network.http_connection_manager
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
        stat_prefix: ingress_http
        codec_type: AUTO
        use_remote_address: true
        skip_xff_append: true
        xff_num_trusted_hops: 1
        route_config:
          name: ingress_http_route
          virtual_hosts:
          - name: ingress_http_vhost_any
            domains: ["*"]
            routes:
            {{ range $i, $backend := $root.Backends }}
            - match:
                prefix: "/"
                headers:
                - name: "X-SERVER-VERSION"
                  string_match:
                    exact: {{ $backend.Version }}
              route:
                cluster: {{ format_version $backend.Version | printf "backend_%s" }}
            {{ end }}
            {{ range $i, $hc := $root.Healthchecks }}
            - match:
                prefix: "/_internal_healthcheck"
                headers:
                - name: "X-HC-INTERNAL-UUID"
                  string_match:
                    exact: {{ $hc.Version }}
              route:
                cluster: {{ format_version $hc.Version | printf "healthcheck_%s" }}
            {{ end }}
            {{ if ne $root.DefaultBackend.UDSPath "" }}
            - match:
                prefix: "/"
              route:
                cluster: default_backend
            {{ end }}
        http_filters:
        - name: envoy.filters.http.router
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

cds.yaml.tmpl はxdsのcds(Cluster discovery service)を記述したcds.yamlを生成するためのテンプレートです。

Envoyのリクエスト流し先のバックエンドclusterを定義して、v1のクラスタ、v2のクラスタ、Envoyのヘルスチェック用のクラスタという感じで複数定義することができます。

{{ $root := . }}
version_info: "{{ $root.ConfigVersion }}"
resources:
{{ range $i, $backend := $root.Backends }}
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
  name: {{ format_version $backend.Version | printf "backend_%s" }}
  connect_timeout: 1s
  type: STATIC
  lb_policy: ROUND_ROBIN
  common_lb_config: {} {{/* 必要な設定を記述する */}}
  health_checks: {} {{/* 必要な設定を記述する */}}
  load_assignment:
    cluster_name: {{ format_version $backend.Version | printf "backend_cluster_%s" }}
    endpoints:
    - locality:
        region: local
        zone: localhost
      load_balancing_weight: 1
      priority: 0 {{/* highest */}}
      lb_endpoints:
      - endpoint:
          address:
            pipe:
              path: {{ $backend.UDSPath }}
    - locality:
        region: local
        zone: localhost
      load_balancing_weight: 1
      priority: 10 {{/* fallback */}}
      lb_endpoints:
      - endpoint:
          address:
            pipe:
              path: {{ $root.DefaultBackend.UDSPath }}
{{ end }}
{{ range $i, $hc := $root.Healthchecks }}
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
  name: {{ printf "healthcheck_%s" $hc.Version  }}
  connect_timeout: 1s
  type: STATIC
  lb_policy: ROUND_ROBIN
  common_lb_config: {}  {{/* 必要な設定を記述する */}}
  load_assignment:
    cluster_name: {{ format_version $hc.Version | printf "hc_cluster_%s" }}
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            pipe:
              path: {{ $hc.UDSPath }}
{{ end }}
{{ if ne $root.DefaultBackend.UDSPath "" }}
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
  name: default_backend
  connect_timeout: 1s
  type: STATIC
  lb_policy: ROUND_ROBIN
  common_lb_config: {}  {{/* 必要な設定を記述する */}}
  load_assignment:
    cluster_name: default_backend_cluster
    endpoints:
    - lb_endpoints:
      - endpoint:
          address:
            pipe:
              path: {{ $root.DefaultBackend.UDSPath }}
{{ end }}

SidecarEnvoyのGoのコード

main.go はSidecarEnvoyのエントリーポイントです。 ログ出力の設定、Envoyのヘルスチェック用エンドポイントの起動、Envoyの設定ファイル生成を行っています。

package main
import (
  ...
)
func switcherAction() error {
        outAdminConfPath := "/tmp/admin.conf"
        outEnvoyYamlPath := "/tmp/envoy.yaml"
        hcEndpointUDS := "/tmp/hc.sock"
        adminEndpointTCP := "0.0.0.0:7530"
        uuid := ... // uuid は必要に応じたもので生成
        hcChecker := &HealthChecker{
                uuid:        uuid,
                hcURL:      "http://127.0.0.1:8080",
                hcEndpoint: "_internal_healthcheck",
        }
        ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
        defer stop()
        // ヘルスチェックのエンドポイントを起動
        go runHealthCheck(ctx, hcEndpointUDS)
        tpl, err := createTemplates(outEnvoyYamlPath)
        if err != nil {
                return errors.WithStack(err)
        }

        // 内部API(後述)を起動
        if err := startInternalAPI(ctx, tpl, adminEndpointTCP, outAdminConfPath, hcChecker); err != nil {
                return errors.Wrapf(err, "admin startup failure")
        }
        startupAdminConf, err := currentAdminConfig(outAdminConfPath)
        if err != nil {
                return errors.WithStack(err)
        }

        // UDSを通じてヘルスチェックを実施、問題なく起動できていることを確認
        if err := hcChecker.CheckUDS(hcEndpointUDS, hcChecker.hcEndpoint); err != nil {
                return errors.Wrapf(err, "initial health check failed")
        }
        startupAdminConf.AddHealthcheck(hcChecker.CurrentUUID(), hcEndpointUDS, hcChecker.hcEndpoint)
        // 全て正常であれば envoy.yaml の生成
        if err := saveAdminAndGenerateEnvoyYaml(tpl, outAdminConfPath, startupAdminConf); err != nil {
                return errors.Wrapf(err, "startup save admin.conf")
        }
        <-ctx.Done()
        return nil
}
func saveAdminAndGenerateEnvoyYaml(tpl *templates, outAdminConfPath string, adminConf *AdminConfig) error {
        if err := saveAdminConfig(outAdminConfPath, adminConf); err != nil {
                return errors.WithStack(err)
        }
        log.Printf("info: admin.conf (%s) saved", outAdminConfPath)
        if err := tpl.Execute(adminConf); err != nil {
                return errors.WithStack(err)
        }
        log.Printf("info: envoy.yaml saved")
        return nil
}
func main() {
        // 実際は github.com/urfave/cli などを使ってます
        if err := switcherAction(); err != nil {
                log.Fatalf("error: %+v", err)
        }
}

次はロガーの設定で、SidecarEnvoyのログまわりを設定します。

ログファイルの名前を決めたり、ログを残す世代数やローテーションするタイミングを設定しています。

package main
import (
        ...
        "github.com/lestrrat-go/file-rotatelogs"
)
const (
        LOG_SUFFIX_PATTERN string = "%Y%m%d"
)
func newLogger() (*rotatelogs.RotateLogs, error) {
        rotate, err := rotatelogs.New(
                fmt.Sprintf("/tmp/general_log.%s", LOG_SUFFIX_PATTERN),
                rotatelogs.WithMaxAge(-1),
        )
        if err != nil {
                return nil, err
        }
        return rotate, nil
}

内部ヘルスチェックを行うHTTPサーバの実装です。

SidecarEnvoyと一緒に起動するEnvoyがSidecarEnvoyを死活監視するために利用します。

package main
import (
        ...
        "github.com/gofiber/fiber/v2"
)
type HealthChecker struct {
        uuid       string
        hcURL      string
        hcEndpoint string
}
func (hc *HealthChecker) CheckUDS(udsPath string, endpoint string) error {
        healthyCount := 3 // 成功回数
        if strings.HasPrefix(endpoint, "/") {
                endpoint = endpoint[1:]
        }
        check := func() error {
                client := http.Client{
                        Transport: &http.Transport{
                                DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
                                        return net.DialUnix("unix", nil, &net.UnixAddr{
                                                Net:  "unix",
                                                Name: udsPath,
                                        })
                                },
                        },
                }
                resp, err := client.Get(fmt.Sprintf("http://_/%s", endpoint))
                if err != nil {
                        return errors.WithStack(err)
                }
                defer resp.Body.Close()
                body, err := io.ReadAll(resp.Body)
                if err != nil {
                        return errors.WithStack(err)
                }
                log.Printf("info: status=%d res=%s uds:%s endpoint:%s", resp.StatusCode, body, udsPath, endpoint)
                if resp.StatusCode != 200 {
                        return errors.Errorf("health check failed status=%d uds:%s endpoint:%s", udsPath, endpoint)
                }
                return nil
        }
        var lastError error
        for i := 0; i < healthyCount; i += 1 {
                if err := check(); err != nil {
                        lastError = errors.WithStack(err)
                        continue
                }
                time.Sleep(100 * time.Millisecond)
        }
        return lastError
}
// UUID の再生成を行って新しいUUIDでマッチすることを確認できるようにします(後述)
func (hc *HealthChecker) RenewUUID() string {
        hc.uuid = ... // uuid は必要に応じたもので生成
        return hc.uuid
}
func (hc *HealthChecker) CurrentUUID() string {
        return hc.uuid
}
func runHealthCheck(ctx context.Context, udsPath string) error {
        uds, err := net.ResolveUnixAddr("unix", udsPath)
        if err != nil {
                return errors.Wrapf(err, "uds path:%s", udsPath)
        }
        healthcheckListener, err := net.ListenUnix("unix", uds)
        if err != nil {
                return errors.Wrapf(err, "listen uds path:%s", udsPath)
        }
        healthcheckApp := fiber.New(fiber.Config{
                AppName:               "switcher/healthcheck",
                Prefork:               false,
                DisableKeepalive:      true,
                DisableStartupMessage: true,
                DisableDefaultDate:    true,
                ProxyHeader:           fiber.HeaderXForwardedFor,
        })
        healthcheckApp.Get("/_internal_healthcheck", func(c *fiber.Ctx) error {
                return c.Status(200).SendString(fmt.Sprintf("%d", time.Now().UnixNano()))
        })
        go func() {
                <-ctx.Done()
                if err := healthcheckApp.ShutdownWithTimeout(10 * time.Second); err != nil {
                        log.Printf("error: healthcheck app shutdown failed: %+v", err)
                }
                healthcheckListener.Close()
                log.Printf("info: healthcheck app shutdown")
        }()
        healthcheckApp.Listener(healthcheckListener)
        return nil
}

envoy.yaml, lds.yaml, cds.yaml はテンプレートから生成し、保存するようにします。

Envoyの Dynamic from filesystem を使っているため、ファイルの変更を検知して自動で再読み込みを行ってくれます。

ここでは os.CreateTemp で同一のディレクトリにファイルを生成、os.Renameでファイルの移動を行って検知させてます。

package main
import (
        ...
        _ "embed"
)
const (
        configGenerateInterval time.Duration = 200 * time.Millisecond
)
var (
        //go:embed envoy.yaml.tmpl
        envoyYamlTemplate []byte
        //go:embed lds.yaml.tmpl
        ldsYamlTemplate []byte
        //go:embed cds.yaml.tmpl
        cdsYamlTemplate []byte
)
type templates struct {
        envoyYaml        *template.Template
        ldsYaml          *template.Template
        cdsYaml          *template.Template
        outEnvoyYamlPath string
        outLDSYamlPath   string
        outCDSYamlPath   string
}
func (t *templates) Execute(adminConf *AdminConfig) error {
        adminConf.LDSYamlPath = t.outLDSYamlPath
        adminConf.CDSYamlPath = t.outCDSYamlPath
        adminConf.ConfigVersion = time.Now().UnixNano()
        baseDir := filepath.Dir(t.outEnvoyYamlPath)
        bufEnvoyYaml := bytes.NewBuffer(nil)
        bufLDSYaml := bytes.NewBuffer(nil)
        bufCDSYaml := bytes.NewBuffer(nil)
        if err := t.envoyYaml.Execute(bufEnvoyYaml, adminConf); err != nil {
                return errors.WithStack(err)
        }
        if err := t.ldsYaml.Execute(bufLDSYaml, adminConf); err != nil {
                return errors.WithStack(err)
        }
        if err := t.cdsYaml.Execute(bufCDSYaml, adminConf); err != nil {
                return errors.WithStack(err)
        }
        lds, err := os.CreateTemp(baseDir, "lds-*.tmp")
        if err != nil {
                return errors.WithStack(err)
        }
        defer lds.Close()
        cds, err := os.CreateTemp(baseDir, "cds-*.tmp")
        if err != nil {
                return errors.WithStack(err)
        }
        defer cds.Close()
        envoy, err := os.CreateTemp(baseDir, "envoy-*.tmp")
        if err != nil {
                return errors.WithStack(err)
        }
        defer envoy.Close()
        if _, err := cds.Write(bufCDSYaml.Bytes()); err != nil {
                return errors.WithStack(err)
        }
        if _, err := lds.Write(bufLDSYaml.Bytes()); err != nil {
                return errors.WithStack(err)
        }
        if _, err := envoy.Write(bufEnvoyYaml.Bytes()); err != nil {
                return errors.WithStack(err)
        }
        if err := cds.Sync(); err != nil {
                return errors.WithStack(err)
        }
        if err := lds.Sync(); err != nil {
                return errors.WithStack(err)
        }
        if err := envoy.Sync(); err != nil {
                return errors.WithStack(err)
        }
        if err := os.Rename(cds.Name(), t.outCDSYamlPath); err != nil {
                return errors.WithStack(err)
        }

        time.Sleep(configGenerateInterval)
        if err := os.Rename(lds.Name(), t.outLDSYamlPath); err != nil {
                return errors.WithStack(err)
        }
        if err := os.Rename(envoy.Name(), t.outEnvoyYamlPath); err != nil {
                return errors.WithStack(err)
        }
        return nil
}
func createTemplates(outEnvoyYamlPath string) (*templates, error) {
        funcMap := makeTemplateFunc()
        envoyTpl, err := template.New("envoy").Funcs(funcMap).Parse(string(envoyYamlTemplate))
        if err != nil {
                return nil, errors.WithStack(err)
        }
        ldsTpl, err := template.New("lds").Funcs(funcMap).Parse(string(ldsYamlTemplate))
        if err != nil {
                return nil, errors.WithStack(err)
        }
        cdsTpl, err := template.New("cds").Funcs(funcMap).Parse(string(cdsYamlTemplate))
        if err != nil {
                return nil, errors.WithStack(err)
        }
        yamlDir := filepath.Dir(outEnvoyYamlPath)
        return &templates{
                envoyYaml:        envoyTpl,
                ldsYaml:          ldsTpl,
                cdsYaml:          cdsTpl,
                outEnvoyYamlPath: outEnvoyYamlPath,
                outLDSYamlPath:   filepath.Join(yamlDir, "lds.yaml"),
                outCDSYamlPath:   filepath.Join(yamlDir, "cds.yaml"),
        }, nil
}
func makeTemplateFunc() template.FuncMap {
        // テンプレート内で使う関数の定義
        m := make(template.FuncMap)
        m["format_version"] = func(version string) string {
                version = strings.ReplaceAll(version, ".", "_")
                version = strings.ReplaceAll(version, "-", "_")
                version = strings.ReplaceAll(version, "+", "_")
                version = strings.ReplaceAll(version, "/", "_")
                version = strings.ReplaceAll(version, "@", "_")
                return version
        }
        m["dir"] = func(path string) string {
                return filepath.Dir(path)
        }
        return m
}

内部API用のHTTPサーバの実装です。

cds.yamlにバックエンドclusterを追加したり削除するためのAPIで、UDSではなくTCPとして公開することで外部から操作しやすいようにしています。 また、cds.yamlの更新を行ったら内部ヘルスチェックも更新を行っていて、生成したファイルが正常に読み込まれることを確認できるようにしています。

  • /backend
    • 登録済みのバックエンドアプリケーション一覧を返す
  • /backend/add
    • バックエンドclusterにバックエンドアプリケーションを追加する
  • /backend/remove
    • バックエンドclusterから登録済みのバックエンドアプリケーションを削除する
  • /backend/update-latest
    • 複数のバックエンドアプリケーションが登録されているときに、どのバージョンにリクエストを流すか変更する
package main
import (
        ...
        "github.com/gofiber/fiber/v2"
        "github.com/gofiber/fiber/v2/middleware/logger"
)
func startAPI(ctx context.Context, tpl *templates, addr string, outAdminConfPath string, hcChecker *HealthChecker) error {
        listener, err := net.Listen("tcp4", addr)
        if err != nil {
                return errors.WithStack(err)
        }
        apiApp := fiber.New(fiber.Config{
                AppName:               "internal API",
                Prefork:               false,
                DisableKeepalive:      true,
                DisableStartupMessage: true,
                DisableDefaultDate:    true,
                Network:               fiber.NetworkTCP4,
                ...
        })
        apiApp.Get("/backend", func(c *fiber.Ctx) error {
                adminConf, err := currentAdminConfig(outAdminConfPath)
                if err != nil {
                        return errors.WithStack(err)
                }
                return c.Status(200).SendString(adminConf.String())
        })
        apiApp.Post("/backend/add", func(c *fiber.Ctx) error {
                sockPath := c.FormValue("sock")
                version := c.FormValue("version")
                hcEndpoint := c.FormValue("hc", "")
                latest := c.FormValue("latest", "")
                isLatest := false
                if latest == "1" {
                        isLatest = true
                }
                if err := hcChecker.CheckUDS(sockPath, hcEndpoint); err != nil {
                        return errors.Wrapf(err, "initial health check failed")
                }
                adminConf, err := currentAdminConfig(outAdminConfPath)
                if err != nil {
                        return errors.WithStack(err)
                }
                updated := adminConf.AddBackend(version, sockPath, hcEndpoint, isLatest)
                if updated != true {
                        return c.Status(200).SendString("no updates")
                }
                // UUID を更新する
                oldUUID := hcChecker.CurrentUUID()
                newUUID := hcChecker.RenewUUID()
                adminConf.UpdateHealthcheckUUID(oldUUID, newUUID)
                if err := saveAdminAndGenerateEnvoyYaml(tpl, outAdminConfPath, adminConf); err != nil {
                        return c.Status(500).SendString(fmt.Sprintf("/backend/add err: %+v", err))
                }
                return c.Status(200).SendString("ok")
        })
        apiApp.Post("/backend/update-latest", func(c *fiber.Ctx) error {
                version := c.FormValue("version")
                adminConf, err := currentAdminConfig(outAdminConfPath)
                if err != nil {
                        return errors.WithStack(err)
                }
                updated := adminConf.UpdateLatest(version)
                if updated != true {
                        return c.Status(200).SendString("no updates")
                }
                oldUUID := hcChecker.CurrentUUID()
                newUUID := hcChecker.RenewUUID()
                adminConf.UpdateHealthcheckUUID(oldUUID, newUUID)
                if err := saveAdminAndGenerateEnvoyYaml(tpl, outAdminConfPath, adminConf); err != nil {
                        return c.Status(500).SendString(fmt.Sprintf("/backend/update-latest err: %+v", err))
                }
                return c.Status(200).SendString("ok")
        })
        apiApp.Post("/backend/remove", func(c *fiber.Ctx) error {
                version := c.FormValue("version")
                adminConf, err := currentAdminConfig(outAdminConfPath)
                if err != nil {
                        return errors.WithStack(err)
                }
                updated := adminConf.RemoveBackend(version)
                if updated != true {
                        return c.Status(200).SendString("no updates")
                }
                oldUUID := hcChecker.CurrentUUID()
                newUUID := hcChecker.RenewUUID()
                adminConf.UpdateHealthcheckUUID(oldUUID, newUUID)
                if err := saveAdminAndGenerateEnvoyYaml(tpl, outAdminConfPath, adminConf); err != nil {
                        return c.Status(500).SendString(fmt.Sprintf("/backend/remove err: %+v", err))
                }
                return c.Status(200).SendString("ok")
        })
        go func() {
                <-ctx.Done()
                if err := apiApp.ShutdownWithTimeout(10 * time.Second); err != nil {
                        log.Printf("error: %+v", err)
                }
                listener.Close()
        }()
        go apiApp.Listener(listener)
        return nil
}

内部APIのロジックになります。 isLatest が指定されたものは Envoy のHTTPのルーティングで default_backend に設定されるようにしています。

package main
import (
        ...
)
type (
        AdminConfig struct {
                DefaultBackend BackendConfig   `json:"default-backend"`
                Backends       []BackendConfig `json:"backends"`
                Healthchecks   []BackendConfig `json:"healthchecks"`
                LDSYamlPath    string          `json:"-"`
                CDSYamlPath    string          `json:"-"`
                ConfigVersion  int64           `json:"-"`
        }
        BackendConfig struct {
                Version    string `json:"version"`
                UDSPath    string `json:"uds-path"`
                HCEndpoint string `json:"hc-endpoint"`
        }
)
func (a *AdminConfig) AddBackend(version, udsPath, hcEndpoint string, isLatest bool) bool {
        updated := false
        if a.hasBackendVersion(version) != true {
                a.Backends = append(a.Backends, BackendConfig{
                        Version:    version,
                        UDSPath:    udsPath,
                        HCEndpoint: hcEndpoint,
                })
                updated = true
        }
        if isLatest {
                a.DefaultBackend = BackendConfig{
                        Version:    version,
                        UDSPath:    udsPath,
                        HCEndpoint: hcEndpoint,
                }
                updated = true
        }
        return updated
}
func (a *AdminConfig) UpdateLatest(version string) bool {
        for _, b := range a.Backends {
                if b.Version == version {
                        a.DefaultBackend = BackendConfig{
                                Version:    b.Version,
                                UDSPath:    b.UDSPath,
                                HCEndpoint: b.HCEndpoint,
                        }
                        return true
                }
        }
        return false
}
func (a *AdminConfig) RemoveBackend(version string) bool {
        newBackends := make([]BackendConfig, 0, len(a.Backends))
        deleted := false
        for _, b := range a.Backends {
                if b.Version == version {
                        deleted = true
                        continue
                }
                newBackends = append(newBackends, b)
        }
        a.Backends = newBackends
        return deleted
}
func (a *AdminConfig) AddHealthcheck(uuid, udsPath, hcEndpoint string) {
        if a.hasHealthcheckVersion(uuid) != true {
                a.Healthchecks = append(a.Healthchecks, BackendConfig{
                        Version:    uuid,
                        UDSPath:    udsPath,
                        HCEndpoint: hcEndpoint,
                })
        }
}
func (a *AdminConfig) RemoveHealthcheck(uuid string) {
        newHealthchecks := make([]BackendConfig, 0, len(a.Healthchecks))
        for _, h := range a.Healthchecks {
                if h.Version == uuid {
                        continue
                }
                newHealthchecks = append(newHealthchecks, BackendConfig{
                        Version:    h.Version,
                        UDSPath:    h.UDSPath,
                        HCEndpoint: h.HCEndpoint,
                })
        }
        a.Healthchecks = newHealthchecks
}
func (a *AdminConfig) UpdateHealthcheckUUID(oldUUID, newUUID string) {
        newHealthchecks := make([]BackendConfig, 0, len(a.Healthchecks))
        for _, h := range a.Healthchecks {
                if h.Version == oldUUID {
                        h.Version = newUUID
                }
                newHealthchecks = append(newHealthchecks, h)
        }
        a.Healthchecks = newHealthchecks
}
func (a *AdminConfig) hasBackendVersion(targetVersion string) bool {
        versions := a.backendVersions()
        vmap := make(map[string]struct{}, len(versions))
        for _, version := range versions {
                vmap[version] = struct{}{}
        }
        if _, ok := vmap[targetVersion]; ok {
                return true
        }
        return false
}
func (a *AdminConfig) backendVersions() []string {
        versions := make([]string, len(a.Backends))
        for i, b := range a.Backends {
                versions[i] = b.Version
        }
        return versions
}
func (a *AdminConfig) hasHealthcheckVersion(targetVersion string) bool {
        versions := a.healthcheckVersions()
        vmap := make(map[string]struct{}, len(versions))
        for _, version := range versions {
                vmap[version] = struct{}{}
        }
        if _, ok := vmap[targetVersion]; ok {
                return true
        }
        return false
}
func (a *AdminConfig) healthcheckVersions() []string {
        versions := make([]string, len(a.Healthchecks))
        for i, h := range a.Healthchecks {
                versions[i] = h.Version
        }
        return versions
}
// 既に生成済みのものがあれば返す、なければ新規作成
func currentAdminConfig(path string) (*AdminConfig, error) {
        f, err := os.Open(path)
        if err != nil {
                if os.IsNotExist(err) {
                        return new(AdminConfig), nil
                }
                return nil, errors.Wrapf(err, "admin.conf (%s)", path)
        }
        defer f.Close()
        adminConf := new(AdminConfig)
        if err := json.NewDecoder(f).Decode(adminConf); err != nil {
                return nil, errors.WithStack(err)
        }
        return adminConf, nil
}
func saveAdminConfig(path string, adminConf *AdminConfig) error {
        f, err := os.Create(path)
        if err != nil {
                return errors.Wrapf(err, "admin.conf (%s)", path)
        }
        defer f.Close()
        if err := json.NewEncoder(f).Encode(adminConf); err != nil {
                return errors.WithStack(err)
        }
        if err := f.Sync(); err != nil {
                return errors.WithStack(err)
        }
        return nil
}

動作確認

ここまでのコードで実際にEnvoyで動かしてみます。 go run コマンドで実行に成功すると、/tmp に envoy,yaml, lds.yaml, cds.yaml が生成されます。 中断したい場合は Ctrl + C で抜けられます。 今回起動したSidecarEnvoyは動作確認に利用するので、このまま停止せずにしています。

$ go run .
[  info ] 2025/12/25 09:42:19 healthcheck.go:95: status=200 res=1766623339415902000 uds:/tmp/hc.sock endpoint:_chk_internal_http
[  info ] 2025/12/25 09:42:19 healthcheck.go:95: status=200 res=1766623339520503000 uds:/tmp/hc.sock endpoint:_chk_internal_http
[  info ] 2025/12/25 09:42:19 healthcheck.go:95: status=200 res=1766623339622652000 uds:/tmp/hc.sock endpoint:_chk_internal_http
[  info ] 2025/12/25 09:42:19 main.go:95: admin.conf (/tmp/admin.conf) saved
[  info ] 2025/12/25 09:42:19 tmpl.go:101: cds.yaml generated:
version_info: "1766623339738116000"
resources:
- "@type": type.googleapis.com/envoy.config.cluster.v3.Cluster
  name: backend_v1
  connect_timeout: 1s
  type: STATIC
〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜
    resource_api_version: "V3"
  lds_config:
    path: "/tmp/lds.yaml"
    resource_api_version: "V3"
[  info ] 2025/12/25 09:42:19 main.go:100: envoy.yaml saved

UDSでアクセスできるGo製の軽量なwebサーバのサンプル

続いて、Envoyのバックエンドclusterに登録するためのwebサーバを用意します。 このwebサーバはGETするとHello Wroldと起動バージョンの組み合わせを返すだけのシンプルな作りです。 接続はUDS経由でしかアクセスできないようになっています。

main パッケージを使っているため、別のディレクトリを作成してmain.goという名前で保存しています。

package main
import (
        "flag"
        "fmt"
        "net"
        "net/http"
        "os"
)
func main() {
        versionPtr := flag.String("v", "v1", "バージョンのサフィックスを指定")
        flag.Parse()
        message := fmt.Sprintf("Hello World %s", *versionPtr)
        sockPath := fmt.Sprintf("/tmp/%s.sock", *versionPtr)
        if err := os.Remove(sockPath); err != nil && !os.IsNotExist(err) {
                panic(err)
        }
        listener, err := net.Listen("unix", sockPath)
        if err != nil {
                panic(err)
        }
        defer listener.Close()
        http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
                w.Write([]byte(message))
        })
        fmt.Printf("サーバを起動しました (Version: %s)\n", *versionPtr)
        fmt.Printf("ソケット: %s\n", sockPath)
        if err := http.Serve(listener, nil); err != nil {
                panic(err)
        }
        return nil
}

このコードで起動してしておきます。

$ go run ./main.go
サーバを起動しました (Version: v1)
ソケット: /tmp/v1.sock

起動したwebサーバにUDS経由で接続してみます。 Hello World v1 のレスポンスが返ることを確認します。

$ curl -i --unix-socket /tmp/v1.sock http://127.0.0.1/
HTTP/1.1 200 OK
Date: Thu, 25 Dec 2025 00:34:32 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8

Hello World v1%

続いて、バージョンを変更して起動してみます。 引数にv2を渡して起動し、

$ go run ./main.go -v v2
サーバを起動しました (Version: v2)
ソケット: /tmp/v2.sock

/tmp/v2.sockに接続して Hello World v2 のレスポンスが返ることを確認します。

$ curl -i --unix-socket /tmp/v2.sock http://127.0.0.1/
HTTP/1.1 200 OK
Date: Thu, 25 Dec 2025 00:39:39 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8

Hello World v2%

起動したwebサーバ v1, v2 は次の動作確認で利用するので起動したままにします。

SidecarEnvoyの生成した設定ファイルでEnvoyを起動

Envoyを起動して、SidecarEnvoyが生成したenvoy.yamlを読み込ませます。

envoyproxy/envoyイメージからEnvoyコンテナを起動すると、Envoyはenvoyユーザで起動します。 envoyユーザで起動すると、Envoyがパーミッションでyamlやこれから接続するUDSに接続できなくなるので、今回はテストしやすいようにrootユーザで起動しています。

--entrypoint="/usr/local/bin/envoy" を起動オプションに渡してrootユーザでEnvoyを起動しています。 また、udsのsockをそのまま参照できるように /tmp を -v で渡しています。

$ docker run -it --net host --rm \
    --entrypoint="/usr/local/bin/envoy" \
    -v /tmp:/tmp \
    envoyproxy/envoy:v1.21.6 --drain-time-s 10 --log-path /dev/stdout --base-id 1 -c /tmp/envoy.yaml

SidecarEnvoyのAPIを実行

SidecarEnvoyが起動したままの状態で内部APIを実行してみます。 先ほど作成したwebサーバの /tmp/v1.sock をバックエンドに登録し、登録されていることを確認

$ curl -X POST -d "sock=/tmp/v1.sock&version=v1&latest=1" http://127.0.0.1:7530/backend/add
$ curl http://127.0.0.1:7530/backend
v1      /tmp/v1.sock    *

バックエンドが登録された状態でEnvoyのlistenerに接続して、UDS経由でのレスポンスがHTTPで返ってくることを確認します。 また、今回登録したバージョンがv1なのでメッセージにもv1が含まれていることがわかります。

$ curl http://127.0.0.1:8080
Hello World v1

続いてv2のwebサーバをバックエンドに追加してみます。 今度はlatest=0に変更して登録し、/tmp/v2.sock が追加されたことを確認します。 ただし、latest=0 としたため、v1 が default_backend のままになっています。

$ curl -X POST -d "sock=/tmp/v2.sock&version=v2&latest=0" http://127.0.0.1:7530/backend/add
$ curl http://127.0.0.1:7530/backend
v1      /tmp/v1.sock    *
v2      /tmp/v2.sock

そのため、再度Envoyのlistenerに接続してもレスポンスは v1 の状態のままになります。

$ curl http://127.0.0.1:8080
Hello World v1

default_backend が正しく動作していることがわかりました。

続いて、v2 のレスポンスが返るようにするには、X-SERVER-VERSION のヘッダを利用します。 Envoyはheader matchで分岐させてバックエンドにリクエストを流していることが確認できます。

$ curl -H 'X-SERVER-VERSION: v2' http://127.0.0.1:8080
Hello World v2

このheader matchが強力で、ミラティブではこの仕組みでカナリアリリースを実現しています。 新バージョン追加時はリクエストの数パーセントだけ新バージョンにヘッダを付与し、少量のリクエストを新バージョンでカナリアリリースの対象にしています。

今度はv2をdefault_backendに変更してみます。

$ curl -X POST -d "version=v2" http://127.0.0.1:7530/backend/update-latest
$ curl http://127.0.0.1:7530/backend
v1      /tmp/v1.sock
v2      /tmp/v2.sock    *

Envoyのlistenerに接続すると、レスポンスがv2に変わったことがわかります。

$ curl http://127.0.0.1:8080
Hello World v2

切り替えは完了しましたが、古いバージョンのほうを残したままにしておくとリソースの無駄遣いになってしまいます。 v1をバックエンドから削除し、内部APIを実行してv1が消えていることを確認します。

$ curl -X POST -d "sock=/tmp/v1.sock&version=v1" http://127.0.0.1:7530/backend/remove
$ curl http://127.0.0.1:7530/backend
v2      /tmp/v2.sock    *

これで安全にv1のwebサーバへのリクエスト振り分けを停止できました。 このあとにv1のHTTPサーバは停止可能になります。

インプレースデプロイにおける新バージョンへのリクエストの切り替えから旧コンテナの停止は、これらを自動化してデプロイを実現しています。

まとめ

今回の改修でデプロイ時間がどの程度改善したか振り返ってみました。

ミラティブではSlackで業務のやりとりやシステム通知をまとめていて、1年前の記録を確認したところデプロイに1時間程度かかっていました。 今回の改修でどれほどデプロイ時間が改善したか計測してみたところ、トータル29分となりデプロイ時間を半分に短縮できました。

既存のGoやPerlのデプロイをカナリアデプロイの仕組みも含むメリットを維持したままインプレースデプロイに置き換えることができ、Envoyで切り替えの安全性も担保できました。

しかし、まだ全てをインプレースデプロイ対応できていない箇所もあり、順次対応予定の機能があります。 例えば、以前までのデプロイ方式では Immutable なVMを構築するようにしていたため、構築時に埋め込んだ情報を使う箇所がありました。 埋め込みの情報はインプレースデプロイ方式で更新できないので、一度インスタンスを全て入れ替えないといけません。 埋め込みの情報の更新は頻度は少ないものの非効率なため、今後改善予定です。

また、今回インプレースデプロイ方式で利用したEnvoyは外からくるトラフィック(ingress)を該当するバージョン(v1,v2)に振り分けることはできるようになったのですが、Envoyから出て行くリクエスト(egress)はv1,v2という情報をまだ持たせられていません。 後段へ流れるリクエストもバージョンによって振り分けられるようになると、後段で動いているサービスも同一のバージョンでサービスを提供できるようになり、ロールバック時の安全性も向上できるのではないかなと考えています。 将来的にはより安全に切り替えるために後段へ流れるリクエストにもv1,v2といった情報を持たせてコントロールできたら良いなと思っています。

We are hiring!

ミラティブのインフラでは日々成長しているミラティブを観測して、サービスの品質をより良くしていくためにインフラを設計してミドルウェアを選定したり、または運用ツールや監視を内製したりしています。 サービスの手触りを感じつつ日々成長するサービスを支えるための技術や知識を学んでみたいという方おまちしております!

www.mirrativ.co.jp

mirrativ.notion.site

speakerdeck.com