监测fastapi服务并自动拉起(不依靠dockerfile)
目前有一个需求,在容器中启动一个fastapi服务后,需要监测其运行状态,当运行中断后,需要进行自动的拉起。则需要处理两种情况:
- 程序所在容器由于服务器重启或者其他原因关闭,导致服务关闭。此时需要自动重启容器,并将服务自动拉起。
- 容器正常运行,服务未知原因终端,需要将中断的服务拉起。
1 服务中断
1.1 fastapi程序
所需要被监测的fastapi程序文件为app_test.py,简单的示例:
from fastapi import FastAPI
import uvicorn
from datetime import datetime
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "FastAPI app_test.py is running!"}
@app.get("/health")
def health_check():
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[HEALTH CHECK] Heartbeat accessed at {current_time}")
return {"status": "ok", "time": current_time}
if __name__ == "__main__":
uvicorn.run("app_test:app", host="0.0.0.0", port=8000)
启动命令为:python3 app_test.py > app_test.log 2>&1 &
1.2 监测/守护sh脚本
针对fastapi的程序,需要撰写一个sh脚本文件,负责监测fastapi程序的运行状态,当运行中断时负责进行拉起。
#!/bin/bash
# === 配置 ===
SERVICE_NAME="app_test.py" # 进程名称
SERVICE_COMMAND="/usr/bin/python3 /data/app_test.py" # 拉起fastapi服务的命令
CRON_JOB="*/1 * * * * root /bin/bash /data/check_service.sh" # 每分钟监测fastapi
CRONTAB_FILE="/etc/crontab"
# >>> log设置
LOG_DIR="/data/docker_service_monitor/logs"
LOG_FILE="$LOG_DIR/service_heartbeat_$(date +%Y%m%d).log" # 滚动创建log文件
mkdir -p $LOG_DIR
find $LOG_DIR -name "service_heartbeat_*.log" -mtime +7 -exec rm {} \; # 保留近7天日志文件
# >>> 临时锁文件
LOCK_FILE="/tmp/disable_fastapi_restart.lock"
# >>> 防无限制重启设置(读取文件中记录的失败次数, 若连续5次未拉取成功则不再继续拉取)
RESTART_FAIL_COUNT_FILE="/tmp/fastapi_restart_fail_count"
RESTART_FAIL_THRESHOLD=5
# === 工具函数 ===
log() {
local level="$1"
local message="$2"
echo "$(date '+%Y-%m-%d %H:%M:%S') [$level] $message" >> "$LOG_FILE"
}
# 检查cron任务是否存在
check_cron_job_exists() {
grep -F "$CRON_JOB" "$CRONTAB_FILE" > /dev/null 2>&1
}
# 添加cron任务
add_cron_job() {
echo "$CRON_JOB" >> "$CRONTAB_FILE"
service cron restart
log "INFO" "Cron任务添加完成, 并重启cron服务"
}
# 检查cron运行状态, 若未开启则开启
check_and_start_cron() {
if ! pgrep -x cron > /dev/null; then
log "WARN" "监测到cron服务未运行, 正在重启..."
service cron restart
log "INFO" "cron服务已重启"
else
log "INFO" "cron服务正常运行"
fi
}
# 检查fastapi程序是否运行
check_and_start_service() {
if ! pgrep -f "$SERVICE_NAME" > /dev/null; then
if [ -f "$LOCK_FILE" ]; then
log "INFO" "检测到锁文件,服务不会被重启"
return
fi
# 检查失败计数(若文件不存在, 则赋值为0)
fail_count=0
if [ -f "$RESTART_FAIL_COUNT_FILE" ]; then
content=$(cat "$RESTART_FAIL_COUNT_FILE")
if [[ "$content" =~ ^[0-9]+$ ]]; then
fail_count=$content
else
log "WARN" "失败计数文件内容非法,已重置为 0"
fail_count=0
fi
fi
if [ "$fail_count" -ge "$RESTART_FAIL_THRESHOLD" ]; then
log "ERROR" "⚠️⚠️⚠️服务多次重启失败 ($fail_count 次),已暂停自动拉起,请人工检查!!!"
return
fi
# 尝试重启服务
log "ERROR" "⚠️服务$SERVICE_NAME 已停止,准备重启 (失败计数: $fail_count)..."
nohup $SERVICE_COMMAND >> "$LOG_DIR/service_app_output.log" 2>&1 &
sleep 2
if pgrep -f "$SERVICE_NAME" > /dev/null; then
log "INFO" "服务$SERVICE_NAME 已确认启动成功"
echo 0 > "$RESTART_FAIL_COUNT_FILE"
else
log "ERROR" "⚠️服务$SERVICE_NAME 启动后未检测到进程"
fail_count=$((fail_count + 1))
echo "$fail_count" > "$RESTART_FAIL_COUNT_FILE"
fi
else
log "INFO" "服务$SERVICE_NAME 正在运行中"
echo 0 > "$RESTART_FAIL_COUNT_FILE" # 正常运行时清零
fi
}
# >>>>>>>>>>>>>>>>>>>>>>>>>>>> 主逻辑 >>>>>>>>>>>>>>>>>>>>>>>
main() {
log "INFO" ">>>>>>>>>> 开始一次服务监测 >>>>>>>>>>"
if ! check_cron_job_exists; then
log "WARN" "未监测到cron任务, 正在添加..."
add_cron_job
else
log "INFO" "cron任务已存在"
check_and_start_cron
fi
check_and_start_service
log "INFO" ">>>>>>>>>> 本次服务监测结束 <<<<<<<<<<"
echo "" >> "$LOG_FILE"
}
main "$@"
# >>> 想要真的关闭任务
# touch /tmp/disable_fastapi_restart.lock
# pkill -f app_test.py # 杀掉FastAPI进程
# >>> 想要开启任务:由于sh文件一直监测app_test.py, 所以只需将锁文件删除即可
# rm /tmp/disable_fastapi_restart.lock
# >>> 重启次数达到5次后,想要重新执行
# rm /tmp/fastapi_restart_fail_count
⚠️⚠️⚠️在终端中手动重启的命令一般为python/python3,所以很容易以为sh脚本拉起时也需要使用python/python3。但实际上cron所使用的环境为干净的环境,与终端的环境并不一致,所以直接使用python/python3可能会报错ModuleNotFoundError: No module named ‘xxx’。此时需要在终端中执行
which python
,然后将sh脚本中的执行命令由python/python3替换为python的实际所在位置,例如SERVICE_COMMAND="/root/anaconda3/bin/python3 /data/hongtao/workspace/northland/docker_service_monitor/app_test.py"
,其中/root/anaconda3/bin/python3
为python的绝对位置。
1.3 测试
此时可以尝试使用pkill -f app_test
将fastapi的进程关闭(app_test为启动fastapi的进程名),然后查看对应的log观察app_test进程是否被成功启动,或者直接使用ps aux | grep app_test
命令查看。
2 容器中断
2.1 容器配置
首先在开启容器时需要添加--restart=always
,使容器一直处以重启状态当中。例:
docker run -it --shm-size 2G --restart=always --name monitor_app_run -p 18100:8000 -p 18101:22 -v /data/:/data/ -w $PWD 3bc338c08e76 bash"
然后进入容器,使用which python/python3
确认python的安装位置,并在守护sh脚本中进行对应的更改,然后在容器中安装fastapi和unicorn。
2.2 宿主机配置
由于不使用dockerfile,所以不考虑entrypoint方案,使用systemd方案。首先在宿主机配置systemd所需使用的文件(最好先确认mycontainer.service文件原始并不存在,防止影响原有的逻辑):
vi /etc/systemd/system/mycontainer.service
编辑mycontainer.service(注意其中的my_container_name需要更改为需要控制的容器名,/data/check_service.sh为守护的sh脚本路径):
[Unit]
Description=My Python Service Container
After=docker.service
Requires=docker.service
[Service]
Restart=always
ExecStart=/usr/bin/docker start -a my_container_name
ExecStop=/usr/bin/docker stop my_container_name
# 容器启动后执行的脚本(sh脚本的路径为容器内的路径)
ExecStartPost=/usr/bin/docker exec my_container_name /bin/bash /data/check_service.sh
[Install]
WantedBy=multi-user.target
然后执行
systemctl daemon-reexec
systemctl daemon-reload
systemctl enable mycontainer.service
systemctl start mycontainer.service
如果执行最后一个命令时报错no device则执行:
sysctl fs.inotify
vim /etc/sysctl.conf
# 添加以下语句,将max_user_watches扩充10倍
fs.inotify.max_user_watches = 81920
# 配置完成后刷新配置
sysctl -p
# 再次查看inotify
sysctl fs.inotify
整体均配置完成后,执行:
systemctl status mycontainer.service
查看配置执行状态是否为running,然后进入容器ps aux | grep app_test
查询监测脚本对应的py进程是否正确被拉起。
3 人为中断进程
当整体的链接被搭建完成后,普通的中断程序操作均会被恢复,此时如果我们真的想要关闭程序该怎么办?
3.1 临时锁文件
在守护sh脚本中已经添加了临时锁文件的判断,即若LOCK_FILE="/tmp/disable_fastapi_restart.lock"中提到的lock文件存在,则跳过拉起操作,并提示存在锁文件,不进行拉起。
如果想要真的关闭任务,进入容器执行:
touch /tmp/disable_fastapi_restart.lock
pkill -f app_test.py # 杀掉FastAPI进程
如果想要再次开启任务,进入容器执行:
rm /tmp/disable_fastapi_restart.lock
经测试,使用pkill -f app_test
、docker restart
、docker stop
的情况均能进行处理,且当真正想要关闭服务时,采用临时文件锁的方式也能够实现。
3.2 模拟报错
# 模拟启动前报错
if __name__ == "__main__":
raise RuntimeError("模拟 FastAPI 启动失败") # 模拟启动失败异常
uvicorn.run("app_test:app", host="0.0.0.0", port=8000)
# 模拟app引用错误
if __name__ == "__main__":
uvicorn.run("app_test:non_existing_app", host="0.0.0.0", port=8000)
# 模拟端口被占用错误
python3 -m http.server 8000 # 先手动占用8000, 再开启服务