凌晨两点被电话吵醒,原因是某台服务器的磁盘写满了,但监控平台设置的告警规则漏掉了这个分区,日志直接在系统盘里涨到 100%,网站白屏了半小时才被值班同事发现。那台机器上装的是精简版 Linux,没有 Python3,没有 PHP,连 wget 都要看运气,唯一确定存在的是 bash 和 curl。也就是从那次以后,我彻底不再纠结“短信接口该怎么接”,直接在 Shell 脚本里用 Curl 调用短信 API,把这一套做成了所有临时告警场景的首选方案。
这条路线对运维、对做自动化脚本的人、对需要在无图形环境里快速发通知的开发者都有参考价值。你不需要装任何第三方 SDK,不需要编译,不需要解决依赖地狱,只要系统里有 bash 和 curl,再拿一份短信平台给的 API 文档,就能写出发短信的函数并集成到监控脚本、定时任务、部署脚本里。这篇内容就从选型逻辑讲起,到真实可用的脚本,再到我实际踩过的那些发送失败的坑,一次性把“Shell + Curl 调短信 API”这件事讲透。
1. 为什么短信告警我首选 Shell + Curl 而不是“正规军” SDK
先聊点掏心窝的话。很多人一听“短信接口”,第一反应是去装云厂商的官方 SDK,Python 有,Java 有,Go 也有。但我这些年做运维和自动化脚本,真实的感受是:在告警场景里,Shell + Curl 赢在“可达性”。
你要知道企业内网的机器并不都是配置齐整的。我碰到过跳板机只有最小化 CentOS 系统,装 Python3 得找运维审批、过安全合规、甚至要配内部 pip 源,一来一回半天就没了;而告警这种东西往往是你半夜就要用的。SDK 的引入还带着一个隐藏成本——它能帮你处理签名和请求封装,但它自身也有版本兼容问题,尤其在老系统上,OpenSSL 版本不匹配、glibc 太旧导致装不上,哪个都能卡你一下。Curl 就不一样,几乎每个 Linux 发行版都预装,十几年老机器上也有。
然后是最重要的:排错效率。Curl 可以在命令行里天然调试,一条curl -v就能看到 HTTP 请求的完整链路——DNS、TCP 握手、TLS 握手、发送 header、接收 body,每一步都摆在眼前。SDK 一旦发不出去,你要么翻源码,要么开 debug 日志,效率和命令行直接试完全是两个级别。
当然我不是说 SDK 一无是处。如果你的业务场景是用户触发的高并发短信验证码,每秒几百上千 QPS,那必须用厂商 SDK,甚至要上连接池和异步;如果你只需要在磁盘告警、网站宕机、备份失败时收到一条短信通知,一天最多几十条,那你为 SDK 付出的所有环境成本都在浪费。告警场景的核心是“极简、可靠、立刻能跑”,Shell + Curl 恰好全部命中。
2. 第一个能跑的脚本:简版 REST 接口怎么用 Curl 调通
2.1 拿到 API 文档后先盯四个要素
接入一个短信平台,我不管它的文档写得多花哨,先找四样东西:接口 URL、鉴权方式、Content-Type 要求、请求体参数。举个例子,很多聚合短信平台提供的是非常传统的 REST 接口,形如:
POST https://api.example-sms.com/v1/sms/send Header: Content-Type: application/json Authorization: Bearer YourAccessToken Body: { "phone": "13800138000", "templateId": "SMS_123456", "params": "{\"name\":\"disk\"}" }这类接口人类友好度极高,本身就是给 HTTP 客户端设计的,用 Curl 调用几乎没有理解成本。
2.2 一个直接可用的 send_sms 函数
基于这种接口,我会把发送逻辑封装成一个 Shell 函数,放到所有脚本里都能source。函数设计有四个要点:token 用全局变量而不是硬编码在函数里,方便后续从配置文件加载;必填参数做校验,避免空手机号白花短信费;curl 必须设置连接超时和总超时,不能让脚本卡死;最后打印 HTTP 状态码和接口返回体,方便排查。代码我用的是长下面这样
#!/usr/bin/env bash # 短信平台相关配置,建议从外部配置文件加载 SMS_API_URL="https://api.example-sms.com/v1/sms/send" SMS_API_TOKEN="YourAccessToken" send_sms() { local phone="$1" local template_id="$2" local params="$3" if [[ -z "$phone" || -z "$template_id" ]]; then echo "[ERROR] phone and template_id are required." >&2 return 1 fi # 构造 JSON,注意 params 本身是 JSON 字符串 local payload payload=$(printf '{"phone":"%s","templateId":"%s","params":"%s"}' \ "$phone" "$template_id" "$params") local http_code local resp_body http_code=$(curl -sS \ --connect-timeout 5 \ --max-time 10 \ -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ${SMS_API_TOKEN}" \ -d "$payload" \ -w '%{http_code}' \ -o /tmp/sms_resp.$$ \ "$SMS_API_URL" 2>/tmp/sms_curl_err.$$) local curl_rc=$? resp_body=$(cat /tmp/sms_resp.$$ 2>/dev/null) if [[ $curl_rc -ne 0 ]]; then echo "[ERROR] curl failed with code $curl_rc: $(cat /tmp/sms_curl_err.$$)" >&2 rm -f /tmp/sms_resp.$$ /tmp/sms_curl_err.$$ return 2 fi rm -f /tmp/sms_resp.$$ /tmp/sms_curl_err.$$ if [[ "$http_code" != "200" ]]; then echo "[ERROR] HTTP $http_code, body: $resp_body" >&2 return 3 fi echo "[INFO] sms sent. http=$http_code body=$resp_body" return 0 }拆解几个容易被忽略的点。
-sS是静默模式加错误显示。只用-s的话,curl 在 DNS 失败、连接被拒时不会输出任何错误,你只能看到返回码非 0,查起来全靠猜;加上-S后错误会打到 stderr。--connect-timeout 5和--max-time 10是告警脚本的命根子——不设这两个参数的话,curl 默认会傻等系统 TCP 超时,那个时间是分钟级的,告警链路会因此延迟很久。-w '%{http_code}'把 HTTP 状态码附加到输出,-o把响应体写文件,两者分拆后才能做到”我用状态码做判断、用响应体做日志“,而不是混在一起再用正则抠。
2.3 模板参数是 JSON 字符串:最容易翻车的点
上面函数里我特意把params定义为字符串,因为它本身要往大 JSON 的“params”字段里塞。这里有个经典问题:如果 params 里含中文,比如{"name":"数据库"},直接拼printf出来的 JSON 是合法 UTF-8,绝大多数平台能正确解析。但如果 params 里含双引号或反斜杠,就需要做转义。别用 sed 手工转——太脆。你可以在调用前用python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$raw_params"转一次,前提是机器上有 python3;没有的话,写一个最小化的转义函数,把"变成\",把\变成\\,把换行变成\n,够用。
顺带说一句:对“params 传 JSON 字符串”这个设计,我见过很多新手误把 JSON 直接铺平到根层级,比如{"phone":"...","name":"数据库"},平台会报“模板参数缺失”。调试的时候看返回体就明白了,但能提前知道的话能少走一段弯路。
2.4 命令行五分钟验证法
不要一上来就写完整脚本,先确认接口本身能通。我用的是这个三步流程:
# 1. 用一条最简 curl 测接口连通性 curl -v --connect-timeout 5 --max-time 10 -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YourAccessToken" \ -d '{"phone":"13800138000","templateId":"SMS_123456","params":"{\"name\":\"test\"}"}' \ https://api.example-sms.com/v1/sms/send # 2. 确认返回内容里业务 code 是否为成功值 # 3. 手机号收到测试短信,说明全链路 OK命令行这一步能直接暴露 80% 的问题:域名通不通、token 对不对、Content-Type 有没有按要求写、JSON 是否畸形。全部确认完再把命令封装成函数,避免“函数写得很漂亮但底层就是个坏接口”的情况。
2.5 嵌进监控脚本的真实例子
函数写好后,实战价值立刻体现。比如磁盘告警我可以写成一个 30 行的脚本:
#!/usr/bin/env bash source /etc/sms_notify/send_sms.sh THRESHOLD=85 CURRENT=$(df / | awk 'NR==2 {print $5}' | tr -d '%') if [[ "$CURRENT" -ge "$THRESHOLD" ]]; then send_sms "13800138000" "SMS_DISK_WARN" "{\"partition\":\"/\",\"usage\":\"$CURRENT\"}" fi这没什么高深的,但胜在直观、低耦合。你要做网站异常检测、SSL 证书到期提醒、数据库备份完成通知,全部是同一个套路:检查条件满足后,调用send_sms。
3. 面对阿里云风格的 RPC 签名接口,纯 Shell 怎么算签名
3.1 RPC 接口为什么让 Shell 选手头疼
现实中叫“阿里云短信 api 发不出去”的情况极多。阿里云短信发送走的是 RPC 风格接口,核心流程是:把所有请求参数按字典序排序,拼接成规范化请求串,然后用 AccessKey Secret 做 HMAC-SHA1 签名,最后把签名放到请求参数里发出去。这套东西用 Python/Java SDK 封装后很简单,但在纯 Shell 里要手动做 URL 编码、HMAC 计算、Base64 编码,很多脚本看起来就头大。
好在 Linux 自带的openssl命令可以完成 HMAC 和 Base64,curl -G可以帮忙做 URL 参数拼接,整体可行。
3.2 用 openssl 造一个签名函数
这里给出一个纯 Shell 的计算签名版本。核心思路是:先准备好待签名字符串,用openssl dgst -sha1 -hmac做 HMAC,再用base64编码输出。注意阿里云早期 RPC 签名算法用的是 HMAC-SHA1;如果你用的是新版 SDK 或按SignatureMethod=HMAC-SHA256传参,把-sha1换成-sha256就行。
#!/usr/bin/env bash # 阿里云短信配置 ALIYUN_ACCESS_KEY_ID="LTAI5tXXXXXXX" ALIYUN_ACCESS_KEY_SECRET="your_secret" ALIYUN_SIGN_NAME="阿里云短信测试" # 短信签名 ALIYUN_TEMPLATE_CODE="SMS_123456" # 模板CODE ALIYUN_ENDPOINT="dysmsapi.aliyuncs.com" urlencode() { # 用 printf 的 % 转义,注意保留字母数字和部分安全字符 local s="$1" s=$(printf '%s' "$s" | sed -e 's/%/%25/g' -e 's/ /%20/g' \ -e 's/+/%2B/g' -e 's/\//%2F/g' -e 's/?/%3F/g' -e 's/#/%23/g' \ -e 's/&/%26/g' -e 's/=/%3D/g' -e 's/@/%40/g' -e 's/:/%3A/g') printf '%s' "$s" } aliyun_sms_signature() { local params_ordered="$1" # 已经按 Key 字典序排列的 query string local sign_str sign_str="$(printf 'POST&%%2F&%s' "$(urlencode "$params_ordered")")" printf '%s' "$sign_str" | openssl dgst -sha1 -hmac "$ALIYUN_ACCESS_KEY_SECRET&" -binary | base64 }这里有个关键细节:签名用的 Key 是AccessKeySecret + "&",很多人都漏掉后面的 &。另外阿里云要求对规范化请求串里的参数再次 URL 编码后拼进待签名字符串,所以我在aliyun_sms_signature里对params_ordered又做了一次urlencode。
3.3 用 curl -G 规避手写 URL 编码
RPC 接口所有参数都是 query string,如果直接拼 URL,一个个手动编码很容易错。更好的做法是让 curl 自己编码——用-G配合多个--data-urlencode,curl 会帮你完成 URL 编码和参数拼接。以短信发送为例,公共参数 + 业务参数的完整调用长这样:
build_aliyun_sms_request() { local phone="$1" local template_param="$2" # 例如 {"name":"Oracle"} # 生成时间戳和随机数 local timestamp local nonce timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) nonce=$(date +%s%N) local common_params=( "AccessKeyId=$ALIYUN_ACCESS_KEY_ID" "Action=SendSms" "Format=JSON" "RegionId=cn-hangzhou" "SignatureMethod=HMAC-SHA1" "SignatureNonce=$nonce" "SignatureVersion=1.0" "Timestamp=$timestamp" "Version=2017-05-25" "PhoneNumbers=$phone" "SignName=$ALIYUN_SIGN_NAME" "TemplateCode=$ALIYUN_TEMPLATE_CODE" "TemplateParam=$template_param" ) # 按字典序排序参数(按 Key 排序,也就是“=”左边的字符串) local sorted_params sorted_params=$(printf '%s\n' "${common_params[@]}" | sort) # 拼接成 key=value&key=value local query_string="" local item for item in $sorted_params; do if [[ -z "$query_string" ]]; then query_string="$item" else query_string="$query_string&$item" fi done local signature signature=$(aliyun_sms_signature "$query_string") # 用 curl -G 构造最终请求,--data-urlencode 会自动编码 curl -sS \ --connect-timeout 5 \ --max-time 10 \ -G \ --data-urlencode "$query_string" \ --data-urlencode "Signature=$signature" \ "https://$ALIYUN_ENDPOINT/" }curl -G的好处是:你给它一个带&的字符串,它不会拆分,而是把整串当作一个 key=value 对;当多个--data-urlencode同时存在时,curl 会用&连接它们。这样签名里已经编码过的query_string和Signature参数都交给 curl 处理,不需要手写完整的 URL。这个方法比我以前傻乎乎拼 URL 稳太多了。
3.4 最小可运行样例:手机号 + 模板 + 签名的完整测试
整个逻辑串起来,一次真实发送只需要三步:先验四个公共参数的状态(时间戳是不是 UTC、SignatureNonce 是否唯一),再拼接排序签名,最后 curl 发请求。通常返回 JSON 里会有一个Code字段,OK表示成功;像isv.SMS_SIGNATURE_ILLEGAL这种,说明签名或模板审核没通过。
3.5 壳层里的隐藏细节
Timestamp必须是 UTC 时间,date -u +%Y-%m-%dT%H:%M:%SZ,不少脚本死在这里。SignatureNonce每次请求必须唯一,我用date +%s%N生成纳秒时间戳,重复概率极低。- 参数排序不是整体按行排序,而是按“参数名”排序,我上面的
sort默认按整行排序,刚好够用,因为参数名AccessKeyId、Action这些首字母已经决定了相对顺序,但如果你有A1和A10这种需要精确时,建议用sort -t= -k1,1限定按 key 排序。 - 千万别在 TemplateParam 这个 JSON 里带多余空格,签名串会含空格然后被编码成
%20,实际发送时会解析不出来。
4. 脚本上线前的改造:超时、重试、去重和日志一个都不能少
4.1 告警脚本最怕“僵尸卡住”
很多初版脚本是直接用上面最简单的函数,跑一段时间后你就发现问题:磁盘满了,脚本调用 curl 发短信,但网络抖动导致 curl 一直卡在等待响应,告警没发出去,脚本本身还占用了一个进程。所以我在所有 curl 调用里强制加两个参数:--connect-timeout 5(TCP 连接建立超时 5 秒)和--max-time 10(整个请求最多 10 秒)。宁可偶尔因为超时漏发,也绝不让脚本变成僵尸进程拖垮监控进程。
4.2 --retry 到底用不用
Curl 内置的--retry 3 --retry-delay 2看起来很香,但用在短信接口上要非常小心。短信不是幂等操作——第二次调用就意味着再发一条短信,如果接口其实已收到请求并成功下发,只是响应在回程时超时,你重试一次用户就收到两条同样的短信。对验证码场景这是灾难,对告警场景虽然没那么严重,但短信是要花钱的。
我的策略是:重试只针对“连接层面”的错误,不重试“HTTP 层”的错误。具体做法很简单——第一次请求返回非 0 或者 HTTP 500/502/503 时,sleep 2 秒后再发一次,最多三次;HTTP 200 但业务 code 失败则直接报警,绝不重试。代码上就是在函数外面套一个 for 循环。
4.3 HTTP 200 不等于发送成功
这是最容易误导人的地方。许多短信平台即使在业务失败时也返回 HTTP 200,因为 HTTP 层是通的,业务错误被包在响应体的 code 字段里。比如阿里云返回{"Message":"InvalidTimeStamp.Expired","Code":"InvalidTimeStamp.Expired"},HTTP 状态码还是 200。所以我在判断成功与否时必须同时检查 HTTP 状态码和业务 code 字段。拿阿里云来说:
case "$resp_body" in *'"Code":"OK"') echo "[INFO] send ok" ;; *) echo "[WARN] send failed: $resp_body" ;; esac别用 grep 找OK就完事,因为可能消息内容是NotOK,要判断的是"Code":"OK"这种精确片段。
4.4 幂等去重:避免告警风暴
告警脚本第一次接入短信之后,最常见的副作用就是告警风暴——磁盘使用率在阈值上下波动,监控每五分钟跑一次,一个小时内你能收到十几条“85% 了”“83% 了”“86% 了”。解决方案有两个层次:
- 在监控侧做:当前状态没恢复就不重复发。脚本里维护一个状态文件,比如
/var/run/sms_disk_warn.lock,当 usage >= 85 时,如果 lock 文件存在就不再发送;只有 usage 降到 80% 以下或脚本重启时删除 lock。 - 在通知侧做:对相同内容做 15 分钟窗口内的去重,我一般用 Redis,但 Shell 脚本里最简单的方案是记录上次发送时间,比较时间差。
4.5 日志留痕是事后追责的唯一依据
短信发没发、发了几次、平台返回什么,这事必须留日志。我会在 send_sms 函数里统一打一行结构化日志:
echo "$(date '+%Y-%m-%d %H:%M:%S') [phone=$phone][template=$template_id][http=$http_code][curl_rc=$curl_rc][body=$resp_body]"写到/var/log/sms_sender.log,这样哪天用户说“我没收到短信”,你能直接翻出当时的请求和响应。比用户描述“好像有个短信但被我删了”靠谱一百倍。
5. 线上排查实录:短信发不出去的时候我按什么顺序查
5.1 先看返回码,再抓原始响应
短信发不出去,我很少直接怀疑平台挂了,通常是我自己的问题。排查顺序是先看脚本日志里记录的 curl 退出码和 HTTP 状态码,然后手工用curl -v跑一遍同样的命令,抓原始响应。curl 退出码是线索宝库,比如 6 是 couldn't resolve host,7 是 failed to connect,28 是 timeout,35 是 SSL connect error。看到 28,先查网络和防火墙;看到 35,查 TLS 版本和证书链。
5.2 案例:curl (56) Recv failure - Connection reset by peer
我遇到过的典型场景是:脚本在客户机房跑,防火墙开了 80/443 出方向,但实际访问短信平台域名时被中间设备干扰,TCP 连接被重置,curl 报 56。这时候curl -v能看到Recv failure: Connection reset by peer。解法不是加超时,而是查出口代理策略或者换一个短信平台接入域名(有些平台同时提供 IP 直连域名用于内网环境)。这类问题里,--resolve参数很实用——你可以在测试时直接把域名解析到指定 IP,排除 DNS 被污染的问题。
5.3 案例:curl (23) Failure writing output to destination
这个报错大部分人看到会懵,因为发送短信 curl 明明没写什么大文件。原因通常是 curl 的输出管道断开了——比如你-o到/dev/full、管道给了一个提前关闭的进程、或者磁盘满了导致临时文件写不进去。在告警场景里,最容易触发的是-o /tmp/sms_resp.$$但/tmp挂载满了。这是个很好笑的死锁:你要告警磁盘满,结果 curl 往 /tmp 写响应时因为磁盘满而失败。解决方法是把响应体输出到内存缓存或直接丢弃(-o /dev/null),只保留 HTTP 状态码用于判断。
5.4 案例:模板变量格式错误导致 isv.SMS_PARAM_ERROR
模板变量是最隐蔽的坑。比如你的短信模板是“您的{name}设备发生告警”,接口要求 params 传入{"name":"Web服务器"}。问题在于,有些平台要求传入的字符串必须转义成{\"name\":\"Web\"},有些平台则要求直接传{"name":"Web"}。同一个 JSON,A 平台能解析,B 平台报参数不合法。我的经验是:先按接口文档给的示例原样传,通了之后再测带空格和中文的情况;如果带中文失败,试一下平台是否要求 URL 编码后的 JSON。
5.5 案例:手机号格式和号段校验
有些平台会校验手机号的号段,106开头、+86前缀、400开头的号码都可能被拒。Shell 脚本里加一行简单的正则校验,能拦截掉大部分低级错误:
if ! [[ "$phone" =~ ^1[3-9][0-9]{9}$ ]]; then echo "[ERROR] invalid phone: $phone" return 4 fi别觉得这是小题大做,我见过因为配置文件里手机号多了一个空格,告警发到“不存在号码”上直到月底账单炸掉。
5.6 我自己的排查清单
| 顺序 | 检查项 | 命令/方法 |
|---|---|---|
| 1 | curl 退出码 | echo $?,对照 curl 错误码表 |
| 2 | DNS 能否解析域名 | getent hosts api.xxx.com |
| 3 | TCP 能否连通 | timeout 5 bash -c 'cat < /dev/null > /dev/tcp/api.xxx.com/443' |
| 4 | HTTP 状态码 | curl -sS -o /dev/null -w '%{http_code}' |
| 5 | 业务 code | 查看响应体 JSON 的 Code/Message |
| 6 | 时间戳与秒级同步 | date -u与平台服务器时间比对 |
| 7 | 签名串算法 | 临时在脚本打印待签名字符串,与文档手工推演对比 |
6. 进阶:从一条短信到一个通知中枢
6.1 短信失败自动降级到其他通道
短信通道稳定性并非 100%,平台偶尔也会故障,更常见的是你账户余额耗尽导致发送失败。但告警不能断。所以我在 send_sms 失败时会自动降级到备用通道——飞书/钉钉 Webhook 或者一个简单的 Telegram Bot。逻辑就是发送失败后的 catch 分支:
# 短信失败后,尝试发钉钉机器人群 if send_sms ...; then echo ok else send_dingtalk "磁盘告警: / 分区使用率 91%" fiShell 函数可以做到通道无感切换,使用者只要调notify "标题" "内容",内部先试短信、再试 webhook。多一层保险,深夜被叫醒的概率能低不少。
6.2 配置外置:不把 AccessKey 硬编码在脚本里
AccessKey 直接写进脚本是安全大忌。我现在的做法是把敏感配置放到/etc/sms_notify/sms.env,权限设成 600,脚本里用source /etc/sms_notify/sms.env加载,再配合set -a和set +a控制导出范围。这样脚本本身可以放心分发到多台机器,密钥只留在一台配置机上。
6.3 和监控系统打通
这套 Shell 函数不是我发完短信就弃用的玩具,我现在把它放在监控体系的正中间。Zabbix 的自定义媒介类型可以直接调 Shell 脚本,Prometheus 的 Alertmanager 可以在 webhook 里用脚本转发,Cron 里更是随手一挂就是一套定时巡检。所有需要“人最终知道”的事件,最后都会穿过这个 send_sms 函数。它的价值不只是发一条短信,而是给整个监控链路提供了一个最简单、最可靠的“最后一公里”。
我在实际项目里就是这么做的:每个项目目录下放一个notify.sh,里面定义 send_sms 和 send_dingtalk 两个函数,所有告警脚本统一 source 它。后来团队里有新人接手报警脚本,不需要理解短信签名算法,只需要知道调notify "分区满了" "dev/sda1 91%"就能把消息送出去。这就是 Shell + Curl 这条路的真正回报——三分钟接入、零依赖、任何人都会改。