pnpm 12.9で社内レジストリだけ並列リクエスト数を下げる

pnpm 12.9で社内レジストリだけ並列リクエスト数を下げる

pnpm 12.9.0から、registriesのエントリごとにnetworkConcurrencyを設定できます。ローカルに立てた2つのレジストリで、制限した側だけ同時リクエスト数が1になり、もう一方は全体設定の4のままになることを12.9.1と12.12.0で確かめました。
2026.10.23

どうも!オペ部の西村祐二です!

pnpmで社内レジストリへの同時リクエスト数を減らそうとして全体の並列数を下げると、ほかのレジストリへの通信まで制限されてしまいます。pnpm 12.9.0では、registriesのエントリごとにnetworkConcurrencyを設定できるようになりました。

社内レジストリと公開レジストリを模した二つのローカルレジストリを用意し、一方だけ並列数を1にしたとき、もう一方の並列数が保たれるかを確認しました。結果、制限した側だけ最大1になり、もう一方は4のままでした。

制限したいURLの下にnetworkConcurrencyを置く

公式ドキュメントのネットワーク設定によると、トップレベルのnetworkConcurrencyは同時に処理するHTTP(S)リクエスト数の上限です。同じページで、特定のレジストリだけ制限したい場合はregistriesのエントリにnetworkConcurrencyを置くよう案内されています。Registriesページによると、エントリ単位の設定は、そのレジストリのoriginへの同時リクエスト数の上限とされています。originはプロトコル、ホスト、ポートの組み合わせです。同じページには、エントリ単位のリクエストも全体の上限に数えられるため、全体より大きな値を指定しても効かないとも書かれています(この点は今回試していません)。

検証では、次の設定を使いました(検証用に足したenableGlobalVirtualStoreとcacheDirは省いています)。ポート番号は検証サーバーの起動時に割り当てられたもので、実行ごとに変わります。

pnpm-workspace.yaml
networkConcurrency: 4
registries:
  "http://127.0.0.1:45287/":
    scopes: ["@corp"]
    networkConcurrency: 1
  "http://127.0.0.1:33967/":
    scopes: ["@public"]

@corpのパッケージを取得するレジストリだけ、上限を1にしています。@public側にはレジストリ単位の指定がないため、全体設定の上限4を使います。社内環境へ適用する際は、URLとスコープを実際のレジストリに合わせます。

12.9.0のリリースノートによると、この設定はpnpm-workspace.yamlとグローバルなconfig.yamlの両方に置けます。

16パッケージをインストールして同時リクエスト数を測る

検証環境はDockerコンテナのUbuntu 24.04.5 LTS(aarch64)、Node.js v24.21.0です。pnpmはnpm i pnpm@<バージョン>で12.8.1、12.9.1、12.12.0をそれぞれ別のディレクトリに入れました。使う前にpnpm --versionの表示を確認しています。12.12.0は2026年10月11日時点でGitHub上の最新リリースです(npmのlatestタグは12.11.2で、12.12.0はnext-12タグ)。

二つのローカルレジストリ(127.0.0.1の別ポート)には、依存関係やインストールスクリプトを持たないダミーパッケージを8個ずつ置きました。サーバーはメタデータとtarballの各応答を返す前に200ms待ち、待っている間に同時に処理しているリクエスト数を数えます。後の表の最大同時数は、メタデータとtarballを合わせた、サーバーごとの最大値です。

各ケースで新しいプロジェクト・store・cacheを使い、既存キャッシュの影響を避けました。HOMEやXDG系のディレクトリも一時ディレクトリへ分け、enableGlobalVirtualStore: falseを指定しています。計測はコンテナのネットワークを切った状態で行いました。プロジェクト内で実行したコマンドは次のとおりです。

pnpm install --ignore-scripts --store-dir ./store --reporter append-only

実際の実行では、storeの指定に各ケースの絶対パスを使いました。成功したケースはそれぞれ3回実行し、3回とも同じ値でした。

pnpm レジストリ単位の指定 corp側の最大同時数 public側の最大同時数 結果
12.8.1 corpのみ1 通信なし 通信なし 設定エラーで終了
12.9.1 corpのみ1 1 4 16パッケージをインストール
12.9.1 指定なし 4 4 16パッケージをインストール
12.12.0 corpのみ1 1 4 16パッケージをインストール
12.12.0 指定なし 4 4 16パッケージをインストール

12.12.0での実行結果と、そのときの設定ファイルです。limitedはcorp側だけ上限を1にしたケース、controlは指定なしのケースです。

pnpm 12.12.0でcorp側だけ最大同時数が1になり、public側は4のままの実行結果

12.9.1・12.12.0のどちらでも、corp側だけ最大1になり、public側は最大4のままでした。制限を外した対照ケースでは、両方が最大4になっています。

成功したケースでは、各サーバーにメタデータが8件、tarballが8件のリクエストが届き、終了コードは0でした。制限によってリクエストの件数が変わるわけではなく、同時に流れる数だけが変わっています。

今回の条件は小さいため、この設定はあくまで上限として読む必要があります。パッケージ数や応答時間が変われば、実際の同時リクエスト数が上限に届かないこともありそうです。この結果は通信の上限を確かめたもので、インストール時間の短縮や本番レジストリの負荷改善を示すベンチマークではありません。

ローカル検証を再実行する

以下の検証コードには、ダミーパッケージ生成・二つのHTTPサーバー・pnpm installの実行と集計をまとめています。Node.jsとtarコマンドを使います。引数には、pnpmの実行ファイルのパス、モード(limitedまたはcontrol)、出力先ディレクトリの3つを渡します。HOMEとXDG系のディレクトリ、CI=trueは検証コードが子プロセスに設定します。例のパスは検証コンテナ内の配置で、pnpmは各バージョンのディレクトリのnode_modules/.bin/pnpmです。

node verify.mjs /work/bin/12.12.0/node_modules/.bin/pnpm limited /work/runs/12.12.0-limited-1

出力先には設定ファイル・install.log・result.jsonを残します。HTTPサーバーはインストール終了後に停止します。

verify.mjs
// 2つのローカルレジストリ(corp / public)を立て、pnpm install 中の同時リクエスト数の最大値を数える。
// 使い方: node verify.mjs <pnpmのパス> <limited|control> <出力先ディレクトリ>
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { execFileSync, spawn } from 'node:child_process';
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';

const [pnpm, mode, outDir] = process.argv.slice(2);
const limited = mode === 'limited';
const PACKAGES = 8;
const DELAY_MS = 200;

// tar.gz のダミーパッケージを作る(tar コマンドを使う)
function makeTarball(dir, name, i) {
  const pkgDir = join(dir, 'src', name, 'package');
  mkdirSync(pkgDir, { recursive: true });
  writeFileSync(join(pkgDir, 'package.json'), JSON.stringify({ name, version: '1.0.0', main: 'index.js' }));
  writeFileSync(join(pkgDir, 'index.js'), `module.exports = ${i};`);
  return execFileSync('tar', ['-czf', '-', '-C', join(dir, 'src', name), 'package']);
}

function startRegistry(scope, dir) {
  const reg = { scope, active: 0, maxInFlight: 0, metadata: 0, tarball: 0, blobs: {} };
  for (let i = 0; i < PACKAGES; i++) {
    const name = `@${scope}/pkg${i}`;
    reg.blobs[name] = makeTarball(dir, name, i);
  }
  reg.server = createServer((req, res) => {
    const path = decodeURIComponent(req.url.split('?')[0]);
    const kind = path.endsWith('.tgz') ? 'tarball' : 'metadata';
    const name = path.slice(1).split('/-/')[0];
    if (!reg.blobs[name]) { res.writeHead(404).end(); return; }
    reg.active++;
    reg.maxInFlight = Math.max(reg.maxInFlight, reg.active);
    reg[kind]++;
    setTimeout(() => {
      let body = reg.blobs[name];
      if (kind === 'metadata') {
        const integrity = 'sha512-' + createHash('sha512').update(body).digest('base64');
        const tarball = `http://127.0.0.1:${reg.server.address().port}/${name}/-/pkg-1.0.0.tgz`;
        body = Buffer.from(JSON.stringify({
          name, 'dist-tags': { latest: '1.0.0' },
          versions: { '1.0.0': { name, version: '1.0.0', dist: { tarball, integrity } } },
          time: { '1.0.0': '2020-01-01T00:00:00Z' },
        }));
      }
      res.writeHead(200, { 'Content-Type': kind === 'metadata' ? 'application/json' : 'application/octet-stream', 'Content-Length': body.length });
      res.end(body);
      reg.active--;
    }, DELAY_MS);
  });
  return new Promise((r) => reg.server.listen(0, '127.0.0.1', () => r(reg)));
}

mkdirSync(outDir, { recursive: true });
const corp = await startRegistry('corp', outDir);
const pub = await startRegistry('public', outDir);
const registries = [corp, pub];

const project = join(outDir, 'project');
mkdirSync(project, { recursive: true });
const deps = {};
for (const r of registries) for (let i = 0; i < PACKAGES; i++) deps[`@${r.scope}/pkg${i}`] = '1.0.0';
writeFileSync(join(project, 'package.json'), JSON.stringify({ name: 'registry-concurrency-test', private: true, dependencies: deps }, null, 2));
let config = `networkConcurrency: 4\nenableGlobalVirtualStore: false\ncacheDir: ${join(outDir, 'cache')}\nregistries:\n`;
for (const r of registries) {
  config += `  "http://127.0.0.1:${r.server.address().port}/":\n    scopes: ["@${r.scope}"]\n`;
  if (limited && r.scope === 'corp') config += '    networkConcurrency: 1\n';
}
writeFileSync(join(project, 'pnpm-workspace.yaml'), config);

// ユーザー設定・キャッシュを出力先の中に閉じ込める
const env = { ...process.env, HOME: join(outDir, 'home'), XDG_CONFIG_HOME: join(outDir, 'config'), XDG_CACHE_HOME: join(outDir, 'xdg-cache'), XDG_DATA_HOME: join(outDir, 'data'), XDG_STATE_HOME: join(outDir, 'state'), CI: 'true' };
mkdirSync(env.HOME, { recursive: true });

const version = execFileSync(pnpm, ['--version'], { env, encoding: 'utf8' }).trim();
// 子プロセスの完了を待つ間もサーバーが応答できるよう、非同期で起動する
const child = spawn(pnpm, ['install', '--ignore-scripts', '--store-dir', join(outDir, 'store'), '--reporter', 'append-only'], { cwd: project, env });
let log = '';
child.stdout.on('data', (d) => (log += d));
child.stderr.on('data', (d) => (log += d));
const exit = await new Promise((r) => child.on('close', r));
writeFileSync(join(outDir, 'install.log'), log);

let installed = 0;
for (const r of registries) for (let i = 0; i < PACKAGES; i++) if (existsSync(join(project, 'node_modules', `@${r.scope}`, `pkg${i}`, 'package.json'))) installed++;
const result = {
  pnpm: version, mode, exit, installedPackages: installed,
  corp: { maxInFlight: corp.maxInFlight, metadata: corp.metadata, tarball: corp.tarball },
  public: { maxInFlight: pub.maxInFlight, metadata: pub.metadata, tarball: pub.tarball },
};
writeFileSync(join(outDir, 'result.json'), JSON.stringify(result, null, 2));
console.log(JSON.stringify(result));
for (const r of registries) r.server.close();

12.8.1ではインストール前に設定エラーになる

12.8.1に同じ構成の設定を渡すと、ERR_PNPM_INVALID_SETTINGで終了しました。エラーメッセージには、registries[URL].networkConcurrencyは既知のレジストリ設定ではないと表示されました(画像のポート番号は別の実行のものです)。

pnpm 12.8.1でregistriesのnetworkConcurrencyがERR_PNPM_INVALID_SETTINGになる様子

まとめ

全体の並列数を保ちながら、特定のレジストリだけ同時リクエスト数を減らせることを確認できました。社内レジストリへの同時接続数は絞りつつ、公開レジストリからの取得まで遅くしたくない構成に合いそうです。導入する際は、試した12.8.1ではpnpm --versionも含めて設定エラーで止まったため、チームのpnpmのバージョンをそろえておきます。

HTTPSや認証を使う社内レジストリ、グローバルなconfig.yamlでの設定、プロキシ経由、macOSとWindowsでの挙動は、今回の対象外です。tarballが別のoriginから配信される構成と、12.9.0そのもの(追加されたことは公式リリースノートで確認)も試していません。

参考リンク:

この記事をシェアする

関連記事