Node.jsのDockerイメージのマルチコア対応をがんばってみた

いろいろ頑張ってみたものの、Docker本家とかその他の情報によると、Dockerfile内部でマルチコア対応は頑張らない方が良いらしい。せっかくがんばったので供養のためにまとめておきます。

* [DockerでNode.jsアプリをイイ感じに保つ4つの方法 #docker](https://www.creationline.com/lab/29422)
* [nodejs.org: Dockerizing a Node.js web app](https://nodejs.org/en/docs/guides/nodejs-docker-webapp/)
* [Docker and Node.js Best Practices](https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#cmd)

# マルチコア対応したい！

Node.jsはシングルコアで動くのでマルチコア対応したいですよね？コア数が生かせないのはかっこ悪いですよね？サーバーにそのままデプロイする場合はpm2とかのプロセスマネージャを使います。pm2のウェブサイトをみると、[Docker用のpm2](https://pm2.io/docs/runtime/integration/docker/)があるじゃないですね。

というわけでそれを使うようにしてみました。

* package.jsonのdependenciesにはpm2のみ
* package.jsonのその他の依存はdevDependenciesに
* npm run buildでサーバーコードはdist/index.jsというシングルjsファイルになる（``@zeit/ncc`` 利用)

package.jsonは以下の通り（ESLintとかビルドに不要なものは省いた）。

```json:package.json
{
  "name": "webserver",
  "version": "1.0.0",
  "scripts": {
    "build": "ncc build src/main.ts"
  },
  "dependencies": {
    "pm2": "^4.4.0"
  },
  "devDependencies": {
    "@types/body-parser": "^1.19.0",
    "@types/compression": "^1.7.0",
    "@types/express": "^4.17.7",
    "@zeit/ncc": "^0.22.3",
    "body-parser": "^1.19.0",
    "compression": "^1.7.4",
    "express": "^4.17.1",
    "http-graceful-shutdown": "^2.3.2",
    "typescript": "^3.9.7"
  }
}
```

サンプルのウェブアプリはこんな感じで作ってみました。大事なのはシグナルを受け取って終了するということです。

```ts:src/main.ts
import express, { Request, Response } from "express";
import compression from "compression";
import bodyParser from "body-parser";
import gracefulShutdown from "http-graceful-shutdown";

const app = express();
app.use(compression());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

app.get("/", (req: Request, res: Response) => {
    res.json({
        message: `hello ${req.headers["user-agent"]}`,
    });
});

const host = process.env.HOST || "0.0.0.0";
const port = process.env.PORT || 3000;

const server = app.listen(port, () => {
    console.log("Server is running at http://%s:%d", host, port);
    console.log("  Press CTRL-C to stop\n");
});

gracefulShutdown(server, {
    signals: "SIGINT SIGTERM",
    timeout: 30000,
    development: false,
    onShutdown: async (signal: string) => {
        console.log("... called signal: " + signal);
        console.log("... in cleanup");
        // shutdown DB or something
    },
    finally: () => {
        console.log("Server gracefulls shutted down.....");
    },
});
```

こちらがDockerfileです。pm2とpm2-runtimeはだけはパスを通すようにしています。

```Dockerfile:Dockerfile
# ここから下がビルド用イメージ

FROM node:12-buster AS builder

WORKDIR app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# ここから下が実行用イメージ

FROM node:12-buster-slim AS runner
WORKDIR /opt/app
COPY package.json package-lock.json ./
RUN npm ci --prod
RUN ln -s /opt/app/node_modules/.bin/pm2 /usr/local/bin/pm2
RUN ln -s /opt/app/node_modules/.bin/pm2-runtime /usr/local/bin/pm2-runtime
COPY ecosystem.config.js ecosystem.config.js
COPY --from=builder /app/dist ./
USER node
EXPOSE 3000
CMD ["pm2-runtime", "start", "ecosystem.config.js"]
```

PM2の設定ファイルは次の通り。``instances: "max"``が勇者の証。

```js:ecosystem.config.js
module.exports = {
    apps: [
        {
            name: "greeting-server",
            script: "/opt/app/index.js",
            env: {
                NODE_ENV: "development",
            },
            env_production: {
                NODE_ENV: "production",
            },
            instances: "max",
            exec_mode: "cluster",
        },
    ],
};
```

これでビルドして実行すると、コア数分プロセスが立ち上がっていることがわかります（Dockerは4コア使うように設定してあります）。

```sh
$ docker build -t webserver .
$ docker --name webserver --rm -it webserver
$ docker exec webserver pm2 list
┌─────┬────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id  │ name               │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├─────┼────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0   │ greeting-server    │ default     │ 1.0.0   │ cluster │ 18       │ 55s    │ 0    │ online    │ 0%       │ 40.8mb   │ node     │ disabled │
│ 1   │ greeting-server    │ default     │ 1.0.0   │ cluster │ 25       │ 55s    │ 0    │ online    │ 0%       │ 41.3mb   │ node     │ disabled │
│ 2   │ greeting-server    │ default     │ 1.0.0   │ cluster │ 32       │ 55s    │ 0    │ online    │ 0%       │ 41.2mb   │ node     │ disabled │
│ 3   │ greeting-server    │ default     │ 1.0.0   │ cluster │ 39       │ 55s    │ 0    │ online    │ 0%       │ 41.2mb   │ node     │ disabled │
└─────┴────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
```

pm2-runtimeはpm2 --no-daemon相当のツールで、フォアグラウンドで動作し、シグナルなどはDockerの作法に従ってきちんと動作するように作られています。

めでたしめでたし・・・ではなかった

Dockerのベストプラクティスを諸々調べると、どれもnode スクリプトで起動せよ、プロセスマネージャとかランチャーは挟むな、と書かれています。ランチャー（npm run）はシグナルを適切に伝達しないということで、pm2-runtimeはそこはきちんとしているので、停止できないとかクリティカル無問題はないです。とはいえ、オーケストレーションツール側でオートスケール、みたいな話とちょっと喧嘩する可能性があるので、そこはもっと深く検証が必要なのかもしれません。

便利なところといえば、ロードバランサー的なものをおかなくても手っ取り早くパフォーマンスはあげられる・・・ぐらいですかね。

pm2はSaaSでObservabilityなサービスをしているようですね。pm2-runtimeはそこにつなげるためのエージェントという色合いが強いのかも・・・という気もしました。npm install pm2すると30MBぐらいどかっとイメージが大きくなってしまうのもいまいちだし、とりあえずこの努力はこのエントリーで供養します。