istio-proxyが自分以外のコンテナ終了を待たずに終了するのをなんとかする

TL;DR

pod 削除時、istio-proxy と実アプリケーションコンテナの終了は同期されません。そして大抵 istio-proxy のほうが早く終了します。

pod は istio-proxy のプロセス終了後、リクエストを受けると即座に503を返すようになります。

つまり、例えば http を serve するようなアプリケーションだと、graceful shutdown が実装されていてもそれが無効になってしまいます。デプロイ時などには非常に困る挙動です。

何もしなければこの問題は現在最新の istio 1.1.1 でも起こります。詳細は以下の issue などが参考になります。

https://github.com/istio/istio/issues/7136

対処方法としては、実アプリケーションの pod 終了を待つように istio-proxy に preStop を入れるか、VirtualService の retry を使うと良いです。

実験準備

httpbin を使い、リクエスト処理中に pod を delete するとどうなるか実験してみます。 準備として、docker.io/citizenstig/httpbin を使った Deployment を2つ作り、片方にだけ istio-proxy を注入します。 その2つの Deployment に Ingress と istio の Gateway を紐づけて挙動を見てみます。ここではGKE(ingress-gce)を使っています。

cat <<EOF | kubectl create -f -
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: httpbin-1
  namespace: httpbin
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: httpbin-1
    spec:
      containers:
      - image: docker.io/citizenstig/httpbin
        imagePullPolicy: IfNotPresent
        name: httpbin
        ports:
        - containerPort: 8000
EOF

はい

kubectl expose deployment httpbin-1 --type LoadBalancer --name httpbin-svc-1 -n httpbin

同じ感じで istio-proxy を持ったものを作ります

cat <<EOF | istioctl kube-inject -f - | kubectl apply -f -
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: httpbin-2
  namespace: httpbin
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: httpbin-2
    spec:
      containers:
      - image: docker.io/citizenstig/httpbin
        imagePullPolicy: IfNotPresent
        name: httpbin
        ports:
        - containerPort: 8000
EOF

httpbin-2 に istio の gateway を割り当てたものを作ります

kubectl expose deployment httpbin-2 --type ClusterIP --name httpbin-svc-2-ci -n httpbin
cat <<EOF | kubectl apply -f -
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: httpbin-gateway
  namespace: httpbin
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "*"
---
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: httpbin-vs
  namespace: httpbin
spec:
  hosts:
  - "*"
  gateways:
  - httpbin-gateway
  http:
  - match:
    route:
    - destination:
        host: httpbin-svc-2-ci
        port:
          number: 8000
EOF

一応整理しておくとこんな感じです

名前 種類 接続先
httpbin-svc-1 Ingress httpbin-1 (istio-proxyなし)
istio-ingressgateway Gateway httpbin-2 (istio-proxyあり)

実験

httpbin には /delay エンドポイントがあります。 /delay/10 のようにすれば10秒間サーバー側で sleep してからレスポンスを返してくれます。

ちなみに、sleep の値はデフォルトで2秒、最大10秒までしか待てないので注意です。

/delay を使って、ここまでに作った pod に対して、curl でリクエストしつつ裏で pod を delete するという操作をしてみます。

httpbin には graceful shutdown が実装されているため、delay 中でサーバー側で処理が続いている場合に pod を delete してもきちんとレスポンスは帰ってくるはず、というのが期待する振る舞いです。

$ export ING1=$(kubectl get svc httpbin-svc-1 -n httpbin -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
$ export GATE=$(kubectl get svc istio-ingressgateway -n istio-system -o jsonpath="{.status.loadBalancer.ingress[0].ip}")

ING1(Ingress&istio-proxyなし)の場合、しっかり10秒待ってからレスポンスが得られます。

$ time curl -so /dev/null -w '%{http_code} ' $ING1:8000/delay/10
$ kubectl delete po (kubectl get po -n httpbin | grep httpbin-1 | cut -d ' ' -f 1) -n httpbin # この操作は別の terminal でやる
pod "httpbin-1-545cbd998-989d8" deleted
200 curl -so /dev/null -w '%{http_code} ' $ING1:8000/delay/10  0.01s user 0.01s system 0% cpu 10.414 total

GATE(Gateway&istio-proxyあり)の場合も上記と同様に、しっかり10秒まってからレスポンスを返してほしいのですが・・・。

$ time curl -so /dev/null -w '%{http_code} ' $GATE/delay/10
$ kubectl delete po $(kubectl get po -n httpbin | grep httpbin-2 | cut -d ' ' -f 1) -n httpbin # この操作は別の terminal でやる
pod "httpbin-2-67cd76c979-dhl2m" deleted
503 curl -so /dev/null -w '%{http_code} ' $GATE/delay/10  0.01s user 0.01s system 0% cpu 2.666 total

503が返ってきてしまいました。

対処法1:preStopを挟む

こちらの issue で言及されている通り、istio-proxy を編集しもう片方の container の終了を待つように preStop を作ります。

containers:
- name: istio-proxy
   lifecycle:
     preStop:
       exec:
         command: ["/bin/sh", "-c", "while [ $(netstat -plunt | grep tcp | grep -v envoy | wc -l | xargs) -ne 0 ]; do sleep 1; done"]

ぱっと見一番シンプルかつ楽な方法に見えます。この方法で上記の問題を解決できるのか試してみます。

試すには httpbin-2 の Deployment を直接編集して preStop を追加すればよいのですが、それではクラスタ全体にこの変更を適用できないため、ここでは istio-sidecar-injector を差し替える方法を紹介したいと思います。

とはいえ難しいことは特になく、istio-system に istio-sidecar-injector という configMap と pod があるので、それを編集するだけです。前者の configMap の値が実際に適用される yaml となります。

# configMapの値は jsonpath を使うときれいに取れる
kubectl get cm istio-sidecar-injector -n istio-system -o jsonpath='{.data.config}' > xxx/config
kubectl delete cm istio-sidecar-injector
# 先程とった config を編集して preStop を差し込む
kubectl create cm istio-sidecar-injector -n istio-system --from-file xxx/config

新しい configMap を istio-sidecar-injector を読み込みませます

kubectl delete po istio-sidecar-injector-c8c4c568b-k9g5f -n istio-system

istio-proxy を更新するためには最終的に Deployment を更新する必要があります。kube-inject をもう一度行います。

cat <<EOF | istioctl kube-inject -f - | kubectl apply -f -
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: httpbin-2
  namespace: httpbin
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: httpbin-2
    spec:
      containers:
      - image: docker.io/citizenstig/httpbin
        imagePullPolicy: IfNotPresent
        name: httpbin
        ports:
        - containerPort: 8000
EOF

kubectl get deploy httpbin-2 -n httpbin -o yaml とかして istio-proxy の preStop が定義されていることを確認してください。

その状態で先程の実験ように time curl -so /dev/null -w '%{http_code} ' $GATE/delay/10 の最中に pod を delete してみると、しっかり200が返ってくることが確認できると思います。

以下はおまけの話ですが、この方法でやる場合 istio の Gateway を使わず Ingress でもきちんとことが確認できます。

httpbin-1 と同じように type: LoadBalancer のものを作ります。

$ kubectl expose deployment httpbin-2 --type LoadBalancer --name httpbin-svc-2 -n httpbin
$ export ING=$(kubectl get svc httpbin-svc-2 -n httpbin -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
$ time curl -so /dev/null -w '%{http_code} ' $ING2:8000/delay/10
$ kubectl delete po $(kubectl get po -n httpbin | grep httpbin-2 | cut -d ' ' -f 1) -n httpbin # この操作は別の terminal でやる
200 curl -so /dev/null -w '%{http_code} ' $ING2:8000/delay/10  0.01s user 0.01s system 0% cpu 10.281 total

Gateway を使っていなければこちらの対処方法のほうが気楽かもしれないです。

対処法2:VirtualServiceでretryする

別の対処法として、VirtualService で retry してしまうという手があります。こちらも手軽な方法かと思います。

$ cat <<EOF | kubectl create -f -
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: httpbin-vs
  namespace: httpbin
spec:
  hosts:
  - "*"
  gateways:
  - httpbin-gateway
  http:
  - match:
    route:
    - destination:
        host: httpbin-svc-2-ci
        port:
          number: 8000
      retries:
        attempts: 2
        perTryTimeout: 1s
EOF

なお、この retry は http status が502-503の場合に行われるようです(参考:https://github.com/istio/istio/issues/8846

アプリケーションの実装が500以外も頻繁に使われるようなものだと、予期しない挙動になる可能性もあるため注意が必要です。

また、言うまでもないですが外部からのリクエストに VirtualService を適用するためには Gateway が必要です。つまり上記のING2ではこちらの方法は通用しません。