رفتن به محتوا
LoopX

داکرایز کردن اپ Node.js

توی این درس یاد می‌گیری یک اپ 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.

لایه‌ها از کم‌تغییر (پایین) به پرتغییر (بالا): پایه‌ی Node، وابستگی‌ها، کد؛ و اجرا با کاربر غیر root.

مثال ۱: ساخت اپ Express (با package-lock.json)

Section titled “مثال ۱: ساخت اپ Express (با package-lock.json)”

اول پروژه را می‌سازیم. به‌جای نصب Node روی سیستم، از خود image استفاده می‌کنیم:

Terminal window
mkdir -p api
docker 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:

api/app.js
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 (در مثال ۵ اثرش را می‌بینی).

api/Dockerfile
FROM node:22-alpine
# بهینه‌سازی و حالت production
ENV NODE_ENV=production
WORKDIR /app
# اول فقط فایل‌های وابستگی (برای کش)
COPY package*.json ./
RUN npm ci --omit=dev
# بعد کد برنامه
COPY . .
# اجرا با کاربر غیر root (در image رسمی node از قبل وجود دارد)
USER node
EXPOSE 3000
CMD ["node", "app.js"]
api/.dockerignore
node_modules
npm-debug.log
.env
.git
  • ENV NODE_ENV=production: بسیاری از کتابخانه‌ها (مثل Express) در این حالت سریع‌تر و کم‌حرف‌ترند.
  • RUN npm ci --omit=dev: دقیقاً مطابق lock نصب می‌کند و وابستگی‌های توسعه را نمی‌آورد.
  • USER node: کاربر node (با شماره‌ی ۱۰۰۰) در image رسمی Node وجود دارد؛ پروسه با دسترسی root اجرا نمی‌شود.
  • CMD ["node","app.js"]: فرم exec، تا Node مستقیم PID 1 باشد و سیگنال‌ها را بگیرد.
Terminal window
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 2
curl -s localhost:8360/
curl -s localhost:8360/health
curl -s localhost:8360/env
docker 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) اجرا می‌شود. حالا لاگ‌ها:

Terminal window
docker logs lx-node1
خروجی
listening on 3000
⚡ بررسی سریع

چرا برای production از npm ci استفاده می‌کنیم؟

مثال ۴: npm ci در برابر npm install

Section titled “مثال ۴: npm ci در برابر npm install”

npm ci اگر package.json و package-lock.json با هم نخوانند شکست می‌خورد. شبیه‌سازی کنیم: یک وابستگی را فقط در package.json اضافه می‌کنیم:

Terminal window
mkdir -p drift && cp api/package.json api/package-lock.json drift/
python3 - <<'LXPY'
import json
p = json.load(open('drift/package.json')); p['dependencies']['lodash'] = '^4.17.21'
json.dump(p, open('drift/package.json', 'w'), indent=2)
LXPY
printf 'FROM node:22-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\n' > drift/Dockerfile
docker 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. Pleas

build عمداً شکست خورد: package.json یک وابستگی (lodash) دارد که در lock نیست. npm install همین را بی‌صدا حل می‌کرد (و lock را عوض می‌کرد)، ولی در CI و production دقیقاً نمی‌خواهیم چیزی بی‌خبر عوض شود. راه‌حل: در توسعه npm install بزن و lock جدید را commit کن؛ در Docker و CI همیشه npm ci.

مثال ۵: خاموش شدن تمیز با SIGTERM

Section titled “مثال ۵: خاموش شدن تمیز با SIGTERM”

برنامه‌ی ما handler دارد. ببین docker stop چقدر طول می‌کشد و برنامه چه می‌گوید:

Terminal window
t() { python3 - "$@" <<'LXPY'
import subprocess, sys, time
s = 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 -2
docker rm lx-node1 >/dev/null
خروجی
docker stop با handler: 0.4 ثانیه
listening on 3000
SIGTERM گرفتم، بستن سرور...

برنامه TERM را گرفت، سرور را بست و با کد 0 تمام شد؛ در کسری از ثانیه. حالا نسخه‌ی بدون handler را مقایسه می‌کنیم. Node به‌عنوان PID 1، بدون handler صریح، سیگنال TERM را نادیده می‌گیرد (درس CMD در برابر ENTRYPOINT):

Terminal window
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.js
printf 'FROM node:22-alpine\nWORKDIR /app\nCOPY . .\nCMD ["node","app.js"]\n' > nohandler/Dockerfile
docker build -q -t lx-node-nohandler ./nohandler >/dev/null 2>&1
docker run -d --name lx-node2 lx-node-nohandler >/dev/null; sleep 2
echo "docker stop بدون handler: $(t docker stop lx-node2) ثانیه | کد خروج: $(docker inspect lx-node2 --format '{{.State.ExitCode}}')"
docker rm lx-node2 >/dev/null
خروجی
docker stop بدون handler: 3.6 ثانیه | کد خروج: 137

بدون handler، docker stop مجبور شد صبر کند و بعد KILL بزند (کد 137). برای سرویس‌های واقعی (بستن اتصال دیتابیس، تمام کردن درخواست‌های در حال انجام) handler لازم است؛ راه دیگر docker run --init است.

مثال ۶: چرا USER node و چرا ترتیب مهم است

Section titled “مثال ۶: چرا USER node و چرا ترتیب مهم است”

ثابت می‌کنیم پروسه root نیست و کش درست کار می‌کند:

Terminal window
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.js
docker 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'[' -k2n
خروجی
uid: 1000 | user: node
0
CACHED #6 [3/5] COPY package*.json ./
CACHED #7 [4/5] RUN npm ci --omit=dev
CACHED #9 [5/5] COPY . .

با --user root می‌توانی در صورت لزوم root شوی، ولی پیش‌فرض image غیر root است. بعد از تغییر app.js، لایه‌ی npm ci از کش آمد و فقط COPY . . دوباره ساخته شد؛ این نتیجه‌ی ترتیب درست دستورات است.

npm ci ابتدا پوشه‌ی node_modules را کاملاً پاک می‌کند و بعد دقیقاً مطابق package-lock.json نصب می‌کند: lock را تغییر نمی‌دهد و اگر با package.json نخواند، خطا می‌دهد. npm install ولی ممکن است نسخه‌ها را تا حد مجاز در package.json (مثل ^4.0.0) بالا ببرد و lock را عوض کند. به همین خاطر npm ci هم تکرارپذیر است و هم در محیط خودکار قابل‌اعتماد.

بیشتر کتابخانه‌های Node (از جمله Express و React) با این متغیر رفتار بهینه‌ی production را فعال می‌کنند: کش، خطاهای کم‌جزئیات و غیرفعال شدن ابزارهای توسعه. به همین دلیل --omit=dev هم همراه آن می‌آید: وابستگی‌های توسعه (تست‌ها، لینترها) داخل image production معنا ندارند.

image های رسمی Node یک کاربر معمولی به اسم node (UID 1000) دارند. با USER node همه‌ی دستورهای بعدی و پروسه‌ی نهایی با آن اجرا می‌شوند. اگر فایل‌هایی با COPY از root کپی شده باشند و node نیاز به نوشتن روی آن‌ها داشته باشد، باید با COPY --chown=node:node کپی کنی (مثال در جدول).

دستور کاربرد
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 درست

نسخه‌ها در هر build می‌توانند عوض شوند (مثال ۴). راه‌حل: npm ci.

Terminal window
mkdir -p nolock && echo '{"name":"x","version":"1.0.0","dependencies":{"express":"^4.19.0"}}' > nolock/package.json
printf 'FROM node:22-alpine\nWORKDIR /app\nCOPY package.json ./\nRUN npm ci\n' > nolock/Dockerfile
docker 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 error

npm ci بدون lock کار نمی‌کند. راه‌حل: یک بار npm install بزن و package-lock.json را commit کن؛ و مطمئن شو در .dockerignore نیست.

۳) کپی node_modules از سیستم میزبان

Section titled “۳) کپی node_modules از سیستم میزبان”

COPY . . بدون .dockerignore پوشه‌ی node_modules میزبان (که ممکن است برای سیستم‌عامل دیگری نصب شده، مثلاً باینری‌های macOS) را وارد image می‌کند و خراب کاری می‌کند. راه‌حل: node_modules در .dockerignore.

اگر 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 اجرا کن و تعداد بسته‌های نصب‌شده را ببین (فقط خط آخر خروجی).

دیدن جواب
Terminal window
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 را بخوان.

دیدن جواب
Terminal window
docker build -q -t node-app ./api >/dev/null 2>&1
docker run -d --name lx-node3 -p 8361:3000 node-app >/dev/null; sleep 2
curl -s localhost:8361/health
docker rm -f lx-node3 >/dev/null
خروجی
{"status":"ok"}
✎ تمرینسخت

ثابت کن image تو با کاربر غیر root اجرا می‌شود و NODE_ENV برابر production است، بدون اینکه کانتینر را در پس‌زمینه بالا بیاوری (فقط با docker run --rm و node -e). بعد اندازه‌ی image را با حالت «بدون .dockerignore و با node_modules میزبان» مقایسه کن (فقط توضیح بده چرا بزرگ‌تر می‌شد).

دیدن جواب
Terminal window
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 بزرگ‌تر و ناسازگار با لینوکس می‌شد.

؟ آزمونک
  1. npm ci با npm install چه فرقی دارد؟

  2. چرا اول package*.json را کپی می‌کنیم و بعد COPY . .؟

  3. USER node چه می‌کند؟

  4. اگر برنامه‌ی Node روی localhost داخل کانتینر گوش بدهد…

  5. برای اینکه docker stop سریع و تمیز باشد، برنامه‌ی Node چه لازم دارد؟

  • ساختار 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 IMGinit کوچک به‌عنوان PID 1