家里的宽带是动态公网IP,运营商隔一两天就重新分配一次地址。最典型的场景就是白天在公司想连回家里NAS取资料,域名解析还指向昨天那个旧IP,远程连接直接超时。手动登录云解析控制台改A记录这事,改过几次的人都知道有多烦:登录、找域名、找到那条记录、改值、保存,一套流程下来两分钟起步,而且一个月要重复好几回。后来我直接用alidns接口写了个自动更新脚本,跑在家里一台常年开机的设备上,每五分钟检查一次公网IPv4是否变化,变了就自动调用阿里云解析的OpenAPI更新A记录。这篇文章就是完整实现过程、关键API细节,以及跑了半年之后踩到的各种坑。
这个方案适合两类人:一是自己域名托管在阿里云解析、家里有NAS或自建服务需要远程访问的;二是对现成DDNS工具不放心、想完全掌控更新逻辑的。全程只需要会一点点Python,跟着文章一步步来就行。
1. 动态公网IP场景下的更新困境:为什么放着现成DDNS不用
1.1 手动改解析记录的重复劳动
先说场景。我的需求其实很简单:人不在家的时候,能通过home.example.com这个域名访问家里的开发机和NAS。家里的出口IP是运营商动态分配的,每次变化之后,域名指向的IP就成了旧值,所有远程访问全部失效。
手动修复的整个链条是这样的:先想办法拿到当前公网IP(打开IP查询页面),登录阿里云控制台,进入云解析DNS,找到对应域名,筛选出那条A记录,把Value改成新IP,保存。整套操作快的时候两分钟,慢的时候五分钟,关键是你不知道IP哪一刻会变,只能等远程连接失败之后才发现,中间这段时间服务一直是断的。
如果你有多个子域名指向同一个动态IP,比如home.example.com、nas.example.com、git.example.com,那每次都要重复改好几次。频率一高,人就会想自动化,这是这类脚本存在的最朴素动机。
1.2 现成方案与自建脚本的对比
做自动化之前,我调研过几条路,简单列个对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 路由器自带DDNS | 零部署,路由管理界面开一下就行 | 大多只支持特定服务商,日志不透明,重启路由可能丢配置 |
| 第三方DDNS服务 | 功能完整,有客户端 | 域名要托管到对方平台,免费服务稳定性没保障 |
| 自己写脚本调用alidns | 域名不动,逻辑完全可控,可同时管多个子域名 | 需要自己处理API细节和部署运维 |
核心矛盾在于:我的域名已经托管在阿里云解析,DNS记录都在那边,最自然的做法就是直接调它的API。用官方SDK的话,签名、超时这些底层细节都不用自己管,实际开发成本比想象中低很多。这也是我最后选择自建脚本而不是再引入一个第三方依赖的原因。
1.3 alidns在这个场景中扮演的角色
上面那句"直接调它的API",说的就是alidns。alidns是阿里云云解析服务的OpenAPI命名空间,解析记录的增删改查都走这一套接口。我们这个场景真正用到的接口只有两个:查询子域名解析记录(DescribeSubDomainRecords)和更新解析记录(UpdateDomainRecord),如果记录不存在还需要用到新增接口(AddDomainRecord)。
理解这一点之后,整个项目的技术面就很窄了,不需要去啃整套API文档,只需要把一个数据流打通:公网IP从哪来、当前解析值是多少、两者不一致时怎么改。后面的内容就是围绕这三步展开的。
2. 动手前必须搞清楚的四件事:权限、调用方式、数据结构、IP来源
2.1 用RAM子账号授权,而不是把主账号AccessKey写进脚本
这是所有自建脚本里最容易踩的坑,必须先说。调用alidns接口需要一个AccessKey ID和AccessKey Secret,很多人图省事直接用主账号的AccessKey,这是非常危险的做法。主账号的AccessKey等于你阿里云账号的万能钥匙,一旦随脚本泄露,对方可以操作你账号下所有资源。
正确做法是在RAM控制台创建一个子账号,只授予云解析DNS的管理权限。操作路径是:RAM访问控制 -> 用户 -> 创建用户 -> 编程访问 -> 授权,选择系统策略AliyunDNSFullAccess。这样即使密钥泄露,攻击者最多只能改你的解析记录,动不了ECS、OSS等其他资源。
权限策略最好再收紧一点。如果你只需要更新某几个域名,可以在授权时改用自定义策略,把Resource限定到具体域名实例上。不过对个人场景来说,AliyunDNSFullAccess已经够用,重点是把密钥本身保存好。
2.2 AccessKey的存放方式
我的做法是走环境变量,不把密钥写进代码文件里:
export ALIDNS_AK_ID="你的AccessKeyId" export ALIDNS_AK_SECRET="你的AccessKeySecret"如果是用cron跑,可以在crontab里直接带上环境变量,或者用一个权限为600的配置文件去读取。反正一个原则:密钥不进代码仓库,代码注释里也不留。Git上传前记得检查.gitignore,这个后面踩坑部分再细说。
2.3 SDK调用还是手写签名
alidns接口本身是HTTP OpenAPI,理论上可以直接用requests发请求,但每次请求都要计算签名,签名规则涉及HMAC-SHA1、URL编码、参数排序,容易在边界细节上报错。官方提供了各种语言的SDK,签名这些事SDK内部已经处理好了,没必要自己造轮子。
我用的是Python SDK,安装命令:
pip install aliyun-python-sdk-core-v3 aliyun-python-sdk-alidns requests需要注意一点,网上很多旧教程用的是老版aliyun-python-sdk-core,在Python 3环境里可能兼容性不好。直接装aliyun-python-sdk-core-v3就行。另外不同SDK版本的导入路径可能有细微差异,如果import报错,用pip show aliyun-python-sdk-alidns确认版本,然后查对应版本的官方文档。
2.4 解析记录的数据结构和RecordId
调用更新接口之前,必须理解解析记录在API里的数据结构。一条核心记录包含以下字段:
| 字段 | 含义 | 示例 |
|---|---|---|
| RecordId | 记录的唯一标识,更新时必须带 | 123456789 |
| RR | 主机记录,也就是前缀 | home |
| Type | 记录类型 | A |
| Value | 记录值,A记录就是IP地址 | 1.2.3.4 |
| Line | 解析线路 | default(默认) |
| TTL | 生效时间,单位秒 | 600 |
查询接口返回的是一个JSON,大致长这样:
{ "TotalCount": 1, "DomainRecords": { "Record": [ { "RR": "home", "Type": "A", "Value": "1.2.3.4", "RecordId": "123456789", "TTL": 600, "Line": "default", "Status": "ENABLE" } ] } }这个RecordId是后续更新操作的关键参数,相当于数据库里的主键。所以脚本流程里第一步永远是先查询拿RecordId,而不是直接拼一条更新请求。
2.5 公网IPv4从哪里获取
动态IP场景里,我们需要知道设备的当前出口公网IP。最省事的办法是请求外部IP查询服务,返回纯文本IP的那种。我常用的几个:
https://api.ipify.org,返回纯IPv4字符串,最简单https://api-ipv4.ip.sb/ip,返回IPv4https://4.ipw.cn,也是纯IPv4返回
用多个源的原因是防止单一服务商暂时不可用导致脚本空转。请求的时候设置10秒超时,全部失败就跳过本轮,等下一轮cron再跑。
如果你自己有一台固定公网IP的服务器,也可以在那上面写一个极简接口返回访问者的IP,这样数据源完全可控,不依赖第三方。不过对大多数家庭场景来说,上面这几个免费接口已经够稳了。
3. 核心脚本实现:获取IP、查询解析、对比更新
3.1 完整代码
把上面的分析落到代码里,整个脚本不到一百行。先贴完整版本,再逐段解释。
#!/usr/bin/env python3 # ddns_alidns.py: 使用alidns API自动更新解析A记录(IPv4) # 依赖: aliyun-python-sdk-core-v3, aliyun-python-sdk-alidns, requests import json import socket import os import requests from aliyunsdkcore.client import AcsClient from aliyunsdkalidns.request.v20150109 import DescribeSubDomainRecordsRequest from aliyunsdkalidns.request.v20150109 import UpdateDomainRecordRequest from aliyunsdkalidns.request.v20150109 import AddDomainRecordRequest # ========== 配置区 ========== AK_ID = os.environ.get("ALIDNS_AK_ID", "") AK_SECRET = os.environ.get("ALIDNS_AK_SECRET", "") REGION = "cn-hangzhou" # alidns的API Region,固定用这个 ROOT_DOMAIN = "example.com" # 主域名 RR = "home" # 主机记录,最终解析 home.example.com TTL = 600 # 解析记录TTL,秒 # ============================ client = AcsClient(AK_ID, AK_SECRET, REGION) IP_SOURCES = [ "https://api.ipify.org", "https://api-ipv4.ip.sb/ip", "https://4.ipw.cn", ] def get_public_ipv4(): """从多个公网IP查询源获取当前出口IPv4,任一成功即返回""" for url in IP_SOURCES: try: r = requests.get(url, timeout=10) ip = r.text.strip() socket.inet_aton(ip) # 校验是否为合法IPv4 return ip except Exception as e: print(f"[WARN] {url} 获取IP失败: {e}") return None def query_a_record(sub_domain): """查询子域名下所有解析记录,返回第一条Type=A的记录""" req = DescribeSubDomainRecordsRequest.DescribeSubDomainRecordsRequest() req.set_SubDomain(sub_domain) resp = json.loads(client.do_action_with_exception(req)) for rec in resp.get("DomainRecords", {}).get("Record", []): if rec.get("Type") == "A": return rec return None def update_a_record(record, value): """更新已有A记录的Value为新的IPv4""" req = UpdateDomainRecordRequest.UpdateDomainRecordRequest() req.set_RecordId(record["RecordId"]) req.set_RR(RR) req.set_Type("A") req.set_Value(value) client.do_action_with_exception(req) def add_a_record(value): """子域名下还没有A记录时,先新增一条""" req = AddDomainRecordRequest.AddDomainRecordRequest() req.set_DomainName(ROOT_DOMAIN) req.set_RR(RR) req.set_Type("A") req.set_Value(value) req.set_TTL(TTL) client.do_action_with_exception(req) def main(): sub_domain = f"{RR}.{ROOT_DOMAIN}" new_ip = get_public_ipv4() if not new_ip: print("本次未获取到公网IPv4,跳过更新,等待下一轮") return print(f"[INFO] 当前公网IPv4: {new_ip}") record = query_a_record(sub_domain) if record is None: print(f"[INFO] 未找到 {sub_domain} 的A记录,新增 -> {new_ip}") add_a_record(new_ip) return old_ip = record.get("Value") if old_ip == new_ip: print("[INFO] 解析记录无需更新") return print(f"[INFO] IP变化: {old_ip} -> {new_ip},调用UpdateDomainRecord") update_a_record(record, new_ip) print(f"[INFO] 更新完成,RecordId={record['RecordId']}") if __name__ == "__main__": main()3.2 代码逻辑逐段拆解
整个数据流是三个函数各管一段,中间用main()串起来。
get_public_ipv4()做的事情是遍历IP源,第一个成功的就直接返回。注意里面的socket.inet_aton(ip),这是一个很值得养成的习惯:任何从外部接口拿到的数据,先做合法性校验再往下走。如果查询服务返回的不是合法IPv4格式,这个校验会直接抛异常,被外层except捕获后继续尝试下一个源。
query_a_record()是查询逻辑。这里用的是按子域名查询的接口,传入home.example.com,返回的是这个子域名下的全部记录。拿到响应后过滤Type == "A"的记录,因为我们只关心IPv4。如果你有一个子域名同时开着A和AAAA记录,这段代码只取A记录,不会误动IPv6那条。
update_a_record()是更新逻辑,注意这里必须把RR、Type、Value三个参数全带上,而不是只传新的IP。这个接口的行为是"全量覆盖",参数缺一不可,具体原因在踩坑部分会展开。
3.3 一个容易被忽略的边界:记录不存在怎么办
脚本上线之前,先确认你的子域名下确实已经有了一条A记录。如果是从零开始配的,第一次运行时query_a_record()会返回None,直接调UpdateDomainRecord会报错。
所以main()里专门处理了这个分支:没查到记录就走add_a_record()新增一条。这看起来是个小细节,但实际价值不小——有了这个分支,脚本天然具有"自举能力",你想给新的子域名加DDNS时,不用先去控制台手动建记录,直接往配置里加一行RR再跑一次脚本就行。
3.4 幂等设计:为什么IP没变就不调用API
脚本里最关键的一行其实是if old_ip == new_ip: return。这个判断让整个脚本变得幂等:无论跑多少次,只要IP没变,就不产生任何外部副作用。
这带来的好处是实实在在的。OpenAPI调用不是无限的,即便没达到服务商限流阈值,没必要频繁打。正常家庭宽带的动态IP几天才换一次,一天最多调用几次更新接口,整个运行成本低到可以忽略。更重要的是,这个判断避免了"每次跑cron都刷一次记录"这种无意义操作,记录的操作时间和操作原因都是真实的,后期排查日志会容易很多。
4. 部署与长期运行:定时任务、异常兜底、验证机制
4.1 crontab配置:为什么是每5分钟一次
脚本写好后放在/opt/ddns/ddns_alidns.py,然后配置定时任务:
crontab -e加入这一行:
*/5 * * * * /usr/bin/python3 /opt/ddns/ddns_alidns.py >> /var/log/ddns_alidns.log 2>&1间隔为什么选5分钟而不是1分钟?两个原因。第一,阿里云解析免费版最低TTL是600秒,也就是说即使你每秒改一次,全球DNS缓存刷新也要10分钟起步,1分钟检查一次的收益约等于零。第二,TTL是10分钟的情况下,5分钟检查一次意味着最坏情况下IP变化后15分钟生效(5分钟内的检查延迟 + 10分钟TTL),这个延迟对家庭远程访问场景完全够用。
crontab里还有个细节容易被忽略:/usr/bin/python3必须写绝对路径,因为cron运行时的PATH环境变量和你的终端不一样。如果报"python3: command not found",用which python3查一下实际路径替换进去。
4.2 其他运行方式
如果你更喜欢systemd体系,可以用timer替代crontab,好处是可以用systemctl status查看运行状态和上次执行时间,日志也统一进journald。不过我个人的实际体验是,家庭服务器上crontab已经足够简单可靠,不需要引入systemd单元文件那套额外配置。
Windows设备上跑的话,用"任务计划程序"创建一个每隔5分钟运行一次的计划任务即可。Synology NAS用户可以放到计划任务里的"脚本"类型,界面操作更直观。总之逻辑都一样:周期性执行这个Python脚本。
4.3 异常兜底:怎么保证脚本不会变成定时炸弹
网络环境不可靠,脚本必须对异常情况有容忍度。我在代码里做了这么几层兜底:
第一层,IP获取多源。三个源全部失败才会跳过本轮,任何一个成功都能继续。即便所有源都暂时不可用,也只是错过一轮,下一轮还会重试,不会误改记录。
第二层,API异常捕获。理论上client.do_action_with_exception()在签名错误、权限不足、网络抖动时都可能抛异常。脚本没有在异常后做复杂重试,而是让它自然失败并输出错误日志,交给下一轮cron重试。这里有个权衡:重试逻辑写在脚本内部会变得复杂,而cron天然提供了定时重试机制,没必要重复造轮子。
第三层,幂等保护。前面说的old_ip == new_ip判断,保证脚本不会在异常恢复后对同一IP重复调用更新接口,避免把自己卡在API限流上。
日志是这套兜底设计里最重要的观测手段。我强烈建议日志里至少包含时间戳、当前IP、操作动作三要素。比如[INFO] 2025-01-12 14:30:01 IP变化: 1.2.3.4 -> 5.6.7.8,调用UpdateDomainRecord,这样任何时候想排查都能快速定位问题。
4.4 上线验证:怎么确认脚本真的在干活
脚本第一次部署后,建议手动跑一次:
python3 /opt/ddns/ddns_alidns.py看到输出[INFO] 解析记录无需更新说明链路已经通了:能拿到公网IP,能查到解析记录。然后做一次主动验证:去控制台把A记录的Value改成一个错误IP,等下一个cron周期跑完再看日志,如果出现了[INFO] IP变化: xxx -> 正确IP,调用UpdateDomainRecord,说明自动修复链路正常。
这个"故意改坏再等恢复"的验证方式,是我测试定时任务类脚本最喜欢用的手段,比单纯看代码靠谱得多。
5. 跑了半年之后:这些坑文档里不会写
5.1 UpdateDomainRecord是全量覆盖,参数一个都不能少
这是新手最容易踩的第一个坑。很多人的第一版代码只传了RecordId和Value,觉得"既然只是改IP,RR和Type又没变,应该不用传"。实际跑起来不是报RecordInvalid就是记录被改得面目全非。这个接口的行为是"用你传入的参数整体替换这条记录",缺少的可选参数会被重置为默认值。
正确的做法就是代码里写的那样,RecordId、RR、Type、Value四个参数全部显式传入。如果这条记录开了分线路解析(比如电信/联通/移动各一条记录),还得带上Line参数,否则默认只改default线路那条。家庭场景一般用默认线路,但知道这个机制总没有坏处,以后万一要配分线路解析不至于一脸懵。
5.2 AccessKey相关报错怎么快速定位
脚本跑起来后如果报错,最常见的两类错误码值得记一下:
| 报错提示 | 含义 | 排查方向 |
|---|---|---|
InvalidAccessKeyId.NotFound | AccessKey不存在或已禁用 | 检查AK_ID是否填对,子账号是否被禁用 |
Forbidden.RAM | 当前账号没有操作权限 | 检查RAM子账号是否绑定了AliyunDNSFullAccess策略 |
RecordInvalid | 记录参数不合法 | 检查RR/Type/Value是否传全,字段值是否符合校验规则 |
我遇到过一种比较隐蔽的情况:在RAM控制台创建子账号后授权,理论上立即生效,但某段时间内调用却报权限不足。后来发现是当时用了多个Region的endpoint,部分endpoint同步延迟导致。解决方法是确认alidns的API Region固定用cn-hangzhou,不要随意改动。脚本里REGION = "cn-hangzhou"这个值基本是恒定的,就算你域名在其他区域,alidns接口也是这个Region。
5.3 运营商CGNAT场景:公网IP本来就是假的
这个坑最致命,因为它在代码层面无法解决。现在很多宽带运营商在部分地区已经不给用户分配真正的公网IP了,你家路由器WAN口拿到的IP是运营商内部网关分配的私网地址,出口经过运营商级NAT后才到公网。
怎么判断自己是不是这种情况?有两个办法。一是去光猫或路由器管理界面看WAN口IP,然后对比外部IP查询接口返回的IP,两者一致说明是真实公网IP,不一致基本就是运营商NAT了。二是在路由器上做个端口映射后,用手机流量从外部访问试试,不通大概率就是NAT导致入站连接无法到达你家。
如果落在CGNAT场景,动态DNS脚本更新得再及时也没用,因为外部连接根本进不来。这时要么向运营商申请公网IP,要么换内网穿透思路,通过一台有公网IP的中转服务器转发流量。这个判断最好在做方案选型时就完成,不然脚本写得再漂亮也是白费功夫。
5.4 解析记录改了但访问不了,问题不一定在DNS
脚本日志显示更新成功后,外部访问还是不通,这种问题会让人很困惑。先别怀疑脚本,按层次排查:
- 第一层,本地DNS缓存。笔记本或手机可能缓存了旧解析结果,用
nslookup home.example.com 8.8.8.8指定公共DNS服务器查询,看返回的IP是不是新值。 - 第二层,TTL生效时间。免费版TTL最低600秒,加上递归服务器的缓存策略,全球节点完全刷新可能要更久。
- 第三层,家里的端口映射。IP更新只是解决了"域名指向哪里"的问题,如果路由器上的端口映射是绑定旧IP的,或者映射规则本身没配置对,照样连不上。DSM上改个监听端口、路由器上改个转发目标,这类问题我在家里没少遇到。
按这个顺序排查,绝大多数"更新了但连不上"的情况都能定位。
5.5 密钥管理教训:AccessKey进Git仓库的后果
最后说一个很多人中招的教训。首批版本我曾经为了省事,把AccessKey直接写进了脚本,还顺手把整个项目目录推到了Git远端仓库。第二天发现后虽然没有造成损失,但处理过程非常麻烦:先在RAM控制台删除旧AccessKey、创建新的,再把远程仓库里的历史提交重写一遍。从那以后我所有项目的密钥都只走环境变量或本地权限隔离的配置文件,代码仓库里永远只留占位符。
如果你也在用Git管理脚本,记得在仓库根目录建.gitignore,把.env、配置文件这些加进去。或者更简单一点,参照文章开头代码里的写法,密钥全部通过os.environ.get()读取,代码本身就是一个干净的可分享版本。
这个脚本我的机器上已经稳定跑了超过半年,期间运营商换过几次IP,每次都自动完成了更新,从IP变化到DNS完全生效最慢一次大约15分钟。当初写的时候花了不到一个小时,省下来的是长期的手工操作时间和远程连接失败带来的烦躁感。如果你也有同样的动态IP困扰,建议直接照这个思路搭一个,把域名解析这件事从"每月手动操作"变成"完全不用管"。真要说有什么后续可做的,我在考虑把多个子域名一起纳入更新,顺便在日志里加一条状态记录用来做月度巡检,那种"所有域名解析均正常"的确认信息,比等到连接失败再被动处理安心得多。