XserverVPS のメモリリソース状況って、どう確認すればいいの?っていう件

この記事は技術的にコアな内容を含みます!
当記事に書いてある内容を実践して、何らかの損害が発生しても、
当ブログ主は一切の責任は負いません。全て自己責任といたします!

今回は XserverVPS においてメモリの使用状況を知りたかったので、
ChatGPTと共同でメモリのリソース状況の確認方法を模索しました。

XserverVPS のサーバーコントロールパネルには以下のリソース状況確認項目がありました。

 CPU
 ディスクI/O
 転送量

しかし、メモリの項目はなかったため、自作してリソース状況を把握することにしました。
メモリのリソース状況の把握には、以下の条件付けを行いました。

条件その1:
 基本的にサーバーに負荷をかけたり、重い挙動にならないこと
条件その2:
 統計データが欲しいので、後から見直すことができるようにすること
条件その3:
 統計データを数字だけでなく、視覚的にも理解できるようにすること

環境:
 サーバーのホストOSはRockyLinux9.4
 ローカル環境のOSはWindows10

使用した言語:
 シェルスクリプト(Linux)
 PowerShell(ver5.1)
 ExcelVBA(Excel2019を使用)

手順1:sysstat(sar)の導入

 sysstat(sar)を利用してメモリ使用状況をログ化する
 下記の通り、sysstat(sar)をインストールし、サービスを常駐させる。

sudo dnf install sysstat
sudo systemctl enable --now sysstat

メモリリソース確認例:

sar -r   # メモリ使用率(履歴)を確認
sar -S   # スワップの利用状況(履歴)を確認

/var/log/sa/saXX に日ごと保存され、CPU・メモリをまとめて確認可能。

記録の仕組み:
 自動で 10分間隔くらいで CPU/メモリの統計を /var/log/sa/saXX に保存してくれます
 ※XXは日付
 負荷はほぼゼロに近いので常時入れても問題ありません。

手順2:sysstat(sar)のログを整形、送信するスクリプト

ChatGPTと共同して最終的に以下のシェルスクリプトを考案しました。
ファイル名は「send_sar_mem_csv_mail.sh」となります。
※必ずメールアドレスに自分のメールアドレスを指定してください

#!/usr/bin/env bash
# send_sar_mem_csv_mail.sh
# 昨日分の sar ログを CSV(カンマ区切り) にして mailx で送信する

set -euo pipefail

# ===== 基本設定 =====
RECIPIENT="★メールアドレス★"   # ←メール送信先★要変更★
HOST="$(hostname -f 2>/dev/null || hostname)"

# デフォルトは昨日。外から YDAY_NUM / YDAY_ISO / SA_FILE を与えればそれを優先
YDAY_NUM="${YDAY_NUM:-$(date -d 'yesterday' +%d)}"
YDAY_ISO="${YDAY_ISO:-$(date -d 'yesterday' +%F)}"
SA_FILE="${SA_FILE:-/var/log/sa/sa${YDAY_NUM}}"

OUTDIR="/var/log/sar_exports"
SUBJECT="[${HOST}] SAR memory report ${YDAY_ISO}"
BODY="sar のメモリ・スワップ統計を添付します。対象日: ${YDAY_ISO}"

# ===== 前提チェック =====
command -v sadf   >/dev/null || { echo "sadf(sysstat) が未インストールです"; exit 2; }
command -v mailx  >/dev/null || { echo "mailx コマンドがありません (s-nail)"; exit 2; }
[[ -r "${SA_FILE}" ]] || { echo "SARファイルがありません: ${SA_FILE}"; exit 3; }

# ===== CSV 生成(; → , へ変換して Excel で開きやすく)=====
mkdir -p "${OUTDIR}"
MEM_CSV="${OUTDIR}/${HOST}_mem_${YDAY_ISO}.csv"
SWP_CSV="${OUTDIR}/${HOST}_swap_${YDAY_ISO}.csv"

# メモリ(sar -r)/ スワップ(sar -S)
# sadf -d はセミコロン区切りなので tr でカンマに変換
sadf -d "${SA_FILE}" -- -r | tr ';' ',' > "${MEM_CSV}"
sadf -d "${SA_FILE}" -- -S | tr ';' ',' > "${SWP_CSV}"

# (任意)60日より前の古い CSV を整理
find "${OUTDIR}" -type f -name "${HOST}_*.csv" -mtime +60 -delete || true

# ===== 添付して送信(mailx -a)=====
echo "${BODY}" | mailx -s "${SUBJECT}" \
  -a "${MEM_CSV}" \
  -a "${SWP_CSV}" \
  "${RECIPIENT}"

上記シェルスクリプトに実行権限を与え、crontab に登録し、日時処理とする

0 4 * * * root /home/user/cron/send_sar_mem_csv_mail.sh # sysstat導入

上記は crontab 追記用のサンプル。実際のコードはスクリプトを置いた位置に設定。
また、サンプルでは毎日AM4:00に実行。

正しくスクリプトが動作するかの確認は、以下のコマンドで実行可能
※ただしsysstat(sar)のサービスを有効化してから最低10分経ってから(ログ生成待機)

cd ★シェルスクリプトを置いたパス★
YDAY_NUM=$(date +%d) SA_FILE=/var/log/sa/sa$(date +%d) ./send_sar_mem_csv_mail.sh

手順3:PowerShellでメール添付ログのExcelデータ置換

手順2の send_sar_mem_csv_mail.sh を実装して正確に実行できると
メモリのリソース状況のCSVログがメールに添付されて送られてきます。
以下のようなファイルがメールで添付されていれば成功です。
※hostnameの部分は環境によって異なり、日付は先のサンプル手動実行では当日

hostname_mem_2025-09-26.csv
hostname_swap_2025-09-26.csv

以下のPowerShellスクリプトを「MakeExcelSarReport.ps1」というファイル名で保存。

# MakeExcelSarReport.ps1
# 使い方:
# - 何も渡さず実行 → ファイル選択ダイアログで mem/swap CSV を選ぶ
# - mem.csv, swap.csv, 出力xlsx をドラッグ&ドロップ(順不同でもOK)
# - フォルダを渡すと、その中から *_mem_*.csv / *_swap_*.csv を自動検出

# MakeExcelSarReport.ps1 (PS 5.1 compatible)
param(
  [string]$MemCsv,
  [string]$SwapCsv,
  [string]$OutXlsx,
  [switch]$ShowExcel
)

# ---------- ユーティリティ ----------

# UTC文字列 → JST の [datetime]
function To-JstDate {
  param([string]$s)
  $t = $s -replace '\s*UTC$',''
  $ci = [System.Globalization.CultureInfo]::InvariantCulture
  $style = [System.Globalization.DateTimeStyles]::AssumeUniversal
  try {
    $dto = [DateTimeOffset]::Parse($t, $ci, $style)
  } catch {
    $fmt = "yyyy-MM-dd HH:mm:ss 'UTC'"
    $dto = [DateTimeOffset]::ParseExact($s, $fmt, $ci, $style)
  }
  return $dto.ToOffset([TimeSpan]::FromHours(9)).DateTime  # [datetime] を返す
}

# UTC文字列 → JST OADate(double)
function To-JstOaDouble {
  param([string]$s)
  $dt = To-JstDate $s       # 必ず [datetime]
  return ($dt.ToOADate())   # 必ず [double]
}

function Resolve-PathSafe {
  param([string]$PathLike)
  if (-not $PathLike) { return $null }
  try {
    $rp = Resolve-Path -LiteralPath $PathLike -ErrorAction Stop
    return $rp.Path
  } catch { return $null }
}

function Resolve-PathsFromArgs {
  param([string[]]$Inputs)

  $result = [ordered]@{
    MemCsv  = $null
    SwapCsv = $null
    OutXlsx = $null
  }

  foreach ($inp in $Inputs) {
    $p = Resolve-PathSafe $inp
    if (-not $p) { continue }

    if (Test-Path -LiteralPath $p -PathType Container) {
      # フォルダ → 中から検出
      $mem = Get-ChildItem -LiteralPath $p -File -Recurse -Include *_mem_*.csv -ErrorAction SilentlyContinue | Select-Object -First 1
      $swp = Get-ChildItem -LiteralPath $p -File -Recurse -Include *_swap_*.csv -ErrorAction SilentlyContinue | Select-Object -First 1
      if ($mem) { $result['MemCsv']  = $mem.FullName }
      if ($swp) { $result['SwapCsv'] = $swp.FullName }
      continue
    }

    if ($p -match '(_mem_).+\.csv$')   { $result['MemCsv']  = $p; continue }
    if ($p -match '(_swap_).+\.csv$')  { $result['SwapCsv'] = $p; continue }
    if ($p -match '\.xlsx$')           { $result['OutXlsx'] = $p; continue }
  }
  return $result
}

# 引数/ドラッグ&ドロップから自動解決
$inputs = @()
if ($MemCsv)  { $inputs += $MemCsv }
if ($SwapCsv) { $inputs += $SwapCsv }
if ($OutXlsx) { $inputs += $OutXlsx }
if ($args -and $args.Count -gt 0) { $inputs += $args }

$auto = Resolve-PathsFromArgs $inputs
if (-not $MemCsv)  { $MemCsv  = $auto['MemCsv'] }
if (-not $SwapCsv) { $SwapCsv = $auto['SwapCsv'] }
if (-not $OutXlsx) { $OutXlsx = $auto['OutXlsx'] }

# 入力が未確定ならダイアログ
Add-Type -AssemblyName System.Windows.Forms | Out-Null
function Pick-File([string]$title) {
  $dlg = New-Object System.Windows.Forms.OpenFileDialog
  $dlg.Title = $title
  $dlg.Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*"
  $dlg.Multiselect = $false
  if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { return $dlg.FileName }
  return $null
}

if (-not $MemCsv)  { $MemCsv  = Pick-File "mem CSV(*_mem_*.csv)を選択" }
if (-not $SwapCsv) { $SwapCsv = Pick-File "swap CSV(*_swap_*.csv)を選択" }

if (-not (Test-Path -LiteralPath $MemCsv  -PathType Leaf)) { throw "mem CSV が不正: $MemCsv" }
if (-not (Test-Path -LiteralPath $SwapCsv -PathType Leaf)) { throw "swap CSV が不正: $SwapCsv" }

# ===== OutXlsx(未指定時)の自動命名:SAR_Report_YYYYMMDD.xlsx =====
if (-not $OutXlsx) {
  # 1) ファイル名から抽出(*_mem_YYYY-MM-DD.csv)
  $reportDate = $null
  if ($MemCsv -match '_(\d{4})-(\d{2})-(\d{2})\.csv$') {
    $reportDate = "$($matches[1])$($matches[2])$($matches[3])"  # YYYYMMDD
  }

  # 2) 取れなければデータの timestamp から抽出
  if (-not $reportDate) {
    $first = $mem | Select-Object -First 1
    if ($first -and $first.timestamp -match '(\d{4})-(\d{2})-(\d{2})') {
      $reportDate = "$($matches[1])$($matches[2])$($matches[3])"
    }
  }

  # 3) 出力パスを決定(mem.csv と同フォルダ)
  $dir = Split-Path -Path $MemCsv -Parent
  $name = if ($reportDate) { "SAR_Report_$reportDate.xlsx" } else { "SAR_Report.xlsx" }
  $OutXlsx = Join-Path -Path $dir -ChildPath $name
}

# ---------- CSV 読み込み ----------
function Import-SarCsv {
  param([string]$Path)

  $raw = Get-Content -LiteralPath $Path -Raw
  $lines = $raw -split "`r?`n" | Where-Object { $_ -and $_.Trim() -ne "" }

  if ($lines.Count -lt 2) { throw "CSVに行がありません: $Path" }

  # 1) 実ヘッダを特定: '# 'で始まり 'timestamp' を含む行を優先
  $headerIndex = $null
  for ($i=0; $i -lt $lines.Count; $i++) {
    $ln = $lines[$i]
    if ($ln -match '^\s*#' -and $ln -match '(?i)\btimestamp\b') {
      $headerIndex = $i; break
    }
  }
  # 念のため:見つからなければ 'timestamp' を含む最初の行をヘッダに
  if ($null -eq $headerIndex) {
    for ($i=0; $i -lt $lines.Count; $i++) {
      if ($lines[$i] -match '(?i)\btimestamp\b') { $headerIndex = $i; break }
    }
  }
  if ($null -eq $headerIndex) { throw "ヘッダ行(timestamp を含む)が見つかりません: $Path" }

  # 2) ヘッダ行の整形(先頭の '# ' を除去)
  $headerRaw = $lines[$headerIndex]
  $header = ($headerRaw -replace '^\s*#\s*','').Trim()

  # 3) クリーン配列を作る:ヘッダを1つだけ入れ、以降のデータ行を追加
  $clean = New-Object System.Collections.Generic.List[string]
  $clean.Add($header) | Out-Null

  for ($j=$headerIndex+1; $j -lt $lines.Count; $j++) {
    $ln = $lines[$j]
    # LINUX-RESTART 等は除外
    if ($ln -match 'LINUX-RESTART') { continue }
    # 重複ヘッダを除外('#'付きでも同一内容なら弾く)
    $lnStripped = ($ln -replace '^\s*#\s*','').Trim()
    if ($lnStripped -eq $header) { continue }
    # コメント行は基本除外
    if ($ln -match '^\s*#') { continue }
    $clean.Add($ln) | Out-Null
  }

  # 4) ConvertFrom-Csv(カンマ区切り前提)
  ($clean -join "`r`n") | ConvertFrom-Csv -Delimiter ','
}


$mem = Import-SarCsv $MemCsv
$swp = Import-SarCsv $SwapCsv

function To-Double([object]$v) { if ($null -eq $v -or $v -eq "") { return $null } [double]$v }

$mem | ForEach-Object {
  $_.'%memused' = To-Double $_.'%memused'
  $_.kbavail    = To-Double $_.kbavail
  $_.'%commit'  = To-Double $_.'%commit'
}
$swp | ForEach-Object {
  $_.'%swpused' = To-Double $_.'%swpused'
}

function New-Stats([double[]]$arr){
  $list = @()
  foreach ($x in $arr) { if ($x -ne $null) { $list += $x } }
  if ($list.Count -eq 0) { return [pscustomobject]@{Avg=$null;Max=$null;Min=$null} }
  [pscustomobject]@{
    Avg = ($list | Measure-Object -Average).Average
    Max = ($list | Measure-Object -Maximum).Maximum
    Min = ($list | Measure-Object -Minimum).Minimum
  }
}

$memusedStats = New-Stats ($mem | ForEach-Object { $_.'%memused' })
$kbavailStats = New-Stats ($mem | ForEach-Object { $_.kbavail })
$commitStats  = New-Stats ($mem | ForEach-Object { $_.'%commit' })
$swpusedStats = New-Stats ($swp | ForEach-Object { $_.'%swpused' })


# ---------- Excel COM ----------
$xlLine = 4
$xlLegendPositionBottom = -4107

$excel = New-Object -ComObject Excel.Application
$excel.Visible = [bool]$ShowExcel
$wb = $excel.Workbooks.Add()
$wsSwap = $wb.Worksheets.Item(1); $wsSwap.Name = "swap"
$wsMem = $wb.Worksheets.Add(); $wsMem.Name = "mem"
$wsSummary = $wb.Worksheets.Add(); $wsSummary.Name = "Summary"


# Summary
$wsSummary.Range("A1").Value2 = "Metric"
$wsSummary.Range("B1").Value2 = "Avg"
$wsSummary.Range("C1").Value2 = "Max"
$wsSummary.Range("D1").Value2 = "Min"

$rows = @(
  @("%memused(%)", [math]::Round($memusedStats.Avg,2), [math]::Round($memusedStats.Max,2), [math]::Round($memusedStats.Min,2)),
  @("kbavail(KB)", [math]::Round($kbavailStats.Avg,0), [math]::Round($kbavailStats.Max,0), [math]::Round($kbavailStats.Min,0)),
  @("%commit(%)",  [math]::Round($commitStats.Avg,2),  [math]::Round($commitStats.Max,2),  [math]::Round($commitStats.Min,2)),
  @("%swpused(%)", [math]::Round($swpusedStats.Avg,2), [math]::Round($swpusedStats.Max,2), [math]::Round($swpusedStats.Min,2))
)
$r=2
foreach($row in $rows){
  $wsSummary.Cells.Item($r,1).Resize(1,4).Value2 = $row
  $r++
}
$wsSummary.Columns.AutoFit() | Out-Null


# mem シート
$wsMem.Range("A1").Value2 = "timestamp"
$wsMem.Range("B1").Value2 = "%memused"
$wsMem.Range("C1").Value2 = "kbavail"
$wsMem.Range("D1").Value2 = "%commit"

# 列Aの書式を変更
$wsMem.Range("A:A").NumberFormat = "hh:mm"

$r=2
foreach($o in $mem){
  $wsMem.Cells.Item($r,1).Value2 = (To-JstOaDouble $o.timestamp)  # ★JSTにして書き込む
  $wsMem.Cells.Item($r,2).Value2 = $o.'%memused'
  $wsMem.Cells.Item($r,3).Value2 = $o.kbavail
  $wsMem.Cells.Item($r,4).Value2 = $o.'%commit'
  $r++
}
$lastRowMem = $r-1
$wsMem.Columns.AutoFit() | Out-Null

# 折れ線(%memused + kbavail(第2軸))
$co1 = $wsMem.ChartObjects().Add(10,10,780,360)
$c1  = $co1.Chart
$c1.ChartType = $xlLine
$c1.HasTitle = $true
$c1.ChartTitle.Text = "Memory usage trend"

# 系列1: %memused(第1軸)
$s1 = $c1.SeriesCollection().NewSeries()
$s1.Name    = "%memused"
$s1.XValues = $wsMem.Range("A2:A$lastRowMem")
$s1.Values  = $wsMem.Range("B2:B$lastRowMem")

# 系列2: kbavail(第2軸)
$s2 = $c1.SeriesCollection().NewSeries()
$s2.Name      = "kbavail"
$s2.XValues   = $wsMem.Range("A2:A$lastRowMem")
$s2.Values    = $wsMem.Range("C2:C$lastRowMem")
$s2.AxisGroup = 2  # 第2縦軸

# 系列3: %commit(第1軸)
$s3 = $c1.SeriesCollection().NewSeries()
$s3.Name    = "%commit"
$s3.XValues = $wsMem.Range("A2:A$lastRowMem")
$s3.Values  = $wsMem.Range("D2:D$lastRowMem")
# 第1軸に重ねるので AxisGroup 設定は不要(既定が第1軸

$c1.Legend.Position = $xlLegendPositionBottom


# swap シート
$wsSwap.Range("A1").Value2 = "timestamp"
$wsSwap.Range("B1").Value2 = "%swpused"

# 列Aの書式を変更
$wsSwap.Range("A:A").NumberFormat = "hh:mm"

$u=2
foreach($o in $swp){
  $wsSwap.Cells.Cells.Item($u,1).Value2 = (To-JstOaDouble $o.timestamp)  # ★JST
  $wsSwap.Cells.Item($u,2).Value2 = $o.'%swpused'
  $u++
}
$lastRowSwp = $u-1
$wsSwap.Columns.AutoFit() | Out-Null

# 折れ線(%swpused)
$co2 = $wsSwap.ChartObjects().Add(10,10,780,360)
$c2  = $co2.Chart
$c2.ChartType = $xlLine
$c2.HasTitle = $true
$c2.ChartTitle.Text = "Swap usage trend"
$s3 = $c2.SeriesCollection().NewSeries()
$s3.Name = "%swpused"
$s3.XValues = $wsSwap.Range("A2:A$lastRowSwp")
$s3.Values = $wsSwap.Range("B2:B$lastRowSwp")
$c2.Legend.Position = $xlLegendPositionBottom


# 保存
if (Test-Path -LiteralPath $OutXlsx) { Remove-Item -LiteralPath $OutXlsx -Force }
$wb.SaveAs($OutXlsx)
if (-not $ShowExcel) { $wb.Close($true); $excel.Quit() }

[System.Runtime.Interopservices.Marshal]::ReleaseComObject($wsSwap)   | Out-Null
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($wsMem)    | Out-Null
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($wsSummary)| Out-Null
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($wb)       | Out-Null
[System.Runtime.Interopservices.Marshal]::ReleaseComObject($excel)    | Out-Null
[gc]::Collect(); [gc]::WaitForPendingFinalizers()
Write-Host "Done: $OutXlsx"

以下のコードを「MakeExcelSarReport.bat」としてバッチファイルを作成

@echo off
setlocal
REM このBATのある場所のPS1を呼ぶ。引数(ドラッグ&ドロップ)はそのまま渡す
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0MakeExcelSarReport.ps1" %*
endlocal
PAUSE

以下が基本的な使用方法:
 1.「MakeExcelSarReport.ps1」と「MakeExcelSarReport.bat」を同階層に配置
 2.バッチファイル実行(ダブルクリック)
 3.memのCSVを選択
 4.swapのCSVを選択
 5.memのCSVと同階層、同日付でSAR_ReportのExcelファイルが生成される。

SAR_ReportExcelファイルの中身は
・1シート目:Summary(要約)
・2シート目:memデータ
・3シート目:swapデータ

手順4:出力Excelデータのメモリリソース状況の項目について

ログのメモリリソース状況が、グラフ化されて各シートに記載されます。
その際の項目の内容については、下記をご覧ください。

%memused (%)
メインメモリ(RAM)の使用率を示す値。
サーバー全体で、どの程度の割合が利用されているかをパーセンテージで表す。
おおむね 80%を超えるようになると、システムは空きメモリを確保しにくくなり、スワップ発生の可能性が高まる。

kbavail (KB)
利用可能なメモリ容量をキロバイト単位で示す値。
Linuxカーネルのメモリ管理アルゴリズムに基づき、実際に新しいプロセスやアプリケーションが利用できる「目安」となる。
%memused よりも、サーバーに余力が残っているかを直感的に把握しやすい。

%commit (%)
コミット済みメモリの割合を表す。
Linux は「オーバーコミット」という仕組みにより、実際の物理メモリ以上の割り当てをアプリケーションに行うことがある。
%commit が高すぎると、実際の要求に応えられなくなり OOM Killer(強制プロセス終了)が発動するリスクがある。

%swpused (%)
スワップ領域の使用率を示す。
物理メモリからあふれたページがスワップに退避されると、この数値が上昇する。
0%で安定していれば健全。高い値を示す場合、ディスクI/Oが増えパフォーマンス低下につながる。

※スクリプトファイルの文字コードについて
 - シェルスクリプト send_sar_mem_csv_mail.sh は UTF-8(LF改行)で保存してください
 - Windows 環境では CRLF改行や Shift-JIS で保存されることがあります
 - PowerShellスクリプト MakeExcelSarReport.ps1 は CRLF改行や Shift-JIS でOK

※補足:今回手間取った点

 1:UTC時刻日付からJST時刻日付の特定のフォーマットに調整
   →時刻のフォーマットは”mm:dd”という形式でないとグラフ化できなかった
   →Excelの日付入力書式がこれでないと反応しない?MSのブラックボックス

 2:Summaryのシートを一番始めに処理させないと動かない
   →シートでグラフを処理する際、アクティブシートだのの影響?
   →ただし、コード上ではシート追加構成の部分とSummaryの処理位置をずらせば可能
   →つまり書き方によってはSummaryを後ろにもできる
   →ただしこれもMSのブラックボックス(;´・ω・)

ブログ主が運営しているゲームです。

 MobileFight

 ジマさんの囲碁入門