LIFULL Creators Blog

「株式会社LIFULL(ライフル)」の社員によるブログです。

Istio を本番環境に導入するまで

こんにちは、技術開発部の相原です。

この記事は LIFULLアドベントカレンダー の16日目です。

LIFULL では アプリケーション実行基盤を刷新すべく、Istio がバージョン 0.2.0 の頃から検証を開始し、現在 1.0.4 を利用しています。

AWS 上で kops を利用して Kubernetes を構築しその上に Istio を展開するという構成です。 EKS は利用していません。

ここに至るまでそれなりにハマりどころ、考慮すべき点に遭遇したので今回はそのことについて書きたいと思います。

以下の文章は

  • kops 1.10.0
  • Kubernetes 1.10.11
  • Istio 1.0.4

を前提としていることをご了承ください。

はじめに

LIFULL HOME'S サービス開始から20年が経ち、コードベースも次第に巨大になってきました。 そしてコードベースが巨大になるにつれて、デプロイ速度の鈍化やメンテナンス性の低下が目立つようになり、開発速度が低下していきました。

そうした変化を受けて LIFULL では、数年前のオンプレミスからの AWS 移行を契機にマイクロサービス化に踏み切ります。 サービスを適切な単位で切り分けてデプロイを独立化し、開発チームに権限を移譲することで分業化に成功しました。

しかし、年月が経ち新たな課題が見え始めました。 それはマイクロサービス化による車輪の再発明と分散システムとしての難しさです。

それぞれのチームがロギング・監視基盤やデプロイフローを個別に構築したり、各アプリケーションごとに Retrying や Timeout を実装することにより、従来の体制と比較して重複する機能が多くなってしまいました。

加えて、下流のサービスに巻き込まれる形での障害も目立つようになり、分散したアプリケーションをうまく運用するという難しさにも直面することになったのです。

そこで我々がとった選択肢が Kubernetes の導入によるアプリケーション実行基盤の統一と、 Service Mesh である Istio 導入によるマイクロサービスに必要な機能の一貫した提供でした。 Istio は Retrying, Timeout や Rate Limitting に加え、CircuitBreaker のようなマイクロサービスに必要な機能を提供するソフトウェアです。 Kubernetes の導入によって開発者への権限の移譲はそのままにロギング・監視基盤やデプロイフローをアプリケーション間で流用した上で、その Istio を導入して開発コストの削減と分散システムとしての信頼性の向上を狙いました。

トラフィックの質が違う複数のアプリケーションを同じインスタンスに同居させることでコストの最適化にも繋がりますし、アプリケーション間のネットワークの距離も縮まるためパフォーマンスへのインパクトも期待できるはずです。(KEP: Topology-aware service routing として可能な限り距離の近い Pod にルーティングする仕様が提案されています)

github.com

加えて、ベンダーロックインからの解放も期待できるということで熟考の上決断に踏み切りました。

本番導入までの障壁

ここからは実際に本番導入に至るまでの障壁について紹介します。

istio-proxy のオーバーヘッド

Retrying, CircuitBreaker, Rate Limitting など様々な機能を実現する istio-proxy ですが当然そのパフォーマンスへのオーバーヘッドは 0 ではありません。 導入するアプリケーションの性質に応じて慎重に導入の是非を考慮すべきです。

istio-proxy によるオーバーヘッドについてはドキュメントにあるのでそちらをご参照ください。

istio.io

ドキュメントにあるように istio-proxy では 1 リクエストにつき概ね 10ms 程度のオーバーヘッドが生じます。

手元の環境でのパフォーマンステスト結果としてもほとんど同様の結果が得られました。

10ms と言われるとアプリケーションの性質によっては微々たるもののように感じますが、仮に対象のアプリケーションが直列で他の API に対して複数回リクエストするとき、そのリクエスト数分だけこのオーバーヘッドが生じることになります。

istio-proxy はその性質上、OUTBOUND のトラフィックに対する処理の比重が大きいという特徴があります。 VirtualService によって実現される Retrying, Routing は OUTBOUND トラフィックに対して発生しますし、CircuitBreaker, Connection Pooling を司る DestinationPolicy に関してもそうです。

一方で INBOUND のトラフィックでの主な処理は Rate Limitting くらいなもので、これはつまりリクエスト元に対する Sidecar の Inject さえやめればパフォーマンスが大きく改善するということを意味します。 深刻なオーバーヘッドが生じた場合はこのような観点から Sidecar の取捨選択を行うべきです。

当然同様の機能をアプリケーション内に実装することに比べれば、多くのケースにおいて istio-proxy でやる方が高速ですし、機能の標準化の観点からも有意義ですが、Sidecar の有無に関してはアプリケーションの性質に応じて慎重に判断する必要があるでしょう。

Resource Quota を有効化した時に Istio の Sidecar が Inject できない

Kubernetes には Resource Quotas という namespace ごとにデプロイ可能なリソース量を制限する機能があります。

これを namespace に対して有効化すると Container に対してリクエストするリソース量、上限まで利用できるリソース量を指定することが義務付けられるというものです。

ある namespace にリソースを際限なく消費されると困るので一般的にこれを使って制限をかけることになります。

通常、LimitRange で default, defaultRequests を指定することで各 Container にデフォルトのリソース量を設定することが可能なので特に気にしませんが、Istio の Sidecar の Inject はこの LimitRange の前に実行されてしまうため 1.0.4 現在ではこれに対して別途対応が必要です。

まず、Sidecar である istio-proxy です。 istio-proxy のリソースは values.yaml から指定できるので以下のようにパッチを当てます。

当然 CPU の利用量はリクエストに応じて増加するためここは随時調整します。

--- install/kubernetes/helm/istio/values.yaml    2018-12-16 00:00:47.590948200 +0900
+++ install/kubernetes/helm/istio/values.yaml   2018-12-16 00:00:33.263144300 +0900
@@ -28,10 +28,10 @@ global:
     resources:
       requests:
         cpu: 10m
-      #  memory: 128Mi
-      # limits:
-      #   cpu: 100m
-      #   memory: 128Mi
+        memory: 128Mi
+      limits:
+        cpu: 1000m
+        memory: 128Mi
 
     # Controls number of Proxy worker threads.
     # If set to 0 (default), then start worker thread for each CPU thread/core.

しかしこれだけではまだデプロイは成功しません。

実は Istio の Sidecar をデプロイしようとすると istio-init という iptables などの設定を行う initContainer が起動します。

Resouce Quota を設定した namespace では initContainer にもリソース指定が義務付けられるためこちらも対応する必要があります。

しかし istio-init 用の設定は values.yaml にないためこちらは少々ハックが必要です。

これは Istio 1.1.0 でリリース予定です

github.com

まず、Sidecar を Inject するためのコンポーネントである istio-sidecar-injector の設定ファイルに値を渡せるように sidecar-injector-configmap.yaml にパッチを当てましょう

--- install/kubernetes/helm/istio/templates/sidecar-injector-configmap.yaml     2018-12-16 05:17:35.419691832 +0000
+++ install/kubernetes/helm/istio/templates/sidecar-injector-configmap.yaml     2018-12-16 05:19:11.799894444 +0000
@@ -43,6 +43,8 @@ data:
             - NET_ADMIN
           privileged: true
         restartPolicy: Always
+        resources:
+{{ toYaml .Values.global.proxy_init.resources | indent 10 }}
       {{- if .Values.global.proxy.enableCoreDump }}
       - args:
         - -c

これで sidecar-injector-configmap.yamlvalues.yaml の内容を解釈できるようになりました。

あとは先ほどの istio-proxy と同じようにリソースを指定するのみです。

こちらは初期化処理をするためだけのコンテナなのでリソースを多く必要としません。

--- install/kubernetes/helm/istio/values.yaml    2018-12-16 00:00:47.590948200 +0900
+++ install/kubernetes/helm/istio/values.yaml   2018-12-16 00:00:45.304157200 +0900
@@ -99,6 +99,13 @@ global:
   proxy_init:
     # Base name for the proxy_init container, used to configure iptables.
     image: proxy_init
+    resources:
+      requests:
+        cpu: 10m
+        memory: 128Mi
+      limits:
+        cpu: 10m
+        memory: 128Mi
 
   # imagePullPolicy is applied to istio control plane components.
   # local tests require IfNotPresent, to avoid uploading to dockerhub.

これで晴れて Sidecar をデプロイできるようになりました。

Istio コンポーネントの HPA がうまく動かない

istio-ingressgateway, istio-policy などのコンポーネントはリクエスト量によってスケールする必要があるためあらかじめ HPA が用意されています。

しかし何も設定しないままデプロイしてしまうと、istio-pilot などの一部のコンポーネントには適切なリソースが確保されますが、その他のコンポーネントはデフォルトのリソース確保量が小さすぎるため常にスケールのトリガーが発火してしまい HPA が期待通りの動きをしません。

Istio コンポーネントにおけるデフォルトのリソース確保量を設定する項目があるのでそちらを調整して解決します。

--- install/kubernetes/helm/istio/values.yaml    2018-12-16 00:00:47.590948200 +0900
+++ install/kubernetes/helm/istio/values.yaml   2018-12-16 00:00:16.539089300 +0900
@@ -163,8 +163,8 @@ global:
   # block in the relevant section below and setting the desired resources values.
   defaultResources:
     requests:
-      cpu: 10m
-    #   memory: 128Mi
+      cpu: 100m
+      memory: 128Mi
     # limits:
     #   cpu: 100m
     #   memory: 128Mi

これで HPA が適切に動作するはずです。

Istio コンポーネントの可用性を上げる

Istio コンポーネントの HPA はまだ十分ではありません。

デフォルトの設定ではそれぞれのコンポーネントが1台ずつデプロイされるのみで、最大でも5台にまでしかスケールしません。

これでは可用性に欠けるため適切な設定を施します。

数が多いためすべての設定はしませんが以下のようなパッチを当てて台数を調整しましょう。

--- install/kubernetes/helm/istio/values.yaml    2018-12-16 00:00:47.590948200 +0900
+++ install/kubernetes/helm/istio/values.yaml   2018-12-16 00:00:50.688467300 +0900
@@ -226,9 +226,9 @@ gateways:
     labels:
       app: istio-ingressgateway
       istio: ingressgateway
-    replicaCount: 1
-    autoscaleMin: 1
-    autoscaleMax: 5
+    replicaCount: 3
+    autoscaleMin: 3
+    autoscaleMax: 10
     resources: {}
       # limits:
       #  cpu: 100m

Kubernetes クラスタを Production Ready に近づける

単に kops で Kubernetes を AWS に構築しただけでは、そのまま運用を始めるには心許ないです。

Node, Pod のスケールアウトもできなければ、運用に必要なメトリクスも不足しているという状況です。

Kubernetes コミュニティにはそれらを補うための素晴らしいソフトウェアがあるのでそれを利用しましょう。

まずは Node のオートスケールの実現です。 これに関しては kops が addon を提供してくれているので素直にそれを利用しましょう。

github.com

これを利用することで Pod のスケールアウト時にリソースが必要なタイミングで新たな Node が起動します。

次は Pod です。 以前から利用されていた heapster は 1.11 で deprecated となり、代替のソフトウェアとして kubernetes-incubator/metrics-server が開発されています。

github.com

HPA はこれらのソフトウェアから取得できるメトリクスに依存しているため導入しましょう。

更に、監視という観点では Pod ごとのメトリクスが取得できているのみでは不足に感じることがあります。 本来必要となるメトリクスは Deployment などの Kubernetes リソースごとの抽象化されたメトリクスです。 Kubernetes は標準でそのような機能を持ち合わせていないため、そのためのソフトウェアである kubernetes/kube-state-metrics を導入してその要求を実現しましょう。

github.com

Node の異常を Events の形式で通知してくれる kubernetes/node-problem-detector も監視の役に立ちます。

github.com

Kubernetes の Events は CrashLoopBackOff や FailedScheduling などの監視に有意義な情報を取得できるものですが、ここで取得できるのはあくまで Kubernetes 内で起きていることのみです。 しかし、kubernetes/node-problem-detector を利用することで Node 内におけるカーネルのデッドロックや OOMKiller の発生をその Events に統合して通知してくれます。 これにより運用に必要な情報は揃ったはずです。

最後は認可の部分です。 Kubernetes はデフォルトで認証認可の仕組みがありますが、AWS を利用している場合 IAM を認可と紐付けたいと考えるでしょう。 その場合は kubernetes-sigs/aws-iam-authenticator (旧 heptio/authenticator) が役に立ちます。

github.com

これを導入することで IAM Role ごとに権限を管理することができるので、開発チームへの権限委譲がスムーズになるでしょう。

CNI plugin を変更する

kops で デプロイした時にデフォルトで利用される kubenet という方式ではルーティングに AWS のルートテーブルを用いる関係上、クラスタ内のノード数が 50 に制限されてしまいます。

これを解決するために様々な CNI plugin がありますが、AWS の場合は VPC のセカンダリ IP を用いる amazon-vpc-routed-eni が素直な選択肢に思えます。

ですが私達は移植性を考慮して、手に馴染んだソフトウェアでもある、クラスタ内に仮想の L3 ネットワークを構築する Project Calico を採用しました。

www.projectcalico.org

影響範囲が大きく安易な選択をできない部分ですが、少なくともデフォルトの状態では今後のスケールに対応できないため、慎重に選択する必要があります。

kops でデプロイしたインスタンスで ext4 のハードリンクが枯れる

kops は AWS に Production Ready な Kubernetes クラスタをデプロイするための素晴らしいツールです。

しかし、現時点ではそのまま運用するためには多少の問題点があるのでそちらについて説明しましょう。

kops ではドキュメントにあるように Debian を Tier 1 サポートとしています。

github.com

ここで注意すべきなのはこの Debian にインストールされている Docker Daemon のバージョンです。 この Debian にインストールされているのはバージョン 17.03.2 の Docker Daemon で、残念なことにこのバージョンの Docker Daemon のストレージドライバは overlay です。

overlayfs はレイヤー内のオブジェクトをハードリンクとして親のレイヤーと共有することで知られています。 その性質のため overlayfs では非常に多くのハードリンクが使用されるという特徴があります。

しかし、ext4 におけるハードリンクの上限は 65000 です。 overlayfs を利用するインスタンスを長期に渡って運用していると必ずこの上限に引っかかる時が来るでしょう。

これを解決する最も単純な解決策は kops edit cluster にて以下のサービスをクラスタ内で有効化することです。

このサービスを有効化することで Container から参照されていないイメージが定期的に削除されます。

  - name: prune-images.service
    roles:
      - Master
      - Node
    manifest: |
      [Unit]
      Description=prune images
      [Service]
      Type=oneshot
      ExecStart=/usr/bin/docker image prune -a -f
  - name: prune-images.timer
    roles:
      - Master
      - Node
    manifest: |
      [Unit]
      Description=prune images
      [Timer]
      OnCalendar=*-*-* 23:59
      Persistent=true
      [Install]
      WantedBy=timers.target

そして最もよい解決方法は overlay2 にストレージドライバを切り替えることです。

幸い kops は overlay2 を利用する Ubuntu のイメージもサポートしているためそちらに切り替えることでこの症状を大幅に改善することができます。

しかし Ubuntu は kops コミュニティから Tier 1 サポートを受けておらず、安定性とはトレードオフになります。

また、Debian では NVMe のインスタンスがサポートされていないという問題点もあります。(以前は Validation エラーが出なかったためハマりどころだった)

c.f.

github.com

externalTraffic: Local の罠

Kubernetes で Service を Type: NodePort でデプロイする時、デフォルトで設定される externalTraffic: Cluster では kube-proxy がそれぞれの Pod に平等にトラフィックを振り分ける関係上、Source IP がその kube-proxy が稼働しているインスタンスの IP になってしまうという問題があります。

これを解決するための手段として externalTraffic: Local を設定するというものがありますが、現在 AWS でこれを実現するためには Type: LoadBalancer な Service に service.beta.kubernetes.io/aws-load-balancer-type: "nlb" を設定してデフォルトで利用される Classic Load Balancer の代わりに Network Load Balancer を利用する必要があります。

本来であればこれで externalTraffic: Local による Source IP の保持が可能になりますが、Network Load Balancer のサポートはまだ beta のため致命的なバグが存在します。

それは VPC の DHCP Options Set の domain-name に依存しているというものです。

この問題に関しては以下の Issue で触れられています。

github.com

domain-name が設定されていない場合、kube-proxy が localEndpoints を見つけることができずロードバランシングされなくなります。

この問題は Kubernetes 1.13 で解消する見込みです。 進捗は以下からトラッキングできます。

github.com

fluentd-kubernetes-daemonset で文字コードでハマる

AWS で Kubernetes を導入する場合、fluent/fluentd-kubernetes-daemonset を用いて CloudWatch Logs にログを流すのが恐らく一番安上がりで簡単です。

github.com

しかし、古き良き日本企業には一つ問題があります。そう、文字コード問題です。

fluent-plugins-nursery/fluent-plugin-cloudwatch-logs では CloudWatch Logs にログを流す際にログを JSON にエンコードしようとします。

しかしこの時、ASCII-8BIT な文字列が流れてきてしまうと UndefinedConversionError として JSON エンコードに失敗してしまいます。 こうなるとログの転送は停止しやがてバッファが溢れてしまうので手を打つ必要があります。

これを解決するには repeatedly/fluent-plugin-record-modifier を使うのがいいでしょう。

github.com

Container の command や lifecycle によるハックでプラグインを追加することも可能ですが、新たにイメージをビルドする方が素直だと思います。

プラグインを追加したら以下の filter を fluent/fluentd-kubernetes-daemonset の ConfigMap に追加すれば解消するはずです。

  <filter **>
    type record_modifier

    char_encoding utf-8
  </filter>

おわりに

まだ枯れているとは言えない領域でそれなりにハマりどころはありましたが、適宜コミュニティへのコントリビューションをしつつ今のところ致命的な問題に遭遇せず運用できています。

Istio を導入することで、今まで車輪の再発明をしていた Retrying, Timeout や Rate Limitting を実行基盤側からアプリケーションに提供することができるようになり、アプリケーションの開発速度向上に寄与することができました。 加えて、CircuitBreaker による障害影響の局所化や、Fault Injection をはじめとした Chaos Engineering に着手する準備も整い、さらなるアプリケーション実行基盤の改善が期待できそうです。

今回 Kubernetes, Istio の導入と共にロギング・監視基盤やデプロイフローの刷新などにも着手していて、LIFULL の技術開発部では分散したアプリケーションをいかにうまく運用するかという点に注力しています。

もしこの辺りに興味のある方がいればまずはカジュアル面談からいかがでしょうか。

hrmos.co

デリバリープラットフォームとして利用している Spinnaker の導入に際して、AWS で Spinnaker の Kubernetes V2 Provider を利用するのにも多少のハックが必要でしたがそれはまたの機会に。