1、問題背景
近期,在線上運行服務時遇到了一個詭異的 Linux 權限問題:root 用戶在操作本該有權限的資源時,卻報了權限錯誤。
報錯如下:
Error: EACCES: permission denied, mkdir '/root/.pm2/logs'
at Object.mkdirSync (fs.js:921:3)
at mkdirpNativeSync (/home/web_server/project/node_modules/pm2/node_modules/mkdirp/lib/mkdirp-native.js:29:10)
at Function.mkdirpSync [as sync] (/home/web_server/project/node_modules/pm2/node_modules/mkdirp/index.js:21:7)
at module.exports.Client.initFileStructure (/home/web_server/project/node_modules/pm2/lib/Client.js:133:25)
at new module.exports (/home/web_server/project/node_modules/pm2/lib/Client.js:38:8)
at new API (/home/web_server/project/node_modules/pm2/lib/API.js:108:19)
at Object. (/home/web_server/、project/node_modules/pm2/lib/binaries/CLI.js:22:11)
at Module._compile (internal/modules/cjs/loader.js:1137:30)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1157:10)
at Module.load (internal/modules/cjs/loader.js:985:32)
這個錯誤非常直觀,就是用戶想要創建/root/.pm2/logs文件夾,但是沒有權限。該服務使用 pm2 做多進程管理。pm2 默認會將其日志信息、進程信息等寫入到$HOME/.pm2下。因為是 root 后用戶所以寫到了/root/.pm2里。
但這個問題的奇怪之處在于,服務是通過 root 用戶啟動的,對/root目錄是具有寫入權限的。但這里卻報了名優權限的錯我。
那么是什么導致 root 用戶操作/root目錄的權限“丟失”了呢?
2、初步排查
項目是容器化部署,使用 npm 啟動,代碼文件位于/home//下。執行npm start即可啟動。
這是我們使用的一套標準的構建與部署「模版」,已經在上百個服務上應用,且一直都正常。知道近期的一次上線出現了上面這個問題。
這次突然出現的這個問題讓我充滿了疑惑 —— 基于對 Linux 系統用戶、用戶組權限控制的理解,不可能出現這個錯誤。難道是我理解有誤?
在疑惑的同時,我嘗試不使用 npm ,直接通過 pm2 命令行pm2 start ..js啟動,發現服務正常啟動了!莫非是 npm 導致的?
而在這次上線的時候,確實更新了基礎鏡像,升級了 npm cli。之前是 v6.x,這次更新到了 v7.x。而當我將 npm 版本回退到 v6.x 后,問題小時。看來是 v7.x 的改動導致了這個問題
3、問題定位
先說結論:npm v6.x 使用 npm 執行命令時默認會使用 模式,將子執行命令的子進程設置為 root 用戶/用戶組,該行為可以通過unafe-pem配置來控制。而在 v7.x 中,如果通過 root 用戶執行 npm ,則會基于當前目錄(cwd)所屬用戶來設置。
下面通過代碼來一起看下。
3.1、v7.x 中 npm 的實現
以下代碼來自 npm/cli v7.11.1
npm 的執行邏輯可以從 lib/exec.js 中查看:
class Exec extends BaseCommand {
// ...
async _exec (_args, { locationMsg, path, runPath }) {
// ...
if (call && _args.length)
throw this.usage
return libexec({
...flatOptions,
args,
// ...
})
}
}

省略無關代碼,可以看到執行 npm 時會調用 方法運行時錯誤沒有權限,=""> 方法內部會調用 #L50"> 方法來執行命令。
因為調用鏈比較長,我把中間代碼省略了,只貼出關鍵的代碼,感興趣的朋友可以點擊文中鏈接跳轉查看。
通過一系列曲折的調用,代碼最后會調用到 ps:///npm/cli/blob/v7.11.1//%/run-/lib/run--pkg.js#L54"> 方法。這個方法最終會使用 內置模塊里提的 spawn 方法來啟動子進程執行命令,其相關代碼如下:
const promiseSpawn = (cmd, args, opts, extra = {}) => {
const cwd = opts.cwd || process.cwd()
const isRoot = process.getuid && process.getuid() === 0
const { uid, gid } = isRoot ? inferOwner.sync(cwd) : {}
return promiseSpawnUid(cmd, args, {
...opts,
cwd,
uid,
gid
}, extra)
}
上面的實現中,有一行非常重要:
const { uid, gid } = isRoot ? inferOwner.sync(cwd) : {}
可以看到,如果當前進程的用戶是 root,則會使用 方法來設置啟動的子進程的 uid 和 gid(也就是用戶 id 和用戶組 id)。
那么 是做什么的呢?它其實就是用來獲取某個文件所屬的用戶與用戶組的:
const inferOwnerSync = path => {
path = resolve(path)
if (cache.has(path))
return cache.get(path)
const parent = dirname(path)
let threw = true
try {
const st = fs.lstatSync(path)
threw = false
const { uid, gid } = st
cache.set(path, { uid, gid })
return { uid, gid }
} finally {
if (threw && parent !== path) {
const owner = inferOwnerSync(parent)
cache.set(path, owner)
return owner // eslint-disable-line no-unsafe-finally
}

}
}
其中最重要的代碼是這幾行:
const st = fs.lstatSync(path)
// ...
const { uid, gid } = st
ps:///dist/-v14.x/docs/api/fs.html#">fs. 方法 會使用 fstat 這個系統調用來獲取文件的 uid 和 gid。
中會將 cwd 傳入來獲取 uid 和 gid。而在我們線上服務的容器里,我們是在 /home// 下執行 npm start,該目錄所屬用戶是 ,用戶組是 。所以 npm 在啟動子進程時“切換”了用戶。
所以實際情況是,pm2 start ..js 相當于是被 用戶啟動的,但是環境變量 $HOME 仍然是 /root。所以在 /root 中創建文件夾,自然就沒有權限。
3.2、v6.x 中 npm 實現方式的區別
以下代碼來自 npm/cli v6.14.8
v7.x 為了權限安全,做了上述操作,那么 v6.x 如何呢?
v6.x 的 npm 入口是 lib/run-.js 文件:
function run (pkg, wd, cmd, args, cb) {
// ...
chain(cmds.map(function (c) {
// pass cli arguments after -- to script.
if (pkg.scripts[c] && c === cmd) {
pkg.scripts[c] = pkg.scripts[c] + joinArgs(args)
}
// when running scripts explicitly, assume that they're trusted.
return [lifecycle, pkg, c, wd, { unsafePerm: true }]
}), cb)
}
而其實際執行則需要從 方法中來找。上面這段代碼的最后一行還有一個非常重要的參數 { : true },之后會用到。
本身代碼并不復雜,主要就是參數調整,然后調用實際函數。和 uid、gid 實際的設置代碼是在 npm-/index.js 中的 里:
function runCmd (note, cmd, pkg, env, stage, wd, opts, cb) {
// ...
var unsafe = opts.unsafePerm
var user = unsafe ? null : opts.user
var group = unsafe ? null : opts.group
// ...
if (unsafe) {
runCmd_(cmd, pkg, env, wd, opts, stage, unsafe, 0, 0, cb)
} else {

uidNumber(user, group, function (er, uid, gid) {
if (er) {
er.code = 'EUIDLOOKUP'
opts.log.resume()
process.nextTick(dequeue)
return cb(er)
}
runCmd_(cmd, pkg, env, wd, opts, stage, unsafe, uid, gid, cb)
})
}
}
// ...
function runCmd_ (cmd, pkg, env, wd, opts, stage, unsafe, uid, gid, cb_) {
// ...
var proc = spawn(sh, args, conf, opts.log)
// ...
}
里會通過傳入的 opt. 參數(就是上面設置的那個 { : true })來判斷是否是 的。如果是 ,則會在調用 時將 uid、gid 設置為 0。0 就代表 root 用戶和 root 用戶組。
而最終在 中的 spawn 就是 中的 spawn 方法:
const _spawn = require('child_process').spawn
// ...
function spawn (cmd, args, options, log) {
// ...
const raw = _spawn(cmd, args, options)
// ...
}
到這里我們就定位到了該問題:
目前從代碼實現來看,似乎沒有特別好的處理方式,比較簡答的兩種就是:
3.3、npm cli 的變更日志
其實,這個變更在 npm v7.0.0-beta.0 發布時的 里是有提到的。不過只有寥寥一行:
The user, group, uid, gid, and -perms are no . When npm is run as root, are run with the uid and gid of the owner.
大致說的就是咱們上面從代碼分析的結論:如果是 root 運行 npm,則在腳本執行時切換到當前工作目錄的 owner。
然后如果你跟著代碼看下來,也會發現 v6.x 中的 -pem 配置,在 v7.0.0 開始就被廢棄了。不過 npm cli 文檔更新的較慢,直到 v7.0.0 正式版發布后的一個月后,才在 v7.0.15 的 里把 -pem 從文檔中移除。
3.4、其他可能出現的問題
這個功能實現的變更,除了會導致一些文件操作時的權限問題,還會有一些其他場景的權限錯誤。例如在如果你用 npm 啟動一個 ,要綁定 443 端口,這個時候可能就會報錯。因為會需要 root 權限來執行這個端口綁定。在 issue 里就有人提到了這個情況。
加餐:#spawn 是如何設置 user 和 group 的?
通過上面的分析運行時錯誤沒有權限,問題已經被解決了。沿著這個問題,可以具體看了下 中, 模塊的 spawn 方法是如何設置 user 和 group 的。
以下代碼基于 v14.16.1。只關注 unix 實現。
中,我們在上層引入的模塊,是直接放在 lib 下面的,而其一般會在調用 lib/ 下的對應模塊,這部分會直接使用 來調用 C++ 對象和方法。 也不例外,你會在 lib//.js 中看到如下代碼:
ChildProcess.prototype.spawn = function(options) {
// ...
const err = this._handle.spawn(options);
// ...
因為比較簡答,所以這里省去了 lib/.js 中的方法。只要知道,我們在 層使用 spawn 方法時,最后會調用到 實例的 spawn 方法即可。可以看到最后是調用了 this..spawn。那么 this. 是什么呢?
它其實就是通過 創建的 對象:
const { Process } = internalBinding('process_wrap');
// ...
function ChildProcess() {
EventEmitter.call(this);
// ...
this._handle = new Process();
// ...
}
這個 的設置在 src/ 中,
static void Spawn(const FunctionCallbackInfo<Value>& args) {
// ...
// options.uid
Local<Value> uid_v =
js_options->Get(context, env->uid_string()).ToLocalChecked();
if (!uid_v->IsUndefined() && !uid_v->IsNull()) {
CHECK(uid_v->IsInt32());
const int32_t uid = uid_v.As<Int32>()->Value();
options.flags |= UV_PROCESS_SETUID;
options.uid = static_cast<uv_uid_t>(uid);
}
// options.gid
Local<Value> gid_v =
js_options->Get(context, env->gid_string()).ToLocalChecked();
if (!gid_v->IsUndefined() && !gid_v->IsNull()) {
CHECK(gid_v->IsInt32());
const int32_t gid = gid_v.As<Int32>()->Value();
options.flags |= UV_PROCESS_SETGID;
options.gid = static_cast<uv_gid_t>(gid);

}
int err = uv_spawn(env->event_loop(), &wrap->process_, &options);
wrap->MarkAsInitialized();
// ...
}
可以看到,它把從 層設置的 uid 和 gid 設置到 上,然后調用了 函數創建子進程。在 中對于創建的子進程會通過 it 來做初始化設置:
int uv_spawn(uv_loop_t* loop,
uv_process_t* process,
const uv_process_options_t* options) {
// ...
if (pid == 0) {
uv__process_child_init(options, stdio_count, pipes, signal_pipe[1]);
abort();
}
// ...
}
最后則是在 it 里通過 和 這兩個系統調用來實現的:
static void uv__process_child_init(const uv_process_options_t* options,
int stdio_count,
int (*pipes)[2],
int error_fd) {
// ...
if ((options->flags & UV_PROCESS_SETGID) && setgid(options->gid)) {
uv__write_int(error_fd, UV__ERR(errno));
_exit(127);
}
if ((options->flags & UV_PROCESS_SETUID) && setuid(options->uid)) {
uv__write_int(error_fd, UV__ERR(errno));
_exit(127);
}
// ...
}
在 官方文檔中也有介紹。
我們通過閱讀代碼也印證了這一點。
完。
往期【排障系列】文章: