koukiblog

たぶんweb系の話題

Istioのタイムアウト・リトライのデフォルト値

Istioのタイムアウト・リトライのデフォルト値が気になって調べた。

結論

タイムアウトは15sec。リトライは1回。5xx、タイムアウトなど一般的にリトライしていい状況であればリトライする。

それぞれHTTPHeaderで挙動を上書き可能

Istio

Istioにはタイムアウト、リトライを制御する仕組みがあり、それはx-envoy-upstream-rq-timeout-ms、x-envoy-max-retriesという2つのHTTPヘッダーでデフォルト値を上書きできると書いてある。

Istio / Traffic Management

リトライ

envoyのx-envoy-max-retriesのデフォルト値を調べる https://www.envoyproxy.io/docs/envoy/latest/configuration/http_filters/router_filter#x-envoy-max-retries

Envoy will default to retrying one time unless explicitly specified

とあるので1回

タイムアウト

x-envoy-upstream-rq-timeout-msを調べる

https://www.envoyproxy.io/docs/envoy/latest/configuration/http_filters/router_filter#config-http-filters-router-x-envoy-upstream-rq-timeout-ms

Setting this header on egress requests will cause Envoy to override the route configuration.

route configuration を上書きするために利用すると書いてあるので、route configurationを調べる。

https://www.envoyproxy.io/docs/envoy/latest/api-v2/api/v2/route/route.proto#envoy-api-field-route-routeaction-timeout

Specifies the upstream timeout for the route. If not specified, the default is 15s. This spans between the point at which the entire downstream request (i.e. end-of-stream) has been processed and when the upstream response has been completely processed.

デフォルトは15秒

いつリトライするのか

envoyはx-envoy-retry-onというヘッダーでどのステータスコードのときにリトライするのかを設定することができる。 https://www.envoyproxy.io/docs/envoy/latest/configuration/http_filters/router_filter#config-http-filters-router-x-envoy-retry-on

Istioのドキュメントにはなかったが、検索してみたところIssueを発見した。 github.com 1.0時点では、ハードコードされていたが、x-retry-onヘッダーを受け付けるようになったらしい。

ちなみに、1.1時点のデフォルト値は

   policy := route.RetryPolicy{
        NumRetries:           &types.UInt32Value{Value: 2},
        RetryOn:              "connect-failure,refused-stream,unavailable,cancelled,resource-exhausted,retriable-status-codes",
        RetriableStatusCodes: []uint32{http.StatusServiceUnavailable},

https://github.com/istio/istio/blob/28f2fbbbb0bb4910130b3362cc18c780e1fac87b/pilot/pkg/networking/core/v1alpha3/route/retry/retry.go#L33 となっています。5xx, タイムアウトなどリトライしてもよさそうな状況では基本的にはリトライする、と認識しておけばよさそうです。

connect-failureなどのそれぞれの定義はこちらにあります。 https://www.envoyproxy.io/docs/envoy/latest/configuration/http_filters/router_filter#x-envoy-retry-on

Istioの理解深めるには、envoy理解するのが効率よさそうだと思いつつ出来てないのでenvoy勉強しないなーと思いました。

Weave Fluxを利用してGKE環境のCDを構築した

Weave Flux(https://github.com/weaveworks/flux)を利用して、GKE環境のCDを構築しました。 k8sでアプリケーションの継続的なデプロイを実現しようと思うと意外と考えないといけないことが多く、Fluxはよい解決策に思えました。

解決したかった課題

解決したかった課題としては、

  • いまクラスタに適用されているk8s manifestsが何なのかわかるようにしたい
  • kubectl applyを自動化したい

です。

Flux?

k8sクラスタに常駐するタイプのCDツールで、Gitレポジトリをポーリングして定期的にGitレポジトリにあるmanifestsをクラスタに適用してくれます。 指定したレポジトリをgit pullしてその中のmanifestsをkubectl apply するだけ、という非常にシンプルなツールです。 コンテナレジストリを監視してイメージの差し替えを自動化する機能もありますが、利用していないので割愛します。

インストール方法

公式レポジトリにデプロイ用のk8s manifestsがあるので、それを編集して kubectl apply するだけです。 github.com flux-deployment.yamlの --git-url(GitレポジトリのURL), --git-branch(ポーリングするブランチ), --git-path(マニフェストがあるディレクトリ) を変更すればほとんどの場合十分だと思います。

flux用に名前空間を分けることもできます。

kubectl create namespace flux
kubectl apply -f deploy -n flux

公式のチュートリアルが充実しています。

flux/get-started.md at master · weaveworks/flux · GitHub

レポジトリの準備

社内ネットワーク内に存在しているGithub Enterpriseを利用していたため、k8sクラスタから接続することはできません。そこで、GCPのCloud Source Repositories ( https://cloud.google.com/source-repositories ) にコードをミラーリングして対応することにしました。

FluxはデフォルトではCloud Source Repositoriesに対応していませんが、公式ドキュメントの"Using a private git"( https://github.com/weaveworks/flux/blob/master/site/standalone-setup.md#using-a-private-git-host )で紹介されている手順を利用すればCloud Source Repositoriesに接続することができます。

kustomize

kustomizeを利用してマニフェストの管理を行なっていたのですが、Fluxはkustomizeに対応していません。

僕の場合は、Cloud Source Repositoriesにマニフェストを持っていく時に kustomize build してしまい、ビルド後のマニフェストだけをコミットするようにしました。

kustomizeサポートは現在議論中のようです。

Kustomize integration support · Issue #1261 · weaveworks/flux · GitHub

出来上がったフロー

社内GHEレポジトリにコミット

CIツールでそれを検知し、kustomize build したものをSource Repositoriesにコミット

クラスタに常駐しているFluxがSource Repositoriesをポーリングし、差分があればkubectl apply

という感じになりました。

履歴も残るし、安定しているのでいまのところ満足しています。いまクラスタに適用されているマニフェストが何なのかが仕組みで保証されているのは安心度高いです。

Fluxのよいところ

シンプルでメンテがほぼ不要なところがよいです。

気をつけないといけないところ

マニフェストの削除にはまだ対応していません。なので、レポジトリからあるリソースを管理するマニフェストを削除しても、そのリソースはずっと残ってしまいます。

レポジトリに存在していないリソースを消していいかというと、そうじゃない場合もありそうなのでなかなか難しいところ。。

KubernetesでPodが終了するときのフロー

ここにかいてあることの個人的なまとめ cloud.google.com

まとめ

アプリケーションはSIGTERMで正常に終了する必要がある

終了フロー

1. PodをTerminating状態にして、Serviceのendpointから削除する

まず、PodはTerminating状態になり、Serviceのendpointから削除されます。コンテナは稼働し続けたまま、新しいトラフィックは入ってこなくなる。

2. preStop Hookの実行

preStop Hookが実行される。外部のアプリケーションを実行しているときなど、SIGTERMで正しく終了出来ない場合はこちらを使う。

3. PodにSIGTERMシグナルの送信

PodにSIGTERMが送信される。k8sは、SIGTERMシグナルで、アプリケーションが正常に終了することを期待している。ここでいう正常終了とは、graceful shutdownのことで、SIGTERMで処理中のプロセスを停止させてしまう場合、処理が途中で打ち切られてしまう可能性が常に発生してしまいます。

4. grace periodを待つ

kubernetesは、2, 3の経過にかかわらず、定められたgrace periodの間、コンテナの停止を待っている。grace periodのデフォルトは30秒で、Pod毎に変更することが可能。

PodのspecにterminationGracePeriodSeconds を指定する

5. SIGKILLを送信し、Podを削除

grace periodの間待ってもコンテナが停止しない場合、SIGKILを送信して強制的に停止させる。そのあと、Podを削除する

まだよくわからないこと

https://kubernetes.io/docs/concepts/workloads/pods/pod/#termination-of-pods 上記ページの記載だと、1のServiceからの削除と、2,3のPod停止処理が同時に行われると書いてある。

つまり、SIGTERMシグナルが送信されたあとに新しいトラフィックが流れる可能性があるが、それはk8sの仕様ということ。ときどき、prestopでsleepしてる対応を見かけるのだが、これが現実的に起きうる頻度なのかが不明。

SIGTERM受け取ったら新しいリクエストは処理しないのが正常なように思えるし、だからといって全てのPodのprestopにsleep仕込んでいくのも違和感があります。。

Stackdriver MonitoringでGKEのPodを監視する場合

Stackdriver MonitoringでGKEのPod,Containerのリソースを監視する場合、アラートが発生したあとにそのPodが削除された場合、そのアラートがずっと残ってしまうという問題があった。 この問題は、Pod, Containerそのもののメトリクスを監視するのではなく、グルーピングすることがで解決できる。 アラートのポリシーを設定するときには、container_nameでグルーピングする方がよさそう。 グルーピングする場合、集約のルールも設定する必要があるが、99 percentile などを設定すれば特定のPodで異常値が発生したとしても見逃すことはない。

Sidecarを利用しているとk8sのjobが終了しない

問題

Sidecarを使っているとkubernetesのJobが正しく終了できない問題がありました。SIdecarコンテナの仕様によっても変わるのですが、cloudsql-proxy, istio-proxyなどよく使われるSidecarではこの問題が発生します。

これはJobを実行するメインのコンテナが処理を完了して終了しても、Sidecarは変わらず動作を続けているからです。

状況

kubernetesにもIstioにもIssueがあがっており、回避策は挙げられているものの、それが利用できるかどうかは状況次第で、解決には至っていないようです。

Better support for sidecar containers in batch jobs · Issue #25908 · kubernetes/kubernetes · GitHub

github.com

現状の解決策

現状の解決策は、

  • Sidecarコンテナを自分でコントロールして自死する仕組みを作る(emptyDirをmountしておいて、そこに特定のファイルがあったら終了コード0で終わるとか)
  • Jobが長引かないことを祈ってconcurrencyPolicy: Replace

の2択のようです。

前者の方法はistio-proxyのように自動で注入されるsidecarには適用しづらいので、後者が適用できるように実行間隔や処理内容を調整する必要があります。。

また、自分でSidecarコンテナを作成する場合は、別コンテナから安全に終了させることができる仕組みを用意しておくとよさそうです。

追記

CronJobの場合は、concurrencyPolicyで対応できるが、Jobの場合はそれがない。

Jobの場合は、activeDeadlineSeconds を設定することで、指定した時間が来たらPodが終了することを保証することができる。

k8sのreadOnlyRootFilesystemを有効にして、Rackアプリケーションでファイルアップロードをするための設定

kubernetesにはreadOnlyRootFilesystemというオプションがあり、このオプションを有効にするとコンテナ内がreadonlyになるためセキュリティの施策の1つとして有効にされることがあります。その状態でRack(Rails,Sinatra,etc)アプリケーションからファイルアップロードを行うときに思ったより手間取ったのでそのときの対応メモ。

Rubyのバージョンは2.6。kuberntesはGKEを利用していて、1.11.7-gke.6でした。

前提とエラーの内容

WebアプリケーションでPOSTのファイルアップロードを受け付けるにはディスクに一時ファイルを書き込みできる必要があります。readOnlyRootFilesystemを有効にした状態でファイル書き込みを許可する場合には、k8sのemptyDirを作成し、それをマウントするのが一般的に取られる設定です。(おそらくですが)

以下のようなyamlになります。

   containers:
        image: rubyapp
        name: app
        securityContext:
          readOnlyRootFilesystem: true
        volumeMounts:
          - name: tmp
            mountPath: /tmp
     volumes:
        - name: tmp
          emptyDir: {}  

nginxのpid書き込みなど、この設定で問題ないのですが、Rackの場合はパーミッションエラーがありうまくいきませんでした。

エラーメッセージをよく見てみると一時ファイルを書き込みしようとしてるディレクトリがなぜかRubyを実行しているカレントディクトリになっています。Rubyの問題のようなので調べて見ると、Rackがファイルアップロード時の一時ファイルを保存するのは Dir.tmpdir で得られるディレクトリということでした。Dir.tmpdirを実行してみたところ、たしかにカレントディレクトリが取得できました。ENV['TMPDIR']を"/tmp"にしても変わらずカレントディレクトリのままでした。

原因

そこで Rubyソースコードを確認してみたところ、原因がわかりました。Dir.tmpdirは以下のようになっています

  def self.tmpdir
    if $SAFE > 0
      @@systmpdir.dup
    else
      tmp = nil
      [ENV['TMPDIR'], ENV['TMP'], ENV['TEMP'], @@systmpdir, '/tmp', '.'].each do |dir|
        next if !dir
        dir = File.expand_path(dir)
        if stat = File.stat(dir) and stat.directory? and stat.writable? and
            (!stat.world_writable? or stat.sticky?)
          tmp = dir
          break
        end rescue nil
      end
      raise ArgumentError, "could not find a temporary directory" unless tmp
      tmp
    end
  end

https://github.com/ruby/ruby/blob/1fae154c07b957278fd336b54256d5c57f21e0d5/lib/tmpdir.rb#L26

問題はこの条件文です。

(!stat.world_writable? or stat.sticky?)

tmpdir候補のディレクトリはこのチェックを突破しないとtmpdirになることができないようです。VolumeMountされたディレクトリを確認したところ、world_writable?はtrueかつ、stickyビットは立っていないことが確認できたので、これが原因で間違いなさそうです。

対応

VolumeMountするディレクトリのパーミッションを変更する方法を調べたのですが見つかりませんでした。

Rubyはどのユーザーでも書き込みできるディレクトリの場合はstickyビットを求めているのに対し、k8sはコンテナ前提なので複数のユーザーがいることはあまり想定していないのでしょう。

仕方ないので、RubyのDir.tmpdirを書き換えてしまいます

class Dir
  class << self
    alias org_tmpdir tmpdir
  end

  def self.tmpdir
    dir = ENV['TMPDIR']
    return File.expand_path(dir) unless dir.nil?

    org_tmpdir
  end
end

manifestsは以下のようにします。

   containers:
      -  image: rubyapp
          name: app
          env:
          - name: TMPDIR
             value: /tmp
          securityContext:
            readOnlyRootFilesystem: true
          volumeMounts:
            - name: tmp
              mountPath: /tmp
     volumes:
        - name: tmp
          emptyDir: {}  

これで RubyのDir.tmpdirが"/tmp"になり、/tmpには書き込みができるのでファイルアップロードができるようになりました。

k8sクラスタのsecretsを別のクラスタにコピーしたいとき

secretsといってもbase64encodeされているだけなので、簡単にコピーできます。

kubectl get secrets -o yaml > current_secrets.yaml

で取り出して

kubectl apply -f current_secrets.yaml

でok。

このときTYPE:kubernetes.io/service-account-tokenなど、おそらくコピーすべきでないsecretsも含まれていることに注意が必要です。

アプリケーションで利用するsecretsにラベルを付与しておけば、secretsを取り出すときにラベルでフィルタできるので便利です。次この作業をするかどうかはわからないのですが、念のため僕はsecretsにlabelを付与しておくことにしました。