פלייבוק חינמי · כלי ב-60 דקות

שרת לסוכני AI שלא נופל ולא נפרץ

ההבטחה: אחרי הערכה הזאת, השרת הקטן שמריץ את הסוכן שלכם (Claude Code, בוט או משימות מתוזמנות) נעול לכניסה בסיסמה, חוזר לבד אחרי אתחול, מתריע לכם בטלגרם כשמשהו נתקע, לא קורס מבנייה אחת כבדה, לא מוציא שום דבר החוצה בלי אישור שלכם, ויש לו גיבוי שבדקתם שאפשר לשחזר ממנו.

  • אביעד גינדי

הבהרה: המלצות כלליות בלבד, לא ייעוץ מקצועי. האחריות לבדוק לפני שימוש היא שלכם. בדקו קודם על שרת שאינו בייצור, ושמרו גישת קונסולה. הנוסח המלא בסוף.

מה יהיה לכם בסוף

  • כניסה רק עם מפתח SSH, חומת אש שפתוחה רק ל-SSH, חסימה אוטומטית של ניסיונות פריצה, ועדכוני אבטחה שמותקנים לבד.
  • סודות בקובץ אחד מוגן, שנטען לשירות ולא נמצא בקוד, ב-git או בלוגים.
  • שירות systemd לסוכן שעולה אחרי אתחול וקם אחרי קריסה, ובדיקה שהוא באמת עונה ולא רק "רץ".
  • כלב שמירה כל 5 דקות שבודק את הסוכן, הדיסק, הזיכרון והמשימה האחרונה, ושולח לטלגרם התראה אחת לכל תקלה והודעה כשהיא נפתרה.
  • זיכרון מוגן: swap, ותקרת זיכרון לפקודות כבדות, כך שבנייה אחת לא מפילה את השרת.
  • משימות מתוזמנות בטוחות: בלי הרצות כפולות, עם לוגים, עם תקרת זמן.
  • סוכן עם הרשאות מינימליות, רשימת פקודות אסורות, ותור אישורים לכל פעולה כלפי חוץ, עם יומן.
  • גיבוי מוצפן כל לילה מחוץ לשרת, ובדיקת שחזור אמיתית.
  • צ'קליסט שבועי של 10 דקות.

מה צריך

  • שרת Ubuntu קטן (VPS), עם גישת root או sudo, ושרת שני או מחשב לבדיקות לפני שמריצים על השרת האמיתי.
  • גישת קונסולה מספק השרת (Console / VNC / Rescue בלוח הבקרה). אם תינעלו מחוץ ל-SSH, זו הדרך חזרה. בדקו שאתם יודעים להיכנס אליה לפני צעד 1.
  • בוט טלגרם להתראות (נפתח ב-BotFather עם /newbot) וה-chat_id שלכם.
  • מקום אחסון מחוץ לשרת לגיבויים: bucket תואם S3, Backblaze B2, Google Drive או כל יעד ש-rclone תומך בו.

איך לקרוא את הערכה: בכל צעד יש למה, קבצים ופקודות להעתקה, ובדיקה עם הפלט שאמור לצאת. בפקודות, SERVER_IP הוא כתובת השרת שלכם, ops הוא המשתמש שלכם, agent הוא המשתמש של הסוכן, ו-my-agent הוא שם השירות. אפשר להחליף את השמות, רק בעקביות.


הערכה, צעד אחרי צעד (כשעתיים)

צעד 1 (25 דקות): נעילה בסיסית

למה: רוב הפריצות לשרתים קטנים הן ניחוש סיסמאות ל-SSH ותוכנה שלא עודכנה. מפתח במקום סיסמה, חומת אש ועדכונים אוטומטיים סוגרים את רוב הסיכון.

1א. מפתח SSH ומשתמש עם sudo. במחשב שלכם (אם עוד אין לכם מפתח):

להעתקה
ssh-keygen -t ed25519 -C "my-laptop"
ssh-copy-id -i ~/.ssh/id_ed25519.pub root@SERVER_IP

בשרת, משתמש אישי עם sudo, שמקבל את אותו מפתח:

להעתקה
sudo adduser --gecos "" ops
sudo usermod -aG sudo ops
sudo install -d -m 700 -o ops -g ops /home/ops/.ssh
sudo install -m 600 -o ops -g ops ~/.ssh/authorized_keys /home/ops/.ssh/authorized_keys

הסיסמה של ops משמשת רק ל-sudo, לא לכניסה.

ומה עם root? אם הסוכן חייב לנהל את השרת עצמו ולכן רץ כ-root, אפשר להשאיר כניסת root, אבל רק עם מפתח (PermitRootLogin prohibit-password, כמו בקובץ למטה). אם עבדתם עם ops, שנו את השורה ל-PermitRootLogin no.

1ב. כניסה רק עם מפתח. שמרו את הקובץ הזה בשם /etc/ssh/sshd_config.d/00-hardening.conf:

00-hardening.conf
# /etc/ssh/sshd_config.d/00-hardening.conf
# sshd uses the FIRST value it reads, and files here are read in name order,
# so "00-" wins over a cloud image's "50-cloud-init.conf".
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
PermitRootLogin prohibit-password
MaxAuthTries 3
LoginGraceTime 30
X11Forwarding no
להעתקה
sudo sshd -t && sudo systemctl restart ssh

חשוב: אל תסגרו את החלון הפתוח. פתחו חלון שני ונסו להיכנס: ssh ops@SERVER_IP ואז sudo -v. רק אם זה עובד, סוגרים את הראשון. אם לא, מתקנים מהחלון הראשון שעדיין מחובר.

בדיקה:
sudo sshd -T | grep -E '^(passwordauthentication|kbdinteractiveauthentication|permitrootlogin)'
# permitrootlogin prohibit-password    (או no)
# passwordauthentication no
# kbdinteractiveauthentication no

ומהמחשב שלכם, ניסיון כניסה בסיסמה צריך להיכשל:

להעתקה
ssh -o PubkeyAuthentication=no -o PreferredAuthentications=password ops@SERVER_IP
# ops@SERVER_IP: Permission denied (publickey).

1ג. חומת אש (ufw). קודם מתירים SSH, ורק אחר כך מפעילים:

להעתקה
sudo ufw allow OpenSSH
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable

בדיקה: sudo ufw status verbose מראה Status: active ושורה 22/tcp (OpenSSH) ALLOW IN Anywhere. לסוכן שמדבר עם טלגרם ועם APIs אין צורך בשום פורט נכנס נוסף. אם יש לספק חומת אש משלו בלוח הבקרה, גם שם להשאיר רק 22.

1ד. fail2ban: חוסם כתובת אחרי כמה ניסיונות כניסה כושלים.

jail.local
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd

[sshd]
enabled = true
להעתקה
sudo apt install -y fail2ban
sudo cp jail.local /etc/fail2ban/jail.local
sudo systemctl restart fail2ban

בדיקה: sudo fail2ban-client status sshd מתחיל ב-Status for the jail: sshd, ומציג Currently banned ו-Total banned.

1ה. עדכוני אבטחה אוטומטיים:

20auto-upgrades
// /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
להעתקה
sudo apt install -y unattended-upgrades
sudo cp 20auto-upgrades /etc/apt/apt.conf.d/20auto-upgrades
בדיקה:
apt-config dump | grep Periodic::Unattended-Upgrade
# APT::Periodic::Unattended-Upgrade "1";
sudo unattended-upgrade --dry-run; echo "exit=$?"
# exit=0

עדכון של הקרנל צריך אתחול: אם הקובץ /var/run/reboot-required קיים, מאתחלים בזמן שנוח לכם (זה בצ'קליסט השבועי).

צעד 2 (10 דקות): סודות במקום אחד

למה: טוקן שנמצא בקוד מגיע בסוף ל-git, לצילום מסך או ללוג. קובץ אחד, שרק root קורא, ו-systemd טוען לשירות, פותר את זה.

להעתקה
sudo install -d -m 755 /etc/my-agent
sudo install -m 600 -o root -g root /dev/null /etc/my-agent/agent.env
sudoedit /etc/my-agent/agent.env
agent.env (דוגמה)
# /etc/my-agent/agent.env   owner root, chmod 600. Never in git, never in code, never printed to logs.
TELEGRAM_TOKEN=put-your-bot-token-here
API_KEY=put-your-model-api-key-here
HEARTBEAT_FILE=/var/lib/my-agent/heartbeat

השירות בצעד 3 טוען את הקובץ עם EnvironmentFile=. systemd קורא אותו כ-root לפני שהוא עובר למשתמש של הסוכן, ולכן הקובץ נשאר 600 ו-root.

בפרויקט של הסוכן, .gitignore עם השורות האלה:

להעתקה
.env
.env.*
*.pem
*.key
secrets/

ובקוד: לא מדפיסים משתני סביבה, לא כותבים טוקן ללוג, ובהודעת שגיאה מסתירים אותו.

בדיקה:
sudo stat -c '%a %U %n' /etc/my-agent/agent.env
# 600 root /etc/my-agent/agent.env
git check-ignore -v .env
# .gitignore:1:.env	.env
git grep -nIE '(api[_-]?key|token|secret|password)\s*[:=]\s*["'\'']?[A-Za-z0-9_\-:]{16,}'
# (בלי פלט)

אם טוקן דלף: מבטלים אותו אצל הספק קודם (בטלגרם: /revoke ב-BotFather, ב-API: מחיקת המפתח בלוח הבקרה), יוצרים חדש, מעדכנים את הקובץ ומפעילים מחדש את השירות. מחיקה מ-git לא מספיקה, כי הטוקן נשאר בהיסטוריה. רק החלפה סוגרת את הדליפה.

צעד 3 (15 דקות): הסוכן חוזר לבד אחרי אתחול

למה: סוכן שמורץ ידנית בטרמינל נעלם באתחול הראשון, בקריסה הראשונה או כשהחיבור נסגר. שירות systemd מעלה אותו עם השרת ומקים אותו אחרי קריסה.

משתמש נפרד לסוכן, בלי סיסמה ובלי sudo:

להעתקה
sudo useradd --system --create-home --home-dir /opt/my-agent --shell /usr/sbin/nologin agent
sudo install -d -o agent -g agent /var/lib/my-agent

/opt/my-agent/run.sh מפעיל את הסוכן עצמו:

להעתקה
#!/usr/bin/env bash
set -euo pipefail
cd /opt/my-agent
exec python3 bot.py        # או: exec node index.js
my-agent.service
[Unit]
Description=My AI agent
Wants=network-online.target
After=network-online.target
# Stop trying after 5 failed starts in 10 minutes (the watchdog will tell you)
StartLimitIntervalSec=600
StartLimitBurst=5

[Service]
Type=simple
User=agent
Group=agent
WorkingDirectory=/opt/my-agent
EnvironmentFile=/etc/my-agent/agent.env
ExecStart=/opt/my-agent/run.sh
Restart=on-failure
RestartSec=10
# Keep one runaway process from taking the whole server down
MemoryMax=1500M
MemorySwapMax=0
# Basic sandboxing: the agent may write only where it needs to
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=/opt/my-agent /var/lib/my-agent

[Install]
WantedBy=multi-user.target
להעתקה
sudo chmod 755 /opt/my-agent/run.sh
sudo cp my-agent.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now my-agent
בדיקה:
systemctl is-enabled my-agent; systemctl is-active my-agent
# enabled
# active
sudo systemctl kill -s KILL my-agent; sleep 15; systemctl is-active my-agent
# active        (systemd הקים אותו אחרי ההריגה)
sudo reboot     # ואחרי שהשרת עולה:
systemctl is-active my-agent
# active

"רץ" זה לא "עובד". תהליך יכול להיות חי ותקוע. לכן הסוכן כותב פעימה (heartbeat): בסוף כל סבב מוצלח של הלולאה שלו הוא נוגע בקובץ. כלב השמירה בצעד 4 בודק שהקובץ טרי.

להעתקה
# bash
touch "$HEARTBEAT_FILE"
# Python
pathlib.Path(os.environ["HEARTBEAT_FILE"]).touch()
# Node.js
fs.writeFileSync(process.env.HEARTBEAT_FILE, String(Date.now()))

שימו את הנגיעה אחרי פעולה שמוכיחה שהסוכן עובד (קריאה מוצלחת לטלגרם, תשובה מהמודל), לא בטיימר נפרד. אם לסוכן יש כתובת בדיקת בריאות ב-HTTP, אפשר להגדיר אותה ב-HEALTH_URL בצעד 4.

בדיקה: sudo find /var/lib/my-agent/heartbeat -mmin -15 מדפיס את הנתיב. אם אין פלט, הסוכן לא כתב פעימה ב-15 הדקות האחרונות.

סוכן אינטראקטיבי (בתוך tmux): סוכן שאתם רוצים להתחבר אליו ולראות אותו עובד, כמו סוכן קוד בשורת פקודה, רץ בתוך tmux. השירות הזה מעלה את ה-tmux עם השרת:

my-agent-tmux.service
[Unit]
Description=My AI agent (interactive, inside tmux)
Wants=network-online.target
After=network-online.target

[Service]
Type=forking
User=agent
Group=agent
WorkingDirectory=/opt/my-agent
EnvironmentFile=/etc/my-agent/agent.env
# Attach later with: sudo -u agent tmux attach -t agent
ExecStart=/usr/bin/tmux new-session -d -s agent /opt/my-agent/run.sh
ExecStop=/usr/bin/tmux kill-session -t agent
# tmux exits cleanly when the agent quits, so restart on any exit
Restart=always
RestartSec=15

[Install]
WantedBy=multi-user.target

מתחברים עם sudo -u agent tmux attach -t agent ויוצאים בלי לעצור עם Ctrl-b ואז d. בדיקה: sudo -u agent tmux ls מראה agent: 1 windows.

צעד 4 (15 דקות): כלב שמירה עם התראות לטלגרם

למה: אתם לא תסתכלו על השרת כל שעה. סקריפט קטן כל 5 דקות בודק את מה שבאמת שובר סוכנים, ושולח הודעה רק כשמשהו משתנה.

מה הוא בודק: שהשירות רץ; שהפעימה טרייה (וכתובת הבריאות, אם הגדרתם); דיסק; RAM פנוי ו-swap; התוצאה של ההרצה האחרונה של המשימה המתוזמנת. על כל תקלה: התראה אחת, תזכורת רק אחרי שעה אם היא עדיין פתוחה, והודעת ✅ כשהיא נפתרה. כשהסוכן נופל או שותק, הוא מפעיל אותו מחדש פעם אחת (לא בלולאה). הטוקן עובר ל-curl דרך stdin, כך שהוא לא מופיע ב-ps או בלוג.

watchdog.sh
#!/usr/bin/env bash
# Agent watchdog: checks the agent and the server, alerts on Telegram once per problem,
# repeats only every REPEAT_MIN minutes, and sends a recovery message when it is fixed.
# Config: /etc/my-agent/watchdog.env (chmod 600). DRY_RUN=1 prints instead of sending.
set -uo pipefail

SERVICE="${SERVICE:-my-agent.service}"            # the agent's systemd unit
HEARTBEAT_FILE="${HEARTBEAT_FILE:-/var/lib/my-agent/heartbeat}"
HEARTBEAT_MAX_MIN="${HEARTBEAT_MAX_MIN:-15}"      # the agent touches this file at least this often
HEALTH_URL="${HEALTH_URL:-}"                      # optional: an HTTP health endpoint of the agent
JOB_UNIT="${JOB_UNIT:-}"                          # optional: the scheduled job whose last result to check
DISK_MAX_PCT="${DISK_MAX_PCT:-85}"
MEM_MIN_AVAIL_MB="${MEM_MIN_AVAIL_MB:-300}"
SWAP_MAX_PCT="${SWAP_MAX_PCT:-80}"
REPEAT_MIN="${REPEAT_MIN:-60}"                    # repeat an open alert at most this often
AUTO_RESTART="${AUTO_RESTART:-1}"                 # restart the agent once when it is down or silent
STATE_DIR="${STATE_DIR:-/var/lib/my-agent-watchdog}"
MEMINFO="${MEMINFO:-/proc/meminfo}"
HOST_LABEL="${HOST_LABEL:-$(hostname -s)}"
DRY_RUN="${DRY_RUN:-0}"

mkdir -p "$STATE_DIR"

send() {
  local text="$1"
  if [[ "$DRY_RUN" == "1" ]]; then
    printf 'DRY-RUN message: %s\n' "$text"
    return 0
  fi
  if [[ -z "${TELEGRAM_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then
    echo "watchdog: TELEGRAM_TOKEN or TELEGRAM_CHAT_ID missing" >&2
    return 1
  fi
  # The URL (with the token) goes through stdin, so it never shows up in `ps` or in logs
  printf 'url = "https://api.telegram.org/bot%s/sendMessage"\n' "$TELEGRAM_TOKEN" |
    curl -sS --max-time 15 -o /dev/null -w '%{http_code}' -K - \
      --data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \
      --data-urlencode "text=${text}" | grep -q '^200$' || echo "watchdog: Telegram send failed" >&2
}

# Remember each open problem in a file: alert when it opens, repeat every REPEAT_MIN, recover when it closes
problem() {   # problem <key> <message>
  local key="$1" msg="$2" f="$STATE_DIR/$1.open"
  if [[ ! -f "$f" ]] || [[ -n "$(find "$f" -mmin +"$REPEAT_MIN" 2>/dev/null)" ]]; then
    send "⚠️ ${HOST_LABEL}: ${msg}" && printf '%s\n' "$msg" > "$f"
  fi
  OPEN+=("$key")
}
resolved() {  # resolved <key> <message>
  local f="$STATE_DIR/$1.open"
  if [[ -f "$f" ]]; then
    send "✅ ${HOST_LABEL}: ${2}" && rm -f "$f"
  fi
}

OPEN=()
restart_once() {
  [[ "$AUTO_RESTART" == "1" ]] || return 0
  local f="$STATE_DIR/restarted"
  # at most one automatic restart per REPEAT_MIN, so a broken agent is not restarted in a loop
  if [[ ! -f "$f" ]] || [[ -n "$(find "$f" -mmin +"$REPEAT_MIN" 2>/dev/null)" ]]; then
    touch "$f"
    if [[ "$DRY_RUN" == "1" ]]; then echo "DRY-RUN restart: $SERVICE"; else systemctl restart "$SERVICE"; fi
  fi
}

# 1. The service is running
if systemctl is-active --quiet "$SERVICE"; then
  resolved service "$SERVICE is running again"
else
  problem service "$SERVICE is not running ($(systemctl is-active "$SERVICE" 2>/dev/null))"
  restart_once
fi

# 2. The agent is really alive: a fresh heartbeat file (and the health URL, if set)
if [[ -f "$HEARTBEAT_FILE" ]] && [[ -z "$(find "$HEARTBEAT_FILE" -mmin +"$HEARTBEAT_MAX_MIN" 2>/dev/null)" ]]; then
  resolved heartbeat "the agent's heartbeat is fresh again"
else
  problem heartbeat "no heartbeat from the agent for over ${HEARTBEAT_MAX_MIN} minutes"
  restart_once
fi
if [[ -n "$HEALTH_URL" ]]; then
  if curl -fsS --max-time 10 -o /dev/null "$HEALTH_URL"; then
    resolved health "the health check answers again"
  else
    problem health "the health check at $HEALTH_URL does not answer"
  fi
fi

# 3. Disk
disk_pct="$(df --output=pcent / | tail -1 | tr -dc '0-9')"
if (( disk_pct >= DISK_MAX_PCT )); then
  problem disk "disk is ${disk_pct}% full (limit ${DISK_MAX_PCT}%)"
else
  resolved disk "disk is back to ${disk_pct}%"
fi

# 4. Memory and swap
mem_avail_kb="$(awk '/^MemAvailable:/ {print $2}' "$MEMINFO")"
swap_total_kb="$(awk '/^SwapTotal:/ {print $2}' "$MEMINFO")"
swap_free_kb="$(awk '/^SwapFree:/ {print $2}' "$MEMINFO")"
mem_avail_mb=$(( ${mem_avail_kb:-0} / 1024 ))
if (( mem_avail_mb < MEM_MIN_AVAIL_MB )); then
  problem memory "only ${mem_avail_mb} MB RAM available (limit ${MEM_MIN_AVAIL_MB} MB)"
else
  resolved memory "RAM is back to ${mem_avail_mb} MB available"
fi
if (( ${swap_total_kb:-0} > 0 )); then
  swap_pct=$(( (swap_total_kb - swap_free_kb) * 100 / swap_total_kb ))
  if (( swap_pct >= SWAP_MAX_PCT )); then
    problem swap "swap is ${swap_pct}% used (limit ${SWAP_MAX_PCT}%)"
  else
    resolved swap "swap is back to ${swap_pct}%"
  fi
fi

# 5. The last run of the scheduled job
if [[ -n "$JOB_UNIT" ]]; then
  result="$(systemctl show -p Result --value "$JOB_UNIT" 2>/dev/null)"
  if [[ -z "$result" || "$result" == "success" ]]; then
    resolved job "$JOB_UNIT succeeded again"
  else
    problem job "the last run of $JOB_UNIT ended with: $result"
  fi
fi

if (( ${#OPEN[@]} == 0 )); then echo "watchdog: all ok"; else echo "watchdog: open: ${OPEN[*]}"; fi
exit 0
my-agent-watchdog.service
[Unit]
Description=Watchdog for my AI agent

[Service]
Type=oneshot
EnvironmentFile=/etc/my-agent/watchdog.env
ExecStart=/usr/local/bin/my-agent-watchdog
TimeoutStartSec=120
my-agent-watchdog.timer
[Unit]
Description=Run the agent watchdog every 5 minutes

[Timer]
OnBootSec=3min
OnUnitActiveSec=5min
AccuracySec=30s

[Install]
WantedBy=timers.target
להעתקה
sudo install -m 755 watchdog.sh /usr/local/bin/my-agent-watchdog
sudo install -m 600 -o root -g root /dev/null /etc/my-agent/watchdog.env
sudoedit /etc/my-agent/watchdog.env

ב-watchdog.env:

להעתקה
TELEGRAM_TOKEN=token-of-your-alerts-bot
TELEGRAM_CHAT_ID=your-chat-id
JOB_UNIT=my-job.service

את ה-chat_id מוצאים כך: שולחים הודעה כלשהי לבוט, ופותחים בדפדפן https://api.telegram.org/bot<TOKEN>/getUpdates. המספר נמצא ב-"chat":{"id":...}. כדאי בוט נפרד להתראות, כדי שתקלה בבוט של הסוכן לא תשתיק גם את ההתראות.

להעתקה
sudo cp my-agent-watchdog.service my-agent-watchdog.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now my-agent-watchdog.timer
בדיקה:
sudo bash -c 'set -a; . /etc/my-agent/watchdog.env; DRY_RUN=1 /usr/local/bin/my-agent-watchdog'
# watchdog: all ok
systemctl list-timers my-agent-watchdog.timer --no-pager
# שורה עם NEXT בעוד פחות מ-5 דקות
sudo systemctl stop my-agent && sudo systemctl start my-agent-watchdog.service
# בטלגרם: ⚠️ ...: my-agent.service is not running (inactive)
# כלב השמירה מפעיל את הסוכן מחדש, ובהרצה הבאה: ✅ ...: my-agent.service is running again

צעד 5 (10 דקות): זיכרון: swap ותקרה לפקודות כבדות

למה: בשרת עם 2 עד 4GB, התקנת חבילות או בנייה אחת יכולות לגמור את הזיכרון. בלי swap, הקרנל הורג תהליך, ולפעמים זה הסוכן. עם swap בלי תקרה, השרת נהיה איטי עד שהוא לא עונה. הפתרון: קצת swap, ותקרה קשיחה לכל דבר כבד.

להעתקה
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
99-agent-swap.conf
# /etc/sysctl.d/99-agent-swap.conf
# Use swap only when RAM is really short, instead of pushing the agent out early
vm.swappiness = 10
להעתקה
sudo cp 99-agent-swap.conf /etc/sysctl.d/ && sudo sysctl --system

בדיקה: swapon --show מראה /swapfile file 2G, ו-cat /proc/sys/vm/swappiness מחזיר 10.

תקרה לפקודה כבדה: במקום להריץ בנייה ישירות, מריצים אותה בתוך תקרה. אם היא חורגת, רק היא נהרגת:

להעתקה
# כמשתמש שלכם
systemd-run --user --scope -p MemoryMax=1500M -p MemorySwapMax=0 npm run build
# כ-root
sudo systemd-run --scope -p MemoryMax=1500M -p MemorySwapMax=0 apt-get -y upgrade

MemorySwapMax=0 אומר לא לברוח ל-swap: הפקודה נעצרת מהר במקום לגרור את כל השרת. לשירות של הסוכן כבר יש MemoryMax בקובץ שלו. בחרו תקרה שמשאירה כמה מאות MB לשאר המערכת.

בדיקה, שהתקרה עובדת:
systemd-run --user --scope -p MemoryMax=200M -p MemorySwapMax=0 python3 -c "x = bytearray(400*1024*1024)"; echo "exit=$?"
# Killed
# exit=137        (והשרת ממשיך לעבוד)
איך יודעים שהקרנל הרג תהליך בגלל זיכרון:
journalctl -k --since "7 days ago" | grep -iE 'out of memory|oom-kill|killed process'
journalctl -u my-agent --since "7 days ago" | grep -i oom

כל שורה כאן אומרת שצריך להוריד תקרה, להוסיף swap או לשדרג את השרת.

צעד 6 (10 דקות): משימות מתוזמנות בלי הפתעות

למה: משימה שרצה כל בוקר ונתקעת, או שתי הרצות שרצות יחד על אותם קבצים, הן תקלות שקשה למצוא. טיימר של systemd פותר את רובן מראש.

cron טיימר של systemd
לוגים צריך להפנות לבד לקובץ אוטומטית ב-journalctl
הרצה שהוחמצה (השרת היה כבוי) לא רצה Persistent=true מריץ אחרי העלייה
תקרת זמן וזיכרון אין TimeoutStartSec, MemoryMax
תוצאת ההרצה האחרונה צריך לבנות לבד systemctl show -p Result, כלב השמירה בודק
my-job.service
[Unit]
Description=My scheduled agent job
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=agent
Group=agent
WorkingDirectory=/opt/my-agent
EnvironmentFile=/etc/my-agent/agent.env
# flock -n: if the previous run still holds the lock, this run exits (code 75) instead of overlapping,
# and 75 counts as success, so a skipped run is not reported as a failure
ExecStart=/usr/bin/flock -n -E 75 /var/lib/my-agent/my-job.lock /opt/my-agent/job.sh
SuccessExitStatus=75
# Kill a run that hangs, so the next one can start
TimeoutStartSec=30min
MemoryMax=1200M
MemorySwapMax=0
my-job.timer
[Unit]
Description=Run my agent job every weekday at 07:30

[Timer]
OnCalendar=Mon..Fri 07:30
# Set the server's time zone, or write the zone here, e.g. OnCalendar=Mon..Fri 07:30 Asia/Jerusalem
# Run a missed run after boot (the server was off at 07:30)
Persistent=true
RandomizedDelaySec=2min

[Install]
WantedBy=timers.target

flock -n מבטיח שאין שתי הרצות במקביל: אם הקודמת עוד רצה, החדשה יוצאת מיד. היא יוצאת עם קוד 75, שמוגדר כהצלחה, כדי שדילוג לא ייחשב תקלה.

להעתקה
sudo cp my-job.service my-job.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now my-job.timer
בדיקה:
systemctl list-timers my-job.timer --no-pager
# NEXT: ביום העבודה הבא ב-07:30
sudo systemctl start my-job.service; journalctl -u my-job -n 20 --no-pager
# הפלט של המשימה, ובסוף: Finished my-job.service
systemctl show -p Result --value my-job.service
# success

מעדיפים cron? לפחות עם נעילה ולוג: 30 7 * * 1-5 flock -n /var/lib/my-agent/my-job.lock /opt/my-agent/job.sh >> /var/log/my-job.log 2>&1.

צעד 7 (10 דקות): הסוכן עצמו: הרשאות מינימליות ואישור לכל פעולה החוצה

למה: סוכן AI מריץ פקודות לפי טקסט, וטקסט אפשר להשתיל (במייל, בדף אינטרנט, בהודעה). הגבול האמיתי הוא מה שהמשתמש שלו מסוגל לעשות במערכת, לא מה שכתוב לו בהוראות.

  1. משתמש משלו, בלי sudo. כבר עשינו בצעד 3. השירות מגביל כתיבה ל-/opt/my-agent ול-/var/lib/my-agent בלבד (ProtectSystem, ReadWritePaths).
  2. רשימת פקודות מותרות ואסורות. לסוכן קוד כמו Claude Code, קובץ .claude/settings.json בתיקיית הפרויקט:
settings.json
{
  "permissions": {
    "allow": [
      "Bash(git status)",
      "Bash(git diff *)",
      "Bash(git log *)",
      "Bash(npm test *)",
      "Bash(npm run build *)"
    ],
    "deny": [
      "Bash(git push *)",
      "Bash(gh pr merge *)",
      "Bash(rm -rf *)",
      "Bash(sudo *)",
      "Bash(curl *)",
      "Bash(ssh *)",
      "Bash(scp *)",
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(//etc/my-agent/**)"
    ]
  }
}

רשימה כזאת עוזרת, אבל היא לא חומה: הגבול הוא הרשאות המשתמש. לכן גם בלי הרשימה, למשתמש agent אין sudo ואין גישה לקבצי הסודות של כלב השמירה והגיבוי.

  1. שום פעולה החוצה בלי אישור. שליחת מייל, פרסום, מיזוג קוד, תשלום: הסוכן לא מבצע. הוא כותב את הפעולה לתור, ואתם מאשרים מהטרמינל שלכם. הסקריפט מסרב לרוץ כמשתמש של הסוכן ובלי טרמינל, מריץ את הפקודה עם ההרשאות של הסוכן, ורושם כל אישור, דחייה ותוצאה ביומן.
approve.sh
#!/usr/bin/env bash
# Human approval gate for outward actions (send email, post, merge, pay).
# The agent never runs these itself: it writes one file per action into the queue,
#   /var/lib/my-agent/outbox/<id>.cmd   (one command line)
#   /var/lib/my-agent/outbox/<id>.why   (one line: why)
# and you approve from your own shell:  approve.sh            (list)
#                                       approve.sh <id>       (show, ask, run, log)
# Install it so the agent cannot run it: sudo install -m 750 -o root -g sudo approve.sh /usr/local/bin/approve
set -euo pipefail
OUTBOX="${OUTBOX:-/var/lib/my-agent/outbox}"
AUDIT="${AUDIT:-/var/log/my-agent/audit.log}"
AGENT_USER="${AGENT_USER:-agent}"
RUN_AS="${RUN_AS:-$AGENT_USER}"   # approved commands run with the agent's rights, not yours
mkdir -p "$OUTBOX" "$(dirname "$AUDIT")"

# The agent must never approve its own actions
if [[ "$(id -un)" == "$AGENT_USER" ]]; then echo "refused: the agent user cannot approve"; exit 3; fi

log() { printf '%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "${SUDO_USER:-$USER}" "$1" "$2" >> "$AUDIT"; }

if [[ $# -eq 0 ]]; then
  shopt -s nullglob
  for f in "$OUTBOX"/*.cmd; do
    id="$(basename "$f" .cmd)"
    printf '%s\t%s\n' "$id" "$(cat "$OUTBOX/$id.why" 2>/dev/null || echo '-')"
  done
  exit 0
fi

id="$1"
[[ "$id" =~ ^[A-Za-z0-9_-]+$ ]] || { echo "bad id"; exit 2; }
cmd_file="$OUTBOX/$id.cmd"
[[ -f "$cmd_file" ]] || { echo "no such action: $id"; exit 2; }
echo "Why:     $(cat "$OUTBOX/$id.why" 2>/dev/null || echo '-')"
echo "Command: $(cat "$cmd_file")"
# A person at a terminal types the answer; piped input is refused
[[ -t 0 || "${APPROVE_TEST_NO_TTY:-0}" == "1" ]] || { echo "refused: run this from a terminal"; exit 3; }
read -r -p "Run it? Type yes to approve: " answer
if [[ "$answer" != "yes" ]]; then
  log "$id" "rejected: $(cat "$cmd_file")"
  mv "$cmd_file" "$cmd_file.rejected"
  echo "rejected"
  exit 0
fi
log "$id" "approved: $(cat "$cmd_file")"
if [[ "$RUN_AS" == "$(id -un)" ]]; then runner=(bash -c); else runner=(sudo -u "$RUN_AS" bash -c); fi
if "${runner[@]}" "$(cat "$cmd_file")"; then log "$id" "done"; mv "$cmd_file" "$cmd_file.done"; else log "$id" "failed"; exit 1; fi
להעתקה
sudo install -m 750 -o root -g sudo approve.sh /usr/local/bin/approve
sudo install -d -o agent -g agent /var/lib/my-agent/outbox
sudo install -d -m 750 /var/log/my-agent

בהוראות של הסוכן כותבים: "כל פעולה כלפי חוץ, כתוב אותה כשורת פקודה אחת לקובץ /var/lib/my-agent/outbox/<id>.cmd, ואת הסיבה בשורה אחת ל-<id>.why. אל תבצע אותה בעצמך".

בדיקה:
sudo -u agent sudo -n true
# sudo: a password is required    (או: agent is not in the sudoers file)
sudo -u agent touch /etc/should-fail
# touch: cannot touch '/etc/should-fail': Permission denied
sudo approve                      # רשימת הפעולות שמחכות
sudo approve <id>                 # מציג, שואל, מריץ, רושם
sudo tail -3 /var/log/my-agent/audit.log

צעד 8 (15 דקות): גיבוי מוצפן, ושחזור שבאמת בדקתם

למה: גיבוי שלא שחזרתם ממנו הוא תקווה, לא גיבוי. ושרת שנמחק לוקח איתו גם את הגיבוי, אם הגיבוי היה עליו.

מה מגבים: קבצי הגדרות (/etc/my-agent, יחידות ה-systemd שכתבתם), את תיקיית הסוכן והנתונים שלו (/opt/my-agent, /var/lib/my-agent). קבצי הסודות נכנסים לגיבוי, ולכן כל הארכיון מוצפן.

להעתקה
sudo apt install -y rclone
sudo rclone config          # יוצרים remote בשם offsite אצל ספק האחסון שבחרתם
sudo install -m 600 -o root -g root /dev/null /etc/my-agent/backup.pass
sudoedit /etc/my-agent/backup.pass     # משפט סיסמה ארוך

את משפט הסיסמה שמרו גם מחוץ לשרת (במנהל סיסמאות). בלעדיו אי אפשר לפתוח את הגיבוי.

backup.sh
#!/usr/bin/env bash
# Nightly backup: configs, env files and data -> one encrypted archive -> off-server storage (rclone).
# Config: /etc/my-agent/backup.env (chmod 600): BACKUP_PATHS, RCLONE_REMOTE, PASSPHRASE_FILE, KEEP_DAYS
set -euo pipefail

BACKUP_PATHS="${BACKUP_PATHS:-/etc/my-agent /opt/my-agent /var/lib/my-agent /etc/systemd/system}"
RCLONE_REMOTE="${RCLONE_REMOTE:?set RCLONE_REMOTE, e.g. offsite:my-agent-backups}"
PASSPHRASE_FILE="${PASSPHRASE_FILE:-/etc/my-agent/backup.pass}"
KEEP_DAYS="${KEEP_DAYS:-30}"
WORK_DIR="${WORK_DIR:-/var/tmp/my-agent-backup}"
STAMP="$(date -u +%Y-%m-%dT%H%MZ)"
NAME="backup-${HOSTNAME:-host}-${STAMP}.tar.gz.gpg"

umask 077
mkdir -p "$WORK_DIR"
trap 'rm -f "$WORK_DIR/$NAME"' EXIT

# shellcheck disable=SC2086  # BACKUP_PATHS is a space-separated list on purpose
tar --create --gzip --file - --exclude='*.lock' --exclude='node_modules' --exclude='.cache' $BACKUP_PATHS 2>/dev/null |
  gpg --batch --yes --quiet --symmetric --cipher-algo AES256 --passphrase-file "$PASSPHRASE_FILE" \
      --output "$WORK_DIR/$NAME"

rclone copy --quiet "$WORK_DIR/$NAME" "$RCLONE_REMOTE/"
rclone delete --quiet --min-age "${KEEP_DAYS}d" "$RCLONE_REMOTE/"
echo "backup ok: $RCLONE_REMOTE/$NAME ($(du -h "$WORK_DIR/$NAME" | cut -f1))"
my-agent-backup.service
[Unit]
Description=Nightly encrypted backup of my AI agent
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
EnvironmentFile=/etc/my-agent/backup.env
ExecStart=/usr/local/bin/my-agent-backup
TimeoutStartSec=1h
Nice=10
IOSchedulingClass=idle
my-agent-backup.timer
[Unit]
Description=Nightly backup at 03:15

[Timer]
OnCalendar=*-*-* 03:15
Persistent=true
RandomizedDelaySec=10min

[Install]
WantedBy=timers.target

ב-/etc/my-agent/backup.env (גם הוא 600): RCLONE_REMOTE=offsite:my-agent-backups.

להעתקה
sudo install -m 755 backup.sh /usr/local/bin/my-agent-backup
sudo install -m 755 restore-test.sh /usr/local/bin/my-agent-restore-test
sudo cp my-agent-backup.service my-agent-backup.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now my-agent-backup.timer
restore-test.sh
#!/usr/bin/env bash
# Restore test: download the newest backup, decrypt it, unpack it into a scratch folder and check it.
# Nothing on the live system is touched. Run it by hand once a month.
set -euo pipefail

RCLONE_REMOTE="${RCLONE_REMOTE:?set RCLONE_REMOTE}"
PASSPHRASE_FILE="${PASSPHRASE_FILE:-/etc/my-agent/backup.pass}"
CHECK_FILE="${CHECK_FILE:-etc/my-agent/agent.env}"   # a file that must be in every backup (path without the leading /)
DEST="$(mktemp -d /var/tmp/restore-test.XXXXXX)"
trap 'rm -rf "$DEST"' EXIT

latest="$(rclone lsf --files-only "$RCLONE_REMOTE/" | grep '\.tar\.gz\.gpg$' | sort | tail -1)"
[[ -n "$latest" ]] || { echo "restore test FAILED: no backups found"; exit 1; }
rclone copy --quiet "$RCLONE_REMOTE/$latest" "$DEST/"
gpg --batch --quiet --decrypt --passphrase-file "$PASSPHRASE_FILE" "$DEST/$latest" | tar --extract --gzip --file - -C "$DEST"
[[ -s "$DEST/$CHECK_FILE" ]] || { echo "restore test FAILED: $CHECK_FILE missing in $latest"; exit 1; }
echo "restore test ok: $latest, $(find "$DEST" -type f | wc -l) files, $CHECK_FILE present"
בדיקה:
sudo systemctl start my-agent-backup.service; journalctl -u my-agent-backup -n 3 --no-pager
# backup ok: offsite:my-agent-backups/backup-...tar.gz.gpg (...)
sudo bash -c 'set -a; . /etc/my-agent/backup.env; /usr/local/bin/my-agent-restore-test'
# restore test ok: backup-...tar.gz.gpg, ... files, etc/my-agent/agent.env present

שחזור מלא לשרת חדש: צעדים 1 ו-5 בשרת החדש, rclone config עם אותו remote, הורדת הגיבוי האחרון, ואז gpg --decrypt --passphrase-file ... backup.tar.gz.gpg | sudo tar -xz -C /, sudo systemctl daemon-reload, והפעלה של השירות והטיימרים. עשו את זה פעם אחת על שרת בדיקה, כדי לדעת כמה זמן זה לוקח.


הצ'קליסט השבועי (10 דקות)

  • ההודעות מכלב השמירה מהשבוע: כל ⚠️ קיבל ✅ או טיפול
  • systemctl --failed ריק
  • ls /var/run/reboot-required אין קובץ, או שתזמנתם אתחול
  • df -h / ו-free -h עם מרווח
  • journalctl -k --since "7 days ago" | grep -i oom ריק
  • sudo fail2ban-client status sshd עובד
  • הגיבוי האחרון מהלילה: journalctl -u my-agent-backup -n 1
  • פעם בחודש: my-agent-restore-test מצליח
  • sudo tail /var/log/my-agent/audit.log: כל פעולה החוצה אושרה על ידיכם
  • טוקנים שלא בשימוש בוטלו

דוגמה מלאה: סטודיו נועה לעיצוב (עסק בדוי)

כל מה שבדוגמה בדוי: העסק, השרת, הבוט והמספרים.

לנועה יש שרת Ubuntu קטן עם 2 ליבות ו-4GB, ועליו בוט טלגרם שעונה ללקוחות על זמינות, ומשימה כל בוקר ב-07:30 שמכינה לה סיכום של הפניות מאתמול.

לפני הערכה: הבוט רץ בחלון SSH פתוח. אחרי עדכון ואתחול של הספק, הבוט לא חזר, ונועה גילתה את זה רק כשלקוחה שאלה למה אין תשובה.

אחרי:

  • כניסה רק עם מפתח, ufw פתוח רק ל-22, ו-fail2ban חסם כבר בשבוע הראשון כמה כתובות.
  • הבוט רץ כשירות my-agent כמשתמש agent, ונוגע בפעימה אחרי כל קריאה מוצלחת לטלגרם.
  • כלב השמירה רץ כל 5 דקות. ביום שלישי בלילה הגיעה ההודעה:
להעתקה
⚠️ studio-vps: no heartbeat from the agent for over 15 minutes
✅ studio-vps: the agent's heartbeat is fresh again

הבוט נתקע אחרי ניתוק רשת, כלב השמירה הפעיל אותו מחדש פעם אחת, ותוך 5 דקות הגיעה הודעת ההחלמה. נועה לא הייתה צריכה לעשות כלום, ובבוקר ראתה מה קרה.

  • המשימה של 07:30 רצה כטיימר עם flock. ביום שהשרת אותחל ב-07:20, Persistent=true הריץ אותה מיד כשעלה.
  • הבוט מציע ללקוחות "לשלוח הצעת מחיר", אבל לא שולח. הוא כותב את המייל לתור, ונועה מאשרת עם sudo approve. ביומן רואים כל אישור.
  • הגיבוי רץ ב-03:15 ל-bucket מחוץ לשרת. בבדיקת השחזור הראשונה התברר שהתיקייה של מסד הנתונים של הבוט לא הייתה ברשימה. הוסיפו אותה ל-BACKUP_PATHS, והבדיקה הבאה עברה.

הבהרה

התוכן כאן הוא המלצות כלליות בלבד, ולא ייעוץ מקצועי, משפטי, חשבונאי או מיסויי. כל עסק וארגון שונה. האחריות לבדוק שהתוכן מתאים למצב שלכם, ולבדוק את הנתונים, הפרומפטים והקוד לפני שימוש, היא שלכם. כלים, מחירים ותנאי שימוש של שירותים חיצוניים משתנים, ולכן כדאי לבדוק אותם במקור. בדקו כל שינוי קודם על שרת שאינו בייצור. שמרו גישת קונסולה מספק השרת, למקרה שתינעלו מחוץ ל-SSH. במערכות רגישות, או כשיש בשרת מידע של לקוחות, התייעצו עם איש מקצוע באבטחת מידע.


ומה הלאה

כאן אתם מקימים ומתחזקים הכל בעצמכם. אפשר לקבל שרת שמוגדר כך מראש, עם כלב שמירה, גיבוי, תור אישורים ומעקב, ומישהו שעונה כשההתראה מגיעה.

רוצים שזה ירוץ אצלכם אוטומטית? aviad@gindiinnovation.com · gindiinnovation.com