Implementing Hot Reloading in Docker
Introduction
السلام عليكم ورحمه الله وبركاته،
في هذه المقالة راح أتكلم عن أحد الأشياء الي تسرع عمل الـ developer إذا كان شغال على container في عملية التطوير خصوصاً إذا كانت الـ architecture المستخدمة هي microservices أو أي وجود للـ docker على سبيل المثال لا الحصر، وهذا الشيء أساسي جداً والي جربه مستحيل يستغني عنه والـ developers يعرفون أهميته الكبيرة لنه يسرع شغلهم بشكل كبير، الي هو ما يعرف بالـ "hot reloading" أو "live reloading" طبعاً المصطلحين نوع ما قد يختلفون لكن بشكل عام they can be used interchangeably الفكرة بكل بساطة أن أي تعديل بالكود بجهاز الـ developer (host) يظهر بشكل لحظي ومباشر بالـ container بدون ما يصير build كل مرة وهذا يضيع وقت كبير وتصير العملية غير مجدية، والهدف من الـ container أنه يوفر حل سريع لتشغيل الـ application فبدل ما صار الـ containerized application يسرع شغلي وسهل أني أنقل نفس الـ development experience لـ developer ثاني صار الآن معيق لعمل الـ developers، وعلى قوله أخوانا المصريين "جبتك يا عبد المعين تعني لقيت يا عبد المعين عايز تتعان ☠️"، ولكن هذا الشيء حله جداً بسيط وهو يمهد لأحد أهم المفاهيم ب Docker وهو مفهوم الـ volumes، طبعاً هذا الشرح راح أستخدم Node.js & Typescript as an example لكن هذا الشيء يمكن تطبيقه مع أي لغة برمجة لن الحل بكل بساطة language-agnostic، وبسم الله نبدأ ندخل بموضوعنا...
Explain Hot Reloading: How It Works in Docker
الـ hot reloading في docker يصير اذا صار فيه sync بين folder معين بجهاز الـ host و الـ folder المقابل له بداخل الـ container بحيث أن أي تعديل من جهة الـ host will be reflected directly بالـ container على طول، وهذا يعني التحديث اللحظي للـ source code راح يتم من جهاز الـ developer إلى الـ container بدون ما يكون فيه أي restart للـ container كل مرة، وهذي الشيء يخدم الـ developer لأنه يشوف الشيء الي طوره ويقدر يختبره بشكل مباشر وسريع بدون ما يسوي restart كل مرة، فقط بمجرد ما يسوي ctrl + s راح يطلع له الي طوره بشكل مباشر ولحظي، طبعاً الشيء هذا يصير بالـ volumes الي هي أحد أهم الأشياء بـ docker وهو بكل بساطة إنشاء bridge بين folder على جهاز الـ host و container ويتطبق مفهوم الـ bind mount ويتم إنشاء volume يربط بين الـ host و الـ container، وهذي صورة توضح أنه كيف يصير الـ synchronization بين جهاز الـ host و الـ container ومن ثم يصير له binding بـ filesystem حق الـ container. طبعاً أستخدامات الـ volumes كثيرة من ضمنها الـ presentencing data بعد ما يصير أي restart للـ container خصوصاً إذا كان database، لن docker بمجرد ما يصير restart للـ container راح يفقد البيانات الي فيه اذا كان database لنه volatile ولكن مع إستخدام الـ volumes راح تنحل المشكلة هذي وراح يصير sync بين الـ container للـ data الي تم تخزينها بالـ database ونقلها بشكل لحظي ومباشر للـ host، وفي طرق ثانية انه ممكن تنرفع الـ data على cloud storge مثل AWS S3 بشكل مباشر ولكن هذا ينفع للـ production uses
Streamlining the Dockerized Environment
في هذا المقال زي ما قلت سابقاً راح أستخدام Node.js & Typescript وهو مجرد personal preference يمكن تطبيق هذا الشيء مع أي لغة لأنه بكل بساطة language-agnostic، الفكرة منه هو توضح استخدام مفهوم الـ volumes وتوظيفه في achieving الـ hot-reloading، وبسم الله نشوف الحين عندنا الـ folder structure وملفات المشروع للـ application وهو simple Node.js application وراح نشبكه ب MongoDB عشان برضوا نشوف أحد استخدامات الـ volumes وهو الـ persistence storage
الـ source code موجود على قتهب: https://github.com/qahta0/seamless-code-updates-hot-reloading-in-docker
import express, { Request, Response } from 'express';
import dotenv from 'dotenv';
import todosRoutes from './routes/todos.routes'
import mongoDBConnection from './config/db';
import bodyParser from 'body-parser';
import morgan from 'morgan'
dotenv.config();
const app: express.Application = express();
const env: string = process.env.ENV || "development"
const host: string = process.env.HOST || "localhost"
const port: number = Number(process.env.PORT) || 5000;
async function startServer(): Promise<void> {
try {
await mongoDBConnection()
app.listen(port, () => {
console.log(`[server]: Server is running at http://${host}:${port} on the ${env} environment`);
});
} catch (error) {
console.error('server error:', error);
process.exit(1);
}
}
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(morgan('dev'))
app.use('/todos', todosRoutes)
app.get('/healthcheck', (req: Request, res: Response) => {
res.status(200).json({
status: 'OK',
message: 'Server is healthy 🚀',
});
});
startServer();
import express, { Router } from 'express';
import {
getAllTodos,
createTodo,
getTodoById,
updateTodoById,
deleteTodoById,
} from '../controllers/todos.controllers';
const router: Router = express.Router();
router.get('/', getAllTodos);
router.post('/', createTodo);
router.get('/:id', getTodoById);
router.put('/:id', updateTodoById);
router.delete('/:id', deleteTodoById);
export default router;
import { Request, Response } from 'express';
import { ITodo, TodoModel } from '../models/todo.model';
export const getAllTodos = async (req: Request, res: Response): Promise<void> => {
try {
const todos: ITodo[] = await TodoModel.find();
res.status(200).json(todos);
} catch (error) {
res.status(500).json({ message: 'Internal Server Error' });
}
};
export const createTodo = async (req: Request, res: Response): Promise<void> => {
try {
const { title, completed } = req.body;
console.log(title, completed)
const newTodo: ITodo = await TodoModel.create({ title, completed });
res.status(201).json(newTodo);
} catch (error) {
res.status(500).json({ message: 'Internal Server Error' });
}
};
export const getTodoById = async (req: Request, res: Response): Promise<void> => {
try {
const id: string = req.params.id;
const todo: ITodo | null = await TodoModel.findById(id);
if (todo) {
res.status(200).json(todo);
} else {
res.status(404).json({ message: 'Todo not found' });
}
} catch (error) {
res.status(500).json({ message: 'Internal Server Error' });
}
};
export const updateTodoById = async (req: Request, res: Response): Promise<void> => {
try {
const id: string = req.params.id;
const { title, completed } = req.body;
const updatedTodo: ITodo | null = await TodoModel.findByIdAndUpdate(id, { title, completed }, { new: true });
if (updatedTodo) {
res.status(200).json(updatedTodo);
} else {
res.status(404).json({ message: 'Todo not found' });
}
} catch (error) {
res.status(500).json({ message: 'Internal Server Error' });
}
};
export const deleteTodoById = async (req: Request, res: Response): Promise<void> => {
try {
const id: string = req.params.id;
const deletedTodo: ITodo | null = await TodoModel.findByIdAndDelete(id);
if (deletedTodo) {
res.status(200).json(deletedTodo);
} else {
res.status(404).json({ message: 'Todo not found' });
}
} catch (error) {
res.status(500).json({ message: 'Internal Server Error' });
}
};
import { Schema, model, Document, Model } from 'mongoose';
export interface ITodo extends Document {
title: string;
completed: boolean;
}
const todoSchema = new Schema<ITodo>({
title: {
type: String,
required: true,
},
completed: {
type: Boolean,
default: false,
},
});
export const TodoModel: Model<ITodo> = model<ITodo>('Todo', todoSchema);
import mongoose from 'mongoose';
const mongoDBConnection = async (): Promise<void> => {
try {
const mongoURI: string = process.env.MONGO_URI || "mongodb://abdullah:abdullah@mongodb:27017";
await mongoose.connect(mongoURI);
console.log('MongoDB connected successfully');
} catch (error) {
console.error('MongoDB connection error:', error);
process.exit(1);
}
};
export default mongoDBConnection;
version: "3.9"
name: seamless-code-updates-hot-reloading-in-docker
services:
app:
container_name: app
build:
context: .
dockerfile: ./docker/Dockerfile
ports:
- 5000:5000
env_file:
- .env
volumes:
- .:/app
- /node_modules
depends_on:
- mongodb
mongodb:
container_name: mongodb
image: mongo
ports:
- 27017:27017
environment:
- MONGO_INITDB_ROOT_USERNAME=abdullah
- MONGO_INITDB_ROOT_PASSWORD=abdullah
volumes:
- mongo_data:/data/db
volumes:
mongo_data:
FROM node:alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production \
&& npm install -g nodemon ts-node \
&& npm cache clean --force
COPY . .
EXPOSE 5000
CMD ["sh", "./docker/start.sh"]
PORT=5000
ENV=development
HOST=localhost
MONGO_URI=mongodb://abdullah:abdullah@mongodb:27017
#!/bin/sh
if [ "$ENV" = "production" ]; then
echo "Running in production mode..."
npm run build
npm start
else
echo "Running in development mode..."
npm run dev
fi
{
"name": "seamless-code-updates-hot-reloading-in-docker",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon server.ts",
"build": "tsc server.ts dist/",
"start": "node dist/server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"body-parser": "^1.20.2",
"dotenv": "^16.1.3",
"express": "^4.18.2",
"mongoose": "^7.2.2",
"morgan": "^1.10.0"
},
"devDependencies": {
"@types/express": "^4.17.17",
"@types/morgan": "^1.9.4"
}
}
###
### GET All Todos
###
GET http://localhost:5000/todos
###
###
### Create Todo
###
POST http://localhost:5000/todos
Content-Type: application/json
{
"title": "This a new todo 🚀",
"completed": true
}
###
###
### Get Todo By ID
###
GET http://localhost:5000/todos/647b9f86413bb3518d3e1ca6
###
###
### Update Todo By ID
###
PUT http://localhost:5000/todos/647b9f86413bb3518d3e1ca6
Content-Type: application/json
{
"title": "This a new todo (updated) 🚀",
"completed": true
}
###
###
### Delete Todo By ID
###
DELETE http://localhost:5000/todos/647b9f86413bb3518d3e1ca6
###
Hot Reloading: Increase Your Development Workflow Speed
طيب عشان نبدأ نطبق الشغل، ممكن تشغله بجهازك اذا ودك، الحين بسوي build & run للـ image الي سوينها وهي عبارة عن RESTful-API مكتوب بـ Express.js + Typescript + Mogodb، ومثل ما هو واضح تم بناء الـ image successfully
الآن بتأكد من الـ containers أنها شغالة تمام والي يتضح لي أنها كذلك 👇
الآن بجرب الـ application
وممكن نشيك الـ healthcheck endpoint من هنا 👇
الآن من نفس ملف الـ sever.ts غيرت بعض الكلام ب response الي يرجع وسويت save وثم رجعت لصفحة الـ chrome وتغير النص بدون ما اسوي restart للـ application
وهنا السيرفر يوضح انه بمجرد ما يصير تغير بالـ source code من طرف الـ developer راح يسوي restart من جديد للـ server
Hot Reloading: How Did This Happen?
بعد ما شفتوا كيف صار الشيء ذا خلونا نعرف كيف صار لنه يعتبر magic ويحتاج تفسير، التفسير بكل بساطة هي احد وظائف الـ volumes وهي تقوم بعمل bridge بين الـ host إلى الـ container ومن ثم يصير فيه synchronization بين الـ directory الي بالـ host و الـ directory الي بالـ container، هذا بالنسبة لموضوع الـ hot reloading، لكن ايضاً الأستخدام الأهم من كل ذا هو الـ persisting the data لن زي ما ذكرت سابقاً أن الـ container volatile فقط بمجرد ما يصير له restart ودع بياناتك ☠️ ولكن مع استخدام الـ volumes راح يصير لها mount على جهاز الـ host او يتم رفعها لـ cloud storage زي مثلا AWS S3 على سبيل المثال لا الحصر.
طيب عشان نفهم كيف الشيء ذا صار نحتاج نحلل ملف الـ docker-compose.yaml
version: "3.9"
name: seamless-code-updates-hot-reloading-in-docker
services:
app:
container_name: app
build:
context: .
dockerfile: ./docker/Dockerfile
ports:
- 5000:5000
env_file:
- .env
volumes:
- .:/app
- /node_modules
depends_on:
- mongodb
mongodb:
container_name: mongodb
image: mongo
ports:
- 27017:27017
environment:
- MONGO_INITDB_ROOT_USERNAME=abdullah
- MONGO_INITDB_ROOT_PASSWORD=abdullah
volumes:
- mongo_data:/data/db
volumes:
mongo_data:
وتحديداً هذي الأسطر
volumes:
- .:/app
- /node_modules
والي يتضح منها الملفين: ملف الـ host وهنا هو ما قبل الـ : وهنا نقوله انه تراه بالـ current directory ومن ثم ما بعد الـ : هو الـ directory بالـ container وهنا يصير الـ bridge الي ذكرته سابقاً وانشرح بالصورة
Hot Reloaders: Its Function & Examples
بعد ما شفنا كيف أن الـ volume يسمح لنا بأننا نسوي bridge مباشر بين الـ directory بجهاز الـ host والـ corresponding directory بالـ container نحتاج نعرف أن وراء هذا التأثير هي الـ hot reloaders، وهي tools فكرتها بكل بساطة أن تسوي auto restart للـ server بمجرد ما يتغير الـ source code عن طريق انها تسوي watch للملفات، طبعاً هذي الـ tools فقط تستخدم بالـ development environments وما تروح الـ production لنها فقط تساعد الـ developer أنه يشوف شغله الي كتبه بشكل مباشر ولحظي. في Node.js يعتبر nodemon أشهر الـ hot reloaders خصوصاً أنه يدعم Typescript، وذكرت بالجدول الـ corresponding alternatives باللغات الثانية...
Language | Tool | Description | GitHub Repo |
---|---|---|---|
PHP | phpwatch | It's a command-line tool that can monitor changes in your PHP application and automatically restart the PHP built-in server. | phpwatch/phpwatch |
Java | Spring Boot DevTools | This set of tools includes an automatic restart feature whenever files change. This is specifically tailored for Spring Boot applications. | Included in spring-projects/spring-boot |
Go | Realize | It's a command-line tool that provides a live-reload feature for Go applications. | oxequa/realize |
Go | Air | A live reload tool for Go that's easy to use for instant reloading. | cosmtrek/air |
Python | Watchdog | Python library and shell utilities to monitor filesystem events. | gorakhargosh/watchdog |
Ruby | Guard | A command-line tool that handles events on file system modifications, perfect for Ruby and Rails development. | guard/guard |
.NET | dotnet watch | It's a file watcher for .NET that can be used as a development tool to rerun a .NET command when source code files change. | Included in .NET CLI |
Rust | Cargo watch | Cargo watch is a utility for Cargo, Rust's package manager, which automatically rebuilds your project when you change a source file. | watchexec/cargo-watch |
Conclusion
في الختام أتمنى أني قدمت محتوى مفيد ولعل وعسى يفيد ولو شخص واحد، هذه المقالة قد لا تخلو من الأخطاء والمشاكل فجل من لا يخطئ وبأذن الله راح تتحسن اذا لقيت مشاكل أو أخطاء، شكراً لكم واتمنى لكم التوفيق. ونلقاكم في مقالات أخرى...
(سبحانك لا علم لنا إلا ما علمتنا إنك أنت العليم الحكيم)