반응형

pm2에 대해선 다른 블로그들을 참조하길 바란다.
백그라운드 상태에서 내가 만든 프로그램을 돌리고 관리하는 서비스다.


채팅서버는 port번호 8985부터 8987 총 3개를 동작시킬 예정이다.
편의를 위해 sh을 만들겠다.

앞서 만든 서버 프로그램이 있는 위치에서 vi start.sh로 파일을 만든다.
pm2로 실행할 때 port 번호를 전달하는 방식이다.

#!/bin/bash

# Start Node.js servers with PM2 on ports 8985 to 8987
for port in {8985..8987}
do
  pm2 start server.js --name app-$port -- $port
done

# Show PM2 process list
pm2 ls

:wp로 저장하고 나온다.


이번엔 종료다.
마찬가지로 vi stop.sh 파일을 만든다.

#!/bin/bash

# Stop Node.js servers with PM2 on ports 8985 to 8987
for port in {8985..8987}
do
  pm2 stop app-$port
  pm2 delete app-$port
done

# Show PM2 process list
pm2 ls

:wp로 저장하고 나온다.

이제 만든 파일들의 권한을 실행 가능하게 바꿔준다.
chmod +x start.sh
chmod +x stop.sh

start.sh를 실행한다.
./start.sh

중지하려면
./stop.sh

하나씩 중지되다가 아래처럼 아무것도 안남게 된다.

반응형
Posted by Hippalus
,

반응형

서버 소스 server.js

너무 간단한 버전이라 딱히 설명할게 없다.
클라이언트가 처음 접속할 때 user 아이디와 참여하고자 하는 방번호(그룹으로 묶여 있어서 이 방번호 안에서 발생한 메세지는 해당 방 참여자에게만 전송 용도)를 보내면 이를 map으로 저장해두고 
chat 메세지가 수신되면 보내는게 고작이다.
개별 실행하려면 node server.js 8985 처럼 포트번호를 적고 실행하면 동작한다.

const express = require('express');
const http = require('http');
const socketIO = require('socket.io');
const { createClient } = require('redis');

const app = express();
const server = http.createServer(app);

const io = socketIO(server, {
    cors: {
        origin: '*',
    }
});

const { v4: uuidv4 } = require('uuid');

// UUID 생성
const SERVERID = uuidv4();

console.log(SERVERID); // 고유한 UUID 출력

// Redis adapter setup
const redisAdapter = require('socket.io-redis');
const redisClient = createClient({
    url: 'redis://localhost:6379'
});
//redisClient.connect().catch(console.error);

redisClient.on('error', (error) => {
    console.error('Redis connection error:', error);
    // 여기에서 적절한 에러 처리를 수행할 수 있습니다.
});

redisClient.connect()
    .then(() => {
        console.log('Connected to Redis server');
    })
    .catch((error) => {
        console.error('Failed to connect to Redis server:', error);
        // 여기에서 적절한 에러 처리를 수행할 수 있습니다.
    });

// Store server's UUID in Redis
redisClient.set('server_id', SERVERID);

io.adapter(redisAdapter(redisClient));

const PING_INTERVAL = 5000; // 5 seconds
const CLIENT_TIMEOUT = PING_INTERVAL * 2; // double seconds

// Heartbeat channel
const HEARTBEAT_CHANNEL = SERVERID + '_' + 'heartbeat';

// Function to publish heartbeat
async function publishHeartbeat() {
    //console.log('Boradcast heartbeat message');
    await redisClient.publish(HEARTBEAT_CHANNEL, 'ping');
}

// Start heartbeat interval
setInterval(publishHeartbeat, PING_INTERVAL);

// Function to check and remove zombie clients
async function checkAndRemoveZombieClients() {
    console.log('Checking for zombie clients');
    const serverId = await redisClient.get('server_id'); // Redis에서 서버의 고유값 가져오기
    const clients = await redisClient.hGetAll('clients');

    for (const key in clients) {
        const client = JSON.parse(clients[key]);
        if (client.serverId === serverId) { // 해당 서버의 고유값과 일치하는 클라이언트만 처리
            const now = Date.now();
            const lastPong = client.lastPong || 0;
            if (now - lastPong > CLIENT_TIMEOUT * 3) {
                console.log(`Removing zombie client: ${key}`);
                // 좀비 클라이언트의 소켓을 disconnect
                const socket = io.sockets.sockets.get(key);
                if (socket) {
                    socket.disconnect(true);
                }
                // Redis에서 클라이언트 정보 삭제
                await redisClient.hDel('clients', key);
            }
        }
    }
}



// Start zombie client check interval
setInterval(checkAndRemoveZombieClients, PING_INTERVAL * 2); // Check every CLIENT_TIMEOUT milliseconds


// Function to check and update client timeout
async function updateClientTimeout(socketId) {
    console.log("updateClientTimeout call! : ", socketId)
    const now = Date.now();
    const clientJson = await redisClient.hGet('clients', socketId);
   
    if (!clientJson) return; // Client not found, possibly already disconnected

    const client = JSON.parse(clientJson);
    client.lastPong = now; // Update last pong time

    // Check if client was in timeout state before this pong
    if (now - client.lastPong > CLIENT_TIMEOUT) {
        console.log(`Client ${socketId} was in timeout state, but responded now.`);
        // Optionally, you can emit an event to the client to notify that it was nearly disconnected
        io.to(socketId).emit('timeout-warning', 'You were about to be disconnected due to inactivity.');
    }

    // Update client info in Redis
    await redisClient.hSet('clients', socketId, JSON.stringify(client));
}

io.on('connection', async (socket) => {
    console.log('A new client has connected.');

    const user = socket.handshake.query.user;
    const itemseq = socket.handshake.query.itemseq;

    console.log('Welcome!!!', user, itemseq);

    // Store client information in Redis
    //await redisClient.hSet('clients', socket.id, JSON.stringify({ user, itemseq, socketId: socket.id, lastPong: Date.now() }));
    try {
        //await redisClient.hSet('clients', socket.id, JSON.stringify({ user, itemseq, socketId: socket.id, lastPong: Date.now() }));
        await redisClient.hSet('clients', socket.id, JSON.stringify({ user, itemseq, socketId: socket.id, lastPong: Date.now(), serverId: SERVERID }));
    } catch (error) {
        console.error('Failed to store client information in Redis:', error);
        // 여기에서 적절한 에러 처리를 수행할 수 있습니다.
    }

    // Subscribe client to heartbeat channel
    const subscriber = redisClient.duplicate();
    await subscriber.connect();
    await subscriber.subscribe(HEARTBEAT_CHANNEL, async (message) => {
        if (message === 'ping') {
            socket.emit('heartbeat', 'ping');
        }
    });

    socket.on('heartbeat', async (msg) => {
        if (msg === 'pong') {
            // Update client's timeout status individually
            await updateClientTimeout(socket.id);
        }
    });

    socket.on('data message', async (msg) => {
        console.log('Received message:', msg);

        if (msg.type === 'bid') {
            console.log("Bid received!!! : ", msg.itemseq);

            // Get all clients from Redis
            const clients = await redisClient.hGetAll('clients');
           
            const clientsToNotify = [];

            for (const key in clients) {
                const client = JSON.parse(clients[key]);

                if (client.itemseq === msg.itemseq) {
                    clientsToNotify.push(client);
                }
            }

            for (const client of clientsToNotify) {
                console.log("Sending to client!", client.socketId);
                io.to(client.socketId).emit('data message', msg);
            }
        } else {
            // Handle other types of messages
        }
    });

    socket.on('disconnect', async () => {
        console.log('Client disconnected.');
        // Unsubscribe from heartbeat channel and close the subscriber
        if (socket.subscriber) {
            await socket.subscriber.unsubscribe(HEARTBEAT_CHANNEL);
            await socket.subscriber.quit();
            delete socket.subscriber;  // Clean up the reference
        }

        await redisClient.hDel('clients', socket.id);
    });
});



// Start the server on the specified port or default to 8985
const port = process.argv[2] || 8985;
server.listen(port, async () => {
    // All redis clients delete.
    await redisClient.del('clients');

    console.log(`Server is running on port ${port}.`);
});



클라이언트 소스 

역시 별거 없다.
https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js 갖다쓰고
connect 버튼 누르면 서버에 접속하고
이후 접속되면 타입으로 전송하고 서버에서 오면 받아서 뿌리는게 끝이다.
 


<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Socket.IO Chat</title>
</head>
<body>
  <ul id="messages"></ul>
  <form id="form" action="">
    <input id="user" type="text" placeholder="user" value="arosones">
    <input id="itemseq" type="text" placeholder="itemseq" value="a01">
    <button id="connectButton">connect</button>
    <input id="bidamount" type="text" autocomplete="off" placeholder="bidamount">
    <button id="sendButton" disabled>send</button>
  </form>

  <script>
    $(document).ready(function() {
       
      var socket;
 
      $('#connectButton').click(function(e) {
        e.preventDefault();

        var user = $('#user').val();
        var itemseq = $('#itemseq').val();
       
        if (user && itemseq) {
            socket = io('https://dev.kobay.co.kr:8984', {
                query: {
                  user: user,
                  itemseq: itemseq
                },

                transports: ['websocket'],
                reconnectionAttempts:2,
                reconnectionDelay: 100
              });

          socket.on('error', (error) => {
            console.log('WebSocket connection error:', error);
                // 여기에 추가적인 오류 처리 로직을 넣을 수 있습니다.
          });
         
          socket.on('connect', () => {
            console.log('WebSocket connected');
           
            $('#connectButton').prop('disabled', true);
            $('#sendButton').prop('disabled', false);
          });

          socket.on('disconnect', () => {
            console.log('WebSocket disconnected');

            $('#connectButton').prop('disabled', false);
            $('#sendButton').prop('disabled', true);
          });

          socket.on('data message', function(msg) {
            console.log("msg : ", msg);

            var item = $('<li>').text(msg.bidamount);

            $('#messages').append(item);
          });


          socket.on('heartbeat', (msg) => {
            console.log("heartbeat rcv : ", msg);
              if (msg === 'ping') {
                    // 'ping' 메시지를 받으면 즉시 'pong'으로 응답
                    socket.emit('heartbeat', 'pong');
              }
          });          
        }
      });

      $('#sendButton').click(function(e) {
        e.preventDefault();

        var bidamount = $('#bidamount').val();
        var itemseq = $('#itemseq').val();

        if (bidamount && socket.connected) {
          var message = {
            type: 'bid',
            bidamount: bidamount,
            itemseq: itemseq,
          };

          socket.emit('data message', message);

          $('#bidamount').val('');
        }
      });
    });
  </script>
</body>
</html>




다만 중요한 부분은 transports: ['websocket'] 이부분이다.
WebSocket 전송 방식만 사용하는건데 이거 빠지면 사정없이 서버에서 끊어지고 난리도 아니다.
이 부분을 몰라서 이틀을 허비했다. ㅡㅡ

추가로
포스트맨으로 테스트 하려면

 

 



반응형
Posted by Hippalus
,

반응형

CentOS 7에서 Redis를 설치하는 방법은 다음과 같다.

1. EPEL 저장소 설치
Redis 패키지는 EPEL 저장소(Extra Packages for Enterprise Linux)를 통해 제공되므로 EPEL 저장소를 시스템에 추가한다.
yum install epel-release
(yum install epel-release yum-utils 로 설치시 유틸리티 도구들도 사용 가능하다)

2. 저장소를 추가하였다면 Redis 설치한다.
yum install redis
설치된 폴더는 /usr/bin이며 실제 시스템 서비스로 관리하기 위해 관리 스크립트가 존재하는 /etc/init.d에서 가지고 놀아야 한다.
서비스 시작 : systemctl start redis
서비스 종료 : systemctl stop redis
서비스 재시작 : systemctl restart redis
서비스 상태 확인 : systemctl status redis
부팅 시 서비스 자동 시작 설정 : systemctl enable redis
일단 서비스를 시작만하자
systemctl start redis

만약 서비스를 종료하거나 시작하는데 먹통이 되고 바로 커맨드 라인으로 복귀되지 않으면 환경설정을 확인해봐야 한다.
systemctl status redis 로 상태를 확인해 보면
redis-shutdown[3419]: ERR Errors trying to SHUTDOWN. Check logs.
이런 로그를 확인할 수 있을것이다.
vi나 cat명령어로 로그를 확인해보면
cat /var/log/redis/redis.log
Permission denied가 보인다.
데이터베이스 파일의 권한 문제, 메모리 부족 문제, 설정 파일 문제 등이 있을 수 있을 수 있는데 
내 경우 Redis가 백그라운드에서 데이터베이스를 저장하려고 시도했지만, RDB 파일을 저장할 위치에 대한 권한 문제가 발생하여 저장에 실패한 상황이었다.

이 경우 환경설정을 손봐야 한다.
/etc/redis.conf 또는 /etc/redis/redis.conf 파일을 열어본다.(내 경우 /etc/redis.conf 였다.)
vi /etc/redis.conf 후 /f 명령어로 dir을 n n n 누르다 보면 발견된다.
다행히 경로는 맞다. dir /var/lib/redis

그럼 권한 문제다.
chown redis:redis /var/lib/redis

Redis 서비스를 재시작 한다.
systemctl restart redis

이제 서비스를 stop, restart 시 정상 동작하게 되고 로그를 봐도 잘 진행됨이 확인 된다.
cat /var/log/redis/redis.log


3. Redis가 원격에서 액세스가 되도록 설정 파일을 바꿔준다.
vi /etc/redis.conf
/127.0.0.1 검색해서 (혹시 또 있다면 n으로 계속 검색)
bind 127.0.0.1을 0.0.0.0 으로 수정
변경 내용을 :wq로 저장 후 서비스 재시작 시켜준다.
systemctl restart redis

하는김에 서버가 6379 포트 수신 중인지 확인
(Redis는 기본적으로 6379 포트에서 실행)
netstat -tulpn | grep LISTEN

서버 확인도 해준다.
Redis 명령창을 실행하면 된다.
redis-cli 입력 후 엔터
ping라고 엔터치면 pong라고 응답이 온다.
기본 명령어( get, set, del )테스트
"hello"라는 key에 "world"라는 value를 저장
set hello world
앞서 저장해둔 "hello" key에 해당하는 value를 확인
get hello 엔터
world 확인


여기까지 정상이면 일단 준비는 끝

조금 응용해보자
Redis 서버의 클라이언트 연결에 관한 정보를 조회하는 데 사용되는 명령어
redis-cli INFO clients 를 입력하면 현재 연결된 클라이언트 정보를 확인할 수 있다.

저장시킨 키에 해당하는 모든 정보를 조회하는 명령어
HGETALL 

node.js에서 hset으로 clients를 추가했고 이를 삭제하려면
DEL clients 
1이 나오면 실제 삭제됨, 0이면 삭제된 값이 없음
(integer) 1
(integer) 0

번외로 뭔가 꼬여서 삭제 / 삭제하는 법을 사족으로 달고자 한다.
이런 오픈소스들은 익숙해지기 전까진 삭제해도 뭔가 껄끄럽다.
프로세스별 사용중인 포트 확인
netstat -tulpn | grep LISTEN

Redis 서비스가 실행 중이면 중지
systemctl stop redis

Redis 패키지를 제거
yum remove redis

Redis 관련 데이터와 설정 파일도 삭제하려면 
rm -rf /etc/redis /var/lib/redis /var/log/redis

반응형
Posted by Hippalus
,

반응형

1. yum 외부 저장소 추가
cd /etc/yum.repos.d
ls를 실행해보면 yum 저장소에는 nginx가 없다.

따라서 vi에디터로 /etc/yum.repos.d에 nginx.repo 파일을 생성 후 아래 스크립트를 복사 붙여넣기 한다. 

[nginx]
name=nginx repo
baseurl=http://nginx.org/packages/centos/7/$basearch/
gpgcheck=1
enabled=1

ESC :wq로 저장 후 나와준다.

*참고
baseurl에 centos/7로 되어 있는데 만약 centos가 8이면 8을 입력하면 된다.
(OS버전 확인 방법은 cat /etc/centos-release)
그리고 gpgcheck 옵션은 리포지토리에서 받은 패키지들의 GPG( GNU Privacy Guard) 서명을 확인할지 여부를 나타내는데 0으로 설정하면 서명을 확인하지 않고 패키지를 설치하게 되는데 무결성 보장, 신뢰성, 보안 취약성 등의 문제가 있을 수도 있으니 그냥 1로 하자.

enabled 활성화된 리포지토리에서 패키지를 설치할 수 있게 해주는 옵션이다.
 

2. yum install
yum install -y nginx
설치 과정에서 Public key for nginx-1.26.0-1.el7.ngx.x86_64.rpm is not installed 라며 제대로 설치가 안 될 경우
RPM 패키지가 서명되었지만 시스템에 해당 서명 키가 설치되어 있지 않아 발생하는 것으로 해결 방법은 해당 패키지에 대한 GPG 키를 가져와서 시스템에 추가한 후 다시 yum install -y nginx를 실행하면 된다.
rpm --import https://nginx.org/keys/nginx_signing.key

 



3.nginx.conf 수정
vi 명령어로 vi /etc/nginx/nginx.conf 파일을 수정한다.
* 내 경우 소스로 업체에서 설치를 해뒀는지 vi /usr/local/nginx/conf/nginx.conf로 수정해야 했다.


열어보면 이상한 설정들이 존재하는데


눈여겨 볼 곳은 이곳 부터다.
worker_processes auto;

events {
    worker_connections 1024;
}
프로세서의 코어수 만큼 연결 처리를 1024개를 자동으로 감지해서 설정해주는 부분인데
논리적으로야 코어가 16코어면 16 X 1024개니 어마어마하다.
하지만 실제 그만큼 처리 가능할리는 만무하니 이정도로만 알고 지나가겠다.

로드밸런싱을 위해 옵션을 주면 접속한 클라이언트들을 적절히 배분해준다.
ip_hash가 일반적으로 사용되지만 내 경우 모두 동일한 IP이므로 정상 동작하지 않을것이다.
따라서 아래 두개 중 least_conn을 적용했다.

다음은 upstream설정이다.
몇개의 서버 프로세스를 실행시킬지에 대한 설정인데 upstream다음에 적당한 이름을 지어주면 된다.
나는 클라이언트가 접속할 포트로 8984를 이용하고, 접속 후 생성된 서버 프로그램용으로 총 3개의 소켓 서버를 사용할 생각이므로 8985 부터 8987까지 추가해뒀다.
물리적 서버도 분리하면 더 많은 클라이언트 처리가 가능하겠지만
node.js 싱글스레드 방식이므로 하나의 local서버에서 포트만 달리 구성해도 나름 효과적이다.

upstream의 이름을 난 그냥 node_app이라 지었고

  upstream node_app {
    least_conn
    server 127.0.0.1:8985;  # 첫 번째 Node.js 인스턴스
    server 127.0.0.1:8986;  # 두 번째 Node.js 인스턴스
    server 127.0.0.1:8987;  # 세 번째 Node.js 인스턴스
    # 필요에 따라 더 많은 인스턴스를 추가
  }
그리고 로드밸런싱을 어떤 방식으로 할지 적어주면 되는데 
아래 4가지 중 하나를 선택하면 된다.
난 현재 연결 수가 가장 적은 서버로 연결되는 방식인 least_conn 방식을 선택했다.

least_conn: 현재 연결 수가 가장 적은 서버로 요청
round_robin (기본값): 순차적으로 각 서버에 요청
ip_hash : 해시값이 3이고 서버가 5개 있다면, 3 % 5 = 3 이므로 4번째 서버(0부터 시작하므로)가 선택
least_time : 요청을 처리하는 데 걸리는 시간에 기반하여 서버를 선택하며 least_time header 또는 least_time last_byte 옵션으로 사용

같은 서버에 port 번호만 다르게 해서 실행할거니 server 127.0.0.1 그리고 포트 번호를 적어줬다.


다음은 추가한 upstream을 호출하는 부분이다.
http를 사용한다면 그냥 80쪽에 추가하면 되지만
server {
     listen 80;
     listen 8984;
}

https(SSL)을 사용하여 통신하겠다면 listen 443 ssl아래에 아래처럼 소켓 클라이언트가 접속할 포트를 추가해주면 되겠다.
내가 지정한 포트는 8984다.

  server {
        listen 443 ssl;
        listen 8984 ssl;
        ...
        이하 ssl 구성 (pem, cache, protocols TLS...등등) 생략
        ...
  }

이제 가장 중요한 /socket.io 설정 부분이다.
이부분 때문에 좀 헤맸다.
proxy_pass : 앞서 upstream으로 분산처리를 하였는데 이 upstream이름을 적어주면 된다.
limit_conn conn_limit_per_ip : 클라이언트가 몇개 이상 접속하게 되면 자동으로 차단하여 서버를 보호할 것이다. 난 테스트 해보니 8000개까지 처리 가능하였다.
다른 설정들은 그냥 복사하면 그만이다.

    location /socket.io/ {
      limit_conn conn_limit_per_ip 8000;

      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header Host $host;

      proxy_pass http://backsvr;
      proxy_set_header X-Server-Name $upstream_addr;

      # enable WebSockets
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection "upgrade";
    }


추가된 결과



4. Nginx 재시작 하여 설정 변경 사항 적용
Nginx가 설치된 위치는 내 경우 /usr/sbin아래이므로
/usr/sbin/nginx -s reload

여기까지 Nginx 준비는 끝이다.

실행 : systemctl start nginx
중지 : systemctl stop nginx
재시작 : systemctl restart nginx
상태 : systemctl status nginx

추가로 만약 nginx 실행 문제가 발생할 경우 확인하는 방법을 설명하자면
만약 path가 없어서 nginx -t명령어 실행이 불가능할 경우
whereis nginx 으로 nginx가 어디에 설치되어 있는지 확인하고 (보통 /usr/sbin 폴더에 있다.)

수정된 conf파일 검사 : /usr/local/nginx/sbin/nginx -t

수정 후 nginx 중지 : /usr/local/nginx/sbin/nginx -s stop
수정 후 nginx 실행 : /usr/local/nginx/sbin/nginx

이 순서대로 진행하면 된다.


반응형
Posted by Hippalus
,

반응형

소켓 프로그램을 만드는건 매우 쉽다.
하지만 잘 만드는건 어렵다.

소켓 프로그램은 일반 응용어플리케이션과 달리 외부 영향과 다양한 변수가 존재한다.
웹소켓은 그나마 TCP/IP와 달리 수월한 편에 속하지만 여전히 잘 만드는건 어렵다.

이번에 작업할 내용은 비교적 간단한 채팅방 개념의 다중 채팅이다.
즉 여러 클라이언트가 소켓 서버에 접속해서 각각 원하는 방에 입장하고 해당 방에서 발생한 대화는 해당 방에만 전달되는 전통적인 그 채팅이다.

기본적인 부분은 차치하고 설계시 고려대상으로 둔 부분은 몇 개의 클라이언트를 지원할 것인가였다.

나름 최대 4000개 이상의 클라이언트를 지원할 생각이다.

그럼 하나의 서버가 이를 다 커버할 수 있을까?
대답은 No

이때 사용할 기술이 PM2, Redis, Nginx다.
PM2는 멀티 프로세스로 서버 소켓 프로그램을 실행할 것이고
Nginx는 멀티 프로세스로 실행되는 서버 소켓 프로그램의 분배 접속에 
Redis는 멀티 프로세스간의 대화 내용을 공유하게 만들어주는 기술로 활용할 것이다.

이제 하나씩 진행해보자.

반응형
Posted by Hippalus
,