سرور Deloy SocketIO با استفاده از Docker و Nginx Load Balancer (+SSL)

در آخرین پروژهام، نیاز داشتم که سرورهای سوکتی داشته باشم که بتوانند اتصالات بیش از 10 هزار برنامه تلفن همراه را مدیریت کنند. همچنین لازم بود در صورت امکان با استفاده از یک اتصال ایمن ارتباط برقرار کنم. پس از بررسی برخی سناریوها، در نهایت می توانم استقرار سرور سوکت را با استفاده از Docker و Nginx در یک سرور خصوصی مجازی مدیریت کنم.
اینم مراحلی که انجام دادم:
ساختار پروژه
به این صورت است که من پروژه سرور سوکت را سازماندهی می کنم
/socket-server
|-- src/
|-- index.js
|-- loggers/
|-- .env
|-- .env_one
|-- .env_two
|-- .env_three
|-- .env_four
|-- Dockerfile
|-- docker-compose.yaml
|-- nginx.conf
|-- package.json
|-- yarn.lock
index.js
import express from "express";
import { createServer } from "http";
import { Redis } from "ioredis";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import logger from "./logger/winston_logger.js";
import bodyParser from "body-parser";
const app = express();
const http = createServer(app);
const port = process.env.PORT;
const serverName = process.env.SERVER_NAME;
const redisHost = process.env.REDIS_HOST;
const redisPort = process.env.REDIS_PORT;
const socketKey = process.env.SECRET_KEY;
let numConnectedSockets = 0;
const pubClient = new Redis({
host: redisHost,
port: redisPort,
keyPrefix: "myapp",
});
const subClient = pubClient.duplicate();
logger.info(
`Redis client connected to ${redisHost}:${redisPort} with status: pub: ${pubClient.status} sub: ${subClient.status}`
);
// listen to redis connection status
pubClient.on("connect", () => {
logger.info("Redis pub client connected!");
});
subClient.on("connect", () => {
logger.info("Redis sub client connected!");
});
// create socket io server with adapter
// add your other services here
const io = new Server(http, {
cors: {
origin: ["http://localhost:3000", "http://localhost:8020"],
methods: ["GET", "POST"],
},
adapter: createAdapter(pubClient, subClient),
});
// express listen to port
http.listen(port, () => {
logger.info(`Server ${serverName} is running on port ${port}`);
});
// socket io connection
io.on("connection", (socket) => {
logger.info(`User connected: ${socket.id} to socket server ${serverName}`);
socket.emit("hi", {
serverName: serverName,
msg: `Hello from socket server ${serverName}!`,
});
// socket io events
socket.on("disconnect", () => {
numConnectedSockets--;
logger.info(`User disconnected: ${socket.id} from ${serverName}!`);
});
socket.on("hello", (msg) => {
logger.info(`Receive a hello from ${socket.id} with ${msg}`);
logger.info(`server socket key is ${socketKey}`);
logger.info(`test socket key vs msg is ${socketKey} vs ${msg}`);
if (msg) {
if (msg === socketKey) {
logger.info(`User ${socket.id} sent the correct key. Welcome!`);
socket.emit("introduce_yourself", {
serverName: serverName,
msg: `Please introduce yourself!`,
});
} else {
socket.disconnect();
}
} else {
socket.disconnect();
}
});
socket.on("introduction", (msg) => {
logger.info(`Receive a introduction from ${socket.id}`);
if (msg) {
const { name, token } = msg;
if (!name || !token) {
logger.error(
`User ${socket.id} did not introduce themselves correctly!. Disconnecting...`
);
// disconnect user
socket.disconnect();
return;
}
logger.info(
`User ${socket.id} introduced as ${name} with token ${token}`
);
// validate name and token
let isAuthorized = false;
if (name === "admin" && token === "admin") {
isAuthorized = true;
logger.info(`User ${socket.id} is an admin!`);
}
if (token === socketKey) {
logger.info(`User ${socket.id} is an authenticated user!`);
isAuthorized = true;
}
if (isAuthorized === false) {
logger.error(
`User ${name} with socket id ${socket.id} is not authorized. Disconnecting...`
);
// disconnect user
socket.disconnect();
return;
}
numConnectedSockets++;
} else {
logger.error(
`Can not get the message from ${socket.id} while introduction. Disconnecting...`
);
logger.error({
msg,
});
// disconnect user
socket.disconnect();
}
});
socket.on("log_event", (data) => {
socket.broadcast.emit("broadcast_log_event", {
serverName: serverName,
msg: data,
});
});
});
// error handling
io.on("error", (error) => {
logger.error(`Error: ${error}`);
});
app.use(bodyParser.json());
// express default route
app.get("/heartbeat", (req, res) => {
res.sendStatus(200);
});
// base url
app.get("/", (_, res) => {
winstonLogger.info(`base endpoint called`);
res.status(200).json({
error: false,
msg: "Base endpoint works",
});
});
app.get("/", (req, res) => {
res.status(200).json({
message: `Welcome to socket server ${serverName}`,
socketConnected: numConnectedSockets,
});
});
همانطور که در اینجا متوجه شدید، ما از آداپتور Redis برای همگام سازی سرورهای سوکت در زمانی که رویدادهای ورودی و آینده منتشر می شوند استفاده می کنیم. برای آداپتور Redis می توانید به اسناد رسمی اینجا بروید.
پیکربندی Nginx
پیکربندی nginx به این صورت است
# Reference: https://www.nginx.com/resources/wiki/start/topics/examples/full/
worker_processes 4;
events {
worker_connections 1024;
}
http {
server {
listen 80;
location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_pass http://nodes;
# enable WebSockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
upstream nodes {
# enable sticky session with either "hash" (uses the complete IP address)
hash $remote_addr consistent;
# or "ip_hash" (uses the first three octets of the client IPv4 address, or the entire IPv6 address)
# ip_hash;
# or "sticky" (needs commercial subscription)
# sticky cookie srv_id expires=1h domain=.example.com path=/;
server server-one:3000;
server server-two:3000;
server server-three:3000;
server server-four:3000;
}
}
Dockerfile
# Use the official Node.js 21 image as the base image
FROM node:21-alpine
# Set the working directory inside the container
WORKDIR /app
# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
# Install the app dependencies
RUN yarn install --production=true
# Copy the rest of the app source code to the working directory
COPY . .
# Set the non-root user to run the application
USER node
# Expose the port on which the app will run
EXPOSE 3000
# Start the application
CMD node --env-file=.env src/index.js
Docker Compose
services:
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
links:
- server-one
- server-two
- server-three
- server-four
ports:
- "3002:80"
server-one:
build: .
expose:
- "3000"
env_file:
- .env_one
restart: always
server-two:
build: .
expose:
- "3000"
env_file:
- .env_two
restart: always
server-three:
build: .
expose:
- "3000"
env_file:
- .env_three
restart: always
server-four:
build: .
expose:
- "3000"
env_file:
- .env_four
restart: always
خوب است، پس می توانیم با استفاده از دستور شروع به استقرار سرور کنیم docker compose
ساخت و دنبال کردن با دستور docker compose up -d
.
ماشین میزبان Nginx + Letsencrypt
تا بتوان از wss://mydomain
برای URL سوکت وقتی مشتری سوکت میخواهد به سرور متصل شود، میتوانیم SSL را با استفاده از Letsencrypt در دستگاه میزبان تنظیم کنیم. در یک پست جداگانه در این مورد خواهم نوشت.
پس از تکمیل تنظیمات SSL، میتوانیم این بلوک پیکربندی را در پیکربندی nginx در داخل server {
مسدود کردن. معمولا در /etc/nginx/sites-available/default
فایل.
location /socket.io/ {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
# the port of the nginx in docker compose
proxy_pass http://localhost:3002;
# enable WebSockets
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
تمام است، سپس کلاینت سوکت شما میتواند با استفاده از آن متصل شود wss://mydomain
آدرس سرور سوکت.
اگر از فلاتر استفاده می کنید. یک افزونه مشتری سوکت io وجود دارد که می توانید از آن استفاده کنید. اینجا را بررسی کن. در زیر کد اتصال به سرور سوکت با استفاده از SSL فعال است.
import 'package:socket_io_client/socket_io_client.dart' as io;
// create a new socket instance
socket = io.io(socketServer, {
'transports': ['websocket'],
'secure': true,
});
ocket.onConnect((_) {
debugPrint('get connected to the server $socketServer');
});
socket.onError((error) {
debugPrint('error: $error');
});
socket.onDisconnect((_) => debugPrint('disconnected from $socketServer'));
امیدوارم این مطلب برای شما مفید باشد. کد نویسی مبارک!