ตั้ง Node.js Socket Server บน Docker ด้วย Docker Compose
บทความนี้จะแนะนำวิธีการตั้งค่าและรัน Node.js Socket Server โดยใช้ Docker และ Docker Compose อย่างละเอียด ตั้งแต่การสร้าง Socket.IO server พื้นฐาน การเขียน Dockerfile ที่เหมาะสม การตั้งค่า docker-compose.yml และการทดสอบการทำงานของระบบ เหมาะสำหรับผู้ที่ต้องการสร้าง real-time application ด้วย WebSocket บน containerized environment
สิ่งที่ต้องเตรียม
ก่อนเริ่มต้น เราจำเป็นต้องติดตั้ง Docker และ Docker Compose ก่อน:
# สำหรับ macOS ด้วย Homebrew
brew install --cask docker
# หรือติดตั้ง Docker Desktop จากเว็บไซต์อย่างเป็นทางการ
# https://www.docker.com/products/docker-desktop
ตรวจสอบว่าติดตั้งเรียบร้อยแล้ว:
docker --version
docker-compose --version
สร้าง Node.js Socket Server
เริ่มต้นด้วยการสร้างโปรเจค Node.js socket server พื้นฐาน:
mkdir nodejs-socket-server
cd nodejs-socket-server
npm init -y
npm install socket.io express cors
npm install -D nodemon
สร้างโครงสร้างไฟล์
nodejs-socket-server/
├── Dockerfile
├── docker-compose.yml
├── package.json
├── server.js
├── public/
│ ├── index.html
│ └── client.js
└── .dockerignore
เขียน Socket Server (server.js)
const express = require("express");
const http = require("http");
const socketIo = require("socket.io");
const cors = require("cors");
const app = express();
const server = http.createServer(app);
const io = socketIo(server, {
cors: {
origin: "*",
methods: ["GET", "POST"],
},
});
// ใช้ CORS middleware
app.use(cors());
app.use(express.json());
app.use(express.static("public"));
// เก็บข้อมูลผู้ใช้ที่ออนไลน์
let onlineUsers = new Map();
// Socket.IO connection handling
io.on("connection", (socket) => {
console.log(`User connected: ${socket.id}`);
// จัดการการเข้าร่วมห้อง
socket.on("join-room", (data) => {
const { username, room } = data;
socket.join(room);
onlineUsers.set(socket.id, { username, room });
console.log(`${username} joined room: ${room}`);
// แจ้งเตือนผู้ใช้คนอื่นในห้อง
socket.to(room).emit("user-joined", {
username,
message: `${username} เข้าร่วมห้องแชท`,
timestamp: new Date().toISOString(),
});
// ส่งรายชื่อผู้ใช้ในห้อง
const roomUsers = Array.from(onlineUsers.values())
.filter((user) => user.room === room)
.map((user) => user.username);
io.to(room).emit("room-users", roomUsers);
});
// จัดการข้อความแชท
socket.on("chat-message", (data) => {
const { message, username, room } = data;
console.log(`Message from ${username} in ${room}: ${message}`);
// ส่งข้อความไปยังทุกคนในห้อง
io.to(room).emit("chat-message", {
username,
message,
timestamp: new Date().toISOString(),
socketId: socket.id,
});
});
// จัดการการพิมพ์
socket.on("typing", (data) => {
const { username, room, isTyping } = data;
socket.to(room).emit("user-typing", {
username,
isTyping,
});
});
// จัดการการส่งไฟล์
socket.on("file-share", (data) => {
const { fileName, fileData, username, room } = data;
console.log(`File shared by ${username} in ${room}: ${fileName}`);
socket.to(room).emit("file-received", {
fileName,
fileData,
username,
timestamp: new Date().toISOString(),
});
});
// จัดการการตัดการเชื่อมต่อ
socket.on("disconnect", () => {
const user = onlineUsers.get(socket.id);
if (user) {
const { username, room } = user;
console.log(`${username} disconnected from room: ${room}`);
// แจ้งเตือนผู้ใช้คนอื่น
socket.to(room).emit("user-left", {
username,
message: `${username} ออกจากห้องแชท`,
timestamp: new Date().toISOString(),
});
onlineUsers.delete(socket.id);
// อัพเดทรยชื่อผู้ใช้ในห้อง
const roomUsers = Array.from(onlineUsers.values())
.filter((u) => u.room === room)
.map((u) => u.username);
io.to(room).emit("room-users", roomUsers);
}
});
});
// REST API endpoints
app.get("/api/health", (req, res) => {
res.json({
status: "OK",
timestamp: new Date().toISOString(),
onlineUsers: onlineUsers.size,
rooms: [...new Set(Array.from(onlineUsers.values()).map((u) => u.room))],
});
});
app.get("/api/rooms/:room/users", (req, res) => {
const { room } = req.params;
const roomUsers = Array.from(onlineUsers.values())
.filter((user) => user.room === room)
.map((user) => user.username);
res.json({
room,
users: roomUsers,
count: roomUsers.length,
});
});
// Error handling
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: "Something went wrong!" });
});
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
console.log(`🚀 Socket server running on port ${PORT}`);
console.log(`📱 WebSocket ready for connections`);
console.log(`🔗 Health check: http://localhost:${PORT}/api/health`);
});
สร้าง Client สำหรับทดสอบ (public/index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Socket.IO Chat Demo</title>
<style>
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
max-width: 800px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
padding: 20px;
backdrop-filter: blur(10px);
}
.login-section,
.chat-section {
display: none;
}
.login-section.active,
.chat-section.active {
display: block;
}
input,
button {
padding: 10px;
margin: 5px;
border: none;
border-radius: 5px;
font-size: 16px;
}
input {
background: rgba(255, 255, 255, 0.9);
color: #333;
}
button {
background: #4caf50;
color: white;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background: #45a049;
}
#messages {
height: 300px;
overflow-y: auto;
background: rgba(255, 255, 255, 0.1);
border-radius: 5px;
padding: 10px;
margin: 10px 0;
}
.message {
margin: 5px 0;
padding: 8px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.2);
}
.message.own {
background: rgba(76, 175, 80, 0.3);
text-align: right;
}
.users-list {
background: rgba(255, 255, 255, 0.1);
border-radius: 5px;
padding: 10px;
margin: 10px 0;
}
.typing-indicator {
font-style: italic;
color: #ccc;
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<!-- Login Section -->
<div class="login-section active" id="loginSection">
<h2>เข้าร่วมห้องแชท</h2>
<input
type="text"
id="usernameInput"
placeholder="ใส่ชื่อผู้ใช้"
required
/>
<input
type="text"
id="roomInput"
placeholder="ใส่ชื่อห้อง"
value="general"
required
/>
<button onclick="joinRoom()">เข้าร่วม</button>
</div>
<!-- Chat Section -->
<div class="chat-section" id="chatSection">
<div class="users-list">
<strong>ผู้ใช้ออนไลน์ในห้อง:</strong>
<div id="usersList">กำลังโหลด...</div>
</div>
<div id="messages"></div>
<div id="typingIndicator" class="typing-indicator"></div>
<input
type="text"
id="messageInput"
placeholder="พิมพ์ข้อความ..."
onkeypress="handleKeyPress(event)"
/>
<input
type="file"
id="fileInput"
accept="image/*,text/*,.pdf,.doc,.docx"
style="display: none;"
onchange="handleFileSelect(event)"
/>
<button onclick="sendMessage()">ส่ง</button>
<button onclick="document.getElementById('fileInput').click()">
📎 ส่งไฟล์
</button>
<button onclick="leaveRoom()">ออกจากห้อง</button>
</div>
</div>
<script src="/socket.io/socket.io.js"></script>
<script src="client.js"></script>
</body>
</html>
สร้าง Client JavaScript (public/client.js)
let socket;
let currentUsername = "";
let currentRoom = "";
let typingTimeout;
function joinRoom() {
const username = document.getElementById("usernameInput").value.trim();
const room = document.getElementById("roomInput").value.trim();
if (!username || !room) {
alert("กรุณาใส่ชื่อผู้ใช้และชื่อห้อง");
return;
}
currentUsername = username;
currentRoom = room;
// เชื่อมต่อ Socket.IO
socket = io();
// จัดการ events ต่างๆ
setupSocketListeners();
// เข้าร่วมห้อง
socket.emit("join-room", { username, room });
// เปลี่ยน UI
document.getElementById("loginSection").classList.remove("active");
document.getElementById("chatSection").classList.add("active");
}
function setupSocketListeners() {
// เมื่อได้รับข้อความ
socket.on("chat-message", (data) => {
displayMessage(
data.username,
data.message,
data.timestamp,
data.socketId === socket.id
);
});
// เมื่อมีผู้ใช้เข้าห้อง
socket.on("user-joined", (data) => {
displayMessage("ระบบ", data.message, data.timestamp);
});
// เมื่อมีผู้ใช้ออกห้อง
socket.on("user-left", (data) => {
displayMessage("ระบบ", data.message, data.timestamp);
});
// อัพเดทรยชื่อผู้ใช้
socket.on("room-users", (users) => {
document.getElementById("usersList").innerHTML =
users.join(", ") || "ไม่มีผู้ใช้ออนไลน์";
});
// จัดการการพิมพ์
socket.on("user-typing", (data) => {
const indicator = document.getElementById("typingIndicator");
if (data.isTyping && data.username !== currentUsername) {
indicator.textContent = `${data.username} กำลังพิมพ์...`;
} else {
indicator.textContent = "";
}
});
// จัดการการรับไฟล์
socket.on("file-received", (data) => {
displayFileMessage(data.username, data.fileName, data.timestamp);
});
}
function sendMessage() {
const messageInput = document.getElementById("messageInput");
const message = messageInput.value.trim();
if (!message) return;
socket.emit("chat-message", {
message,
username: currentUsername,
room: currentRoom,
});
messageInput.value = "";
stopTyping();
}
function handleKeyPress(event) {
if (event.key === "Enter") {
sendMessage();
} else {
startTyping();
}
}
function startTyping() {
socket.emit("typing", {
username: currentUsername,
room: currentRoom,
isTyping: true,
});
clearTimeout(typingTimeout);
typingTimeout = setTimeout(stopTyping, 1000);
}
function stopTyping() {
socket.emit("typing", {
username: currentUsername,
room: currentRoom,
isTyping: false,
});
}
function handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function (e) {
socket.emit("file-share", {
fileName: file.name,
fileData: e.target.result,
username: currentUsername,
room: currentRoom,
});
};
reader.readAsDataURL(file);
// Reset file input
event.target.value = "";
}
function displayMessage(username, message, timestamp, isOwn = false) {
const messagesDiv = document.getElementById("messages");
const messageDiv = document.createElement("div");
messageDiv.className = `message ${isOwn ? "own" : ""}`;
const time = new Date(timestamp).toLocaleTimeString("th-TH");
messageDiv.innerHTML = `<strong>${username}</strong> [${time}]: ${message}`;
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function displayFileMessage(username, fileName, timestamp) {
const messagesDiv = document.getElementById("messages");
const messageDiv = document.createElement("div");
messageDiv.className = "message";
const time = new Date(timestamp).toLocaleTimeString("th-TH");
messageDiv.innerHTML = `<strong>${username}</strong> [${time}]: ส่งไฟล์ 📎 <em>${fileName}</em>`;
messagesDiv.appendChild(messageDiv);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
function leaveRoom() {
if (socket) {
socket.disconnect();
}
// Reset UI
document.getElementById("chatSection").classList.remove("active");
document.getElementById("loginSection").classList.add("active");
document.getElementById("messages").innerHTML = "";
document.getElementById("usersList").innerHTML = "กำลังโหลด...";
document.getElementById("messageInput").value = "";
document.getElementById("typingIndicator").textContent = "";
}
สร้าง Dockerfile
FROM node:18-alpine
# ตั้งค่า working directory
WORKDIR /app
# คัดลอก package.json และ package-lock.json
COPY package*.json ./
# ติดตั้ง dependencies
RUN npm ci --only=production && npm cache clean --force
# คัดลอก source code
COPY . .
# สร้าง non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
# เปลี่ยน owner ของไฟล์
RUN chown -R nodejs:nodejs /app
USER nodejs
# เปิด port
EXPOSE 3000
# คำสั่งรัน
CMD ["node", "server.js"]
สร้าง docker-compose.yml
version: "3.8"
services:
socket-server:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
volumes:
- ./logs:/app/logs
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Optional: เพิ่ม Nginx reverse proxy
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
depends_on:
- socket-server
restart: unless-stopped
profiles:
- nginx
# Optional: เพิ่ม Redis สำหรับ scaling
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
command: redis-server --appendonly yes
profiles:
- redis
volumes:
redis_data:
networks:
default:
driver: bridge
สร้าง .dockerignore
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
.env.local
.env.production
.git
.gitignore
README.md
.DS_Store
.vscode
.idea
*.log
logs
coverage
.nyc_output
การรันและทดสอบ
รันด้วย Docker Compose
# รันในโหมดพื้นฐาน
docker-compose up -d
# รันพร้อม Nginx reverse proxy
docker-compose --profile nginx up -d
# รันพร้อม Redis (สำหรับ scaling)
docker-compose --profile redis up -d
ตรวจสอบการทำงาน
# ตรวจสอบ container ที่รันอยู่
docker-compose ps
# ดู logs
docker-compose logs -f socket-server
# ทดสอบ health check
curl http://localhost:3000/api/health
# ทดสอบ API
curl http://localhost:3000/api/rooms/general/users
ทดสอบ Socket Connection
- เปิดเบราว์เซอร์ไปที่
http://localhost:3000 - เข้าร่วมห้องแชทด้วยชื่อผู้ใช้และชื่อห้อง
- เปิดแท็บใหม่และเข้าร่วมห้องเดียวกัน
- ทดสอบการส่งข้อความและการแชร์ไฟล์
สรุป
การตั้งค่า Node.js Socket Server ด้วย Docker และ Docker Compose มีขั้นตอนดังนี้:
- สร้าง Socket Server: ใช้ Socket.IO และ Express สำหรับสร้าง real-time communication
- Dockerization: เขียน Dockerfile ที่เหมาะสมสำหรับ Node.js application
- Orchestration: ใช้ Docker Compose เพื่อจัดการ services และ networking
- Testing: ทดสอบการทำงานของ socket connections และ API endpoints
- Scaling: เพิ่มความสามารถในการ scale และ load balancing
- Monitoring: เพิ่ม logging และ metrics สำหรับติดตามการทำงาน
ตัวอย่างโค้ดนี้ครอบคลุมฟีเจอร์สำคัญของ real-time application เช่น:
- การส่งข้อความแบบ real-time
- การจัดการห้องแชท
- การแสดงสถานะการพิมพ์
- การแชร์ไฟล์
- การจัดการผู้ใช้ออนไลน์
- REST API สำหรับ monitoring
สามารถนำไปปรับแต่งและพัฒนาต่อเพื่อให้เหมาะกับความต้องการของโปรเจกต์ได้
เคล็ดลับ: ในโปรดักชั่นควรเพิ่ม SSL/TLS, rate limiting, และ database ที่เหมาะสมสำหรับเก็บข้อมูลถาวร
สามารถดูตัวอย่างโค้ดและโปรเจกต์เต็มได้ที่ GitHub: https://github.com/earth774/nodejs-socket-docker