さくらのレンタルサーバでNode.jsを動かす

目次

はじめに

無料でNode.jsを動かせるサーバは無いんだと諦めていたら、知り合いがさくらのレンタルサーバを使っていいよと言われた。なので、その動かすようにした時のメモ。リソース制限がキツかったり、管理者権限(root) が無いのでかなり苦労した。 上手い人ならチューニングして動かせるかもしれないけど、私にはそれをする気も能力もないので諦めた。Pythonなら用意されているので、Node.jsじゃなくてPythonで書き直そうかなと一瞬考えた。環境を構築した時には以下のPython環境が用意されていました。

$ python -V
Python 3.8.12
$ python2 -V
Python 2.7.18
$ python3 -V
Python 3.8.12

環境

  • 今回使ったのはスタンダードプラン。月500円。プラン比較 - レンタルサーバーはさくらインターネット
  • 管理者権限は無い。
  • 同じようなものだと、Amazon Lightsailがあって月額3.50 USDのプランでは512 MBのメモリ、1 CPU、20 GBのSSDストレージ、1 TBのデータ転送が含まれています。
  • ただし、こっちは為替リスクにより円安になって、1ドル150円くらいだと、さくらのレンタルサーバと変わらない。

環境構築

もともと用意されてないし、管理者権限がないので、FreeBSDのパッケージ管理ソフトであるPortsも利用できないし、バイナリもないので、ソースコードからコンパイルしないといけないで、非常に時間がかかった。

nodenv をインストール

さくらのレンタルサーバに Node.js をインストールする | グッドネイバーを参考にして入れた。大事なのは nice -n 20 として、CPU制限制限を書けないようにすることである。負荷がかかるとプロセスが自動的にkillされる。エラーとしては以下のように出る。正直最初はわからなくて、原因を突き止めるのにかなり苦労した。コンパイルしたけど、CPUの負荷を下げるオプションをつけても30分くらいで終わったと思う。

*******************
gmake[1]: *** [tools/icu/icudata.target.mk:154: /home/{username}/.nvm/.cache/src/node-v22.6.0/files/out/Release/obj.target/icudata/gen/icudt75_dat.o] Error 254
gmake[1]: *** Waiting for unfinished jobs....
4 warnings generated.
4 warnings generated.
4 warnings generated.
4 warnings generated.
4 warnings generated.
4 warnings generated.
rm 8c3088cd124dcfb29bbecb881b969aa0bdcd130e.intermediate
gmake: *** [Makefile:137: node] Error 2
nvm: install v22.6.0 failed!

起動

nice -n 20 node index.jsなら動くが、PM2で永続化処理をしようとするも駄目だった。sysctl -n hw.ncpuでCPUが8個だったので、以下のようにecosystem.config.jsを書いて、クラスタ化処理でCPU負荷を分散させるも駄目だった。

module.exports = {
  apps: [
    {
      name: 'discordbot',
      script: 'index.js',
      exec_mode: 'cluster',
      instances: 8,
      autorestart: true,
      watch: false,
      max_memory_restart: '200M',
      node_args: '--max-old-space-size=512',
    },
  ],
};

起動するとこんな感じになる。 ↺ で何回がプロセスが再起動されているのがわかる。何回か立ち上がるが、最後はプロセスをkillされて終わる。

[{user}@www3444 ~/Node.js/DiscordBot]$ ./start_nice_pm2.sh
[PM2] Starting /home/{user}/Node.js/DiscordBot/index.js in fork_mode (1 instance)
[PM2] Done.
┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐
│ id │ name               │ mode     │ ↺    │ status    │ cpu      │ memory   │
├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤
0  │ discordbot         │ fork     │ 0    │ online    │ 0%       │ 91.5mb   │
└────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘
[{user}@www3444 ~/Node.js/DiscordBot]$ pm2 list
┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐
│ id │ name               │ mode     │ ↺    │ status    │ cpu      │ memory   │
├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤
0  │ discordbot         │ fork     │ 6    │ online    │ 0%       │ 99.5mb   │
└────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘
[{user}@www3444 ~/Node.js/DiscordBot]$ pm2 list
┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐
│ id │ name               │ mode     │ ↺    │ status    │ cpu      │ memory   │
├────┼────────────────────┼──────────┼──────┼───────────┼──────────┼──────────┤
0  │ discordbot         │ fork     │ 14   │ online    │ 0%       │ 186.0mb  │
└────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘
[{user}@www3444 ~/Node.js/DiscordBot]$ pm2 list
┌────┬────────────────────┬──────────┬──────┬───────────┬──────────┬──────────┐
│ id │ name               │ mode     │ ↺    │ status    │ cpu      │ memory   │
└────┴────────────────────┴──────────┴──────┴───────────┴──────────┴──────────┘

苦肉の策

PM2のプロセスをチェックする間隔が短すぎて、PM2では起動出来ない原因かもしれないと思った。nice -n 20 node index.jsならアプリは動くので、CRONを設定したい | さくらのサポート情報で定期的にレポジトリのチェックとプロセスチェックをするようにした。

  • 1時間おきにGithubのレポジトリの更新チェックしてあれば、pullして、nice -n 20 node index.jsで起動する。
  • 5️分おきにnodeの名前があるかチェックしてプロセスがなければnice -n 20 node index.jsで起動するのを作った。
  • レポジトリの更新が有った場合や、nodeが起動してない場合には、DiscordにDMで発言するようにした。

1時間おきにCRONで実行されるプログラム

#!/usr/local/bin/bash

# Define log file
LOGFILE="/home/takmusic/Taskscript/e.log"

# Navigate to the project directory
cd /home/takmusic/Node.js/DiscordBot

# Log the current date and time
# :Qecho "$(date '+%Y-%m-%d %H:%M:%S') - Checking for updates..." >> $LOGFILE 2>&1

# Check for updates from the remote repository
git fetch origin >> $LOGFILE 2>&1
LOCAL=$(git rev-parse @)
REMOTE=$(git rev-parse @{u})

# If there are updates, pull and restart the Node.js process
if [ $LOCAL != $REMOTE ]; then
  echo "$(date '+%Y-%m-%d %H:%M:%S') - Update available. Pulling changes..." >> $LOGFILE 2>&1
  git pull origin master >> $LOGFILE 2>&1

  # Get the latest commit hash and message
  COMMIT_HASH=$(git log -1 --pretty=format:"%h")
  COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s")

  # Stop the current Node.js process and restart it
  pkill node
  echo "$(date '+%Y-%m-%d %H:%M:%S') - Restarting Node.js process..." >> $LOGFILE 2>&1
  nice -n 20 node index.js >> $LOGFILE 2>&1 &

  # Send a notification with the latest commit hash and message
  nice -n 20 node send_dm.js "GitHub repo has been updated. Commit: $COMMIT_HASH - $COMMIT_MESSAGE. Node.js restarted." >> $LOGFILE 2>&1
else
  echo "$(date '+%Y-%m-%d %H:%M:%S') - No updates found." >> $LOGFILE 2>&1
fi

5分おきにCRONで実行されるプログラム

#!/usr/local/bin/bash

# Define log file
LOGFILE="/home/takmusic/Taskscript/e.log"

# Define last startup log file (to store the last start time of the Node.js process)
LASTSTARTFILE="/home/takmusic/Taskscript/laststart.log"

# Node.js path
NODE_PATH="/home/takmusic/.nodenv/shims/node"  # node.js __________

# Navigate to the project directory
cd /home/takmusic/Node.js/DiscordBot

# Log the current date and time
# echo "$(date '+%Y-%m-%d %H:%M:%S') - Checking Node.js process..." >> $LOGFILE 2>&1

# Check if the Node.js process is running
if ! pgrep -x "node" > /dev/null; then
  echo "$(date '+%Y-%m-%d %H:%M:%S') - Node.js process not found. Starting the process..." >> $LOGFILE 2>&1
  nohup nice -n 20 $NODE_PATH index.js >> $LOGFILE 2>&1 &
  echo "$(date '+%Y-%m-%d %H:%M:%S')" > $LASTSTARTFILE  # Record the start time

  # Send a notification that the Node.js process was down and restarted
  nohup nice -n 20 $NODE_PATH send_dm.js "Node.js process was down and has been restarted." >> $LOGFILE 2>&1
else
  # If process is running, check how long it has been since the last restart
  if [ -f "$LASTSTARTFILE" ]; then
    LASTSTART=$(date -r "$LASTSTARTFILE" +%s)  # Get the last start time in seconds
    CURRENTTIME=$(date +%s)  # Get the current time in seconds
    DIFF=$(( (CURRENTTIME - LASTSTART) / 86400 ))  # Calculate the difference in days

    if [ "$DIFF" -ge 25 ]; then
      echo "$(date '+%Y-%m-%d %H:%M:%S') - 25 days since the last start. Restarting Node.js process..." >> $LOGFILE 2>&1
      pkill -x "node"  # Kill the existing Node.js process
      nohup nice -n 20 $NODE_PATH index.js >> $LOGFILE 2>&1 &  # Restart the Node.js process
      echo "$(date '+%Y-%m-%d %H:%M:%S')" > $LASTSTARTFILE  # Update the last start time

      # Send a notification that the Node.js process was restarted after 25 days
      nohup nice -n 20 $NODE_PATH send_dm.js "Node.js process has been restarted after 25 days." >> $LOGFILE 2>&1
    else
      echo "$(date '+%Y-%m-%d %H:%M:%S') - Node.js process is running and has been running for $DIFF days." >> $LOGFILE 2>&1
    fi
  else
    echo "$(date '+%Y-%m-%d %H:%M:%S') - No record of last start time. Creating a new record and restarting the process..." >> $LOGFILE 2>&1
    nohup nice -n 20 $NODE_PATH index.js >> $LOGFILE 2>&1 &
    echo "$(date '+%Y-%m-%d %H:%M:%S')" > $LASTSTARTFILE  # Create a new start time record

    # Send a notification that the Node.js process has been restarted
    nohup nice -n 20 $NODE_PATH send_dm.js "Node.js process has been restarted with no previous start time recorded." >> $LOGFILE 2>&1
  fi
fi

コンソールには表示をするが、いつも見ているわけには行かないので、Discordで個人にDMを送るようにした。

send_dm.js は以下

require('dotenv').config();
const { Client, GatewayIntentBits } = require('discord.js');

// 環境変数からトークンとDMのIDを取得
const token = process.env.DISCORD_BOT_TOKEN;
const dmId = process.env.DISCORD_DM_ID;

// コマンドライン引数からメッセージ内容を取得
const messageContent = process.argv.slice(2).join(' ');

// クライアントインスタンスを作成
const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.DirectMessages],
  partials: ['CHANNEL'] // DMを送るには 'CHANNEL' の部分を指定する必要があります
});

// BOTがログインした際に実行される
client.once('ready', () => {
  console.log(`Logged in as ${client.user.tag}!`);
  
  // ユーザーにDMを送る処理
  client.users.fetch(dmId)
    .then(user => {
      return user.send(messageContent);
    })
    .then(() => {
      console.log('DM sent successfully.');
      process.exit(0); // 処理を終了
    })
    .catch(err => {
      console.error('Error sending DM:', err);
      process.exit(1); // エラー時は終了コード1で終了
    });
});

// BOTをログインさせる
client.login(token);

最後に

諦めかけていたけれど、何とかアイデアをひねり出して、ひとまず動作させることができた。どこまで正常に動作するかは、今後様子を見て判断しようと思う。 色々やったけど、さくらのレンタルサーバのCPU制限が強くて、すぐにkillされてアプリが起動できない。私にはそれをうまくことチューニングする気も能力もないので諦めた。