近期,在線上運行服務時遇到了一個詭異的 Linux 權限問題:root 用戶在操做本該有權限的資源時,卻報了權限錯誤。html
報錯以下:node
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.<anonymous> (/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
裏。linux
但這個問題的奇怪之處在於,服務是經過 root 用戶啓動的,對 /root
目錄是具備寫入權限的。但這裏卻報了名優權限的錯我。c++
那麼是什麼致使 root 用戶操做 /root
目錄的權限「丟失」了呢?git
項目是容器化部署,使用 npm script 啓動,代碼文件位於 /home/web_server/project
下。執行 npm start
便可啓動。github
這是咱們使用的一套標準的構建與部署「模版」,已經在上百個服務上應用,且一直都正常。知道近期的一次上線出現了上面這個問題。web
此次忽然出現的這個問題讓我充滿了疑惑 —— 基於對 Linux 系統用戶、用戶組權限控制的理解,不可能出現這個錯誤。難道是我理解有誤?npm
在疑惑的同時,我嘗試不使用 npm script,直接經過 pm2 命令行 pm2 start ecosystem.config.js
啓動,發現服務正常啓動了!莫非是 npm 致使的?c#
而在此次上線的時候,確實更新了基礎鏡像,升級了 npm cli。以前是 v6.x,此次更新到了 v7.x。而當我將 npm 版本回退到 v6.x 後,問題小時。看來是 v7.x 的改動致使了這個問題。api
先說結論:npm v6.x 使用 npm script 執行命令時默認會使用 unsafe 模式,將子執行命令的子進程設置爲 root 用戶/用戶組,該行爲能夠經過 unafe-pem
配置來控制。而在 v7.x 中,若是經過 root 用戶執行 npm script,則會基於當前目錄(cwd)所屬用戶來設置。
下面經過代碼來一塊兒看下。
如下代碼來自 npm/cli v7.11.1
npm script 的執行邏輯能夠從 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 script 時會調用 libexec
方法,libexec
方法內部會調用 runScript
方法來執行命令。
由於調用鏈比較長,我把中間代碼省略了,只貼出關鍵的代碼,感興趣的朋友能夠點擊文中連接跳轉查看。
經過一系列曲折的調用,代碼最後會調用到 promiseSpawn
方法。這個方法最終會使用 child_process 內置模塊裏提的 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,則會使用 inferOwner
方法來設置啓動的子進程的 uid 和 gid(也就是用戶 id 和用戶組 id)。
那麼 inferOwner
是作什麼的呢?它其實就是用來獲取某個文件所屬的用戶與用戶組的:
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
複製代碼
fs.lstatSync
方法 會使用 fstat
這個系統調用來獲取文件的 uid 和 gid。
promiseSpawn
中會將 cwd 傳入來獲取 uid 和 gid。而在咱們線上服務的容器裏,咱們是在 /home/web_server/project
下執行 npm start
,該目錄所屬用戶是 web_server
,用戶組是 web_server
。因此 npm 在啓動子進程時「切換」了用戶。
因此實際狀況是,pm2 start ecosystemt.config.js
至關因而被 web_server 用戶啓動的,可是環境變量 $HOME
仍然是 /root
。因此在 /root
中建立文件夾,天然就沒有權限。
如下代碼來自 npm/cli v6.14.8
v7.x 爲了權限安全,作了上述操做,那麼 v6.x 如何呢?
v6.x 的 npm script 入口是 lib/run-script.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)
}
複製代碼
而其實際執行則須要從 lifecycle
方法中來找。上面這段代碼的最後一行還有一個很是重要的參數 { unsafePerm: true }
,以後會用到。
lifecycle 自己代碼並不複雜,主要就是參數調整,而後調用實際函數。和 uid、gid 實際的設置代碼是在 npm-lifecycle/index.js
中的 runCmd
裏:
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)
// ...
}
複製代碼
runCmd
裏會經過傳入的 opt.unsafePem
參數(就是上面設置的那個 { unsafePerm: true }
)來判斷是不是 unsafe
的。若是是 unsafe
,則會在調用 runCmd_
時將 uid、gid 設置爲 0。0 就表明 root 用戶和 root 用戶組。
而最終在 runCmd_
中的 spawn
就是 child_process
中的 spawn
方法:
const _spawn = require('child_process').spawn
// ...
function spawn (cmd, args, options, log) {
// ...
const raw = _spawn(cmd, args, options)
// ...
}
複製代碼
到這裏咱們就定位到了該問題:
unsafe-pem
這個 npm config,npm script 就會在啓動子進程時默認設置爲 root。目前從代碼實現來看,彷佛沒有特別好的處理方式,比較簡答的兩種就是:
/home/web_server/project
所屬用戶/用戶組改成 root。但權限的改動可能會引起其餘問題。其實,這個變動在 npm v7.0.0-beta.0 發佈時的 CHANGELOG 裏是有提到的。不過只有寥寥一行:
The user, group, uid, gid, and unsafe-perms configurations are no longer relevant. When npm is run as root, scripts are always run with the effective uid and gid of the working directory owner.
大體說的就是我們上面從代碼分析的結論:若是是 root 運行 npm,則在腳本執行時切換到當前工做目錄的 owner。
而後若是你跟着代碼看下來,也會發現 v6.x 中的 unsafe-pem
配置,在 v7.0.0 開始就被廢棄了。不過 npm cli 文檔更新的較慢,直到 v7.0.0 正式版發佈後的一個月後,纔在 v7.0.15 的 Release 裏把 unsafe-pem
從文檔中移除。
這個功能實現的變動,除了會致使一些文件操做時的權限問題,還會有一些其餘場景的權限錯誤。例如在若是你用 npm script 啓動一個 nodejs server,要綁定 443 端口,這個時候可能就會報錯。由於會須要 root 權限來執行這個端口綁定。在 issue 裏就有人提到了這個狀況。
經過上面的分析,問題已經被解決了。沿着這個問題,能夠具體看了下 Nodejs 中,child_process 模塊的 spawn
方法是如何設置 user 和 group 的。
如下代碼基於 Nodejs v14.16.1。只關注 unix 實現。
Nodejs 中,咱們在上層引入的模塊,是直接放在 lib
下面的,而其通常會在調用 lib/internal
下的對應模塊,這部分會直接使用 internalBinding 來調用 C++ 對象和方法。child_process 也不例外,你會在 lib/internal/child_process.js
中看到以下代碼:
ChildProcess.prototype.spawn = function(options) {
// ...
const err = this._handle.spawn(options);
// ...
複製代碼
由於比較簡答,因此這裏省去了 lib/child_process.js
中的方法。只要知道,咱們在 JavaScript 層使用 spawn
方法時,最後會調用到 ChildProcess 實例的 spawn
方法便可。能夠看到最後是調用了 this._handle.spawn
。那麼 this._handle
是什麼呢?
它其實就是經過 binding 建立的 Process 對象:
const { Process } = internalBinding('process_wrap');
// ...
function ChildProcess() {
EventEmitter.call(this);
// ...
this._handle = new Process();
// ...
}
複製代碼
這個 binding 的設置在 src/process_wrap.cc
中,
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();
// ...
}
複製代碼
能夠看到,它把從 JavaScript 層設置的 uid 和 gid 設置到 options 上,而後調用了 uv_spawn
函數建立子進程。在 uv_spawn
中對於建立的子進程會經過 uv__process_child_init
來作初始化設置:
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();
}
// ...
}
複製代碼
最後則是在 uv__process_child_init
裏經過 setuid
和 setgid
這兩個系統調用來實現的:
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);
}
// ...
}
複製代碼
在 Nodejs 官方文檔中也有介紹。
咱們經過閱讀代碼也印證了這一點。
完。
往期【排障系列】文章: