backend-ci / test (push) Failing after 4m39s
PS5 Invoke-RestMethod 对字符串 body 默认按 Latin-1 编码导致中文变 ???, 改为手动转 UTF-8 字节数组并声明 charset=utf-8。
233 lines
9.4 KiB
PowerShell
233 lines
9.4 KiB
PowerShell
# Author: fruit.com.cn
|
||
#
|
||
# 服务看门狗:每 2 分钟检查一次,挂了自动拉起,连续失败发飞书告警。
|
||
# 注册为 Windows 计划任务后无人值守运行。
|
||
|
||
param(
|
||
[string]$ComposeDir = "C:\FruitServer",
|
||
[string]$LogDir = "C:\FruitServer\logs",
|
||
[string]$FeishuWebhook = "",
|
||
[int]$MaxRestarts = 3,
|
||
[string]$FrontendTask = "FruitShorts-Web-3005"
|
||
)
|
||
|
||
$ErrorActionPreference = "Continue"
|
||
$ComposeFile = Join-Path $ComposeDir "docker-compose.yml"
|
||
$LogFile = Join-Path $LogDir "watchdog-$(Get-Date -Format 'yyyy-MM-dd').log"
|
||
# 连续失败计数落盘文件:计划任务每 2 分钟起一个新进程,计数只放进程内永远累积不到
|
||
# $MaxRestarts,告警会静默哑火。
|
||
$StateFile = Join-Path $LogDir "watchdog-state.json"
|
||
|
||
function Write-Log($msg) {
|
||
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') $msg"
|
||
Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
function Send-Alert($title, $msg) {
|
||
if (-not $FeishuWebhook) { return }
|
||
$json = @{
|
||
msg_type = "text"
|
||
content = @{ text = "[$title] $msg" }
|
||
} | ConvertTo-Json -Depth 3
|
||
try {
|
||
# PS5 的 Invoke-RestMethod 对字符串 body 默认按 Latin-1 编码,中文会变成 ???,
|
||
# 必须手动转 UTF-8 字节数组发送
|
||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
|
||
Invoke-RestMethod -Uri $FeishuWebhook -Method Post -Body $bytes -ContentType "application/json; charset=utf-8" -TimeoutSec 10
|
||
} catch {
|
||
Write-Log "飞书告警发送失败: $_"
|
||
}
|
||
}
|
||
|
||
# 连续失败计数的读写:JSON 落盘,跨进程保留
|
||
function Import-FailCounts {
|
||
$counts = @{}
|
||
if (-not (Test-Path $StateFile)) { return $counts }
|
||
try {
|
||
$saved = Get-Content -Path $StateFile -Raw | ConvertFrom-Json
|
||
foreach ($prop in $saved.PSObject.Properties) {
|
||
$counts[$prop.Name] = [int]$prop.Value
|
||
}
|
||
} catch {
|
||
Write-Log "看门狗计数文件解析失败,各服务按 0 重新计数: $_"
|
||
}
|
||
return $counts
|
||
}
|
||
|
||
function Export-FailCounts($counts) {
|
||
try {
|
||
$counts | ConvertTo-Json | Set-Content -Path $StateFile -Encoding UTF8
|
||
} catch {
|
||
Write-Log "看门狗计数文件写入失败: $_"
|
||
}
|
||
}
|
||
|
||
function Test-Docker {
|
||
docker info --format "{{.ServerVersion}}" 2>$null | Out-Null
|
||
return ($LASTEXITCODE -eq 0)
|
||
}
|
||
|
||
function Test-Http([string]$url) {
|
||
try {
|
||
$response = Invoke-WebRequest -Uri $url -TimeoutSec 10 -UseBasicParsing -ErrorAction Stop
|
||
return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 400)
|
||
} catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
function Test-Container([string]$container) {
|
||
$status = docker inspect -f "{{.State.Status}}" $container 2>$null
|
||
return ($status -eq "running")
|
||
}
|
||
|
||
function Invoke-Compose([string[]]$composeArgs) {
|
||
& docker compose -f $ComposeFile @composeArgs 2>&1 | Out-Null
|
||
return $LASTEXITCODE
|
||
}
|
||
|
||
# 确保日志目录存在
|
||
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||
|
||
# 告警 Webhook:命令行参数 > 环境变量 > deploy 目录下的 feishu-webhook.txt。
|
||
# 计划任务注册命令里不写密钥(密钥不该进版本库),由部署机本地放文件或配环境变量。
|
||
if (-not $FeishuWebhook) { $FeishuWebhook = $env:FRUITSHORTS_FEISHU_WEBHOOK }
|
||
if (-not $FeishuWebhook) {
|
||
$webhookFile = Join-Path $PSScriptRoot "feishu-webhook.txt"
|
||
if (Test-Path $webhookFile) {
|
||
$FeishuWebhook = (Get-Content -Path $webhookFile -Raw -ErrorAction SilentlyContinue)
|
||
if ($FeishuWebhook) { $FeishuWebhook = $FeishuWebhook.Trim() }
|
||
}
|
||
}
|
||
if (-not $FeishuWebhook) {
|
||
Write-Log "未配置飞书 Webhook(参数/环境变量/feishu-webhook.txt 均为空),告警只写本地日志"
|
||
}
|
||
|
||
# Docker Desktop 登录后偶尔尚未就绪,先等待并尝试拉起。
|
||
# 注意:本脚本以 SYSTEM 身份运行,绝不能直接 Start-Process Docker Desktop.exe。
|
||
# WSL 不支持本地系统账户运行(WSL_E_LOCAL_SYSTEM_NOT_SUPPORTED),
|
||
# 以 SYSTEM 启动会把 docker-desktop 发行版搞成僵死状态(VHD 已挂载无法终止),
|
||
# 结果是登录用户会话里的 Docker Desktop 也永远起不来。
|
||
# 因此改为触发以登录用户身份运行的 FruitShorts-DockerDesktop 计划任务。
|
||
if (-not (Test-Docker)) {
|
||
Write-Log "Docker 未就绪,尝试通过用户会话启动 Docker Desktop"
|
||
$desktopTask = "FruitShorts-DockerDesktop"
|
||
try {
|
||
# Docker Desktop.exe 是任务进程树的一部分,GUI 活着时任务一直是 Running,
|
||
# 直接 Start-ScheduledTask 会被 IgnoreNew 忽略,所以先结束旧实例再拉起。
|
||
$t = Get-ScheduledTask -TaskName $desktopTask -ErrorAction Stop
|
||
if ($t.State -eq "Running") {
|
||
Stop-ScheduledTask -TaskName $desktopTask -ErrorAction SilentlyContinue
|
||
Start-Sleep -Seconds 5
|
||
}
|
||
Start-ScheduledTask -TaskName $desktopTask -ErrorAction Stop
|
||
} catch {
|
||
Write-Log "触发 Docker Desktop 启动任务失败: $_"
|
||
}
|
||
|
||
for ($i = 0; $i -lt 18; $i++) {
|
||
Start-Sleep -Seconds 5
|
||
if (Test-Docker) { break }
|
||
}
|
||
}
|
||
|
||
if (-not (Test-Docker)) {
|
||
Write-Log "Docker 在 90 秒内未就绪,跳过本轮检查"
|
||
Send-Alert "服务异常" "Docker Desktop 无法启动,请人工检查桌面机。"
|
||
exit 1
|
||
}
|
||
|
||
# 健康检查定义:名称 → 检查方式
|
||
$checks = @(
|
||
@{ Name = "app-api"; Type = "http"; Url = "http://127.0.0.1:8081/healthz"; Service = "app-api"; Container = "fruitshorts-app-api" },
|
||
@{ Name = "admin-api"; Type = "http"; Url = "http://127.0.0.1:8080/healthz"; Service = "admin-api"; Container = "fruitshorts-admin-api" },
|
||
@{ Name = "frontend"; Type = "frontend"; Url = "http://127.0.0.1:3005/dist-dev/" },
|
||
@{ Name = "Grafana"; Type = "http"; Url = "http://127.0.0.1:3000/api/health"; Service = "grafana"; Container = "fruitshorts-grafana" },
|
||
@{ Name = "PostgreSQL"; Type = "docker"; Service = "db"; Container = "fruitshorts-db" },
|
||
@{ Name = "Redis"; Type = "docker"; Service = "redis"; Container = "fruitshorts-redis" },
|
||
@{ Name = "Prometheus"; Type = "http"; Url = "http://127.0.0.1:9090/-/healthy"; Service = "prometheus"; Container = "fruitshorts-prometheus" }
|
||
)
|
||
|
||
# 跟踪每个服务的连续失败次数(跨进程从状态文件恢复)
|
||
$failCounts = Import-FailCounts
|
||
|
||
foreach ($check in $checks) {
|
||
$name = $check.Name
|
||
$healthy = $false
|
||
|
||
if ($check.Type -eq "http") {
|
||
$healthy = Test-Http $check.Url
|
||
} elseif ($check.Type -eq "frontend") {
|
||
$healthy = Test-Http $check.Url
|
||
} else {
|
||
$healthy = Test-Container $check.Container
|
||
}
|
||
|
||
if ($healthy) {
|
||
if ($failCounts[$name] -gt 0) {
|
||
Write-Log "$name 恢复正常(此前连续失败 $($failCounts[$name]) 次)"
|
||
Send-Alert "服务恢复" "$name 已恢复正常"
|
||
}
|
||
$failCounts[$name] = 0
|
||
} else {
|
||
if ($null -eq $failCounts[$name]) {
|
||
$failCounts[$name] = 0
|
||
}
|
||
$failCounts[$name] = $failCounts[$name] + 1
|
||
$count = $failCounts[$name]
|
||
Write-Log "$name 不健康(第 $count 次)"
|
||
|
||
if ($check.Type -eq "frontend") {
|
||
Write-Log "$name 重启计划任务 $FrontendTask"
|
||
Stop-ScheduledTask -TaskName $FrontendTask -ErrorAction SilentlyContinue
|
||
Start-Sleep -Seconds 2
|
||
Start-ScheduledTask -TaskName $FrontendTask -ErrorAction SilentlyContinue
|
||
Start-Sleep -Seconds 6
|
||
$healthy = Test-Http $check.Url
|
||
} elseif ($check.Type -eq "http") {
|
||
Write-Log "$name 重启 Compose 服务 $($check.Service)"
|
||
Invoke-Compose @("restart", $check.Service) | Out-Null
|
||
Start-Sleep -Seconds 12
|
||
$healthy = Test-Http $check.Url
|
||
|
||
if (-not $healthy) {
|
||
Write-Log "$name 重启后仍不健康,执行 compose up -d"
|
||
Invoke-Compose @("up", "-d", $check.Service) | Out-Null
|
||
}
|
||
} else {
|
||
Write-Log "$name 拉起容器 $($check.Container)"
|
||
docker start $check.Container 2>&1 | Out-Null
|
||
Start-Sleep -Seconds 5
|
||
$healthy = Test-Container $check.Container
|
||
|
||
if (-not $healthy) {
|
||
Write-Log "$name 启动失败,执行 compose up -d"
|
||
Invoke-Compose @("up", "-d", $check.Service) | Out-Null
|
||
}
|
||
}
|
||
|
||
# 连续失败达到上限 → 告警。计数会跨进程累积,所以只在首次达阈值和
|
||
# 之后每 30 轮(约 1 小时)提醒一次,避免每 2 分钟刷屏。
|
||
if (-not $healthy -and $count -ge $MaxRestarts -and
|
||
($count -eq $MaxRestarts -or $count % 30 -eq 0)) {
|
||
Send-Alert "服务异常" "$name 已连续 $count 次不健康,已尝试重启。请人工介入检查。"
|
||
Write-Log "$name 连续 $count 次失败,已发告警"
|
||
} elseif ($healthy) {
|
||
Write-Log "$name 已自动恢复"
|
||
}
|
||
}
|
||
}
|
||
|
||
# 磁盘检查:超过 85% 告警
|
||
$drive = (Get-PSDrive C -ErrorAction SilentlyContinue)
|
||
if ($drive) {
|
||
$pct = [math]::Round(($drive.Used / ($drive.Used + $drive.Free)) * 100)
|
||
if ($pct -gt 85) {
|
||
Write-Log "磁盘使用率 ${pct}%(超过 85%)"
|
||
Send-Alert "磁盘告警" "C 盘使用率 ${pct}%,请清理日志或扩容"
|
||
}
|
||
}
|
||
|
||
# 本轮结果落盘,供下一轮(2 分钟后的新进程)继续累加
|
||
Export-FailCounts $failCounts
|