LoginSignup
4

More than 1 year has passed since last update.

posted at

updated at

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

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

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
What you can do with signing up
4