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程度に削減することができました。