いろいろ頑張ってみたものの、Docker本家とかその他の情報によると、Dockerfile内部でマルチコア対応は頑張らない方が良いらしい。せっかくがんばったので供養のためにまとめておきます。
- DockerでNode.jsアプリをイイ感じに保つ4つの方法 #docker
- nodejs.org: Dockerizing a Node.js web app
- Docker and Node.js Best Practices
マルチコア対応したい!
Node.jsはシングルコアで動くのでマルチコア対応したいですよね?コア数が生かせないのはかっこ悪いですよね?サーバーにそのままデプロイする場合はpm2とかのプロセスマネージャを使います。pm2のウェブサイトをみると、Docker用のpm2があるじゃないですね。
というわけでそれを使うようにしてみました。
- package.jsonのdependenciesにはpm2のみ
- package.jsonのその他の依存はdevDependenciesに
- npm run buildでサーバーコードはdist/index.jsというシングルjsファイルになる(
@zeit/ncc
利用)
package.jsonは以下の通り(ESLintとかビルドに不要なものは省いた)。
{
"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"
}
}
サンプルのウェブアプリはこんな感じで作ってみました。大事なのはシグナルを受け取って終了するということです。
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はだけはパスを通すようにしています。
# ここから下がビルド用イメージ
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"
が勇者の証。
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コア使うように設定してあります)。
$ 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ぐらいどかっとイメージが大きくなってしまうのもいまいちだし、とりあえずこの努力はこのエントリーで供養します。