Skip to content

Add RAM sentinel daemon for OOM prevention - #160

Open
idelcano wants to merge 21 commits into
masterfrom
script/add_oom_ram_control_script
Open

idelcano wants to merge 21 commits into
masterfrom
script/add_oom_ram_control_script

Conversation

@idelcano

Copy link
Copy Markdown
Contributor

📌 References
Issue: https://app.clickup.com/t/4528615/869e36g3p

📝 Implementation
Adds ram_sentinel.py, a daemon that monitors available RAM and stops
Monit-managed services when memory drops critically low, then restores
them once RAM recovers. Configured per-environment via systemd ExecStart
flags — no code changes needed between servers.

Key design decisions:

  • Two stop strategies: kill (SIGKILL via Monit PID, for Java/Tomcat)
    and stop (clean monit stop, for Docker/Postgres)
  • Services stopped in tier order, restored in reverse
  • State persisted to /run/stopram/state.json so an interrupted recovery
    is resumed on daemon restart
  • --dry-run mode reads RAM and PIDs for real but skips all destructive
    commands

How to install

  1. Copy ram_sentinel.py to /usr/local/bin/ram_sentinel.py
  2. Run a dry-run with the trigger set above current available RAM to
    force an emergency cycle and verify tier names and PID resolution:
    python3 /usr/local/bin/ram_sentinel.py
    --emergency-trigger <above_current_available_MB to test>
    --safe-recovery <target_MB>
    --tier kill:<monit_name>
    --dry-run
  3. Confirm output shows correct PID found and [DRY-RUN] would... for
    each action
  4. Create the appropriate .service file for the server to
    /etc/systemd/system/ram-sentinel.service
  5. Enable and start the service:
    systemctl daemon-reload && systemctl enable --now ram-sentinel
    journalctl -u ram-sentinel -f

@idelcano
idelcano requested a review from cgbautista August 18, 2026 18:16
  Add --notify-script, --server-name, and --notify-level CLI args.
  On each kill/stop action (and optionally on recovery) the sentinel
  calls the given script via subprocess with MONIT_HOST/SERVICE/DESCRIPTION
  env vars, matching the monit_clickup_notifications.py convention.

  State file format updated to store full tier objects ({type, name})
  instead of bare name strings, with backwards-compatible load_state().

@cgbautista cgbautista left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are an extreme case that is ignored: having no free memory at all or failing to retrieve free memory (that is probably one symptom that there is no free memory).
Also, the monit service names, even though they are checked and issue a warning, do not prevent the oom-monitoring service from starting when it will be unable to do anything in the worst-case scenario.

Comment thread monitoring/ram_sentinel/ram_sentinel.py Outdated
if trigger_ram is not None
else f"RAM after stop: {ram_after} MB."
)
notify(f"{tier['type'].upper()} {name}: service stopped. {ram_info}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no actual check on the stop_tier() return, so it may report that some mispelled/non-existant service was stopped.
Also, at least for the stop command, monit may require some time to render the service stopped (it is a background process), so I would rather say "sent a {tier["type"]} signal to the service" as it may even fail to stop at all.

Comment thread monitoring/ram_sentinel/ram_sentinel.py Outdated

for tier in cfg.tiers:
available_ram = stop_tier_and_settle(cfg, tier, stopped_tiers, trigger_ram=available_ram)
if available_ram > 0 and available_ram >= cfg.emergency_trigger:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in the other case, 0 should be treated as a critical condition.

Comment thread monitoring/ram_sentinel/ram_sentinel.py Outdated
last_log = 0.0

while available_ram < cfg.safe_recovery:
if 0 < available_ram < cfg.emergency_trigger and remaining_tiers:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though available_ram would not actually be 0.000 in most scenarios, it is technically possible to have that value returned.
I understand that this 0 < available_ram part is designed for the "failure" of the get_available_memory_mb() function and not for the unlikely real 0mb-free scenario, assuming that not being able to retrieve the free memory is something "that may happen at any time", so any action to free ram is not necessary. But in the current code (calling free -m in a subprocess) that function would probably fail in a high memory pressure scenario as forking the new process would likely be refused by the OS.
IMO, it is safer to assume that the failure of the get_available_memory_mb() is equivalent to the 0mb-free scenario and remove the 0 < part of the condition.

Comment thread monitoring/ram_sentinel/ram_sentinel.py Outdated
while True:
try:
available_ram = get_available_memory_mb()
if 0 < available_ram < cfg.emergency_trigger:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As in the other case, 0 should be treated as a critical condition.

Comment thread monitoring/ram_sentinel/ram_sentinel.py Outdated
def run_cmd(cmd, shell=False, timeout=None):
try:
if isinstance(cmd, str) and not shell:
cmd = cmd.split()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should use instead:

import shlex
[...]
cmd = shlex.split(cmd)

to ensure that any parameter that may contain spaces is treated as a whole and not just splited in half (i.e. run_cmd(f"monit status {monit_name}") where monit_name contains spaces)

Comment thread monitoring/ram_sentinel/ram_sentinel.py Outdated

def get_available_memory_mb():
try:
output = run_cmd("free -m")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of running a command (as it requires to create a new process and it demands extra memory, which could be blocked in OOM situations), it would be better to read /proc/meminfo directly:

    with open("/proc/meminfo", "r") as f:
        for line in f:
            if line.startswith("MemAvailable:"):
                # meminfo outputs in KB, convert it to MB instead
                return int(line.split()[1]) // 1024
    raise RuntimeError("MemAvailable couldn't be found in /proc/meminfo")

Comment thread monitoring/ram_sentinel/ram_sentinel.py
Comment thread monitoring/ram_sentinel/ram_sentinel.py Outdated
def wait_for_ram_settle(threshold_mb, max_seconds=SETTLE_SECONDS):
"""Polls RAM for up to max_seconds. Returns available RAM when stable or timeout."""
deadline = time.monotonic() + max_seconds
while time.monotonic() < deadline:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to avoid the extra call to get_available_memory_mb() as you are already extracting the value in the loop. The ram > 0 part seems unnecesary as it is already implied in the other comparison.

while True:
        ram = get_available_memory_mb()
        if ram >= threshold_mb or time.monotonic() >= deadline:
            return ram
        time.sleep(CHECK_INTERVAL_SECONDS)

),
)
parser.add_argument(
"--notify-script",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After setting up the notify script, I would make sure it exists as a file to issue a warning and disable notifications (or refusing to start with a wrong argument, as it could be removed)

Comment thread monitoring/ram_sentinel/ram_sentinel.py Outdated
env["MONIT_SERVICE"] = "RAM-SENTINEL"
env["MONIT_DESCRIPTION"] = f"[{level.upper()}] {description}"
subprocess.run(
["python3", _NOTIFY_SCRIPT],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather assume that the notification script has a shebang and execution permissions than to assume it is a python3 script.

…ee -m

Spawning `free -m` requires forking a new process, which the kernel may
refuse under heavy memory pressure, leaving the sentinel unable to read
available memory exactly when it is needed. Read /proc/meminfo directly
instead; the value is the same one `free` reports as "available".

On failure the function still returns 0, which will be treated as a
critical condition by the callers.
The `0 < available_ram` guards skipped both a real 0 MB reading and a
failed memory read. A failed read is most likely a symptom of the OOM
condition itself, so ignoring it left the host unprotected in the worst
case. Remove the guard in the main loop, the escalation loop and the
wait/restore loop so that 0 always triggers (or continues) recovery.
The snapshot is logged right when the emergency trigger fires, before any
tier is stopped. Running `free -m` and a `ps | head` shell pipeline at
that moment requires several forks that may fail or hang under memory
pressure, delaying the recovery. Read /proc/meminfo and
/proc/<pid>/status (VmRSS) directly to log the memory summary and the
top processes by RSS.
At runtime a failed memory read is treated as 0 MB (critical). If the
read fails permanently because of the platform (no /proc mounted, kernel
without MemAvailable), that would stop every tier right after startup
and never restore them, since 0 never reaches --safe-recovery.

Split the reader into read_mem_available_mb(), which raises on failure,
and get_available_memory_mb(), which returns 0 for runtime use. Call the
raising version once at startup and exit with an error if it fails. Only
readability is checked, not the value, so a restart during a real 0 MB
situation still starts the sentinel.
Check both exit conditions (threshold reached or deadline passed) in a
single `if`, so each iteration reads available memory once and the
function always returns the value it has just checked. This removes the
extra read after the timeout and the redundant `ram > 0` guard, which is
implied by `ram >= threshold_mb`.
…ames

Use shlex.split() instead of str.split() in run_cmd() so quoted arguments
are kept together, and quote the Monit service name with shlex.quote()
where it is interpolated into monit commands (shlex.split alone would
still split an unquoted name).

Monit service names are single tokens, so this is not expected to change
behaviour with valid tier names; it only makes argument handling robust.
…y sent

stop_tier() returned nothing, so a notification saying "service stopped"
was sent even when the tier name was wrong, no PID was found or the
SIGKILL failed. It also claimed the service was stopped although
`monit stop` only queues the request in the Monit daemon.

stop_tier() now returns (sent, action) and the notification reports
either the action sent ("SIGKILL sent to PID N", "stop request sent to
Monit") or a FAILED message. Failed tiers are still recorded as stopped
because they were already unmonitored and must be re-monitored on
restore.
…valid

Tiers not found in `monit summary` were only logged as a warning, so a
misspelled tier still cost 20-50 s of recovery time (unmonitor, stop
timeout, settle wait) and a sentinel with no valid tier kept running
while being unable to act.

validate_tiers() now returns only the tiers known to Monit and replaces
cfg.tiers with them. The sentinel exits with an error if no valid tier
is left or if Monit can't be queried, since every action goes through
Monit. systemd's Restart=always retries until Monit is available.
- Refuse to start if --notify-script is set but the file doesn't exist
  or isn't executable, so broken alerting is caught at deploy time
  instead of silently failing during an emergency.
- Execute the script directly instead of through `python3`, so any
  language with a shebang works (same convention as Monit's exec).
- Log a warning with stderr when the script exits with a non-zero code,
  which was previously ignored.

README and --help updated with the shebang/execute permission
requirement.
Compute the shell-quoted Monit service name once at the top of
stop_tier() and reuse it in the unmonitor/stop commands instead of
calling shlex.quote() on every command.
A tier whose action couldn't be sent (no live PID, SIGKILL failed or
monit stop failed) still went through the 3 s reclaim sleep and the
settle wait (up to 20 s), losing ~23 s while RAM is critical and
nothing has been freed. Skip both waits when stop_tier() reports a
failure so recovery moves on to the next tier right away.
A `monit stop` that times out was reported as a failure and, since the
previous commit, skipped the settle wait. Monit may still be stopping
the service in that case, so escalating immediately could stop the next
tier unnecessarily.

run_cmd()/maybe_run() get a raise_on_timeout flag so stop_tier() can
tell a rejected request (error exit, escalate right away) from a
timeout (reported as "result unknown", settle wait applies as usual).
run_cmd() only caught TimeoutExpired and CalledProcessError. If the
kernel refuses to create the child process (OSError ENOMEM, the typical
OOM situation) or the binary is missing, the exception propagated up to
the main loop and aborted the whole recovery: remaining tiers were not
tried and the next iteration restarted from the first tier with an
empty stopped list, overwriting the state file.

Catch OSError and return None like any other command failure, so the
tier is reported as failed and recovery escalates to the next one.
notify() waited for the notification script (up to 15 s, it usually
makes an HTTP call) in the middle of the recovery, delaying the
escalation to the next tier even when the current one had failed.

Run the script in a daemon thread so recovery continues immediately.
The thread still waits for the script, so non-zero exit codes and
errors keep being logged.
stopped_names is only used to build remaining_tiers before the loop;
adding to it inside the loop had no effect.
The only shell=True caller was the `ps | head` pipeline removed when the
system snapshot moved to /proc. Removing the parameter also ensures no
command is ever passed through a shell.
…itical

Once every tier has been stopped, the sentinel just waits for RAM to
recover, logging every 30 s but sending no alert. If RAM is still below
the emergency trigger at that point, send a single critical
notification so someone can step in manually.
Resuming a pending recovery at startup ran outside any try block, so an
unexpected error crashed the sentinel and systemd restarted it in a
loop, leaving the host unprotected. Log the error like the main loop
does and continue; the state file is only cleared after a successful
restore, so the pending tiers are not lost.
@idelcano

Copy link
Copy Markdown
Contributor Author

Replies to review

General comment (no free memory / tiers not preventing startup)

Both covered. A failed memory read is now treated as 0 MB (critical), and the sentinel refuses to start if no valid tier is left (see the replies below).

get_available_memory_mb(): read /proc/meminfo

Done, thanks. It now reads MemAvailable directly, no subprocess. On failure it returns 0, which is treated as critical. At startup, if /proc/meminfo can't be read at all, the sentinel refuses to start. Otherwise it would stop every tier and never restore them.

0 < available_ram (3 comments)

Agreed, removed in all three places. 0 MB now always triggers or continues the recovery.

wait_for_ram_settle()

Applied your version.

run_cmd(): shlex.split

Applied. Note that on its own it wouldn't fix the example, because the name is inserted without quotes. So the names are also quoted with shlex.quote(). Monit names shouldn't contain spaces anyway, so this is just for robustness.

stop_tier() return value / "service stopped" message

Agreed on both. stop_tier() now returns whether the action was actually sent, and the notification says what happened:

  • SIGKILL sent to PID 1234
  • stop request sent to Monit
  • FAILED: ... when nothing could be sent

If a tier fails, recovery moves on to the next one without waiting. A monit stop timeout is reported as "result unknown" and the normal wait applies.

validate_tiers()

Agreed. Unknown tiers are now removed from the list (with a warning). The sentinel refuses to start if no valid tier is left or if Monit can't be queried.

--notify-script existence check

Done. If the script doesn't exist or isn't executable, the sentinel refuses to start.

Notification script run via python3

Agreed, it is now executed directly (it needs a shebang and execute permission). Documented in the README and --help.

Other changes

  • The system snapshot is read from /proc instead of running free/ps.
  • If a command can't be started (e.g. out of memory), the tier is treated as failed instead of aborting the whole recovery.
  • Notifications are sent in a background thread, so they don't delay the recovery. A non-zero exit code from the script is now logged.
  • A single critical notification is sent when all tiers are stopped and RAM is still critical.
  • Resuming a pending recovery at startup can no longer crash the sentinel.

@idelcano
idelcano requested a review from cgbautista September 18, 2026 12:37
@idelcano

Copy link
Copy Markdown
Contributor Author

@cgbautista thanks for the review, you found some critical improvements.
I answer all the single comments in my last comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants