Optimizing Docker Images
Introduction
في هذا المقال راح اتكلم كيف ممكن نحسن الـ size لـ docker image من خلال بعض الـ techniques الي ممكن تساعد في تقليل حجم الـ docker image، طبعاً I will assume you have the basic understanding of docker as a technology، طيب بسم الله خلونا نبدأ بالموضوع...
What is Docker?
قبل لا ابدأ ودي أعرف بشكل بسيط وش الـ Docker اصلاً وليش نستخدمه ونزيد الـ overhead على الـ engineer بتعلم tools كثيرة وتعقد عملية تطوير الـ Softwares؟ في رأيي الشخصي المتواضع أرى أن وحدة من أفضل الـ tool الي انبنت في عالم الـ sofware هي Docker وهذي الـ tool تستحق بجدارة أن تكون من ضمن أي flow لأي مشروع، وهذي الـ tool حلت وراح تحل مشاكل كبيرة وأهمها "ترا الكود ما يشتغل بجهازي" ومشكلة نشر الـ applications بين الـ engineers بدون تثبيت أي dependency في جهاز الـ engineer فيما يعرف بمبدأ الـ zero pollution والي يهدف إلى عدم تثبيت أي dependency على جهاز الـ engineer، يعني كل شيء يتم تطويره بالـ container نفسه وما يتسرب لجهاز الـ engineer بحيث لو تم الإنتهاء من العمل ما راح يسبب او يستهلك موارد من الجهاز لأن كل شيء موجود بالـ container الي سهل التحكم به والتخلص منه بكل سهولة بعكس لو كان مثبت بجهازك قد يغير البرنامج تخصيص معين أو قد يعبثت بالأعدادت وهذا شيء وارد جداً، أو قد يكون software ضار من الأساس وهنا تكمن الكارثة. بكل بساطة Docker سهل لنا سهولة نشر الـ applications بين الـ engineers، بالإضافة الى سهولة نشر الـ application الـ sysadmins ومن خلاله يتم تشغيله على الـ infrastructure باستخدام أحد الـ orchestrations tools مثل Kubernetes من خلال command line واحد تقدر تشغل بما يعرف ب container الي هو a running instance من image تم بنائها مسبقًا لتكون نسخة واحدة من هذا الـ application، ممكن تعتبرها زي الـ Object و Class بالجافا، نقدر نقول أن الـ Class هو الـ Image وأن ال Object هو الـ Container
Containerization Process
الـ containerization هو عمل encapsulation للـ application & dependencies إلى lightweight, portable, and isolated containers والهدف من هذي العملية تحديداً هو توفير الـ consistency بين جميع البيئات من بيئة الـ development، وبيئة الـ testing، و بيئة الـ production حيث أن تشغيل الـ container في اي بيئة من هذي البيئات راح يعطي نفس النتائج، وأيضا الهدف من هذي العملية هو الـ scalability وهو أني ممكن اسوي scale up or down لعدد الـ containers الي احتاجهم فيما يعرف بالـ replicas واذا زاد الـ load عندي على الـ service راح أزود عدد الـ containers وأسوي scale up من خلال أنشاء container جديد وأذا قل الـ load سويت له kill ووقفت الـ container عشان ما يستهلك موراد على الفاضي، بالإضافة الى سهولة نقل الـ container بين بيئات التطوير المختلفة والعديد من المزايا الي ممكن نحصلها من عملية الـ containerization
Why Should Care to Optimizing Docker Images?
سؤال مهم لازم ينطرح وهو ليش نحتاج نقلل حجم الـ Images على الرغم من التكلفة الرخيصة للـ hard-disks مقارنة ب RAM & CPU؟، والسبب يرجع أن تقليل حجم الـ Images راح يقلل building time للـ Image بـ CI/CD pipeline وهذا راح يقلل بشكل جوهري وقت بناء الـ Image، بالإضافة إلى تقليل الـ pulling time اذا صار في تغير بالـ Image وصار الـ developer يحتاج يسوي pull للـ Image عشان يحصل على آخر تحديث والسبب يرجع أنه اذا كان حجم الـ Image جداً كبير فراح يستهلك bandwidth عالي عشان يتم التحديث لآخر أصدار. فهنا الـ optimization techniques الي ممكن نسويها راح يكون لها تأثير كبير على الـ pulling time والي راح يرضي الـ developer بالأخير. فكون أن الـ Image تكون بحجم صغير هذا الشيء راح يقلل الـ cost في جوانب كثيرة منها less disk space، و reduce container start time.
Node.js Application Containerization as an Example
طيب عشان توضح الفكرة لازم نسوي containerization لأحد الـ applications طبعاً هنا أنا بختار express.js application والي هو عبارة عن simple CRUD api، الي يهمني ويهمكم الآن هو الي راح يصير بعملية الـ containerization، فنحتاج إلى الملفات التالية:
- الـ source code وراح يكون موجود في ملف الـ server.js الي هو عبارة عن express server + mongodb for simple todo CRUD RESTful-API endpoints
- الـ Dockerfile وهو الي راح يتم من خلالها تحويل الـ source code حقنا إلى image
- الـ docker-compose.yaml وهذا الملف هو نقطة الإنطلاق الي راح يتم من خلاله إستدعاء الـ Dockerfile الي سوينها بالخطوة 2 ومن ثم استخدامه كـ Dockerfile، طبعاً بكل بساطة فكرة الـ docker-compose.yaml هو بدل كتابة الـ commands بالطرفية اكتبها ب .yaml file بحيث يسهل علي تعديلها بالمستقبل وهذا الشيء يزيد الـ maintainability
- الـ RESTfulAPI.http عشان نقدر نسوي call للـ RESTful-API endpoints الي سوينها بدون ما نستخدم gui tool زي postman مثلاً
هذي الـ 4 ملفات الي نحتاجها عشان نسوي الـ optimizations techniques، وهي كالتالي:
const express = require('express');
const bodyParser = require('body-parser');
const mongoose = require('mongoose');
const app = express();
app.use(bodyParser.json());
// Connect to MongoDB
mongoose.connect('mongodb://db/todo-app', {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => {
console.log('Connected to MongoDB');
})
.catch((error) => {
console.error('Error connecting to MongoDB:', error);
});
// Define a Todo schema
const todoSchema = new mongoose.Schema({
text: String,
});
const Todo = mongoose.model('Todo', todoSchema);
// Get all todos
app.get('/todos', async (req, res) => {
try {
const todos = await Todo.find();
res.json(todos);
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
});
// Create a new todo
app.post('/todos', async (req, res) => {
try {
const { text } = req.body;
const newTodo = new Todo({ text });
const savedTodo = await newTodo.save();
res.status(201).json(savedTodo);
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
});
const port = 3000;
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
FROM node:19
WORKDIR /app
COPY package.json .
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
version: '3.9'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- 3000:3000
depends_on:
- db
db:
image: mongo:4.4
volumes:
- ./data:/data/db
### GET /todos
GET http://localhost:3000/todos
### GET /todos/:id
GET http://localhost:3000/todos/1
### POST /todos
POST http://localhost:3000/todos
Content-Type: application/json
{
"id": 3,
"text": "New Todo"
}
### PUT /todos/:id
PUT http://localhost:3000/todos/1
Content-Type: application/json
{
"text": "Updated Todo"
}
### DELETE /todos/:id
DELETE http://localhost:3000/todos/1
هنا الـ folder structure حق المشروع الي راح نجرب عليه الـ techniques الي راح نستخدمها
.
└── optimizing-docker-images/
├── .dockerignore
├── .gitignore
├── docker-compose.yaml
├── Dockerfile
├── package-lock.json
├── package.json
├── RESTfulAPI.http
└── server.js
Start Dockerization Process (before optimizations)
بالخطوة الأولى راح اشغل الـ container من الـ image من خلال الـ "docker-compose up"
بالخطوة الثانية راح اتأكد ان الـ containers عندي شغالة مضبوط وهي 2 containers بهذي الحالة، الأولى هو app والثاني هو mongodb
بالخطوة الثالثة راح أدخل داخل الـ logs تبع الـ app container & mogodb container عشان أتأكد أن الـ containers شغاله تمام
Testing the Dockerized Image
نحتاج نتأكد أن الـ application الي سوينا له dockerization شغال ومضبوط، وهذا الشيء راح يتم من خلال ملف الـ "RESTfulAPI.http" وهنا انا جربت الـ CRUD كاملة عشان أتأكد انها شغالة تمام
GET_ALL_TODOS
CREATE_NEW_TODO
GET_TODO
UPDATE_TODO
DELETE_TODO
Image Size Before Optimizations
نلاحظ هنا قبل الـ optimizations الي راح نسويها هو حجم الـ application حقنا الي هو عبارة عن simple RESTfulAPI وحجمه 1.34GB والي يعتبر كبير جداً على application بهذا الحجم الصغير وفي إطار عمل صغير زي express.js الي يعتبر من أصغر الـ backend frameworks حيث انه opinionated framework فلو كان الـ application حقنا تبع fully-fledged framework مثلا Django او Laravel او Spring Boot كان راح نشوف الحجم أكبر بكثير، فهنا نعرف أننا نحتاج نسوي optimizations كثير على الـ image لين نوصل لحجم مرضي يحقق لنا الأهداف المذكورة سابقاً وأهمها تقليل أهم وقتين ممكن تأثر بشكل كبير على أداء الـ developer & stystem وهي pulling time & building time + يعطينا نفس الـ output للـ application حقنا، فبسم الله خلونا نبدأ نسوي الـ optimizations وراح نستخدم أكثر من طريقة كالتالي:
Starrting Optimizing The Dockerized Image
Method1: Use a different light base image
أحد أسباب الحجم الكبير للـ container هو حجم الـ base image المستخدم الي قد تشكل النسبة العظمى من حجم الـ container والسبب أن بوقت كتابة ملف الـ Dockerfile قد لا ينتبه المطور للنسخة الي قاعد يبني عليها الـ application حقه وقد يرى فعلاً أن الـ application حقه شغال تمام ومضبوط فخلاص ما في أي داعي لأي optimization دام انه شغال تمام. هنا ممكن يسبب تسرع أخذ القرار الـ ad hoc design بسبب أنه شغال تمام ويعطيني output الي احتاجه بغض النظر عن الـ costs الي يتسبب فيها سواء كانت مادية او وقتية مثل pulling & building time او الـ resources المهدرة بسبب ان الـ image ماهي optimized بشكل كافي
في العادة هناك نسختين تستخدم بشكل مكثف عند استخدام الـ docker أحدها Alpine والثانية BusyBox وهناك ما يعرف ب Distroless وأشهرها هي Scratch وهي بكل بساطة images contain only your application and its runtime dependencies بدون أي وجود لأي distro، أدري الفكرة مجنونة لكن يبي لها مقالة بحد ذاتها، وهذي يعني والتوزيعات الي تستخدم في docker كثيرة وحجمها صغير راح أذكر الأكثر أستخداماً في الجدول أدناه. طبعاً زي ما قلت Alpine بالذات و BusyBox هي أكثر توزعتين Linux تستخدم مع الـ containers بسبب حجمها الصغير لأنها توفر الحد الأدنى من الـ packages الي معظم الـ application تحتاجها بالـ background عشان تشتغل تمام بدون أي مشاكل، فخلونا نحول من الـ node:latest الى node:alpine ونشوف الفرق كم يطلع معنا بالزبط
راح اسوي build مرة ثانية بدون الـ caching عشان يحذف كل الـ cached layers ويبدأ من الصفر في بناء الـ image، وبعد ما يخلص راح أشغله زي ما هو واضح
FROM node:alpine
WORKDIR /app
COPY package.json .
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
طبعاً نلاحظ الفرق الكبير بين الـ image الأولى والـ image الثانية الأولى كانت 1.34GB والثانية كانت 524.61MB ولو نحسب نسبة التحسن بتكون: 1.34/(100*0.524) نسبة التحسن تقريباً وصلت الى 60% بالحجم وهذي النسبة جداً كبيرة
زي ما ذكرت سابقاً هذا الجدول يوضح أكثر الـ distros أستخداماً في بناء الـ images وربما أشهرها على الأطلاق هو alpine & busybox & scratch، طبعاً بالنسبة لـ scratch يحتاج لمقالة بحد ذاته لنه فعلاً looking very promising to me
Base Image | Description | Size Range | Package Manager | Popularity | Docker Hub URL |
---|---|---|---|---|---|
Alpine | Lightweight Linux distribution known for its small size, simplicity, and security. | 5MB - 10MB | apk | Widely used | Alpine Docker Hub |
BusyBox | Software suite providing stripped-down Unix tools in a single executable. | 1MB - 5MB | None | Used for minimalistic containers | BusyBox Docker Hub |
Scratch | Empty base image with no files, libraries, or package manager. | Negligible | None | Used for truly minimalistic containers | Scratch Docker Hub |
Ubuntu Minimal | Minimal variant of the Ubuntu distribution tailored for containerized environments. | 25MB - 100MB | apt | Popular among Ubuntu users and Docker community | Ubuntu Docker Hub |
Debian Slim | Lightweight version of the Debian Linux distribution with a minimal set of packages. | 20MB - 100MB | apt | Popular among Debian users and Docker community | Debian Docker Hub |
Bullseye-slim | Lightweight variant of the Debian 11 (Bullseye) distribution with a minimal set of packages and libraries. | 20MB - 100MB | apt | Used as a lightweight base image in Docker environments | Bullseye-slim Docker Hub |
فالخطوة الأولى الي تتخذها أنك دايما تحدد الـ tag للـ base image الي راح تبني عليها الـ application حقك قبل لا تحط الـ latest version الي ممكن ما قد تكون not compatible with some of the dependencies that you have in your application
Method 2: Employ Multi-stage Builds
نجي الحين إلى طريقة جداً مهمه تلعب دور محوري في تقليل حجم الـ image الي راح نبنيها وهي طريقة تقسيم بناء الـ image على أكثر من stages وهذي الطريقة تسمى الـ multi-stage builds، الفكرة منها بكل بساطة انه راح يصير في separation لأكثر من stage بنفس الـ Dockerfile وهذا يعني أن الـ build راح يكون layer و production راح يكون layer ثانية، وهذا الشيء يسمح للـ image بتقليل حجمها لأنها راح تسوي eliminating للـ build dependencies and intermediate artifacts، يعني فقط الـ production artifacts هي الي راح يصير لها copy للـ final stage والشيء هذا راح ينتج لنا image حجمها صغير جداً وراح نشوف الآن تطبيق لها
# Stage 1: Build stage
FROM node:alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
# Add any build commands if needed
# RUN npm run build
# Stage 2: Production stage
FROM node:alpine
WORKDIR /app
COPY --from=build /app/package.json .
COPY --from=build /app/server.js .
RUN npm install --production
EXPOSE 3000
CMD ["node", "server.js"]
الآن راح أسوي build للـ image مرة ثانية بدون الـ caching عشان يحذف الـ cached layers القديمة ويبدأ من الصفر في بناء الـ image حقتنا، وبعدها راح نشغل الـ image عشان يطلع لنا container منها، وزي ما نلاحظ أن الحجم السابق حقنا كان 524.61MB والآن بعد استخدام طريقة الـ multi-stage builds صار حجم الـ container حقنا 206.39MB ونجي نحسب نسبة التحسن الآن وهي كالتالي: (100*206.39)/524.61 = 39% وهذا يعني أن نسبة التحسن هي 61% وهذي النسبة جداً كبيرة وفعلا فرق ضخم بسبب استخدام هذي الـ method
Method 3: Minimize Image Layers
هذي كذلك واحدة من الـ methods الكويسة الي ممكن تقلل الـ size بشكل جداً كبير وهي أننا نقلل عدد الـ layers الي يمشي عليها الـ Docker وهو يمر على الـ Dockerfile حيث أن كل سطر عبارة عن Layer بحد ذاتها وحنا نهدف إلى أقل عدد من الـ layers بحيث أن نقلل قدر المستطاع عدد Layers + بنفس الوقت راح يعطينا نفس النتيجة، راح توضح بالصورتين القادمة بإذن الله 👇
# Define base image
FROM node:alpine
# Set working directory
WORKDIR /app
# Copy package.json separately
COPY package.json .
RUN npm install
# Copy server.js separately
COPY server.js .
# Copy each subdirectory separately
COPY /routes ./routes
RUN echo "Routes Copied"
COPY /middleware ./middleware
RUN echo "Middleware Copied"
COPY /models ./models
RUN echo "Models Copied"
COPY /public ./public
RUN echo "Public Copied"
COPY /views ./views
RUN echo "Views Copied"
# Set environment variable separately
ENV NODE_ENV production
ENV PORT 3000
# Expose port
EXPOSE 3000
# Start the application
CMD ["node", "server.js"]
# Define base image
FROM node:alpine
# Set working directory
WORKDIR /app
# Copy package.json and install app dependencies in one step
COPY package*.json ./
RUN npm install --only=production && npm cache clean --force
# Copy all application files at once
COPY . .
# Set environment variable in one step
ENV NODE_ENV=production \
PORT=3000
# Expose port and start the application in one step
EXPOSE 3000
CMD ["node", "server.js"]
فزي ماهو واضح الفرق الكبير بين الصورتين، حنا نطمح للصورة الثانية when we have less layers والفكرة هنا بكل بساطة تقليل عدد الـ layers الي راح ينفذها الـ docker engine وهو يمشي على الـ docker file وبكذا راح يقلل الـ caching وراح يفرق معنا بشكل كبير بالحجم...
Method 4: Exclude Unnecessary Files with .dockerignore
هذي الطريقة بسيطة جداً والي يعرف .gitignore راح يعرف على طول أنه نفس الهدف والمفهوم، بكل بساطة يتم وضع الـ unnecessary files & directories بالملف ذا وراح يصير لها ignore خلال وقت الـ build وهي ملفات ما نحتاج ننقلها مباشرة إلى الـ container مثلا ملفات الـ node_modules و tests و dist و out إلى آخره من ملفات الـ build او الـ dependencies الي راح يتم تثبيتها بشكل منفصل بالـ container دون الحاجة لنقها أو ملفات الـ configurations زي مثلا .vscode...
# Exclude development and test files
node_modules/
tests/
# Exclude build artifacts
dist/
# Exclude editor-specific files
.vscode/
Conclusion
في الختام أتمنى أني قدمت محتوى مفيد ولعل وعسى يفيد ولو شخص واحد، هذه المقالة قد لا تخلو من الأخطاء والمشاكل فجل من لا يخطئ وبأذن الله راح تتحسن اذا لقيت مشاكل أو أخطاء، شكراً لكم واتمنى لكم التوفيق. ونلقاكم في مقالات أخرى...
(سبحانك لا علم لنا إلا ما علمتنا إنك أنت العليم الحكيم)
Credits
https://github.com/munishdell/book-1/blob/master/docker/docker-in-action.pdf
https://medium.com/the-agile-crafter/docker-image-optimization-from-1-16gb-to-22-4mb-53fdb4c53311
https://docs.docker.com/develop/develop-images/multistage-build/
https://medium.com/@fatimamaryamrauf/top-5-tips-for-docker-image-optimization-3ba4e25557fb
https://docs.docker.com/develop/develop-images/dockerfile_best-practices/