
網站維運的自動化腳本
問:
備份腳本要怎麼寫?
排程沒執行但也沒有錯誤通知
cron 執行找不到指令怎麼辦?
怎麼避免腳本重複執行?
答:
自動化的三個原則
- 失敗要能被知道——靜默失敗是最糟的情況
- 可以重複執行——重跑一次不應造成問題
- 留下紀錄——出事時能追溯
第一項最常被忽略。排程腳本停止運作而沒人發現,比沒有這個腳本更危險——因為你以為它在跑。
用 logging 而不是 print
import logging
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
LOG_FILE = BASE_DIR / 'logs' / 'task.log'
LOG_FILE.parent.mkdir(exist_ok=True)
logging.basicConfig(
filename=LOG_FILE,
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
encoding='utf-8',
)
logging.info('開始執行')
logging.warning('略過 3 筆資料')
logging.error('連線失敗')
為什麼不用 print
- 排程執行時 print 的輸出可能不知去向
- 沒有時間戳記
- 無法分級
- 無法在不改程式的情況下調整詳細程度
錯誤處理與通知
import logging, sys, traceback
def main():
# 實際工作
...
if __name__ == '__main__':
try:
main()
logging.info('執行完成')
except Exception:
logging.error('執行失敗\n' + traceback.format_exc())
notify('備份腳本執行失敗,請查看日誌')
sys.exit(1)
通知的方式
- 寄送電子郵件
- 推送到通訊軟體
- 寫入監控系統
離開時要回傳非零的結束碼——排程系統與監控工具可以據此判斷失敗。
只監控失敗是不夠的
這一點很重要:如果排程根本沒執行,就不會有失敗通知。
建議的做法
- 成功時也記錄一筆,並定期確認有這筆紀錄
- 檢查產出的檔案——備份檔的時間戳是否為最新
- 設定「超過 N 小時未成功執行就通知」的監控
這與 API 串接的靜默失效是同樣的問題。
資料庫備份腳本
import subprocess, gzip, shutil, os
from pathlib import Path
from datetime import datetime, timedelta
BACKUP_DIR = Path('/backup/db')
KEEP_DAYS = 14
def backup(dbname):
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
sql_path = BACKUP_DIR / f'{dbname}_{ts}.sql'
cmd = [
'mysqldump',
'--defaults-extra-file=/etc/mysql/backup.cnf',
'--single-transaction', '--routines', '--triggers', '--events',
'--default-character-set=utf8mb4',
dbname,
]
with open(sql_path, 'wb') as out:
subprocess.run(cmd, stdout=out, check=True)
# 壓縮
gz_path = sql_path.with_suffix('.sql.gz')
with open(sql_path, 'rb') as f_in, gzip.open(gz_path, 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
sql_path.unlink()
# 驗證檔案不是空的
if gz_path.stat().st_size < 1024:
raise RuntimeError('備份檔案異常過小')
return gz_path
三個重點
- 密碼放在設定檔而非指令列——指令列參數會出現在行程列表
check=True——指令失敗時會拋出例外,不會靜默continue- 驗證產出檔案——大小異常就視為失敗
備份的完整要求見資料庫備份與還原的技術要點。
清理過期檔案
from datetime import datetime, timedelta
def cleanup(folder, keep_days=14):
cutoff = datetime.now() - timedelta(days=keep_days)
removed = 0
for f in Path(folder).glob('*.gz'):
if datetime.fromtimestamp(f.stat().st_mtime) < cutoff:
f.unlink()
removed += 1
logging.info(f'清除過期備份 {removed} 個')
務必先用「只列出不刪除」的版本確認,刪錯檔案是無法復原的。
日誌分析
import re
from collections import Counter
pattern = re.compile(r'^(\S+) .* "(\w+) (\S+).*" (\d{3})')
def analyze(log_path, top=20):
ips, paths, status = Counter(), Counter(), Counter()
with open(log_path, encoding='utf-8', errors='replace') as f:
for line in f:
m = pattern.match(line)
if not m:
continue
ip, method, path, code = m.groups()
ips[ip] += 1
status[code] += 1
if code == '404':
paths[path] += 1
return ips.most_common(top), status, paths.most_common(top)
實用的分析方向
- 請求量異常的來源——可能是掃描或攻擊
- 404 最多的路徑——改版後的遺漏轉址
- 狀態碼分布——5xx 突然增加代表有問題
日誌判讀見Nginx 與 Apache 的疑難排解。
排程設定
# crontab -e # 每日凌晨 3 點備份 0 3 * * * /path/to/.venv/bin/python /path/to/scripts/backup.py # 每小時檢查 0 * * * * /path/to/.venv/bin/python /path/to/scripts/check.py
四個常見的排程陷阱
- 沒用絕對路徑——排程的工作目錄與環境變數與登入時不同
- 沒指定虛擬環境的直譯器——會用到系統的 Python 而找不到套件
- 時區設定不同——確認伺服器時區
- 執行時間重疊——上一次還沒跑完就啟動下一次
防止重複執行
import fcntl, sys
lock_file = open('/tmp/mytask.lock', 'w')
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
logging.warning('前一次執行尚未結束,本次略過')
sys.exit(0)
可重複執行的設計
腳本應該重跑一次也不會造成問題:
- 寫入前先檢查是否已存在
- 用「更新或新增」而非單純新增
- 檔案輸出時包含時間戳,或先寫暫存檔再改名
# 先寫暫存檔,完成後才改名
tmp = target.with_suffix('.tmp')
write_data(tmp)
tmp.replace(target) # 原子性操作
這樣即使中途失敗,也不會留下半完成的檔案。
先做不會造成傷害的版本
任何會刪除、覆蓋、修改資料的腳本,建議都先做一個「只顯示會做什麼」的模式:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--dry-run', action='store_true',
help='只顯示會執行的動作,不實際執行')
args = parser.parse_args()
if args.dry_run:
print(f'[模擬] 將刪除 {f}')
else:
f.unlink()
這個習慣可以避免大部分的災難。
本文的範例以 Python 3 為例,實際的套件版本與 API 可能隨版本更新而異。在正式環境執行任何批次處理前,請先以少量資料測試並確實備份。
發表於2026-08-05
更新於2026-08-24