تو نوشته قبلی با هم یه پروژه NodeJS رو Dockerize کردیم که درنهایت Dockerfile زیر شد خروجی نهایی که ایمیج رو از روش ساختیم:
FROM node:18.17-alpine WORKDIR /home/app COPY . . RUN npm i CMD npm run start
خب همونطور که گفتم تو این نوشته قراره این Dockerfile رو بررسی کنیم و به صورت بهینه بازنویسی کنیم و best practice ها رو توش رعایت کنیم. قبل از این کار بیاییم یه سری از این best practice های اساسی رو با هم مرور کنیم.
همونطور که میدونید از دستور FROM برای مشخص کردن base ایمیجمون استفاده میکنیم و با دستورات دیگه با توجه به اینکه base رو چی انتخاب کردیم نیازمندیهای دیگه نرمافزار رو به ایمیج اضافه میکنیم. تو هر Dockerfile حداقل یک بار از دستور FROM استفاده میشه (برای مشخص کردن base) ولی ممکنه بیشتر از یک بار هم برای مواردی که بیلد multi stage داریم هم استفاده کنیم.
FROM ubuntu:23.10
اسم تگ بعد از اسم image (که اینجا ubuntu) و بعد از `:` میاد که اینجا 23.10 هست، چرا؟ بخاطر اینکه تگ نشون دهنده یه ورژن مشخص از اون image هست و اینطوری مشخص میکنیم که دقیقا از چه ورژنی از این ایمیج میخواییم استفاده کنیم. اگه تگ رو مشخص نکنیم به صورت پیشفرض از تگ latest استفاده میشه، یعنی:
FROM ubuntu
برابر است با:
FROM ubuntu:latest
اینطوری یعنی هر دفه که ما ایمیجمون رو بیلد میکنیم از روی آخرین ورژن Ubuntu ساخته میشه و مشکل موقعی ایجاد میشه که تو آخرین آپدیت Ubuntu یه چیز جدیدی اضافه یا کن شده باشه که دیگه با ایمیجی که میخواییم بسازیم سازگار نیست! پس بهتره مشخص کنیم دقیقا کدوم ورژن Ubuntu رو میخواییم تا مطمعن باشیم هر دفه که ایمیج رو بیلد میکنیم هیچ چیز ناخواسته ای کم و زیاد نشده.
ایمیجی که برای base انتخاب میکنیم تاثیر خیلی زیادی روی سایز نهایی ایمیجمون داره، پس همیشه ایمیجی رو انتخاب کنیم که فقط چیزهایی که نیاز داریم رو داشته باشه، نه بیشتر نه کمتر. مثلا ما اینجا Ubuntu رو برای بیس انتخاب کردیم که تقریبا 25MB حجمشه، سوالی که اینجا باید از خودمون بپرسیم اینه که آیا ما واقعا به همهی امکاناتی که Ubuntu بهمون میده نیاز داریم؟ یا میتونیم یه ایمیج دیگه با حجم پایینتر انتخاب کنیم؟
مثلا اگه alpine برامون کافیه پس بهتره از alpine بجای Ubuntu استفاده کنیم چون جحمش فقط 3MB هست! البته اینم بگم که صرفا فقط فضایی که اشغال میکنه نیست، بلکه alpine منابع کمتری مثل رم و CPU در مقایسه با Ubuntu استفاده میکنه. پس تو انتخاب base ایمیج دقت کنید.
سعی کنید همیشه از ایمیج های رسمی استفاده کنید تا خیالتون راحت باشه که مشکل امنیتی نداره و اگه مشکلی وجود داره حلش میکنن. برای اینکار به دوتا نکته توجه کنید:
مشخص کردن work directory یکی از مهمترین best practiceهاست که باید رعایت کنیم تا مطمعن باشیم داریم دستورات رو تو چه دایرکتوری اجرا میکنیم. بریم سراغ best practiceهایی که باید برای WORKDIR رعایت کنیم:
بجای اینکه از دستور cd برای اجرای دستورات در یک دایرکتوری مشخص، همیشه از WORKDIR استفاده کنیم.
WORKDIR /app
اینطوری هم خواناتر هست هم از مشکلات احتمالی جلوگیری میکنیم
# Good WORKDIR /app # Bad WORKDIR app
یعنی بجای استفاده از دایرکتوری `/` root، یا دایرکتوری های جنرال مثل usr/src/ از دایرکتوریهای خاص مثل app/my-app-name/ استفاده کنیم، اینطوری از تداخلی که با چیزهای دیگه ممکنه بوجود بیاد جلوگیری می کنیم و هم اینکه کاملا مشخص هست که اطلاعات مربوط به پروژه کجا هست.
WORKDIR /app/my-app-name
هرچند میشه از Environment Variables زمان تعریف working directory استفاده کرد ولی بهتره این کار رو نکنیم چون خوانایی داکرفایل میاد پایین و یا حتی ممکنه با تغییر ناخواسته اون Environment Variable مشکلی ناخواسته بوجود بیاد.
FROM ubuntu:20.04 # Bad WORKDIR $APP_PATH # Good WORKDIR /myapp
همیشه مطمعن باشین user کانتینرتون همه دسترسیهای لازم به این working directory رو داره.
دستور RUN یکی از دستورهای اساسی تو داکرفایل به حساب میاد که باهاش دپندنسیهای مورد نیاز رو اضافه می کنیم. و اما best practiceهای زمان استفاده از RUN:
هر دستور RUN که تو داکرفایل استفاده میکنیم یه لایه جدید برای ایمیج ایجاد میکنه که هر لایه نقش خیلی زیادی در حجم نهایی ایمیج، کش کردن، سرعت بیلد و... داره پس اگه میشه چند دستور رو با استفاده از یک RUN اجرا کنیم بهتره تنها یک بار از RUN استفاده کنیم و دستورات رو به صورت chain بنویسیم، برای درک بهتر مثال زیر رو ببینید:
RUN apt-get update RUN apt-get install -y some-package
در مثال بالا از ۲ دستور RUN برای نصب یه پکیجی استفاده شده که باعث میشه دوتا لایه جداگونه برای ایمیج ایجاد کنه درصورتی که اگه به شکل زیر دستورات رو Chain کنیم فقط یک لایه ایجاد میشه و دقیقا همون نتیجه رو هم داره:
RUN apt-get update && \ apt-get install -y some-package
خیلی وقتا بعد از اجرای یه دستوری ممکنه فایلهایی وجود داشته باشه که ما دیگه بهشون نیاز نداریم، پس بهتره این فایلها رو تو همون لایه حذف کنیم تا حجم اون لایه بیاد پایین، مثلا اینجا ما اومدیم یه پکیجی رو نصب کردیم:
RUN apt-get update && \ apt-get install -y some-package
این دستور یه لایه جدید ایجاد میکنه که پکیج جدید رو بهش اضافه میکنه ولی می تونه حجمش کمتر و تمیز تر باشه. برای این مثال، تو سیسمعامل های دبیان بیس، بهتره دستورات زیر رو هم اضافه کنیم تا فایلهای اضافه زمان اجرای دستور رو حذف کنیم:
RUN apt-get update && \ apt-get install -y some-package && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*
اینطوری بعد از نصب پکیج فایلهای اضافه رو حذف میکنیم و حجم نهایی لایه میاد پایین. این نکته کاملا بستگی به دستوری داره که اجرا میکنین، ممکنه دستوری چیزی برای تمیز کاری نداشته باشه یا داشته باشه ولی در کل این نکته تو ذهنتون باشه که اگه میشه باید تمیز کرد.
گفتن نداره ولی :)، پکیجی که نیاز نداریم رو صرفا نصب نکنین چون شاید ممکنه نیاز بشه، چون دار فضا اشغال میکنه و همینطور حجم لایه و درنهایت ایمیج رو بالا میبره.
بعضی دستورات ممکنه interactive باشن، یعنی برای انجام کاری نیاز به تایید داشته باشن، ولی از اونجایی که تو فرایند بیلد ایمیج ما دیگه دسترسی برای تایید کردن نداریم (اصلا تایید کردن معنایی نداره) باید مطمعن باشیم که دستورات تو حالت Non-Interactive اجرا میشن. مثلا برای سیستمعامل های دبیان بیس میتونیم دستور اینطوری بنویسیم:
RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y some-package && \ apt-get clean && \ rm -rf /var/lib/apt/lists/*
با استفاده از DEBIAN_FRONTEND و y- آپشن میتونیم مطمعن باشیم که مراحل اجرای دستور بدون نیاز به وقفه ای اجرا میشه.
هیچ وقت secretهایی مثل password یا API Key رو مستقیم توی اسکریپت داکرفایل نزاریم، بجاش در زمان بیلد از طریق ARG این secret ها رو داکرفایل اضافه کنیم:
Bad practice:
FROM node:14 ENV API_KEY=your_actual_api_key_here
Good practice:
FROM node:14 ARG API_KEY ENV API_KEY=$API_KEY
همونطور که از gitignore. استفاده میکنیم باید از dockerignore. استفاده کنیم تا فایلهایی که نیاز نیستن ناخواسته کپی نشن تا بیمورد حجم ایمیجمون بالا نره.
خیلی خلاصه، یوزر root بهتره فقط برای جاهایی استفاده بشه که حتما نیاز به دسترسی root داریم، مثل نصب کردن پکیجهای سیستم، بقیه دستورات که نیاز به دسترسی root ندارن بهتره با non-root user اجرا بشن. خیلی نکتههای مهمی راجع به استفاده از root user و non-root user هست که تو این نوشته نمیگنجه ولی این نکته ها رو یادتون باشه:
FROM node:14 # As root: Create app directory and install dependencies WORKDIR /app COPY . . RUN npm install # As root: Create a non-root user named 'user1' RUN useradd user1 && chown -R user1 /app # Switch to 'user1' for runtime USER user1 # Run the application as 'user1' CMD [ "node", "index.js" ]
هرچی فک کردم عنوان فارسی خوب نتونستم پیدا کنم، واقعا سخته فارسی رو پاس داشت :)) به این best practice اصطلاحا Optimizing Docker Layer Caching یا Leveraging Build Cache گفته میشه. بیایین اینو با یه مثال توضیح بدیم که قابل درکتر بشه.
فرض کنیم این Dockerfileی هست که برای پروژهمون پیاده کردیم:
FROM node:18.17-alpine COPY . /app WORKDIR /app RUN npm install
حالا بیاییم مرحله به مرحله چک کنیم که چه اتفاقی میافته:
این داکرفایل از لحاظ تکنیکی هیچ مشکلی نداره، ولی... بهینه نیس، اصلا هم بهینه نیست. چرا؟ بزارین اینطوری بهش نگاه کنیم، در زمان دولوپ یه برنامه یا تو release های مختلف بیشترین چیزی که تغییر میکنه کد برنامه ست که تو app/ قرار داره و اصولا خیلی کم پیش میاد که بخواییم پکیجی رو به برنامه اضافه کنیم یا ازش کم کنیم درسته؟ این تو ذهنتون باشه، ببینیم داکر چطوری لایهها رو کش میکنه.
داکر وقتی تغییر جدیدی توی هر کدوم از لایه ها نبینه از کش استفاده میکنه و دوباره اون لایه رو ایجاد نمیکنه، ولی وقتی تغییری تو یه لایه ایجاد میشه دیگه داکر از کش اون لایه و همهی لایههای زیریش استفاده نمیکنه و دوباره اون لایه ها رو ایجاد میکنه. یعنی اگه تغییری توی کد ایجاد بشه یعنی یه تغییری تو لایهای که با COPY ایجاد کردیم اتفاق افتاده، یعنی اینجا:
COPY . /app
پس این لایه و همهی لایههای زیریش حتی اگه تغییری هم توشون اتفاق نیافته باشه دوباره ایجاد میشن، یعنی این لایهها:
WORKDIR /app RUN npm install
درصورتی که هیچ تغییری توی پکیجها مون وجود نداره و همچنان میتونن کش بشن! پس این ترتیب درست نیست و باید تا جایی که میشه، لایههایی که احتمال تغییر توشون بالا هست رو باید به لایه های پایین تر انتقال بدیم. با این تعریف حالت بهینه داکرفایل بالا میشه این:
FROM node:18.17-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . .
توی این حالت دیگه اگه تغییری تو کد برنامه ایجاد بشه لایه های بالایی همچنان کش میشن.
با استفاده از LABELها میتونیم متادیتا به ایمیج اضافه کنیم که کمک میکنه اطلاعات بیشتری نسبت به ایمیج داشته باشیم، مثلا اینکه maintainer کی هست و یا versionش چیه و... و همیچنین لیبلها کمک میکنن که بفهمیم هدف نهایی یه ایمیج مشخص چی هست.
FROM node:14 # Add metadata using the LABEL instruction LABEL maintainer="Mehdi Zarepour <mehdi.zarepoor@gmail.com>" LABEL version="1.0" LABEL description="This is a sample Node.js app" LABEL git.url="https://github.com/mehdizarepour/backend.git" LABEL build.date="2023-09-14" WORKDIR /app COPY package*.json ./ RUN npm install COPY . . CMD ["npm", "start"]
به یکی از موارد استفاده از لیبلها میشه به پیدا کردن یه ایمیج با یه لیبل مشخص اشاره کرد:
docker images --filter "label=version=1.0"
خب بنظر بهتره بیشتر از این طولانی نشه که از حوصله خارج نشه :) پیشنهاد میکنم حتما موارد دیگه که من نگفتم رو از سایت خود داکر بخونید، اگه چیزی رو اشتباه گفتم یا پرکتیس بهتری میشناسید بهم بگید :)
https://docs.docker.com/develop/develop-images/dockerfile_best-practices
خب حالا بعد از همهی این توضیحات بیاییم اون داکرفایل اول نوشته رو بهینه کنیم:
# Start with a specific version of the base image FROM node:18.17-alpine LABEL maintainer="Mehdi Zarepour <mehdi.zarepoor@gmail.com>" # Install system-level dependencies (if any) as root, but We don't have any :) # Create a non-root user and switch to it RUN adduser -D node USER node # Set up the working directory WORKDIR /home/node/app # Copy only the necessary files for npm install to use cache effectively COPY package*.json ./ # Install project dependencies RUN npm i # Copy the rest of the application COPY --chown=node . . # Start the application CMD ["npm", "run", "start"]
نمیدونم کسی منتظر بود بازم بنویسم یا نه :) ولی اگه کسی بود و میخواد بدونه چرا طول کشید تا اینو بنویسم باید بگم که اینسری افسوردگی خیلی سنگین بود، یکم طول کشید ازش بیام بیرون :)) سعی میکنم بازم بنویسم ;)
در پایان مثل همیشه اینکه اگه نوشته رو دوست داشتید با دوستان به اشتراک بزارید که هم بهشون کمک کنه و هم انرژی بشه برای من که ادامه بدم :)
و اینکه میتونید منو با آیدی mehdi_zarepour تو توییتر پیدا کنید، نوشتههای جدید رو اونجا توییت میکنم. فعلا نوشتههام راجع به داکر خواهد بود.
نظرتونم بنویسین :)