リリース作業を委譲するためにやったこと

概要

現在SREとして働いているが、複数あるアプリケーションのリリース作業を依頼されることが多かった。
依頼者の職種はエンジニアやマネージャなど。
そのリリース依頼のほとんどを委譲したので、やったことを記録する。

課題

手作業

  • 作業ミス
  • 時間がかかる
  • sshログインなどの権限管理

作業依頼

  • コミュニケーションコストが増える
  • デプロイしたものに問題があるかどうかわからない
  • SREが休むととデプロイできない
  • 作業依頼によるコンテキストスイッチ
  • デプロイを忘れてしまう
  • 誰に依頼するかわからない
  • 作業状況がわからない

やったこと

なお過程も含むので現段階で使われてないものもある

自動化

  • ローカルでおこなう手作業をCIで自動化
    • semantic releaseをGitHub Actionsのworkflow dispatchから実行
    • release-drafter/release-drafterによるリリースノート作成の自動化
    • GitHub APIによるリリースノート作成の自動化
  • オペレーションサーバに接続しておこなう手作業をバッチ化
    • aws ecs run-taskとして実行
  • Slackのワークフローによるテンプレ化
    • 依頼のメンション先や作業順を固定化
    • 作業状況をオープンに
  • 通知
    リリースを誰がいつやっているかわかるように

ドキュメンテーション

大体下記のようなことに気をつけて手順書を作成
いい手順書とは - tom-256.log
ほかにも下記のようなことを工夫した。

  • 手順書を実際に使ってモブ作業する
  • モブ作業の前に自分で手順書を使って動作確認する
    説明するときに躓くと相手側が不安な気持ちになり、スムーズに委譲できないので作業時間を割いて実施
  • 画像を用意して伝わりやすくする
  • リリーステンプレを用意して、作業が終わったらチェックボックスを埋める

コミュニケーション

  • モブ作業
    • 移行期間を設ける
      一度のモブ作業で終わるのではなく、複数回実施して段階的におこなう
  • ドキュメント
    • 責任範囲の説明(定常作業担当と仕組みづくり・監視担当の棲み分け)
    • 双方にとってメリットがあるという理由
  • アフターフォロー
    • 作業自体は委譲するが、改善案や相談があればすぐに伝えてもらう
    • 問題があれば前のやり方に戻すことも可能であると伝える

ほかに気をつけること

  • 自動化せずに委譲すると事故を引き起こす可能性が上がるので自動化してから委譲する
  • 運用を見据えた設計をする
    自動化可能か、仕組みとしてスケール可能か、チームとしてスケール可能か
  • 自動化のコストと実行頻度を考慮する
    コスパの悪い自動化をしない

まとめ

やったことをまとめた。
一度委譲しようとして難色を示されたことがあったが、モブ作業などを整備して再びコミュニケーションをとったら委譲することができたので、 十分に準備をしてコミュニケーションする。

最近やっていること2022年夏

概要

直近1年くらいでやっていたことをまとめる

全体の方向性

  • 持続可能な開発組織づくり
    • 開発生産性の向上
    • 各チームが自律的に開発できるように責任と権限を整備
    • 開発チームのフィードバックサイクルの向上
    • SREチームのトイル削減

具体的にやったこと

  • 課題の洗い出し
  • 定常作業のドキュメンテーション
  • ドキュメント削除会
  • トイル削減のための調整
  • リリースフローの整備
  • ワークフローのSlack化
  • DesignDocの提案とそれを用いたコミュニケーション
  • TerraformのCI改善
  • GitHub ActionsのOIDC連携
  • モノレポの設計・実装
  • コンテナのビルド時間削減
  • MongoDBのスロークエリ改善
  • 社内LT
  • 社外登壇
  • 壁打ち(アプリケーション設計、テスト設計)

まとめ

振り返るタイミングがあったのでまとめた。
粒度があれだけど一旦書き出してみた。
掘り下げるといろいろかけることがありそう。
今後を考えるいいきっかけになった。

too many open filesエラーが出たときにlsofコマンドで確認する

概要

too many open filesエラーが出たときにlsofコマンドで確認するときの手順を説明する機会があったのでまとめる。

環境

node -v
v16.16.0
docker -v
Docker version 20.10.17, build 100c701

用意するもの

Dockerfile
file.txt
index.js

Dockerfile

FROM node:16.16.0-bullseye-slim
COPY . .
RUN apt update && apt install -y lsof procps linux-perf
CMD ["node","index.js"]

サンプルプログラム index.js

const fs = require('fs');

const openFileAndDontClose = () => {
  fs.open('file.txt', 'r', (err, fd) => {
    console.log(`fd: ${fd}`)
    console.log(`err: ${err}`)
  });
}

setInterval(openFileAndDontClose, 1000)

1秒ごとにfile.txtを開き続ける。

検証

docker buildx build -t test .
docker run --name test test
docker exec -it test /bin/bash

nodeプロセスのPIDを確認

ps aux            
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0   4100  3340 pts/0    Ss   13:41   0:00 /bin/bash
root        10  0.1  0.4 352596 33644 pts/0    Sl+  13:41   0:00 node index.js
root        21  0.0  0.0   4100  3520 pts/1    Ss   13:41   0:00 /bin/bash
root        34  0.0  0.0   6700  3012 pts/1    R+   13:42   0:00 ps aux
ulimit -Sn
1048576
ulimit -Hn
1048576

ファイルディスクリプタの確認

/proc/PID/fd

ls -l /proc/1/fd
total 0
lrwx------ 1 root root 64 Jul 16 14:00 0 -> /dev/null
l-wx------ 1 root root 64 Jul 16 14:00 1 -> 'pipe:[56376]'
lr-x------ 1 root root 64 Jul 16 14:00 10 -> 'pipe:[55593]'
l-wx------ 1 root root 64 Jul 16 14:00 11 -> 'pipe:[55593]'
lrwx------ 1 root root 64 Jul 16 14:00 12 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Jul 16 14:00 13 -> 'anon_inode:[eventpoll]'
lr-x------ 1 root root 64 Jul 16 14:00 14 -> 'pipe:[50868]'
l-wx------ 1 root root 64 Jul 16 14:00 15 -> 'pipe:[50868]'
lrwx------ 1 root root 64 Jul 16 14:00 16 -> 'anon_inode:[eventfd]'
lr-x------ 1 root root 64 Jul 16 14:00 17 -> /file.txt
lr-x------ 1 root root 64 Jul 16 14:00 18 -> /dev/null
lr-x------ 1 root root 64 Jul 16 14:00 19 -> /file.txt
l-wx------ 1 root root 64 Jul 16 14:00 2 -> 'pipe:[56377]'
lr-x------ 1 root root 64 Jul 16 14:02 20 -> /file.txt
lr-x------ 1 root root 64 Jul 16 14:02 21 -> /file.txt
lr-x------ 1 root root 64 Jul 16 14:02 22 -> /file.txt
lr-x------ 1 root root 64 Jul 16 14:02 23 -> /file.txt
...

23 -> /file.txt
fdの番号 -> 開いている対象

lsofコマンド

lsof -p PID
# もしくは
# lsof -c プロセス名
lsof -p 1
COMMAND PID USER   FD      TYPE DEVICE SIZE/OFF    NODE NAME
node      1 root  cwd       DIR  0,142     4096  270825 /
node      1 root  rtd       DIR  0,142     4096  270825 /
node      1 root  txt       REG  0,142 81180048 2890255 /usr/local/bin/node
node      1 root  mem       REG  254,1          2890255 /usr/local/bin/node (path dev=0,142)
node      1 root  mem       REG  254,1          2885690 /lib/x86_64-linux-gnu/libc-2.31.so (path dev=0,142)
node      1 root  mem       REG  254,1          2885735 /lib/x86_64-linux-gnu/libpthread-2.31.so (path dev=0,142)
node      1 root  mem       REG  254,1          2885704 /lib/x86_64-linux-gnu/libgcc_s.so.1 (path dev=0,142)
node      1 root  mem       REG  254,1          2885711 /lib/x86_64-linux-gnu/libm-2.31.so (path dev=0,142)
node      1 root  mem       REG  254,1          2886466 /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.28 (path dev=0,142)
node      1 root  mem       REG  254,1          2885698 /lib/x86_64-linux-gnu/libdl-2.31.so (path dev=0,142)
node      1 root  mem       REG  254,1          2885678 /lib/x86_64-linux-gnu/ld-2.31.so (path dev=0,142)
node      1 root    0u      CHR    1,3      0t0       5 /dev/null
node      1 root    1w     FIFO   0,11      0t0   56376 pipe
node      1 root    2w     FIFO   0,11      0t0   56377 pipe
node      1 root    3u  a_inode   0,12        0   12340 [eventpoll]
node      1 root    4r     FIFO   0,11      0t0   50866 pipe
node      1 root    5w     FIFO   0,11      0t0   50866 pipe
node      1 root    6r     FIFO   0,11      0t0   50867 pipe
node      1 root    7w     FIFO   0,11      0t0   50867 pipe
node      1 root    8u  a_inode   0,12        0   12340 [eventfd]
node      1 root    9u  a_inode   0,12        0   12340 [eventpoll]
node      1 root   10r     FIFO   0,11      0t0   55593 pipe
node      1 root   11w     FIFO   0,11      0t0   55593 pipe
node      1 root   12u  a_inode   0,12        0   12340 [eventfd]
node      1 root   13u  a_inode   0,12        0   12340 [eventpoll]
node      1 root   14r     FIFO   0,11      0t0   50868 pipe
node      1 root   15w     FIFO   0,11      0t0   50868 pipe
node      1 root   16u  a_inode   0,12        0   12340 [eventfd]
node      1 root   17r      REG  0,142        0 3807111 /file.txt
node      1 root   18r      CHR    1,3      0t0       5 /dev/null
node      1 root   19r      REG  0,142        0 3807111 /file.txt
node      1 root   20r      REG  0,142        0 3807111 /file.txt
node      1 root   21r      REG  0,142        0 3807111 /file.txt
...
lsof -p 1
COMMAND PID USER   FD      TYPE DEVICE SIZE/OFF    NODE NAME
...
node      1 root   20r      REG  0,142        0 3807111 /file.txt
  • FDは20番をreadで開いているという意味
  • TYPEのREGはregular file
  • NAMEは開いている対象

今回のプログラムはfile.txtを開き続けるので、file.txtが多く表示される。

総数だけ見たいときは下記

lsof -p 1 | wc -l

ulimitを変更する

docker run --name test --ulimit nofile=100:100 test
ulimit -Sn 
100
ulimit -Hn
100

上限を30にすると29まで使った次に、too many open filesのエラーが出ることを確認

docker run --name test --ulimit nofile=30:30 test
fd: 17
err: null
fd: 19
err: null
fd: 20
err: null
fd: 21
err: null
fd: 22
err: null
fd: 23
err: null
fd: 24
err: null
fd: 25
err: null
fd: 26
err: null
fd: 27
err: null
fd: 28
err: null
fd: 29
err: null
fd: undefined
err: Error: EMFILE: too many open files, open 'file.txt'

まとめ

too many open filesのエラーが出たとき、どのプロセスが、なんのファイルを開こうとしているのか確認する手順は下記

  1. ps auxでプロセス番号確認
  2. lsof -p PIDでファイルの上表を表示
  3. NAME欄で多く表示されているものを確認する

対応として、

  • プログラムで不具合はないか確認
  • ulimitで開くことのできるファイルの上限を上げる

参考情報

man lsof
...
  FD is followed by one of these  characters,  describing  the
                  mode under which the file is open:

                       r for read access;
                       w for write access;
                       u for read and write access;
                       space if mode unknown and no lock
                            character follows;
                       `-' if mode unknown and lock
                            character follows.
...
man lsof
...
TYPE       is  the  type  of  the node associated with the file - e.g.,
                  GDIR, GREG, VDIR, VREG, etc...
...
                  or ``REG'' for a regular file;
...

https://nodejs.org/dist/latest-v16.x/docs/api/fs.html#fsopenpath-flags-mode-callback

マイクロサービス移行とモノレポを選択した理由について

概要

仕事でマイクロサービス移行にともなってモノレポを採用した。 その理由をまとめる。 具体的な内容には触れない。

課題

長らくモノリスで開発してきたことによる

  • コード量の増加
  • テスト時間の増加
  • 依存関係によるデプロイの遅れ
  • 開発者への依存

が課題になっていた。

改善方針

これに対して、

  • マイクロサービス分離
  • モノレポによる開発体験の統一

によって解決を図った。

具体策

サービス分離

  • 機能単位でモノリスアプリケーションを分離し、サービスごとに開発サイクルを回せるように
    コードの変更差分に対してのみCI実行、サービスごとにCI並列実行

  • サービスごとに独立したデプロイ
    Gitタグで任意のタイミングでサービスごとにDockerイメージを作成し、デプロイ

これにより

  • CI実行時間の削減
  • ワークフローの統一化
  • デプロイの依存関係の解消

を実現した。

モノレポ

npm workspaceを利用、モノレポフレームワークなし

  • 開発ワークフローの統一

  • 差分実行、並列実行
    GitHub Actionsで変更差分に対してのみCI実行、サービスごとにCIを並列実行

  • 開発ツール・バージョンの統一
    jestなどの各設定ファイルを共通化
    npm packageとしてワークスペースから参照

  • セットアップの自動化
    Makefileを実行するだけで開発をはじめられるように

なぜモノレポか?

工夫するとポリレポでもモノレポのメリットの一部は実現できる

  • GitHub Template Repository(マイクロサービスのテンプレート)
  • GitHub Actions Reusable Workflow(CIワークフローの共通化)
  • GitHub Actions Repository Dispatch(リポジトリ間のCIトリガー)
  • npm packageによる設定ファイルの共通化
チームのコラボレーションのため
  • 特定の機能が特定の開発者に依存しつつある
  • 特定の開発者しかメンテナンスしないリポジトリがある
  • コードを共有する仕組みが整っていない

これらの課題を解決するため

セットアップの仕組み化と適切なサービス分離により、認知不可を低めつつ
担当していないプロダクトコードも目に入りやすい状況を作る。

また、プロダクトコードはすべて一つのリポジトリにある状況を作ることで、リポジトリの管理が不要になる。

依存関係の解決のため

現状OpenAPIを別リポジトリで開発しており、 開発時の依存関係解決のワークフローを簡潔にしたい

今後必要なこと

  • CIプロセスの継続的な改善
    開発が進むにつれビルドや静的解析の速度が課題になる

  • CDプロセスの改善
     プログレッシブデリバリー

  • マイクロサービス化に向けた組織構成について
     現状の組織構成のままやっていけるかチーム内で検討

まとめ

開発ワークフローの課題をサービス分離にともなって解決していっているという話を書いた。
一番の目的は開発サイクルを早め、市場の変化に対応すること
現在、開発基盤を大方作り終え、今後フィードバックを受けながら改善していく

  • マイクロサービスや開発チームが増えてきたときに、開発体験が悪くならない仕組みを作る
     CIの差分実行・並列実行、CIの共通化、セットアップの簡略化などで実現

  • モノレポを採用することでチームのコラボレーションを促進する

npm workspaceとnpm installの挙動確認

環境

% node -v
v16.13.0
% npm -v
8.11.0

概要

npm workspaceでnpm installを実行すると
 1. ワークスペースルート
 2. (バージョンが異なるものは)各々のワークスペース
の順にインストールされる

構成

npm-workspace % tree . -a -I node_modules 
.
├── .gitignore
├── .npmrc
├── app-a
│   └── package.json
├── app-b
│   └── package.json
├── package-lock.json
└── package.json
save-exact=true
{
  "name": "npm-workspace",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "workspaces": [
    "app-a",
    "app-b"
  ]
}

挙動の確認

 %npm i rimraf -w app-a
 %npm i rimraf@2 -w app-b

結果A

 % tree .     
.
├── app-a
│   └── package.json
├── app-b
│   ├── node_modules
│   │   └── rimraf
│   │       ├── LICENSE
│   │       ├── README.md
│   │       ├── bin.js
│   │       ├── package.json
│   │       └── rimraf.js
│   └── package.json
├── node_modules
│   ├── app-a -> ../app-a
│   ├── app-b -> ../app-b
│   ├── balanced-match
│   │   ├── LICENSE.md
│   │   ├── README.md
│   │   ├── index.js
│   │   └── package.json
│   ├── brace-expansion
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── index.js
│   │   └── package.json
│   ├── concat-map
│   │   ├── LICENSE
│   │   ├── README.markdown
│   │   ├── example
│   │   │   └── map.js
│   │   ├── index.js
│   │   ├── package.json
│   │   └── test
│   │       └── map.js
│   ├── fs.realpath
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── index.js
│   │   ├── old.js
│   │   └── package.json
│   ├── glob
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── common.js
│   │   ├── glob.js
│   │   ├── package.json
│   │   └── sync.js
│   ├── inflight
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── inflight.js
│   │   └── package.json
│   ├── inherits
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── inherits.js
│   │   ├── inherits_browser.js
│   │   └── package.json
│   ├── minimatch
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── minimatch.js
│   │   └── package.json
│   ├── once
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── once.js
│   │   └── package.json
│   ├── path-is-absolute
│   │   ├── index.js
│   │   ├── license
│   │   ├── package.json
│   │   └── readme.md
│   ├── rimraf
│   │   ├── CHANGELOG.md
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── bin.js
│   │   ├── package.json
│   │   └── rimraf.js
│   └── wrappy
│       ├── LICENSE
│       ├── README.md
│       ├── package.json
│       └── wrappy.js
├── package-lock.json
└── package.json

21 directories, 65 files

package-lock.jsonを削除し、コマンドの実行順を変えてみる

% rm package-lock.json 
% rm -rf node_modules && rm -rf app-b/node_modules
%npm i rimraf@2 -w app-b
 %npm i rimraf -w app-a

結果

% tree .
.
├── app-a
│   ├── node_modules
│   │   └── rimraf
│   │       ├── CHANGELOG.md
│   │       ├── LICENSE
│   │       ├── README.md
│   │       ├── bin.js
│   │       ├── package.json
│   │       └── rimraf.js
│   └── package.json
├── app-b
│   └── package.json
├── node_modules
│   ├── app-a -> ../app-a
│   ├── app-b -> ../app-b
│   ├── balanced-match
│   │   ├── LICENSE.md
│   │   ├── README.md
│   │   ├── index.js
│   │   └── package.json
│   ├── brace-expansion
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── index.js
│   │   └── package.json
│   ├── concat-map
│   │   ├── LICENSE
│   │   ├── README.markdown
│   │   ├── example
│   │   │   └── map.js
│   │   ├── index.js
│   │   ├── package.json
│   │   └── test
│   │       └── map.js
│   ├── fs.realpath
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── index.js
│   │   ├── old.js
│   │   └── package.json
│   ├── glob
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── common.js
│   │   ├── glob.js
│   │   ├── package.json
│   │   └── sync.js
│   ├── inflight
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── inflight.js
│   │   └── package.json
│   ├── inherits
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── inherits.js
│   │   ├── inherits_browser.js
│   │   └── package.json
│   ├── minimatch
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── minimatch.js
│   │   └── package.json
│   ├── once
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── once.js
│   │   └── package.json
│   ├── path-is-absolute
│   │   ├── index.js
│   │   ├── license
│   │   ├── package.json
│   │   └── readme.md
│   ├── rimraf
│   │   ├── LICENSE
│   │   ├── README.md
│   │   ├── bin.js
│   │   ├── package.json
│   │   └── rimraf.js
│   └── wrappy
│       ├── LICENSE
│       ├── README.md
│       ├── package.json
│       └── wrappy.js
├── package-lock.json
└── package.json

以上の結果より、コマンドが実行された順番でワークスペースルートにインストールされることを確認できた。

npm installの実行順について

下記のような状態で

app-a/package.json

...
  "dependencies": {
    "rimraf": "3.0.2"
  },
...

app-b/package.json

...
  "dependencies": {
    "rimraf": "2.7.1"
  },
...
  • node_modulesとpackage-lock.jsonを削除してnpm iしたところAと同じ構造になることを確認
  • package.jsonのworkspaceの順序を入れ替えてnpm iしてもAと同じ構造になることを確認

package.json

  "workspaces": [
    "app-b",
    "app-a"
  ]
  • app-bの名前を変えてnpm iを実行すると、ワークスペースルートにrimraf@2.7.1がインストールされることを確認
%mv app-b app
% npm i 

よってnpm iの実行順序はディレクトリ名順で実行されることがわかった。

まとめ

npm workspaceとnpm installの挙動について初めて見たときよくわからなかったのでまとめた。

.npm-init.jsを使用してmonorepoでpackage.jsonの初期値を設定する

概要

npm workspaceを利用するとき、package.jsonの初期値を設定することで構築の手間を減らす方法を示す。
これによりnpm initしたときに、npm scriptsやnpmパッケージが初期設定される。

環境

% node -v
v16.13.2
% npm -v
8.10.0

実装

% tree -a -I '.git|.gitignore' 
.
├── .npm-init.js
├── .npmrc
└── package.json

package.json

{
  "name": "hoge",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "workspaces": [],
  "dependencies": {
    "yargs": "17.5.1"
  }
}

.npm-init.js

const yargs = require("yargs/yargs");
const { hideBin } = require("yargs/helpers");
const argv = yargs(hideBin(process.argv)).argv;
const scope = "@tom-256";
module.exports = {
  name: `${scope}/${argv.w}`,
  version: "0.0.0",
  description: "",
  main: "dist/index.js",
  scripts: {
    test: "jest",
    build: "tsc",
  },
  keywords: [],
  author: "",
  license: "ISC",
  dependencies: {
    express: "4.18.1",
  },
  devDependencies: {
    jest: "28.1.0",
  },
};

.npmrc

init-module=.npm-init.js

この状態でワークスペースを作ると下記のようになる。

npm init -y -w package-a
Wrote to /Users/.../hoge/package-a/package.json:

{
  "name": "@tom-256/package-a",
  "version": "0.0.0",
  "description": "",
  "main": "dist/index.js",
  "scripts": {
    "test": "jest",
    "build": "tsc"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "4.18.1"
  },
  "devDependencies": {
    "jest": "28.1.0"
  }
}

まとめ

npm init時のscriptsやnpmパッケージの初期値を設定する方法を示した。
ただしこのままだと、初期値のnpmパッケージのバージョンが古くなってしまう。
npmパッケージのバージョン管理はRenovate等でおこなうことを想定したときに、.npm-init.jsに記述してあるバージョンをどのように管理にするか考える必要がある。
また、tscofnig.jsonや.eslinrc.jsなどの設定ファイルも構築するように.npm-init.jsに記述しておくことで更に手間を減らし、設定を統一することができる。
モノレポで構築の手間を減らしたり、設定を統一するケースで有用なのでメモした。

参考情報

https://docs.npmjs.com/cli/v8/commands/npm-init
https://docs.npmjs.com/cli/v8/using-npm/config#init-module
https://docs.npmjs.com/creating-a-package-json-file

補足

npm scriptsの設定を揃えたい場合はnpm set scriptを利用する。
https://docs.npmjs.com/cli/v8/commands/npm-set-script
例:npm set-script test "jest" -ws

Node.jsでパッケージが参照しているnode_modulesのパスを取得する

結論

require.resolveを使う

Use the internal require() machinery to look up the location of a module, but rather than loading the module, just return the resolved filename.

const path = require.resolve(<PACKAGE>)

https://nodejs.org/dist/latest-v16.x/docs/api/modules.html#requireresolverequest-options

環境

% node -v
v16.13.2
% npm -v
8.10.0

動作確認

npm init -y
npm init -y -w apps/api-a
npm i cowsay --save-exact
npm i express --save-exact
npm i express@4.0.0 --save-exact -w apps/api-a
require-resolve % tree -L 3 -I node_modules
.
├── apps
│   └── api-a
│       ├── index.js
│       └── package.json
├── package-lock.json
└── package.json

apps/api-a/package.json

{
  "name": "api-a",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "4.0.0"
  }
}

package.json

{
  "name": "require-resolve",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "workspaces": [
    "apps/api-a"
  ],
  "dependencies": {
    "cowsay": "1.5.0",
    "express": "4.18.1"
  }
}

CommonJSの場合

apps/api-a/index.js

const cowsay = require("cowsay");
console.log(require.resolve("cowsay"));

const express = require("express");
console.log(require.resolve("express"));

実行結果

node apps/api-a/index.js
/.../require-resolve/node_modules/cowsay/index.js # さかのぼって参照
/.../require-resolve/apps/api-a/node_modules/express/index.js # 最も近いもの 

挙動の説明

https://nodejs.org/dist/latest-v16.x/docs/api/modules.html#loading-from-node_modules-folders

上のドキュメントにある通り、node_modulesをさかのぼって参照する。
ディレクトリルートにexpress@4.18.1、exapi-aにexpress@4.0.0がインストールされており、
apps/api-a/index.jsは一番近いexpressを見に行く。
どのnode_modulesを利用しているかはrequrie.resolveで取得できる。

ES Modulesの場合

apps/api-a/index.js

console.log(await import.meta.resolve("cowsay"));
console.log(await import.meta.resolve("express"));

apps/api-a/package.json

  "type": "module",

実行結果

% node --experimental-import-meta-resolve apps/api-a/index.js
file:///.../require-resolve/node_modules/cowsay/index.js
file:///.../require-resolve/apps/api-a/node_modules/express/index.js

もしくは

import { createRequire } from "module";
const require = createRequire(import.meta.url);
console.log(require.resolve("cowsay"));
console.log(require.resolve("express"));
% node apps/api-a/index.js
/.../require-resolve/node_modules/cowsay/index.js
/.../require-resolve/apps/api-a/node_modules/express/index.js

参考ドキュメント

https://nodejs.org/dist/latest-v16.x/docs/api/esm.html#importmetaresolvespecifier-parent https://nodejs.org/dist/latest-v16.x/docs/api/esm.html#no-requireresolve

まとめ

どのnode_modulesを参照しているのかパスを取得する方法をまとめた。
モノレポはnode_modulesが複数できるので、node_module内のファイルを参照するときにパスを考慮する必要がある。