koukiblog

たぶんweb系の話題

KubernetesにNodeJSアプリをデプロイするときにやったこと

KubernetesにNodeJSアプリをデプロイするときに行なったことのまとめ。

問題

NodeJSで作ったアプリをk8s上にデプロイしていたとき、下記のような問題が発生していました。アプリケーションは、TypeScript, express、next.jsを利用しています。

  • Pod終了時にときどきエラーになったり、正常終了しなかったりする
  • コンテナサイズが大きすぎる(1GB越・・!)

一つずつ解決していきます

Podの終了

以前記事にしたのですが、k8s上のアプリケーションは、SIGTERMで正常終了する必要があります。なので、まずはSIGTERMで正常終了させるように進めていきます。 KubernetesでPodが終了するときのフロー - koukiblog

起動コマンドの変更

問題が発生しているアプリの起動コマンドは"npm start" でした。npm start でnodeを起動すると、SIGTERMを受け取るプロセスはnodeではなくnpmです。アプリ側でSIGTERMをハンドリングしても意味がないので、まずはシグナルをNodeJSのプロセスで受け取れるようにします。

DockerfileのCMDを npm start から nodeコマンドに切り替えればよいだけなのですが、NodeJSにはpid 1では正しく動作しないという制約( https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#handling-kernel-signals ) があり、initプロセスを挟む必要があります。

dockerコマンドの場合、 --init オプションを利用することでinitプロセスを挟むことができます。

docker run -it --init node

上記相当のことをk8sで行うには、tiniを利用します。

FROM node:11.14.0-alpine

# Setup tini
ENV TINI_VERSION v0.18.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-static /tini
RUN chmod +x /tini

ENTRYPOINT ["/tini", "--"]

CMD ["node", "/server.js"]

とすればokです。上記の例では、alpineを利用しているので、tiniではなく、tini-staticを利用しています。

シグナルハンドラの追加

ここまでで、SIGTERMシグナルが受け取れるようになったので、次はSIGTERMシグナルでGraceful shutdownできるようにアプリケーションに修正を加えます。

expressの場合、terminus( https://github.com/godaddy/terminus ) というnpmモジュールを利用するのがよさそうでした。シグナルハンドラを自前で書かなくてよいのに加え、httpサーバの安全な停止、コールバック関数の提供を行なってくれます。

terminusのREADMEにも記載がありますが、下記のようにterminusでラップするだけで安全に停止させることができます。ちなみに、5秒待っているのは、SIGTERMシグナルが送信された後でもリクエストが流れてくる可能性があるからです。(どちらかというとリクエスト送信側にリトライを仕込むのが筋がよさそうではあります)

const http = require('http');
const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('ok');
});

const server = http.createServer(app);

function beforeShutdown () {
  // given your readiness probes run every 5 second
  // may be worth using a bigger number so you won't
  // run into any race conditions
  return new Promise(resolve => {
    setTimeout(resolve, 5000)
  })
}
createTerminus(server, {
  beforeShutdown
})

server.listen(PORT || 3000);

なお、terminus内部では、stopable( https://github.com/hunterloftis/stoppable )というモジュールを利用してNodeJSを停止しています。

コンテナサイズの削減

TypeScript, next.jsを利用すると、事前にビルドが必要になり、ビルドに必要なnpmモジュールのサイズは非常に大きくなりがちです。なので、マルチステージビルドを利用し、ビルド用のコンテナでビルドした成果物を空の実行用コンテナにコピーするという方法が非常に有効です。 yarnを使ってモジュールの管理をしている場合、下記のようになります。

FROM node:11.14.0-alpine as builder

# Add source, and Install all dependencies
RUN yarn install && yarn cache clean

# buid output /dist dir
RUN yarn build

FROM node:11.14.0-alpine as runner

COPY --from=builder /dist /dist

RUN yarn install --production && yarn cache clean

ENTRYPOINT ["/tini", "--"]

CMD ["node", "/server.js"]

package.jsonのdevDependeciesにビルドに必要なパッケージ群、dependenciesにサーバ起動に必要なパッケージ群を記載しておくことで、実行用のコンテナから不要なパッケージを除くことができます。

なお、yarn install したあとに yarn cache cleanしているのは、yarnのcacheを全て削除するためです。 yarn は一度インストールしたnpmモジュールをキャッシュする仕組みがあるため、キャッシュ削除しないと膨大なサイズになってしまいます。( clearしないまま使っていたとき、yarnのキャッシュディレクトリのサイズが400MBほどになっていました。)

ここまで実施することで、1GBのコンテナを250MB程度に削減することができました。

istio-proxyが有効なJobを正常終了させる

以前の記事で、現在は有効な手段がないという記事を書きましたが、数ヶ月たって状況が変わっていたのでアップデート

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

workaround

Podの起動コマンドの最後にistio-proxyを停止させるコマンドを追加するという手段が有効です。

具体的には、

  • Pod specに "shareProcessNamespace: true"を追加
  • コンテナのコマンドに"pkill -f /usr/local/bin/pilot-agent"を追加

の2つです。

manifestsは下記のようになります。

          containers:
          - name: job_container
            image: job_image
            command: ["/bin/sh","-c"]
            args: ["/container_start && pkill -f /usr/local/bin/pilot-agent"]
          shareProcessNamespace: true

Istio 1.0.x では、pkillしてもistio-proxyが正常終了せず、Jobが完了しなかったのですが、1.1.xから正常終了するように変わっていました。

このIssue内に記載があります。

Better support for sidecar containers in batch jobs · Issue #6324 · istio/istio · GitHub

kubernetes本体の対応

kubernetes本体で、この問題を根本的に解決する対応が進んでいます。まだ開発中ですが、そう遠くない将来使えるようになりそうです。

Sidecar Containers · Issue #753 · kubernetes/enhancements · GitHub

manifests内でcontainerがsidecarかどうかを宣言することができて、sidecarの場合は他のPodが終了するときに一緒に終了してくれるようです。

今後

当面はSidecar毎に都度回避策を考えていくしかない状態ですが、Sidecar Containersが実装されれば状況はかなりよくなりそうです。

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が終了することを保証することができる。