چطوری داکرفایل بهینه نویسیم (Dockerfile Best Practices)

تو نوشته قبلی با هم یه پروژه 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همونطور که میدونید از دستور FROM برای مشخص کردن base ایمیجمون استفاده می‌کنیم و با دستورات دیگه با توجه به اینکه base رو چی انتخاب کردیم نیازمندی‌های دیگه نرم‌افزار رو به ایمیج اضافه می‌کنیم. تو هر Dockerfile حداقل یک بار از دستور FROM استفاده میشه (برای مشخص کردن base) ولی ممکنه بیشتر از یک بار هم برای مواردی که بیلد multi stage داریم هم استفاده کنیم.مواردی که باید زمان استفاده از FROM در نظر داشته باشیم۱. همیشه از tag استفاده کنیم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 ایمیج دقت کنید.۳. ایمیج قابل اعتماد انتخاب کنیمسعی کنید همیشه از ایمیج های رسمی استفاده کنید تا خیالتون راحت باشه که مشکل امنیتی نداره و اگه مشکلی وجود داره حلش میکنن. برای اینکار به دوتا نکته توجه کنید:توی داکرهاب جلوی اسم ایمیج نوشته شده باشه (Docker Official Image)تعداد دانلود بالایی داشته باشهدستور WORKDIRمشخص کردن work directory یکی از مهم‌ترین best practiceهاست که باید رعایت کنیم تا مطمعن باشیم داریم دستورات رو تو چه دایرکتوری اجرا می‌کنیم. بریم سراغ best practiceهایی که باید برای WORKDIR رعایت کنیم:۱. همیشه از WORKDIR استفاده کنیمبجای اینکه از دستور cd برای اجرای دستورات در یک دایرکتوری مشخص، همیشه از WORKDIR استفاده کنیم.WORKDIR /app۲. همیشه از Absolute Path استفاده کنیماینطوری هم خواناتر هست هم از مشکلات احتمالی جلوگیری می‌کنیم# Good WORKDIR /app # Bad WORKDIR app۳. همیشه از دایرکتوری‌های خاص (Dedicated) استفاده کنیمیعنی بجای استفاده از دایرکتوری `/` root، یا دایرکتوری های جنرال مثل usr/src/ از دایرکتوری‌‌های خاص مثل app/my-app-name/ استفاده کنیم، اینطوری از تداخلی که با چیزهای دیگه ممکنه بوجود بیاد جلوگیری می کنیم و هم اینکه کاملا مشخص هست که اطلاعات مربوط به پروژه کجا هست.WORKDIR /app/my-app-name۴. از Environment Variables برای WORKDIR استفاده نکنیمهرچند میشه از Environment Variables زمان تعریف working directory استفاده کرد ولی بهتره این کار رو نکنیم چون خوانایی داکرفایل میاد پایین و یا حتی ممکنه با تغییر ناخواسته اون Environment Variable مشکلی ناخواسته بوجود بیاد.FROM ubuntu:20.04 # Bad WORKDIR $APP_PATH # Good WORKDIR /myapp۵. دسترسی به working directoryهمیشه مطمعن باشین user کانتینرتون همه دسترسی‌های لازم به این working directory رو داره.دستور RUNدستور RUN یکی از دستورهای اساسی تو داکرفایل به حساب میاد که باهاش دپندنسی‌های مورد نیاز رو اضافه می کنیم. و اما best practiceهای زمان استفاده از RUN:۱. استفاده از Chain Commandsهر دستور 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/*اینطوری بعد از نصب پکیج فایل‌های اضافه رو حذف می‌کنیم و حجم نهایی لایه میاد پایین. این نکته کاملا بستگی به دستوری داره که اجرا می‌کنین، ممکنه دستوری چیزی برای تمیز کاری نداشته باشه یا داشته باشه ولی در کل این نکته تو ذهنتون باشه که اگه میشه باید تمیز کرد.۳. فقط پکیج‌های لازم رو نصب کنیمگفتن نداره ولی :)، پکیجی که نیاز نداریم رو صرفا نصب نکنین چون شاید ممکنه نیاز بشه، چون دار فضا اشغال می‌کنه و همینطور حجم لایه و درنهایت ایمیج رو بالا می‌بره.۴. استفاده از Non-Interactive Modeبعضی دستورات ممکنه 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هاهیچ وقت secretهایی مثل password یا API Key رو مستقیم توی اسکریپت داکرفایل نزاریم، بجاش در زمان بیلد از طریق ARG این secret ها رو داکرفایل اضافه کنیم:Bad practice:FROM node:14 ENV API_KEY=your_actual_api_key_hereGood practice:FROM node:14 ARG API_KEY ENV API_KEY=$API_KEYاستفاده از dockerignore.همونطور که از gitignore. استفاده می‌کنیم باید از dockerignore. استفاده کنیم تا فایل‌هایی که نیاز نیستن ناخواسته کپی نشن تا بی‌مورد حجم ایمیجمون بالا نره.استفاده از root user و non-root userخیلی خلاصه، یوزر root بهتره فقط برای جاهایی استفاده بشه که حتما نیاز به دسترسی root داریم، مثل نصب کردن پکیج‌های سیستم، بقیه دستورات که نیاز به دسترسی root ندارن بهتره با non-root user اجرا بشن. خیلی نکته‌های مهمی راجع به استفاده از root user و non-root user هست که تو این نوشته نمیگنجه ولی این نکته ها رو یادتون باشه:حواستون باشه که non-root user دسترسی لازم به دایرکتوری که قراره روش کار کنه داشته باشهتا جای ممکن بین یوزرها سوییج نکنید، همه‌ی کارهای لازم رو root user انجام بدین و بعد سوییچ کنین روی non-rootهمیشه با non-root user دستورات CMD و ENTRYPOINT رو اجرا کنین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 [ &quotnode&quot, &quotindex.js&quot ]ترتیب اجرای دستورات برای بهینه کردن کشهرچی فک کردم عنوان فارسی خوب نتونستم پیدا کنم، واقعا سخته فارسی رو پاس داشت :)) به این best practice اصطلاحا Optimizing Docker Layer Caching یا Leveraging Build Cache گفته میشه. بیایین اینو با یه مثال توضیح بدیم که قابل درک‌تر بشه.فرض کنیم این Dockerfileی هست که برای پروژه‌مون پیاده کردیم:FROM node:18.17-alpine COPY . /app WORKDIR /app RUN npm installحالا بیاییم مرحله به مرحله چک کنیم که چه اتفاقی میافته:تو مرحله اول `node:18.17-alpine` به عنوان بیس ایمیج درنظر گرفته میشهکد اپلیکیشنمون با استفاده از دستور COPY کپی میشنبا استفاده از WORKDIR، دایرکتوری رو به app/ تغییر میدهو درنهایت پکیج‌های برنامه رو نصب می‌کنهاین داکرفایل از لحاظ تکنیکی هیچ مشکلی نداره، ولی... بهینه نیس، اصلا هم بهینه نیست. چرا؟ بزارین اینطوری بهش نگاه کنیم، در زمان دولوپ یه برنامه یا تو 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هابا استفاده از LABELها می‌تونیم متادیتا به ایمیج اضافه کنیم که کمک میکنه اطلاعات بیشتری نسبت به ایمیج داشته باشیم، مثلا اینکه maintainer کی هست و یا versionش چیه و... و همیچنین لیبل‌ها کمک میکنن که بفهمیم هدف نهایی یه ایمیج مشخص چی هست.FROM node:14 # Add metadata using the LABEL instruction LABEL maintainer=&quotMehdi Zarepour <mehdi.zarepoor@gmail.com>&quot LABEL version=&quot1.0&quot LABEL description=&quotThis is a sample Node.js app&quot LABEL git.url=&quothttps://github.com/mehdizarepour/backend.git&quot LABEL build.date=&quot2023-09-14&quot WORKDIR /app COPY package*.json ./ RUN npm install COPY . . CMD [&quotnpm&quot, &quotstart&quot]به یکی از موارد استفاده از لیبل‌ها میشه به پیدا کردن یه ایمیج با یه لیبل مشخص اشاره کرد:docker images --filter &quotlabel=version=1.0&quotخب بنظر بهتره بیشتر از این طولانی نشه که از حوصله خارج نشه :) پیشنهاد میکنم حتما موارد دیگه که من نگفتم رو از سایت خود داکر بخونید، اگه چیزی رو اشتباه گفتم یا پرکتیس بهتری میشناسید بهم بگید :)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=&quotMehdi Zarepour <mehdi.zarepoor@gmail.com>&quot # 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 [&quotnpm&quot, &quotrun&quot, &quotstart&quot]نمی‌دونم کسی منتظر بود بازم بنویسم یا نه :) ولی اگه کسی بود و میخواد بدونه چرا طول کشید تا اینو بنویسم باید بگم که اینسری افسوردگی خیلی سنگین بود، یکم طول کشید ازش بیام بیرون :)) سعی میکنم بازم بنویسم ;)در پایان مثل همیشه اینکه اگه نوشته رو دوست داشتید با دوستان به اشتراک بزارید که هم بهشون کمک کنه و هم انرژی بشه برای من که ادامه بدم :)و اینکه میتونید منو با آی‌دی mehdi_zarepour تو توییتر پیدا کنید، نوشته‌های جدید رو اونجا توییت میکنم. فعلا نوشته‌هام راجع به داکر خواهد بود.نظرتونم بنویسین :)