要打造一個「可以動的 Docker Image」很簡單,參考 Node.js 官方文件 Dockerizing a Node.js web app 就可以產出一個將近 1GB
的 Docker Image
|
|
就開始想
- 這樣安全嗎?
- 可以打造更輕量、更好 ship 的 Image 嗎?
後來找到這一篇文章10 best practices to containerize Node.js web applications with Docker覺得十分實用,也解決安全性上的疑慮,以下摘要重點
1. 選擇正確的 Base Image 並透過 Build Stage 精簡產出
tldr;
- 採用 alpine 或 -slim 版本的 base image
- 用 sha256 指定 base image 版本避免異動
- 支援多階段,可以前期 build 用比較大的 base image,最後產出在使用精簡的 base image
1 2 3 4 5 6 7
FROM node:latest AS build .... # --------------> The production image FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a .... COPY --from=build /usr/src/app/node_modules /usr/src/app/node_modules ....
進入 docker hub node.js 官方 image,可以看到玲琅滿目的版本,除了對應不同的 node.js 版本,底層的 os 可以分成幾種
- stretch: 基於 debian 9
- bulter: 基於 debian 10
- alpine: 基於 alpine linux
坦白說目前還不太理解 debian 9 / 10 真正的差異,而 alpine linux 目標是打造最輕量的 container os,最大差異在採用 musl libc 取代 glibc,如果使用的 js 套件有利用到 libc 可能會有問題
而帶有 -slim
結尾則是代表該 image 是最輕量可運行 Node.js 的 container os,也是官方建議在生產環境採用
例如說 node:14.15.4-stretch 尺寸 942MB
vs node:14.15.4-stretch-slim 尺寸 167MB
,前者連 build 工具都有包含如 python / node-gyp 等,這導致尺寸差異非常巨大
安裝越多工具的 Image,導致的潛藏性安全漏洞就越多,所以正式環境運行盡量採用 slim 版本
所以最棒的是在第一階段採用完整 Image 方便 build node_modules,最後階段產出用精簡 Image 確保運行時沒有多餘的工具
2. 確保只安裝 production 需要的 node modules 並指定 NODE_ENV 為 production
透過
RUN npm ci --only=production
只安裝 dependencies 而沒有 dev_dependencies 開發用的套件
npm ci 與 npm install 看似都在安裝套件但有很大的差異;
npm ci 只讀取 lock file,並用 package.json 做比對,如果 lock file 與 package.json 版本不合會噴出錯誤,最適合用在要穩定且強一致的套件版本要求
npm install 則是讀取 package.json,並透過 lock file 做安裝的版本指定,如果有套件沒有出現在 lock file 中,則 npm 直接安裝
運行環境,根據 Node.js 的不成文規定,請指定NODE_ENV=production
,各個套件都會針對 production 進行優化,另如文中舉例 express.js 會在生產環境加入頁面 cache 機制
3. 不要用 root 運行 container!!
記得加入
USER node
切換使用者,並記得 COPY 時要給予使用者相對權限COPY --chown=node:node . /usr/src/app
這很重要,也很容易忘記,給予用戶不多不少的權限一直是安全性的基本準則,預設 docker container 內是以 root 運行 process,但如果不小心應用程式有漏洞,甚至有可能讓駭客跳脫 container context 獲得 host root 權限
4. 正確接收程序中斷事件與優雅地退出
採用 dump-init 直接啟動 Node.js process
1 2
RUN apk add dumb-init CMD ["dumb-init", "node", "server.js"]
並記得在 Node.js 中偵測事件
1 2
process.on('SIGINT', closeGracefully) process.on('SIGTERM', closeGracefully)
伺服器長時間運行時,總會遇到版本更新的時候,最理想的做法是讓中斷流量並讓程序完成剩餘工作後優雅退出,但如果採用的方式錯誤 Docker Container 會無法收到系統中斷的事件 SIGKILL
、SIGTERM
等
以下是幾種常見的 Container 執行指令,但分別有一些問題
1. CMD “npm” “start”
這會遇到兩個問題
- 透過 npm 啟動 Node.js process,但是
npm 不會正確 pass 所有的系統中斷到 Node.js process 上
CMD "cmd" "params" ..
與CMD ["cmd", "params"]
是不同的,前者是先啟動 shell 再去執行後面的 cmd;而後者則是直接執行 cmd,最直接的差異就是PID 1 是 shell 還是 cmd
,透過 shell 同樣有可能不會收到全部的系統中斷
2. CMD [“node”, “index.js”]
優化上面的指令,改由直接啟動 node.js 少了 npm 與 shell,這會遇到另一個問題 linux 對於 PID 1 的程序有特別的處理
,因為 PID 1 代表此程序要負責系統的初始化,所以 Kernel 會有額外的處理機制,實際的影響有
- 一般的程序收到
SIGTERM
後 Kernel 會有預設的結束處理,但是PID 1 沒有預設終止處理,也就是收到後沒有主動退出就不會退出
- 如果有 orphan process 會被主動掛載到 PID 1 之下,但一般的 process 不會去處理 orphan process,會留下很多 zombie process
根據 Node.js Docker 小組建議,不要讓 Node.js 運行在 PID 1 上
Node.js was not designed to run as PID 1 which leads to unexpected behaviour when running inside of Docker. For example, a Node.js process running as PID 1 will not respond to SIGINT (CTRL-C) and similar signals
最終解法:透過 init process 再去啟動 Node.js
最後用 dumb-init,這套由 yelp 釋出的啟動 process,會正確將所有的系統中斷都 pass 給所有的 child process,並且在退出時清除所有的 orphan process
Introducing dumb-init, an init system for Docker containers
市面上還有不同的選擇,可以參考此篇 Choosing an init process for multi-process containers
5. 正確處理 Build 階段使用的機敏資料
善用 multi stage,讓機敏資料不要外洩在 Image 當中,並使用 mount secret 同步機敏資料
1
RUN --mount=type=secret,id=npmrc,target=/usr/src/app/.npmrc npm ci --only=production
如果有一些機敏資料是在 Building 階段所需要,例如要去 private github 拉 repo 的 .npmrc 等資料,需要被妥善處理,常見的錯誤示範有
- hard code 寫死
- 採用環境變數,在 docker build 時指定,但如果用 docker history 還是會被發現
奇怪的是我並沒有在 Docker 文件 Use bind mounts 看到 –mount type=secret 的說明,但確實有在官方教學看到 Build images with BuildKit
結語
最終的產出會長這樣
|
|