摘要:硬盘空间快满了没人提醒,我写了个脚本盯着。这篇把脚本完整贴出来:要盯的路径填哪儿、两条线怎么定、通知渠道怎么配、怎么装成开机和定时两个任务,以及「等待时间按看表不按次数算」这个我自己重写才补上的坑。Python 和 PowerShell 两版都给了,Windows 自带就能跑。
各位亲爱的读者朋友,上一篇咱们聊的是条码打印机打不出来、扫不出来那九种情况,今天这篇说个更平常的:硬盘空间快满了,我怎么第一时间知道的。
先看一张图,你想不想,让硬盘空间使用情况,在自己设置的阈值报警范围时,第一时间收到预警提示?

注:这条是开机时脚本跑起来推的提示,不是出了故障的报警。真到报警的时候,推出来的消息也是这个格式。
一、个人电脑满了删点东西就过去了,单位不是这么回事
按理说硬盘空间满了不算什么大毛病,删点东西就过去了。但这种情况一般来说只对咱们个人使用的电脑硬盘适用。
作为一个单位或是企业的运维,我们手里需要管理的电脑、服务器往往都不止一台两台。有些服务器自己的硬盘插口数量有限,插满了想再接硬盘都接不上,所以很多企业另有专门的存储阵列,用的是光纤通道存储(FC SAN)——通过双光纤链路连到服务器上,在服务器系统里看着跟本地硬盘一样,可数据其实都躺在外面的阵列里。这么多的硬盘空间,如果有一个注意不到,盘满就会出问题。所以,对于我们来说,存储空间管理也是一个重中之重的事情。
为什么说是重中之重的事?因为硬盘空间满了它不会提前跟你商量,也不打电话,等它满了,我这最直接的体现就是体检软件保存数据报错、报告导不出来、片子传不上去……可以说体检业务就直接瘫痪了,根本没办法进行下去。前台那边还排着队,你这时候再去翻是哪些文件把硬盘空间撑满的,那场面想想都头疼。
所以有些单位或企业上了运维监控平台,大屏上设备状态一览无余,还能自己设报警阈值,有问题能提前收到提醒。
但对于有些单位或企业,没有上运维监控平台,也没有数据看板,但多多少少还有一些关键设备的提示。不过一般这种提示,都只发到科主任那儿。而真正负责这堆事务的运维反而啥提示都没有,要么自己多连接远程查看,要么等科主任通知处理。
我现在的情况就是如此,自己需要盯着的这块,啥检测手段都没有。或者说,只是自己这没有,全靠人盯。
而服务器不止一台,要盯的硬盘空间也不止一个。各个工作站上有各种业务存储需要注意的硬盘空间,各台服务器上,尤其是存放射、CT、B超这些大量图片的,更需要时刻关注。体检一天做下来,这些文件就一直在往里堆,一个不注意,硬盘满了,业务立马就得停下来。
这几年我养成一个习惯——想起来就打开资源管理器看一眼,红了就整理整理,或者从存储里再给服务器划一块新的空间。
说实话,这习惯靠不住。人一忙,半个月想不起来看一次;真等你想起它,可能人正在外检的路上,或者在别的科室蹲着修电脑。
所以我一直想,有没有什么办法可以找个管家帮我盯着?
我的要求也不高,就三条:快满了提前告诉我;真满了别等我去发现;处理完了再给我说一声,恢复了。说白了,我不想当那个"硬盘空间满了才知道"的人。
二、我到底想解决什么问题
其实我最初的目的是为了监测数据库占用情况,因为体检,图片信息比较多,体检数据量大了,很有可能三五天,硬盘空间就会满了。另外,定时备份数据库那会儿也占地方:备份文件本身要往盘上写,备份的时候事务日志还不能截断、日志文件会临时涨一截,两样叠在一块儿,剩余空间就往下掉。所以我盯的这条线,主要就是防备份那阵子把盘撑满。这也就是我开篇放的截图里为什么每天都会有一条提示。
三、动手之前,先说清两件事
在给各位读者正式分享我的脚本前,先说清两件事,不然容易空欢喜:第一,这个东西查到的是"硬盘空间",不是"文件夹"。你把共享目录填进去,它报给你的是那块硬盘空间的总量和剩余量,不是这个文件夹占了多大。而且不用把共享映射成 Y:、Z: 这种盘符——直接把路径填进去就行。
第二,脚本需要运行在内外网都能通的电脑上。内网,就是能正常访问需要监测的服务器地址,好及时掌握服务器空间使用情况;外网,把监测情况通过企业微信发告警到自己手机。
至于外网接收告警怎么配置,之前的几篇文章都写过,还不会配置的读者朋友可以看看我之前写的《使用脚本让手机消息告诉我网络出问题了》——那篇把群机器人怎么建、Webhook 地址在哪儿找,从零上手一步一步都写了;《数据库卡死之前,我一般能提前知道》里也在用同一套群机器人推送。这里就不重复了。
限制说清楚了,再说我这脚本是怎么一点点调出来的。
四、第一版踩的三个坑
我一开始把"文件"和"图片"两个共享都填进去,跑起来一看,两个数字一模一样——它们本来就在同一块硬盘空间上。留两个不算错,只是这个数盯一个就够了。
所以我开篇那张截图里,才会看到两行一模一样的数字。
第一版我写得很简单:一个脚本,配上计划任务每 5 分钟跑一次,剩余空间低于我定的线,就往微信上发一条。
结果没几天就出幺蛾子了。
每天早上我一登录电脑,过大概 5 分钟,手机就收到一条:无法访问,系统找不到指定的路径。再过 5 分钟,又来一条:已恢复。天天如此,准时得像闹钟。
我先是怀疑脚本里路径写错了。把脚本读的路径打出来跟实际地址对着看,一个字符都不差。又怀疑是服务器那头共享断了。登录服务器一看,共享也好端端地,别的电脑还在正常读写片子。
折腾了两天才想明白,这坑是我自己挖的:计划任务配置时,我设置的是"只在用户登录后运行"。登录那一刻,任务开始跑,可那会儿 Windows 还没把网络硬盘空间连上——虽然桌面已经出来了,但网络功能还没有通,它那条连接还没拉起来。脚本去问"这个硬盘空间还剩多少",系统肯定回它一句"找不到路径"。
脚本没错,是我让它问得太早了。
后来我改了三处。
第一处,跑之前先等一等。脚本先自己去探一下共享通不通,不通就隔 15 秒再探,最多等 12 分钟。共享正常的时候,第一次探就通过,一点时间都不耽误;不正常的时候,它就安安静静等到连上了再查。
第二处,加了个单实例锁(就是让同一时间只跑一个,别叠着跑)。因为等待这段时间可能已经过去了 5 分钟,下一个周期又触发一次:等的那个还在等,新的那个又来报一遍,越弄越乱。
第三处,路径别用映射的盘符,虽然映射过来的盘符在"我的电脑"中打开,可以很直观的看到硬盘空间使用情况,但脚本调用时就很容易出问题,直接用 UNC 路径(就是 \\服务器\\共享目录 这种写法,不经过 Y:、Z: 这类盘符)。盘符是"你这个登录用户才有的东西",计划任务跑起来的时候,它经常压根就不存在。
五、还有个时间上的小坑
还有一个小坑,是我这次重写才补上的:等待的时间得"看表",不能"数睡了几次"。
什么意思呢。"看表"是每等一轮就回头看看真实时间过去多久,超了就收工;"数睡了几次"是心里先算好要睡几轮,轮数是拿 12 分钟除以 15 秒算出来的 48 轮,睡完 48 轮就收工。
平时没区别,因为共享正常的时候探一下是瞬间的事,每轮就睡 15 秒。可网络真断的时候,探一次本身就得等十几秒(我实测过,确实是 14 秒多)——这一轮就不是 15 秒了,是三十秒。按 48 轮算下来,说好等 12 分钟,实际得等二十几分钟,网络更差的时候能到四十来分钟。
所以改成看表:不管探一次要多久,只要真的过了 12 分钟就收工。我实测过,探一次 14 秒也好、30 秒也好,最后都停在 12 分钟左右。
作者有话说:如果上面的解释和打比方没有看懂也没关系,不影响脚本的正常使用,其实这就是一个程序执行算法问题,如果感兴趣的读者朋友多,可以评论区留言,我到时候单独开篇解释,要不这篇就放不下了!
六、第二个毛病:一天能收到几十条
第二个毛病是:它不知道"这事儿我报过了"。
第一版是每 5 分钟查一次,只要红了就发。硬盘空间一红,我一天能收到几十条一模一样的信息。更气人的是,等我把旧片子归档清出去、硬盘空间已经绿了,它照样在发——因为它只看见"红",不知道我刚才动过手。
后来我让它记着点:这次查完,“哪几个硬盘空间是红的"存到一个小文件里。下一轮查完先跟上次比一比,一样就不吭声,变了才发——红了一个,报;全恢复了,报一声"已恢复”。中间不管查多少次,都闭嘴。
是不是感觉很简单?就这么一个"记着上次说过什么"的动作,手机从一天几十条,变成一天最多两条。
七、脚本正文,两版任选
脚本我写了两版,功能一模一样,你挑一版就行。
先说怎么挑:
已经装了 Python,想改成自己的逻辑 —— 用下面这个 Python 版,改起来更顺手。
手上没装 Python,或者压根不想折腾环境 —— 用再往后那个 PowerShell 版。它是 Windows 自带的,不用装任何东西,存成 .ps1 文件双击就能跑,计划任务里也是直接调它。我自己平时用的就是这版。
两版的开头都是同样三处要改,你只要照着填就行。
先看 Python 版。
# -*- coding: utf-8 -*-
"""盯硬盘空间的:剩余空间不够就发微信,恢复了也发一条。
只依赖 Python 标准库;Windows 上配 pythonw.exe 跑,不弹黑窗口。
"""
import json
import os
import shutil
import time
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
BASE = Path(__file__).resolve().parent
LOG = BASE / "disk_monitor.log"
STATE = BASE / "disk_state.json"
LOCK = BASE / "disk_monitor.lock"
# ======== 要改的就这三处,这里的 IP 地址只是举例,修改成你自己需要监测的地址 ========
TARGETS = [
{"label": "体检图片", "path": r"\\\\服务器IP\\共享目录"},
{"label": "本地C盘", "path": "C:/"},
]
MIN_FREE_GB = 500.0 # 剩余不到 500G 就报
MIN_FREE_PERCENT = 20.0 # 剩余比例不到 20% 也报
WECOM_WEBHOOK = "" # 企业微信群机器人地址,不用就留空
SERVERCHAN_KEY = "" # Server 酱 SendKey,不用就留空
# ================================
WAIT_MAX = 720 # 开机/登录后最多等共享 12 分钟
WAIT_POLL = 15 # 每 15 秒探一次
DROP_LOCK_AFTER = 900 # 超过 15 分钟的锁当作上次异常退出,直接接管
def log(msg):
line = "[%s] %s" % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg)
try:
with open(LOG, "a", encoding="utf-8") as f:
f.write(line + "\\n")
except Exception:
pass
def load_state():
try:
return json.loads(STATE.read_text(encoding="utf-8"))
except Exception:
return {}
def save_state(state):
try:
STATE.write_text(json.dumps(state, ensure_ascii=False, indent=2),
encoding="utf-8")
except Exception as e:
log("状态文件写不进去:%s" % e)
def take_lock():
"""同一时间只让一个跑,避免上一轮没完、下一轮又上来。"""
try:
if time.time() – LOCK.stat().st_mtime > DROP_LOCK_AFTER:
LOCK.unlink()
log("发现陈旧锁,直接接管")
except FileNotFoundError:
pass
except Exception:
pass
try:
os.close(os.open(str(LOCK), os.O_CREAT | os.O_EXCL | os.O_RDWR))
return True
except FileExistsError:
return False
except Exception as e:
log("加锁失败,按无锁继续:%s" % e)
return True
def drop_lock():
try:
LOCK.unlink()
except Exception:
pass
def wait_ready(targets, max_wait=WAIT_MAX, poll=WAIT_POLL):
"""刚开机、刚登录时网络硬盘空间还没连好,先等一会儿再查,别急着报故障。
按"看表"算时间,不按"睡了几次"算:网络真断的时候,光探一次就要
等十几秒,按次数算能等成半个多小时。
"""
started = time.time()
pending = list(targets)
while pending and time.time() < started + max_wait:
still = []
for t in pending:
try:
shutil.disk_usage(t["path"])
except Exception:
still.append(t)
if not still:
return True
pending = still
time.sleep(poll)
if pending:
log("等了约 %d 秒仍连不上:%s" % (
time.time() – started, "、".join(t["label"] for t in pending)))
return not pending
def human(n):
gb = n / (1024.0 ** 3)
return "%.2f TB" % (gb / 1024.0) if gb >= 1024 else "%.2f GB" % gb
def check(t):
it = {"label": t["label"], "path": t["path"], "ok": True,
"free": 0, "total": 0, "percent": 0.0, "err": ""}
try:
u = shutil.disk_usage(t["path"])
except Exception as e:
it["ok"] = False
it["err"] = str(e)
return it
it["total"], it["free"] = u.total, u.free
if u.total:
it["percent"] = u.free * 100.0 / u.total
return it
def is_alert(it):
if not it["ok"]:
return True
if (it["free"] / (1024.0 ** 3)) <= MIN_FREE_GB:
return True
return it["percent"] <= MIN_FREE_PERCENT
def notify(title, msg, dry_run=False):
if dry_run:
log("[试运行] " + title)
log("[试运行] " + msg.replace("\\n", " | "))
return
if not WECOM_WEBHOOK and not SERVERCHAN_KEY:
log("没配通知渠道,这次不发")
return
if WECOM_WEBHOOK:
try:
data = json.dumps({"msgtype": "text",
"text": {"content": title + "\\n" + msg}},
ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
WECOM_WEBHOOK, data=data,
headers={"Content-Type": "application/json; charset=utf-8"})
urllib.request.urlopen(req, timeout=20).read()
except Exception as e:
log("企业微信没发出去:%s" % e)
if SERVERCHAN_KEY:
try:
body = urllib.parse.urlencode({"title": title, "desp": msg})
url = "https://sctapi.ftqq.com/%s.send" % SERVERCHAN_KEY
urllib.request.urlopen(urllib.request.Request(
url, data=body.encode("utf-8")), timeout=20).read()
except Exception as e:
log("Server 酱没发出去:%s" % e)
def build(items, suffix):
lines = ["检查时间:" + datetime.now().strftime("%Y-%m-%d %H:%M:%S"), ""]
for it in items:
if it["ok"]:
lines.append("%s:剩余 %s / 共 %s(%.1f%%)" % (
it["label"], human(it["free"]), human(it["total"]), it["percent"]))
else:
lines.append("%s:打不开(%s)" % (it["label"], it["err"]))
return "磁盘空间" + suffix, "\\n".join(lines)
def main(dry_run=False):
log("—— 开始检查 ——")
if not take_lock():
log("上一轮还没跑完,这轮跳过")
return 0
try:
wait_ready(TARGETS)
items = [check(t) for t in TARGETS]
for it in items:
it["alert"] = is_alert(it)
log("[%s] %s 剩 %.2fG(%.1f%%)%s" % (
"报警" if it["alert"] else "正常", it["label"],
it["free"] / (1024.0 ** 3), it["percent"],
"" if it["ok"] else " 打不开:" + it["err"]))
now = sorted(it["path"] for it in items if it["alert"])
state = load_state()
if now != sorted(state.get("alert", [])):
title, msg = build(items, "预警" if now else "已恢复")
notify(title, msg, dry_run)
state["alert"] = now
state["last_notify"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
save_state(state)
log("状态变了,已推送:%s" % title)
else:
log("状态没变,不重复推")
finally:
drop_lock()
return 0
if __name__ == "__main__":
import sys
main(dry_run="–dry-run" in sys.argv)
下面是 PowerShell 版,完整代码。和上面那个 Python 版比,它不依赖任何外部环境,Windows 7/10/11 自带,不用装 Python、不用配运行环境。
# 硬盘空间监控工具 (PowerShell 版,免安装,兼容 Windows 7 / 10 / 11)
# 盯指定的硬盘空间(本地盘或服务器共享目录),剩余空间低于阈值就发告警;
# 恢复正常后再发一条"已恢复"通知。
#
# 用法:
# 试运行(只写日志、不真发消息):powershell -ExecutionPolicy Bypass -File .\\disk_monitor.ps1 -DryRun
# 正式运行(真发消息) :powershell -ExecutionPolicy Bypass -File .\\disk_monitor.ps1
# 装成计划任务 :程序填 powershell.exe,参数填
# -ExecutionPolicy Bypass -File "完整路径\\disk_monitor.ps1"
#
# 说明:查空间分两条路——
# 本地盘 :用 Windows 自带的 .NET 驱动器接口,直接读 C:\\ 这种根目录;
# 共享目录:用系统自带的磁盘清单(Win32_LogicalDisk)按共享路径反查,
# 取不到就临时映射一个盘符再查,查完立刻解除。
# 两条路都不解析文字输出、不受系统语言影响,Win7/Win10/Win11 均兼容。
param(
[switch]$DryRun
)
$ScriptDir = Split-Path –Parent $MyInvocation.MyCommand.Definition
$LogFile = Join-Path $ScriptDir 'disk_monitor.log'
$StateFile = Join-Path $ScriptDir 'disk_state.json'
$LockFile = Join-Path $ScriptDir 'disk_monitor.lock'
# ==================== 要改的就这三处,下面的 IP 和路径只是举例 ====================
$Targets = @(
# 服务器上的共享目录:写 UNC 路径,别写映射盘符(Y:、Z: 那种)
@{ label = '体检图片'; path = '\\\\服务器IP\\共享目录' }
# 本地盘:直接写盘符加冒号
@{ label = '本地C盘'; path = 'C:\\' }
)
# 剩余空间小于这个值(G)就报
$MinFreeGB = 500.0
# 剩余比例小于这个百分比就报
$MinFreePercent = 20.0
# 通知渠道:企业微信群机器人的 Webhook 地址,留空就不用
$WecomWebhook = ''
# 通知渠道:Server 酱的 SendKey,留空就不用
$ServerChanKey = ''
# 共享目录还没连上时,要不要自己临时映射一个盘符去连一次?
# 默认 $false —— 连不上就在等待循环里隔 15 秒重试,
# 开着的话每次探测都要等网络超时,一轮下来十几分钟就白等了。
# 前提:你的共享是开机就自动重连的那种(映射盘符常驻),一般不用开。
$TryMapUnc = $false
# ============================================================================
# 开机/登录后最多等共享 12 分钟
$WaitMaxSeconds = 720
# 每 15 秒探一次
$WaitPollSeconds = 15
# 超过 15 分钟的锁当作上次异常退出,直接接管
$DropLockAfterSeconds = 900
# ———- 日志(带自动滚动,防止长期运行日志无限增大) ———-
$global:LogLineCount = 0
function Trim-LogIfNeeded {
try {
$fi = Get-Item –Path $LogFile –ErrorAction SilentlyContinue
if ($fi -and $fi.Length -gt 5MB) {
$tail = Get-Content –Path $LogFile –Encoding UTF8 –Tail 12000
Set-Content –Path $LogFile –Value $tail –Encoding UTF8
}
} catch {}
}
function Write-Log {
param([string]$msg)
$ts = Get-Date –Format 'yyyy-MM-dd HH:mm:ss'
$line = "[$ts] $msg"
Write-Output $line
try {
Add-Content –Path $LogFile –Value $line –Encoding UTF8
$global:LogLineCount++
if ($global:LogLineCount % 100 -eq 0) { Trim-LogIfNeeded }
} catch {}
}
# ———- 查一个本地盘的空间 ———-
function Get-LocalDiskSpace {
param([string]$path)
try {
$di = New-Object System.IO.DriveInfo($path)
if ($di.DriveType -eq [System.IO.DriveType]::NoRootDirectory) {
return @{ ok = $false; total = 0; free = 0; percent = 0.0; err = "路径不是磁盘根目录" }
}
$total = [int64]$di.TotalSize
$free = [int64]$di.AvailableFreeSpace
$percent = 0.0
if ($total -gt 0) { $percent = [math]::Round($free * 100.0 / $total, 1) }
return @{ ok = $true; total = $total; free = $free; percent = $percent; err = '' }
} catch {
return @{ ok = $false; total = 0; free = 0; percent = 0.0; err = $_.Exception.Message }
}
}
# ———- 查一个共享目录(UNC)的空间 ———-
# 思路:系统的磁盘清单(Win32_LogicalDisk)里记着每个网络盘的原始共享路径,
# 按共享路径反查就能拿到空间,快(百来毫秒)。
# 反查不到有两种可能:①这台机器还没连过这个共享 ②共享现在不通。
# 区分办法是先问系统"这个共享在不在清单里"——在,说明连过,
# 用临时盘符再试一次;不在,就直接判不通,不去做那几秒到几十秒的网络等待。
function Get-UncDiskSpace {
param([string]$path)
$unc = $path.TrimEnd('\\')
$vol = $null
try {
$vol = Get-CimInstance –ClassName Win32_LogicalDisk –ErrorAction Stop |
Where-Object { $_.DriveType -eq 4 -and $_.ProviderName -and ($_.ProviderName.TrimEnd('\\') –ieq $unc) } |
Select-Object –First 1
} catch {
Write-Log "查磁盘清单失败:$_"
}
if ($vol -and $vol.Size) {
$total = [int64]$vol.Size
$free = [int64]$vol.FreeSpace
$percent = 0.0
if ($total -gt 0) { $percent = [math]::Round($free * 100.0 / $total, 1) }
return @{ ok = $true; total = $total; free = $free; percent = $percent; err = '' }
}
# 清单里没有这个共享:多半是这台机器还没连过它。
# 想让它自己去连一次(会等网络超时,一轮下来十几分钟就没了),
# 把下面这行的开关改成 $true 就行。默认不做。
if ($TryMapUnc) {
$letter = 'Q'
for ($i = 0; $i -lt 20; $i++) {
$letter = [char]([int][char]'Q' + $i)
if (-not (Test-Path –Path ($letter + ':\\') –ErrorAction SilentlyContinue)) { break }
}
$mapped = $false
try {
cmd /c "net use ${letter}: `"${unc}`" /persistent:no" 2>&1 | Out-Null
if (Test-Path –Path ($letter + ':\\') –ErrorAction SilentlyContinue) {
$mapped = $true
$r = Get-LocalDiskSpace –path ($letter + ':\\')
if ($r.ok) { return $r }
}
} catch {
Write-Log "临时映射盘符失败:$_"
} finally {
if ($mapped) { cmd /c "net use ${letter}: /delete /y" 2>&1 | Out-Null }
}
}
return @{ ok = $false; total = 0; free = 0; percent = 0.0; err = "共享目录尚未连通:$unc" }
}
# ———- 查一个路径的空间(自动分本地盘 / 共享目录) ———-
function Get-DiskSpace {
param([string]$path)
if ($path.StartsWith('\\\\')) { return Get-UncDiskSpace –path $path }
return Get-LocalDiskSpace –path $path
}
# ———- 字节数转成人看得懂的单位 ———-
function Format-Size {
param([int64]$bytes)
$gb = $bytes / 1GB
if ($gb -ge 1024) { return ('{0:N2} TB' –f ($gb / 1024)) }
return ('{0:N2} GB' –f $gb)
}
# ———- 判断某个空间是不是要报警 ———-
function Test-Alert {
param($info)
if (-not $info.ok) { return $true }
if (($info.free / 1GB) -le $MinFreeGB) { return $true }
if ($info.percent -le $MinFreePercent) { return $true }
return $false
}
# ———- 等待共享连上(刚开机、刚登录时网络还没就绪) ———-
function Wait-Ready {
param($targets)
# 按"看表"算时间,不按"睡了几次"算:网络真断的时候,光探一次就要
# 等十几秒,按次数算能等成半个多小时。
$started = Get-Date
$pending = @($targets)
while ($pending.Count -gt 0) {
if (((Get-Date) – $started).TotalSeconds -ge $WaitMaxSeconds) { break }
$still = @()
foreach ($t in $pending) {
$info = Get-DiskSpace –path $t.path
if (-not $info.ok) { $still += $t }
}
if ($still.Count -eq 0) { return $true }
$pending = $still
Start-Sleep –Seconds $WaitPollSeconds
}
if ($pending.Count -gt 0) {
$names = ($pending | ForEach-Object { $_.label }) -join '、'
Write-Log ("等了约 {0:N0} 秒仍连不上:{1}" –f ((Get-Date) – $started).TotalSeconds, $names)
return $false
}
return $true
}
# ———- 单实例锁(同一时间只让一个跑) ———-
function Enter-Lock {
try {
if (Test-Path –Path $LockFile) {
$age = (Get-Date) – (Get-Item –Path $LockFile).LastWriteTime
if ($age.TotalSeconds -gt $DropLockAfterSeconds) {
Remove-Item –Path $LockFile –Force –ErrorAction SilentlyContinue
Write-Log '发现陈旧锁,直接接管'
}
}
} catch {}
try {
$fs = [System.IO.File]::Open($LockFile, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
$fs.Close()
return $true
} catch {
return $false
}
}
function Exit-Lock {
try { Remove-Item –Path $LockFile –Force –ErrorAction SilentlyContinue } catch {}
}
# ———- 读上次记的状态 ———-
function Get-State {
try {
if (Test-Path –Path $StateFile) {
$raw = Get-Content –Path $StateFile –Raw –Encoding UTF8
if ($raw) { return ($raw | ConvertFrom-Json) }
}
} catch {}
return $null
}
function Save-State {
param($obj)
try {
$json = $obj | ConvertTo-Json –Depth 5
Set-Content –Path $StateFile –Value $json –Encoding UTF8
} catch {
Write-Log "状态文件写不进去:$_"
}
}
# ———- 通知(企业微信 / Server 酱,二选一即可,都填就都发) ———-
function Send-Wecom {
param([string]$webhook, [string]$content)
if (-not $webhook) { return $false }
$body = @{ msgtype = 'text'; text = @{ content = $content } } | ConvertTo-Json –Compress
try {
$resp = Invoke-RestMethod –Uri $webhook –Method Post –ContentType 'application/json; charset=utf-8' –Body ([System.Text.Encoding]::UTF8.GetBytes($body)) –TimeoutSec 20
return ($resp.errcode -eq 0)
} catch {
Write-Log "企业微信没发出去:$_"
return $false
}
}
function Send-ServerChan {
param([string]$sendkey, [string]$title, [string]$content)
if (-not $sendkey) { return $false }
try {
$uri = "https://sctapi.ftqq.com/$sendkey.send"
$resp = Invoke-RestMethod –Uri $uri –Method Post –Body @{ title = $title; desp = $content } –TimeoutSec 20
return ($resp.code -eq 0)
} catch {
Write-Log "Server 酱没发出去:$_"
return $false
}
}
# ———- 组装消息内容 ———-
function Build-Message {
param($items, [string]$suffix)
$lines = @()
$lines += ('检查时间:' + (Get-Date –Format 'yyyy-MM-dd HH:mm:ss'))
$lines += ''
foreach ($it in $items) {
if ($it.ok) {
$lines += ('{0}:剩余 {1} / 共 {2}({3:N1}%)' –f $it.label, (Format-Size $it.free), (Format-Size $it.total), $it.percent)
} else {
$lines += ('{0}:打不开({1})' –f $it.label, $it.err)
}
}
$title = '磁盘空间' + $suffix
return @{ title = $title; body = ($lines -join "`n") }
}
# ———- 主流程 ———-
Write-Log '—— 开始检查 ——'
if (-not (Enter-Lock)) {
Write-Log '上一轮还没跑完,这轮跳过'
exit 0
}
try {
Wait-Ready –targets $Targets | Out-Null
$items = @()
foreach ($t in $Targets) {
$info = Get-DiskSpace –path $t.path
$info | Add-Member –NotePropertyName label –NotePropertyValue $t.label
$info | Add-Member –NotePropertyName path –NotePropertyValue $t.path
$info | Add-Member –NotePropertyName alert –NotePropertyValue (Test-Alert $info)
$items += $info
$flag = if ($info.alert) { '报警' } else { '正常' }
$extra = if ($info.ok) { '' } else { ' 打不开:' + $info.err }
Write-Log ('[{0}] {1} 剩 {2:N2}G({3:N1}%){4}' –f $flag, $t.label, ($info.free / 1GB), $info.percent, $extra)
}
# 记着上次哪几个是红的,下次比一比,变了才发
$now = @($items | Where-Object { $_.alert } | ForEach-Object { $_.path } | Sort-Object)
$state = Get-State
$prev = @()
if ($state -and $state.alert) { $prev = @($state.alert | Sort-Object) }
if (($now -join '|') -ne ($prev -join '|')) {
$suffix = if ($now.Count -gt 0) { '预警' } else { '已恢复' }
$msg = Build-Message –items $items –suffix $suffix
if ($DryRun) {
Write-Log ('[试运行] ' + $msg.title)
Write-Log ('[试运行] ' + ($msg.body -replace "`n", ' | '))
} elseif (-not $WecomWebhook -and -not $ServerChanKey) {
Write-Log '没配通知渠道,这次不发'
} else {
if ($WecomWebhook) { Send-Wecom –webhook $WecomWebhook –content ($msg.title + "`n" + $msg.body) | Out-Null }
if ($ServerChanKey) { Send-ServerChan –sendkey $ServerChanKey –title $msg.title –content $msg.body | Out-Null }
}
Save-State @{ alert = $now; last_notify = (Get-Date –Format 'yyyy-MM-dd HH:mm:ss') }
Write-Log ('状态变了,已推送:' + $msg.title)
} else {
Write-Log '状态没变,不重复推'
}
} finally {
Exit-Lock
}
两版开头要改的地方,名字不一样,对照着看:
| 要盯哪些路径 | TARGETS | $Targets |
| 剩余空间低于多少 G 就报 | MIN_FREE_GB | $MinFreeGB |
| 剩余比例低于多少就报 | MIN_FREE_PERCENT | $MinFreePercent |
| 企业微信群机器人地址 | WECOM_WEBHOOK | $WecomWebhook |
| Server 酱 SendKey | SERVERCHAN_KEY | $ServerChanKey |
其余的默认值不用动。PowerShell 版多一个 $TryMapUnc 开关,默认关着——它管的是"这台机器还没连过这个共享时,要不要自己临时映射一个盘符去连一次",一般用不上。
第一处,填你要盯的路径。本地盘写成 C:/ 这样;服务器上的共享目录写 UNC 路径,别写映射盘符。label 是你自己看的名字,写了"体检图片",消息里就显示"体检图片"。
第二处,两条线。剩余空间那条是"剩余少于多少 G 就报",剩余比例那条是"剩余比例少于多少就报",两条满足一条就报。我们这两个硬盘空间都是几个 T 的,线就定在 500G 和 20%。
第三处,通知渠道。企业微信群机器人的地址、Server 酱的 SendKey(就是它给你的那串钥匙),填一个就行;两个都填,就两边都发一遍。
怎么让它自己跑起来?Windows 的计划任务里,我建了两个任务。
一个跟着开机走:触发器选计算机启动时,这样不管这台机器什么时候开,脚本都能跑起来,正好也是我开篇那张截图里那条提示的来源。作用就是检测脚本是否正常运行,如果没有这条检测有可能脚本未正常运行也不知道。我之前设置的一个盯一个数据接口是否正常运行时,就吃过这样的亏,所以后来才加了一条开机检测的。
另一个按点走:触发器设成每天 20:00 开始、每 5 分钟重复一次、持续 12 小时,专门盯着数据库备份那段时间——备份一般排在晚上,正好让脚本提前盯着,备份把空间撑满之前我就能知道。
两个任务的操作按你用的那版来:
用 Python 版——程序选 pythonw.exe(不带黑窗口的那个 Python,跑起来你根本看不见),参数填脚本的完整路径。
用 PowerShell 版——程序选 powershell.exe,参数填 -ExecutionPolicy Bypass -File "脚本完整路径"。那个 Bypass 是必须的,不然 Windows 默认不让你跑 .ps1 脚本;至于弹不弹黑窗口,计划任务里跑本来就看不见。
两版都是这样:悄悄地跑,不弹窗、不打扰你。
我这里的设置时间只是举例,各位读者请根据自己的实际情况设置,我设置这个时间段就是之前跟大家说过的,主要是为了监测数据库备份时的情况,备份一般都是设置在晚上没有业务时。正常业务我现在的存储完全够用一个月的,就是数据库备份时波动比较大,所以我设置是在晚上。
八、跑起来之后还要注意这几件事
跑起来之后,脚本旁边会多两个文件:一个 disk_monitor.log,是日志,每次查了什么、报没报,都记在里面;一个 disk_state.json,就是记着"上次哪几个硬盘空间红了"的那个。要是哪次没收到消息又觉得不对劲,先去看日志,比猜快。
想先试试它到底发不发得出去,别等真满了才知道通知没通。第一次跑的时候加个 –dry-run 参数(PowerShell 版是 -DryRun),它只把要说的话写进日志、不真发,你看着满意了再拿掉。我们单位以前那个接口程序,就是悄悄死了三个月没人知道——通知这条路,一定要先自己走一遍。
九、阈值怎么定
阈值这块,我的经验是:比例那条线比绝对值靠谱。同样剩 500G,放在 4T 的硬盘空间上还挺宽裕,放在 1T 的硬盘空间上已经危险了。你先按自己硬盘空间的体量,把比例定在 15% 到 20% 之间,跑一个月,看它报得早不早、烦不烦,再调。
还有一句得说:别把线定到 90% 才报。留出处理的余地,你才有时间从容地把旧片子归档、把日志清掉,而不是等人家排队排到门口了,你手忙脚乱地删文件。
这事儿捅破了不值一提,没捅破能耗掉一整个下午——尤其你不在现场、还得靠别人打电话告诉你"电脑又出问题了"的时候。
各位同行,你们那边是怎么盯硬盘空间的?有没有更省事的招,评论区聊聊,相互学习印证一下。
下期送上——网是通的却一直卡,我踩过的三个无线坑。
文中案例均已脱敏,不出现系统账号与服务器地址。
作者:老刘说医信,医院信息科运维。



![[Android] 简捷分身双开 -应用多开分身独立运行-171主机测评](https://www.171host.com/wp-content/uploads/2026/11/20261101024518-6ae6a7bec2a4c-220x150.jpg)