הבהרה: המלצות כלליות בלבד, לא ייעוץ מקצועי. האחריות לבדוק לפני שימוש היא שלכם. בדקו קודם על שרת שאינו בייצור, ושמרו גישת קונסולה. הנוסח המלא בסוף.
מה יהיה לכם בסוף
- כניסה רק עם מפתח 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:
# /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: חוסם כתובת אחרי כמה ניסיונות כניסה כושלים.
# /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ה. עדכוני אבטחה אוטומטיים:
// /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
# /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
[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 עם השרת:
[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 או בלוג.
#!/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
[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
[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
# /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, כלב השמירה בודק |
[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
[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 מריץ פקודות לפי טקסט, וטקסט אפשר להשתיל (במייל, בדף אינטרנט, בהודעה). הגבול האמיתי הוא מה שהמשתמש שלו מסוגל לעשות במערכת, לא מה שכתוב לו בהוראות.
- משתמש משלו, בלי sudo. כבר עשינו בצעד 3. השירות מגביל כתיבה ל-
/opt/my-agentול-/var/lib/my-agentבלבד (ProtectSystem,ReadWritePaths). - רשימת פקודות מותרות ואסורות. לסוכן קוד כמו Claude Code, קובץ
.claude/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 ואין גישה לקבצי הסודות של כלב השמירה והגיבוי.
- שום פעולה החוצה בלי אישור. שליחת מייל, פרסום, מיזוג קוד, תשלום: הסוכן לא מבצע. הוא כותב את הפעולה לתור, ואתם מאשרים מהטרמינל שלכם. הסקריפט מסרב לרוץ כמשתמש של הסוכן ובלי טרמינל, מריץ את הפקודה עם ההרשאות של הסוכן, ורושם כל אישור, דחייה ותוצאה ביומן.
#!/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 # משפט סיסמה ארוך
את משפט הסיסמה שמרו גם מחוץ לשרת (במנהל סיסמאות). בלעדיו אי אפשר לפתוח את הגיבוי.
#!/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))"
[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
[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
#!/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