13
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-07-29

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

マルチコア対応したい!

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とかビルドに不要なものは省いた)。

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"
  }
}

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

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
# ここから下がビルド用イメージ

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"が勇者の証。

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コア使うように設定してあります)。

$ 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ぐらいどかっとイメージが大きくなってしまうのもいまいちだし、とりあえずこの努力はこのエントリーで供養します。

13
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
4