توی این درس یاد میگیری یک اپ Node.js (یک API ساده با Express) را قدمبهقدم داکرایز کنی، با رعایت چیزهایی که یک Dockerfile حرفهای دارد: npm ci بهجای npm install برای نصب دقیقاً مطابق package-lock.json، ترتیب لایهها برای کش، NODE_ENV=production، اجرا با کاربر غیر root (USER node)، .dockerignore و خاموش شدن تمیز با SIGTERM. در انتها image را با دستور docker build -t node-app . میسازی و تست میکنی.
تشبیه: آمادهسازی یک غذا برای رستوران
Section titled “تشبیه: آمادهسازی یک غذا برای رستوران”اگر هر آشپز دستور را کمی متفاوت بپزد، طعم هر شعبه فرق میکند. package-lock.json مثل دستور دقیق با وزنها است: «۲۰۰ گرم از این مارک». npm ci یعنی «دقیقاً طبق همین وزنها بپز»؛ npm install یعنی «تقریباً شبیه این، هر چه مارک جدید بود هم اشکال ندارد». برای رستوران زنجیرهای (production) باید همه جا یک طعم باشد؛ پس npm ci.
ساختار image یک اپ Node
Section titled “ساختار image یک اپ Node”مثالهای عملی
Section titled “مثالهای عملی”مثال ۱: ساخت اپ Express (با package-lock.json)
Section titled “مثال ۱: ساخت اپ Express (با package-lock.json)”اول پروژه را میسازیم. بهجای نصب Node روی سیستم، از خود image استفاده میکنیم:
mkdir -p apidocker run --rm -v "$PWD/api":/app -w /app node:22-alpine sh -c 'npm init -y >/dev/null 2>&1 && npm install express --silent 2>&1 | tail -1'ls api | tr '\n' ' 'node -e "console.log(Object.keys(require('./api/package.json').dependencies))" 2>/dev/null || python3 -c "import json;print(list(json.load(open('api/package.json'))['dependencies']))"node_modules package-lock.json package.json [ 'express' ]دستور npm install express فایل package.json (فهرست وابستگیها) و package-lock.json (نسخههای دقیق همهی وابستگیها و زیروابستگیها) را ساخت. حالا کد API:
const express = require('express');const app = express();
app.get('/', (req, res) => res.json({ message: 'سلام از Express در داکر' }));app.get('/health', (req, res) => res.json({ status: 'ok' }));app.get('/env', (req, res) => res.json({ NODE_ENV: process.env.NODE_ENV, user: require('os').userInfo().username }));
const server = app.listen(3000, '0.0.0.0', () => console.log('listening on 3000'));
// خاموش شدن تمیز: داکر با docker stop سیگنال SIGTERM میفرستدprocess.on('SIGTERM', () => { console.log('SIGTERM گرفتم، بستن سرور...'); server.close(() => process.exit(0));});دو نکتهی مهم در کد: ۱) listen(3000, '0.0.0.0'): برنامه روی همهی آدرسها گوش میدهد (اگر فقط localhost بود، از بیرون کانتینر قابل دسترسی نبود؛ درس پورتها). ۲) handler برای SIGTERM (در مثال ۵ اثرش را میبینی).
مثال ۲: Dockerfile
Section titled “مثال ۲: Dockerfile”FROM node:22-alpine
# بهینهسازی و حالت productionENV NODE_ENV=production
WORKDIR /app
# اول فقط فایلهای وابستگی (برای کش)COPY package*.json ./RUN npm ci --omit=dev
# بعد کد برنامهCOPY . .
# اجرا با کاربر غیر root (در image رسمی node از قبل وجود دارد)USER node
EXPOSE 3000CMD ["node", "app.js"]node_modulesnpm-debug.log.env.gitENV NODE_ENV=production: بسیاری از کتابخانهها (مثل Express) در این حالت سریعتر و کمحرفترند.RUN npm ci --omit=dev: دقیقاً مطابق lock نصب میکند و وابستگیهای توسعه را نمیآورد.USER node: کاربرnode(با شمارهی ۱۰۰۰) در image رسمی Node وجود دارد؛ پروسه با دسترسی root اجرا نمیشود.CMD ["node","app.js"]: فرم exec، تا Node مستقیم PID 1 باشد و سیگنالها را بگیرد.
مثال ۳: build و اجرا
Section titled “مثال ۳: build و اجرا”docker build -t node-app ./api 2>&1 | grep -E '^#[0-9]+ \[[0-9]/[0-9]\]' | sed -E 's/@sha256:[0-9a-f]+//' | awk '!s[$0]++'docker run -d --name lx-node1 -p 8360:3000 node-app >/dev/null; sleep 2curl -s localhost:8360/curl -s localhost:8360/healthcurl -s localhost:8360/envdocker images node-app --format 'اندازه: {{.Size}}'#5 [1/5] FROM docker.io/library/node:22-alpine#6 [4/5] RUN npm ci --omit=dev#7 [2/5] WORKDIR /app#8 [3/5] COPY package*.json ./#9 [5/5] COPY . .{"message":"سلام از Express در داکر"}{"status":"ok"}{"NODE_ENV":"production","user":"node"}اندازه: 245MBهمهی رفتارها را میبینی: API جواب میدهد، NODE_ENV برابر production است و پروسه با کاربر node (نه root) اجرا میشود. حالا لاگها:
docker logs lx-node1listening on 3000چرا برای production از npm ci استفاده میکنیم؟
npm ci تکرارپذیر است و ناهماهنگی package.json و lock را فوراً نشان میدهد.
مثال ۴: npm ci در برابر npm install
Section titled “مثال ۴: npm ci در برابر npm install”npm ci اگر package.json و package-lock.json با هم نخوانند شکست میخورد. شبیهسازی کنیم: یک وابستگی را فقط در package.json اضافه میکنیم:
mkdir -p drift && cp api/package.json api/package-lock.json drift/python3 - <<'LXPY'import jsonp = json.load(open('drift/package.json')); p['dependencies']['lodash'] = '^4.17.21'json.dump(p, open('drift/package.json', 'w'), indent=2)LXPYprintf 'FROM node:22-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\n' > drift/Dockerfiledocker build -t lx-node-ci ./drift 2>&1 | grep -E 'npm error' | head -3 | cut -c1-140#8 3.677 npm error code EUSAGE#8 3.679 npm error#8 3.679 npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Pleasbuild عمداً شکست خورد: package.json یک وابستگی (lodash) دارد که در lock نیست. npm install همین را بیصدا حل میکرد (و lock را عوض میکرد)، ولی در CI و production دقیقاً نمیخواهیم چیزی بیخبر عوض شود. راهحل: در توسعه npm install بزن و lock جدید را commit کن؛ در Docker و CI همیشه npm ci.
مثال ۵: خاموش شدن تمیز با SIGTERM
Section titled “مثال ۵: خاموش شدن تمیز با SIGTERM”برنامهی ما handler دارد. ببین docker stop چقدر طول میکشد و برنامه چه میگوید:
t() { python3 - "$@" <<'LXPY'import subprocess, sys, times = time.time()subprocess.run(sys.argv[1:], stdout=subprocess.DEVNULL)print(round(time.time() - s, 1))LXPY}echo "docker stop با handler: $(t docker stop lx-node1) ثانیه"docker logs lx-node1 2>&1 | tail -2docker rm lx-node1 >/dev/nulldocker stop با handler: 0.4 ثانیهlistening on 3000SIGTERM گرفتم، بستن سرور...برنامه TERM را گرفت، سرور را بست و با کد 0 تمام شد؛ در کسری از ثانیه. حالا نسخهی بدون handler را مقایسه میکنیم. Node بهعنوان PID 1، بدون handler صریح، سیگنال TERM را نادیده میگیرد (درس CMD در برابر ENTRYPOINT):
mkdir -p nohandler && cp api/package.json api/package-lock.json nohandler/printf "const http = require('http'); http.createServer((q,r)=>r.end('hi')).listen(3000,'0.0.0.0',()=>console.log('up'));\n" > nohandler/app.jsprintf 'FROM node:22-alpine\nWORKDIR /app\nCOPY . .\nCMD ["node","app.js"]\n' > nohandler/Dockerfiledocker build -q -t lx-node-nohandler ./nohandler >/dev/null 2>&1docker run -d --name lx-node2 lx-node-nohandler >/dev/null; sleep 2echo "docker stop بدون handler: $(t docker stop lx-node2) ثانیه | کد خروج: $(docker inspect lx-node2 --format '{{.State.ExitCode}}')"docker rm lx-node2 >/dev/nulldocker stop بدون handler: 3.6 ثانیه | کد خروج: 137بدون handler، docker stop مجبور شد صبر کند و بعد KILL بزند (کد 137). برای سرویسهای واقعی (بستن اتصال دیتابیس، تمام کردن درخواستهای در حال انجام) handler لازم است؛ راه دیگر docker run --init است.
مثال ۶: چرا USER node و چرا ترتیب مهم است
Section titled “مثال ۶: چرا USER node و چرا ترتیب مهم است”ثابت میکنیم پروسه root نیست و کش درست کار میکند:
docker run --rm node-app node -e "console.log('uid:', process.getuid(), '| user:', require('os').userInfo().username)"docker run --rm --user root node-app sh -c 'id -u'echo "console.log('v2')" >> api/app.jsdocker build --progress=plain ./api 2>&1 | grep -E '^#[0-9]+ (CACHED|\[[0-9]/[0-9]\])' | sed -E 's/@sha256:[0-9a-f]+//' | awk '/CACHED/{c[$1]=1} /\[[0-9]\/[0-9]\]/{n[$1]=$0} END{for(k in n) print (c[k]?"CACHED ":"اجرا شد ") n[k]}' | grep -E 'npm ci|COPY' | sort -t'[' -k2nuid: 1000 | user: node0CACHED #6 [3/5] COPY package*.json ./CACHED #7 [4/5] RUN npm ci --omit=devCACHED #9 [5/5] COPY . .با --user root میتوانی در صورت لزوم root شوی، ولی پیشفرض image غیر root است. بعد از تغییر app.js، لایهی npm ci از کش آمد و فقط COPY . . دوباره ساخته شد؛ این نتیجهی ترتیب درست دستورات است.
پشت پرده
Section titled “پشت پرده”npm ci دقیقاً چه میکند؟
Section titled “npm ci دقیقاً چه میکند؟”npm ci ابتدا پوشهی node_modules را کاملاً پاک میکند و بعد دقیقاً مطابق package-lock.json نصب میکند: lock را تغییر نمیدهد و اگر با package.json نخواند، خطا میدهد. npm install ولی ممکن است نسخهها را تا حد مجاز در package.json (مثل ^4.0.0) بالا ببرد و lock را عوض کند. به همین خاطر npm ci هم تکرارپذیر است و هم در محیط خودکار قابلاعتماد.
NODE_ENV=production
Section titled “NODE_ENV=production”بیشتر کتابخانههای Node (از جمله Express و React) با این متغیر رفتار بهینهی production را فعال میکنند: کش، خطاهای کمجزئیات و غیرفعال شدن ابزارهای توسعه. به همین دلیل --omit=dev هم همراه آن میآید: وابستگیهای توسعه (تستها، لینترها) داخل image production معنا ندارند.
image رسمی node و کاربر node
Section titled “image رسمی node و کاربر node”image های رسمی Node یک کاربر معمولی به اسم node (UID 1000) دارند. با USER node همهی دستورهای بعدی و پروسهی نهایی با آن اجرا میشوند. اگر فایلهایی با COPY از root کپی شده باشند و node نیاز به نوشتن روی آنها داشته باشد، باید با COPY --chown=node:node کپی کنی (مثال در جدول).
جدولهای مرجع
Section titled “جدولهای مرجع”| دستور | کاربرد |
|---|---|
npm install |
توسعه؛ ممکن است lock را عوض کند |
npm ci |
CI و Docker؛ دقیقاً مطابق lock، اگر نخواند شکست میخورد |
npm ci --omit=dev |
بدون وابستگیهای توسعه (production) |
npm run build |
build پروژه (در multi-stage) |
| بخش Dockerfile | دلیل |
|---|---|
FROM node:22-alpine |
نسخهی مشخص و سبک (بهجای latest) |
ENV NODE_ENV=production |
رفتار بهینه |
COPY package*.json سپس npm ci |
کش وابستگیها |
COPY --chown=node:node . . |
کدی که node بتواند بخواند/بنویسد |
USER node |
غیر root |
CMD ["node","app.js"] |
فرم exec، PID 1 درست |
اشتباهات رایج
Section titled “اشتباهات رایج”۱) npm install در Dockerfile
Section titled “۱) npm install در Dockerfile”نسخهها در هر build میتوانند عوض شوند (مثال ۴). راهحل: npm ci.
۲) نبودن package-lock.json
Section titled “۲) نبودن package-lock.json”mkdir -p nolock && echo '{"name":"x","version":"1.0.0","dependencies":{"express":"^4.19.0"}}' > nolock/package.jsonprintf 'FROM node:22-alpine\nWORKDIR /app\nCOPY package.json ./\nRUN npm ci\n' > nolock/Dockerfiledocker build -t lx-node-ci ./nolock 2>&1 | grep -E 'npm error' | head -2 | cut -c1-140#8 1.223 npm error code EUSAGE#8 1.223 npm errornpm ci بدون lock کار نمیکند. راهحل: یک بار npm install بزن و package-lock.json را commit کن؛ و مطمئن شو در .dockerignore نیست.
۳) کپی node_modules از سیستم میزبان
Section titled “۳) کپی node_modules از سیستم میزبان”COPY . . بدون .dockerignore پوشهی node_modules میزبان (که ممکن است برای سیستمعامل دیگری نصب شده، مثلاً باینریهای macOS) را وارد image میکند و خراب کاری میکند. راهحل: node_modules در .dockerignore.
۴) اجرا با root
Section titled “۴) اجرا با root”اگر USER node را نگذاری، پروسه با root اجرا میشود و هر آسیبپذیری برنامه به root میرسد. راهحل: USER node.
۵) گوش دادن فقط روی localhost
Section titled “۵) گوش دادن فقط روی localhost”اگر app.listen(3000) یا listen(3000, 'localhost') باشد، از بیرون کانتینر قابل دسترسی نیست (درس پورتها). راهحل: 0.0.0.0.
دستور npm ci را داخل یک کانتینر node:22-alpine روی پوشهی api اجرا کن و تعداد بستههای نصبشده را ببین (فقط خط آخر خروجی).
دیدن جواب
docker run --rm -v "$PWD/api":/app -w /app node:22-alpine sh -c 'npm ci --omit=dev 2>&1 | grep -E "added|up to date"; rm -rf node_modules'added 68 packages, and audited 69 packages in 15sتمرین اصلی: یک API سادهی Express را داکرایز کن. اپ api را با docker build -t node-app ./api بساز، روی پورت 8361 اجرا کن و /health را بخوان.
دیدن جواب
docker build -q -t node-app ./api >/dev/null 2>&1docker run -d --name lx-node3 -p 8361:3000 node-app >/dev/null; sleep 2curl -s localhost:8361/healthdocker rm -f lx-node3 >/dev/null{"status":"ok"}ثابت کن image تو با کاربر غیر root اجرا میشود و NODE_ENV برابر production است، بدون اینکه کانتینر را در پسزمینه بالا بیاوری (فقط با docker run --rm و node -e). بعد اندازهی image را با حالت «بدون .dockerignore و با node_modules میزبان» مقایسه کن (فقط توضیح بده چرا بزرگتر میشد).
دیدن جواب
docker run --rm node-app node -e "console.log(process.env.NODE_ENV, process.getuid())"docker images node-app --format 'اندازه: {{.Size}}'production 1000اندازه: 245MBخروجی production 1000 یعنی NODE_ENV درست و uid غیر صفر. بدون .dockerignore، پوشهی node_modules میزبان (و .git و …) هم کپی میشد، پس image بزرگتر و ناسازگار با لینوکس میشد.
آزمونک
Section titled “آزمونک”npm ci با npm install چه فرقی دارد؟
تکرارپذیر و مناسب CI/Docker.
چرا اول package*.json را کپی میکنیم و بعد COPY . .؟
وابستگیها کمتغییرند و باید پایینتر بمانند.
USER node چه میکند؟
کاربر node در image رسمی Node از قبل هست.
اگر برنامهی Node روی localhost داخل کانتینر گوش بدهد…
درس پورتها را ببین.
برای اینکه docker stop سریع و تمیز باشد، برنامهی Node چه لازم دارد؟
PID 1 بدون handler سیگنال TERM را نادیده میگیرد.
جمعبندی
Section titled “جمعبندی”- ساختار Dockerfile Node:
FROM node:22-alpine←ENV NODE_ENV=production←WORKDIR←COPY package*.json←RUN npm ci --omit=dev←COPY . .←USER node←CMD ["node","app.js"]. npm ciتکرارپذیر است و مطابق lock؛package-lock.jsonرا commit کن و داخل.dockerignoreنگذار..dockerignore:node_modules،.env،.git.- برنامه باید روی
0.0.0.0گوش بدهد وSIGTERMرا مدیریت کند. - اجرا با کاربر غیر root (
USER node)، فرم exec برایCMD.
| دستور | کاری که میکند |
|---|---|
npm ci --omit=dev | نصب دقیق مطابق lock بدون dev |
docker build -t node-app . | ساخت image |
docker run -d -p 3000:3000 node-app | اجرا با پورت |
USER node | غیر root |
ENV NODE_ENV=production | حالت production |
COPY --chown=node:node . . | مالکیت فایلها برای کاربر node |
process.on("SIGTERM", ...) | خاموش شدن تمیز در برنامه |
docker run --init IMG | init کوچک بهعنوان PID 1 |