服务器凌晨三点挂了没人知道,第二天早上业务方找上门来,这种画面相信搞运维和搞独立开发的朋友都不陌生。我自己手里也有好几台Linux机器,跑着一堆定时脚本、数据任务、备份任务,出问题不可怕,可怕的是出问题的时候你根本不知情。后来我把所有关键任务都加了一个“消息出口”——脚本执行出错就立刻发短信到我手机,而这个出口的实现方式,就是今天要聊的东西:在Linux脚本里用Shell配合Curl命令,快速调用短信API。
这篇内容不是简单丢一段代码给你,而是会从“为什么要这么干”“签名是怎么算出来的”“curl参数为什么这么选”“线上发不出去怎么排查”这几个角度完整拆一遍。写完你不仅能直接在项目里用,还能在面试或者跟同事扯方案的时候,把底层原理讲得明明白白。适合三类人看:一类是做运维的,需要给监控脚本加告警通道;一类是后端开发,临时需要一个“脚本通知能力”但不想引入一堆依赖;还有一类是个人站长,手里几台服务器想用最低成本搞定通知。无论哪一类,这个方案的核心优势都很直接:Linux自带的curl加一个简单的Shell脚本,零第三方依赖,改起来也快。
1. 先说结论:这玩意儿到底解决什么问题
1.1 短信通知在系统里的位置
很多人一听“发短信”就觉得要接服务商SDK、要写Java/Python接口、要搞异步队列,其实那是营销短信平台的玩法。对于“服务器告警”“任务跑完通知我”“定时脚本异常了告诉我”这种低频、高重要度的场景,你根本不需要那么重的东西,只需要一个能打的HTTP请求就够了。短信API本质上就是一个公开的HTTP接口,你传手机号、模板编号、模板参数、签名,它返回一个JSON告诉你成功还是失败。这种接口用curl就能调,压根不需要SDK。
1.2 为什么是Shell加Curl,而不是Python或者写个独立程序
我知道肯定有人会说“我用Python写个脚本调用requests库不也很快吗”。但你想过没有,你要通知的那个场景本身就在Shell世界里:crontab里跑的备份脚本、你手动执行的一条部署命令、一台IoT设备上的初始化脚本,它们本来就在bash环境下。如果为了发个短信去引入Python环境、装库、再做异常处理,成本反而上去了。
curl是Linux系统自带的老牌工具,几乎所有发行版都预装了,不需要额外安装任何东西,写法也直白——就是发一个POST请求。更重要的是,Shell脚本里能直接把前后逻辑串起来:磁盘检查发现超过阈值,当场调用发送函数,三行代码搞定通知。这种“顺手粘上去”的爽感,是重型SDK给不了的。
1.3 这个方案适合什么场景,不适合什么场景
适合:单条告警通知、定时任务结果通知、脚本报错通知、设备离线通知、低频业务提醒。这些场景一天顶多几十条,对吞吐量没有要求,对发送延迟也能接受几秒。
不适合:验证码发送、营销群发、需要高并发批量的业务短信。那种场景请老老实实用服务商SDK,走正式的业务流程,不要用Shell脚本去硬扛。
明确了这个边界之后,我们再看具体实现,你会发现短信API调用的核心难点根本不在curl,而在“签名”。
2. 动手前的三个关键认知
2.1 短信API的本质就是一个HTTP请求
你现在不妨把短信API想象成一个窗口:你递进去一张单子,上面写着“把这个内容发给这个手机号”,窗口工作人员核验你的身份之后,帮你把短信发出去。这张单子就是HTTP请求,窗口地址就是API域名和接口路径,工作人员核验身份的过程就是鉴权。国内主流的云厂商短信API,比如阿里云、腾讯云、华为云,基本都是这个模式,只是请求格式和签名算法略有差异,思路完全一致。
以阿里云短信为例,接口地址是dysmsapi.aliyuncs.com,调用方式是向这个地址POST一个表单,里面带上Action参数、你的AccessKeyId、短信签名、模板编号、模板参数值、时间戳、随机数等一堆“公共参数”,服务端校验通过后执行发送,然后返回JSON。整个过程里,唯一需要动脑子的是下面这个鉴权三件套。
2.2 鉴权三件套:AccessKeyId、AccessKeySecret与签名
任何云厂商的API,都绕不开两个东西:AccessKeyId和AccessKeySecret。你可以简单理解成:AccessKeyId是你的工号,别人看见工号知道你是谁;AccessKeySecret是你的印章,千万不要给别人看到,谁拿到印章谁就能以你的名义发短信。
那么签名是什么呢?你可以这样类比:签名是“拿印章盖出来的一张条子”。具体到技术上,就是你把这次请求的所有参数按规则拼成一个字符串,然后用AccessKeySecret作为密钥,对这段字符串做HMAC-SHA1哈希,得到的结果再做Base64编码,最终得到一段看起来像乱码的字符串。把这个字符串作为Signature参数一并传给服务端,服务端用同样的算法自己算一遍,如果结果一致,就说明请求确实来自持有AccessKeySecret的人。
这里有一个非常重要的点:同一个请求参数,只要你改了任何一个值,签名就完全变了。所以签名本质上起到了“防篡改”的作用,这也是我为什么建议你在脚本里保留完整的签名计算过程,而不是图省事写死在请求里。
2.3 签名和模板要提前过审,这个最容易踩坑
我第一次用短信API的时候,犯了一个特别蠢的错误——文档都没细看,直接用服务商控制台给的测试签名和测试模板去调接口,结果返回isv.SMS_SIGNATURE_ILLEGAL。后来才明白,国内短信服务商要求:你要发短信,必须先申请一个“签名”(比如你的App名称或公司名称),再创建一个“模板”(短信正文,支持用变量占位),两项审核通过之后才能调用接口。
审核通常需要几分钟到几小时不等,取决于服务商和你提交的内容。签名不能乱写,一般是品牌名、App名或者网站名,模板正文里需要变量的地方用${code}这种占位符。这个环节虽然不涉及代码,但如果你没提前申请,后面代码写得再漂亮也白搭。所以动手写脚本之前,请先检查你的账号下有没有审核通过的签名和模板。
3. Shell脚本调用短信API的完整实现
3.1 一个能直接跑的通用函数
我用阿里云短信API作为例子,因为它的签名算法比较典型,其他厂商的API你只要把域名和参数名替换一下,核心逻辑可以直接复用。这个函数你复制回去改掉AccessKeyId和AccessKeySecret就能跑。
#!/bin/bash # send_sms.sh - 阿里云短信通知脚本 SMS_ACCESS_KEY_ID="LTAI5tXXXXXXXXXXXXXXXX" SMS_ACCESS_KEY_SECRET="your_secret_here" SMS_SIGN_NAME="你的签名" SMS_TEMPLATE_CODE="SMS_123456789" SMS_REGION_ID="cn-hangzhou" # URL编码函数:按RFC3986规则,统一用xxd做字节级转换 urlencode() { local input="$1" printf '%s' "$input" | xxd -p | tr -d '\n' | sed 's/\(..\)/%\1/g' | sed 's/%2d/-/g; s/%5f/_/g; s/%2e/./g; s/%7e/~/g' } # 发送短信函数 # 用法: send_sms "13800138000" '{"code":"123456"}' send_sms() { local phone="$1" local template_param="$2" local timestamp local nonce timestamp=$(date -u +%Y-%m-%dT%H:%M:00Z) nonce=$(date +%s%N) # 准备参数行(key=value),用printf按行输出,通过sort按字典序排序 local params params=$(printf '%s\n' \ "AccessKeyId=$(urlencode "$SMS_ACCESS_KEY_ID")" \ "Action=SendSms" \ "Format=JSON" \ "PhoneNumbers=$(urlencode "$phone")" \ "RegionId=$SMS_REGION_ID" \ "SignName=$(urlencode "$SMS_SIGN_NAME")" \ "SignatureMethod=HMAC-SHA1" \ "SignatureNonce=$nonce" \ "SignatureVersion=1.0" \ "TemplateCode=$SMS_TEMPLATE_CODE" \ "TemplateParam=$(urlencode "$template_param")" \ "Timestamp=$(urlencode "$timestamp")" \ "Version=2017-05-25" \ | sort) # 拼成key1=value1&key2=value2&...形式的规范化查询字符串 local canonical_query canonical_query=$(printf '%s' "$params" | paste -sd '&' -) # 构造待签名串: POST&%2F&<再次URL编码后的规范化查询字符串> local string_to_sign string_to_sign="POST&$(urlencode '/')&$(urlencode "$canonical_query")" # 计算签名: HMAC-SHA1, 密钥是 AccessKeySecret+"&", 结果Base64 local signature signature=$(printf '%s' "$string_to_sign" | openssl dgst -sha1 -hmac "$SMS_ACCESS_KEY_SECRET&" -binary | base64) # 发送请求 local resp resp=$(curl -s -X POST "https://dysmsapi.aliyuncs.com/" \ --data-urlencode "Signature=$signature" \ --data "$canonical_query" \ --connect-timeout 10 \ --max-time 30) echo "$resp" }调用方式很简单,你把它保存成send_sms.sh,然后source进来或者直接在脚本里执行:
source ./send_sms.sh result=$(send_sms "13800138000" '{"code":"123456"}') echo "$result"返回的JSON长这样:
{"Message":"OK","RequestId":"12345-6789","Code":"OK","BizId":"123456"}看到"Code":"OK"就说明发送成功了。
3.2 签名算法逐项拆解,这一步看懂就全通了
上面那段代码里,最不好理解的就是签名计算,我来把它拆成几块讲清楚。
第一块是规范化查询字符串。云厂商的要求是:所有公共参数按参数名ASCII码升序排序,参数名和参数值都要做URL编码,然后用&连接。比如排序前参数列表是乱的,排序后变成AccessKeyId=...、Action=...、Format=JSON这样依次排列。为什么要排序?因为签名是一个完整的字符串,如果服务端和你客户端拼接的字符串内容不一致,签名就永远对不上。排序就是为了让双方的拼接顺序完全一致。
第二块是URL编码,这也是最容易出错的地方。阿里云要求的是RFC3986编码规则:除了字母、数字以及-、_、.、~这4个保留字符之外,其他字符一律编码成%XX形式,空格编码成%20而不是+。很多人踩坑就踩在这里——用curl --data-urlencode时它内部会正确编码,但自己拼签名串时用了普通的encodeURIComponent逻辑,导致空格变+、中文没处理好,签名永远验证失败。
我上面给的urlencode函数用的是字节级转换:先把字符串用xxd转成十六进制,再统一加%前缀,最后把RFC3986允许的4个字符还原。这种方式对中文、英文、数字、特殊字符都一视同仁,也不会因为Shell的环境locale不同产生差异。实测在常见Linux发行版上都很稳。
第三块是待签名串的结构。阿里云的签名串格式是固定的:
HTTPMethod & 编码后的路径 & 编码后的规范化查询字符串具体到代码里就是POST&%2F&<再次URL编码后的canonical_query>。注意这个“再次编码”的细节:整个查询字符串要先拼好,然后对整个字符串再做一次URL编码。也就是说,前面已经编码过的%,在第二次编码时又变成了%25。我第一次实现的时候把这个细节漏了,结果每次签名都对不上,后来仔细看文档才发现是“对规范化后的查询字符串再进行一次编码”,不是直接拿拼好的串去签。
第四块是HMAC-SHA1计算。Linux自带的openssl命令就能做,不需要装额外组件。密钥由AccessKeySecret加上一个&组成,算法是HMAC-SHA1,输出转Base64。注意这里有个隐含的坑:如果你的AccessKeySecret里本身含有特殊字符,一定要看清楚密钥拼接时&有没有被错误处理,好在一般密钥都是字母数字组合,不太会遇到。
3.3 curl参数为什么这样选,而不是图省事直接发
我看到过很多简化版的示例代码,就一行curl -X POST "https://dysmsapi.aliyuncs.com/?Action=SendSms&PhoneNumbers=xxx",把AccessKeyId明文拼接在URL里,连签名都没有,这种只能做技术验证,不能上生产。
我上面的代码里,有几个参数选择是经过考虑的。-s是静默模式,不让curl把进度条和额外信息打到标准输出,方便直接捕获请求结果。-X POST指定请求方法,阿里云短信接口推荐POST方式,参数放请求体里,避免超长URL。--data-urlencode "Signature=$signature"这一段是为了让curl帮我对Signature的值做一次URL编码——因为Base64产生的+、/、=这些字符在URL里会被解释成特殊含义,如果不编码,服务端收到的值就变了。--connect-timeout 10和--max-time 30这两个超时参数必须加。短信接口如果不可用,你的告警脚本可不能一直卡在那等响应,该放弃就放弃,日志里记一笔就行,不然告警系统本身也会变成故障源。
还有一个细节:参数名本身我建议用--data-urlencode,但参数值如果已经是编码过的(比如canonical_query里的所有值都已经是%XX形式),就要用--data直接传,否则--data-urlencode会对%再做一次编码,把数据变成双重重编码,服务端解析时就乱了。
4. 把通知函数塞进现有脚本:定时任务、重试与防抖
4.1 接入crontab,让脚本出错了自动喊人
写好了发送函数,最自然的用法就是配合crontab做定时巡检。比如你有一个磁盘空间检测脚本/usr/local/bin/check_disk.sh,每隔5分钟跑一次,发现某个分区超过90%就发短信。你只需要在脚本里source发送函数,然后在检测逻辑触发的地方调用即可:
#!/bin/bash source /usr/local/bin/send_sms.sh threshold=90 use_percent=$(df -h / | awk 'NR==2 {print $5}' | sed 's/%//') if [ "$use_percent" -gt "$threshold" ]; then send_sms "13800138000" '{"alert":"disk high, please check"}' fi然后把脚本挂到crontab:
*/5 * * * * /usr/local/bin/check_disk.sh >> /var/log/check_disk.log 2>&1这里有个小技巧:短信内容不要写太细,因为短信模板审核要求正文里不能放太宽泛的内容,一般写成“您的服务器磁盘占用已超过阈值,请登录查看”这种固定话术,再把具体数据放到模板参数里。
4.2 网络抖动是常态,发送失败要有重试机制
短信API本质是HTTP请求,网络不可能永远稳定。我自己就遇到过深夜告警触发时,刚好赶上API网关微微抖动,第一次请求超时丢包,结果这条重要告警就没了。所以真正上生产前,务必加一层简单重试。
send_sms_with_retry() { local phone="$1" local param="$2" local attempt local resp for attempt in 1 2 3; do resp=$(send_sms "$phone" "$param") if echo "$resp" | grep -q '"Code":"OK"'; then return 0 fi echo "[$(date)] send_sms failed, attempt=$attempt, resp=$resp" >> /var/log/sms_notify.log sleep $((attempt * 2)) done return 1 }sleep $((attempt * 2))是一种朴素的指数退避,第一次失败等2秒,第二次失败等4秒,第三次失败等6秒。重试三次基本能扛过大部分瞬时抖动。注意判断成功的时候不要只看HTTP状态码,而是看返回JSON里的Code字段,因为业务失败时HTTP状态码也是200,但Code不是OK。
4.3 防止一分钟内收到几十条轰炸短信:锁文件与去重
磁盘告警这种场景有个麻烦:如果你没处理,磁盘持续超阈值,crontab每5分钟触发一次,你就每5分钟收到一条短信,晚上能被活活吵醒。所以一定要做去重和防抖。
最简单的方式是锁文件。比如你想让同一个告警在30分钟内只发一次,可以这么写:
alert_lock="/tmp/disk_alert.lock" if [ -f "$alert_lock" ]; then exit 0 fi touch "$alert_lock" # 发送短信 send_sms_with_retry "13800138000" '{"alert":"disk high"}' # 30分钟后自动释放 nohup sh -c "sleep 1800 && rm -f $alert_lock" &思路很简单:锁文件存在就直接退出,不存在则创建并发送,同时起一个后台任务过30分钟删除锁文件。这里要注意,如果你把脚本放在快速循环里,nohup后台任务的清理方式理论上可行,但更稳的做法是手动把告警恢复后主动删除锁文件,这样收到“恢复通知”后锁文件也被清掉,避免30分钟内出现新告警时被屏蔽。
4.4 日志与调试:把每次请求写清楚,别等出事了再猜
我在生产脚本里一定会加一行日志,把每次短信通知的时间、接收号码、返回结果记到一个固定文件里。这不是为了好看,是真到了排查问题时,没有日志全靠猜,效率低到想哭。
echo "[$(date +%F\ %T)] phone=$phone resp=$resp" >> /var/log/sms_notify.log顺便提醒一句,不要日志里打印AccessKeySecret,哪怕你自认为是内网服务器也不行。密钥泄露是短信API最大的风险,攻击者拿到你的密钥就能拿你的账号发短信,烧的是你的钱。正确做法是把AccessKeyId和AccessKeySecret放在一个只有root可读的配置文件里,脚本运行时source进来,这样脚本本体可以放进版本管理,密钥不会泄露。
5. 常见问题排查速查表
5.1 调用短信API返回的错误码
我在实际使用中遇到的API报错,按出现频率排序,主要就是下面这几种。我把常见问题整理成了一份速查表,建议复制到你的运维笔记里。
| 返回错误 | 含义 | 排查方向 |
|---|---|---|
SignatureDoesNotMatch | 签名不一致,服务端计算的签名和请求里的签名对不上 | 检查签名串拼接顺序、URL编码规则、密钥是否错误,重点检查是否忘了对规范化查询字符串做第二次编码 |
InvalidTimeStamp.Expired | 时间戳无效或过期 | 服务器时间是否不准,执行date -u看UTC时间,必要时配置NTP同步;时间偏差超过15分钟就会报这个错 |
isv.SMS_SIGNATURE_ILLEGAL | 短信签名不合法或未审核通过 | 去控制台确认签名审核状态,确认签名名称拼写无误 |
isv.MOBILE_NUMBER_ILLEGAL | 手机号格式非法 | 检查号码是否11位、是否包含空格或+86前缀,部分服务商不支持国际号码 |
isv.BUSINESS_LIMIT_CONTROL | 触发业务流控 | 短信接口有QPS限制,降低发送频率,或者拆分请求 |
isv.AMOUNT_NOT_ENOUGH | 余额不足 | 去控制台充值 |
TemplateParam相关报错 | 模板参数与模板内容不匹配 | 检查JSON格式是否正确,键名是否和模板里的${变量名}一致 |
这里特别说一下SignatureDoesNotMatch,这是大家第一次接短信API时遇到最多的坑。我建议你在调试阶段临时加一段输出,把string_to_sign打印出来,然后去服务商的签名调试工具里粘贴进去对比,多试几次就能找到差异。常见的差异集中在:参数排序不对、编码规则不对、拼串时多了或少了&、没有对整个查询串做二次编码。
5.2 curl本身的报错怎么判断
除了API业务层的报错,curl在网络层也会报一堆问题。这些报错有些是服务器环境问题,有些是外部网络问题。
curl: (28) Connection timed out表示TCP连接建立超时,常见原因:服务器出方向网络受限、API域名解析出的IP无法访问、本地防火墙拦截。你可以先用curl -v看详细过程,看卡在DNS解析、TCP建连还是TLS握手,然后用telnet或者nc单独测一下目标端口通不通,逐步定位。curl: (56) Recv failure: Connection reset by peer这类错误通常是服务端主动断开连接,常见原因是TLS版本不兼容或者请求体格式不对被网关直接丢弃,可以尝试加--tlsv1.2或者检查Content-Type头。
如果是复杂的网络环境问题,先停掉所有复杂的链路配置,直接用最简单的HTTP请求不带任何证书验证做连通性测试,确认基础网络没问题后再还原配置。绝大多数“curl报错发不出去”其实都是网络层问题,而不是API参数问题。
# 快速连通性测试 curl -v --connect-timeout 5 https://dysmsapi.aliyuncs.com/ -o /dev/null如果这条命令能走完TLS握手并返回HTTP响应,说明网络通;如果卡住,先解决网络,再回来讨论参数。
5.3 Shell脚本里的中文、引号和转义坑
中文内容在Shell短信脚本里是个大坑。首先是脚本文件本身编码:如果你在Windows上编辑了脚本再传到Linux上,文件可能是GBK编码或者带CRLF换行符,执行时中文模板参数就会乱掉,最后服务端收到乱码,短信内容要么乱码要么报错。建议统一用UTF-8编码,并在脚本开头加一行export LANG=en_US.UTF-8避免locale问题。
其次是单引号和双引号的陷阱。模板参数是一个JSON字符串,里面既有单引号又有双引号。在Shell里传JSON字符串,外层用单引号包住是安全的,因为JSON里只有双引号没有单引号,比如'{"code":"123456"}'。一旦模板参数里自己也包含单引号,就要考虑转义或者改用数组方式拼接。我的经验是:模板参数越简单越好,尽量只传数字、字母和短字符串,不要传复杂嵌套结构。
还有一个很隐蔽的坑:短信平台对模板变量的长度有限制,不同厂商不一样,但一般单个变量不超过20个字符。别把一长段日志塞进参数里,那样不仅可能被截断还可能触发模板不匹配。
6. 一些场景扩展,让这个脚本更有价值
6.1 多手机号通知与收件人配置
实际运维中往往不只通知一个人。你可以把接收人写成一个以空格分隔的列表,在循环里逐条调用,这样新同事入职加个手机号就行,不用改脚本逻辑。
NOTIFY_PHONES="13800138000 13900139000" for phone in $NOTIFY_PHONES; do send_sms_with_retry "$phone" '{"alert":"deploy failed"}' done但注意,你每发一条都是要钱的,告警群发阈值要克制,一般两三个人足够,别把整个团队都拉进去。
6.2 其他云厂商短信API怎么适配
如果你用的是腾讯云短信,核心思路一样,只是签名算法从HMAC-SHA1换成了TC3-HMAC-SHA256,请求头里要加X-TC-Action等参数,URL也变成cvc.tencentcloudapi.com。用华为云则是OpenStack风格的签名。刚接触不同服务商时,建议先看官方文档里的“签名方法”段落,把“参数排序+编码+拼接+哈希”这个模式吃透,你会发现各家差别其实就是算法名和拼串格式的不同,没有本质区别。
6.3 配合企业微信或钉钉机器人做双通道
短信虽然可靠,但成本比IM通知高不少。我现在的做法是:严重告警走短信加企业微信机器人双通道,普通告警只发企业微信。这样做既保证了“重要事情一定能找到人”,又把短信费用控制在合理范围。企业微信机器人比短信API更简单,一个webhook地址加curl就能发,这个大家有兴趣可以自己去接。
7. 写到最后的一点个人体会
聊了这么多技术细节,最后说点实际操作层面的心得。短信通知这件事,最怕的不是代码写不出来,而是把它当成一个“补丁”随意粘贴。我踩过几次坑之后,现在给自己定了几个原则:密钥永远放配置文件且进不了版本库;发送函数必须支持日志输出和重试;告警一定要分级,同一类告警必须做防抖;短信模板先想清楚再申请,不要拿“测试内容”糊弄审核。
另外还有一个实用技巧:在开发阶段,别真的给自己发短信,可以在发送函数里加一个环境变量开关,比如SMS_DRY_RUN=1时只打印请求内容而不实际发送,这样调试脚本逻辑时既不会花钱也不会打扰别人。等到确认万事俱备,再把开关关掉。
最后想说的是,Shell脚本调用短信API看着简单,但它把“系统自动化”和“人为干预”这两件事恰到好处地连在了一起。服务器出问题不可怕,怕的是出了问题没人知道。你手里的每一台Linux服务器,其实都值得拥有这样一个小小的喊话能力。希望这篇文章能帮你少走几步弯路,把这个能力稳稳当当地加到自己的工具箱里。