Contents

使用原生脚本实现高可用 Cloudflare 动态 DNS (DDNS) 解析

引言:动态IP环境下的域名解析挑战

在基础设施运维中,服务器拥有动态公网IPv4地址(如住宅宽带、某些VPS服务商的NAT产品)是常见的挑战。DNS记录无法与变化的IP保持同步,将直接导致服务中断。Cloudflare 强大的API为这一难题提供了优雅的解决方案。本文将介绍一套经过生产环境验证的、基于原生Bash脚本和Systemd Timer的Cloudflare DDNS自动更新方案,该方案具备高可靠性、错误处理及详尽的日志记录。

第一部分:配置 Cloudflare API 访问凭证

安全是自动化运维的基石。Cloudflare API Tokens 提供了比全局API密钥更细粒度的权限控制。

  1. 生成专用API令牌

    • 登录 Cloudflare仪表板,点击右上角头像,进入 「我的个人资料」
    • 切换至 「API令牌」 标签页,点击 「创建令牌」
    • 我们采用 「编辑区域 DNS」 模板,它已预置了所需的最小权限集:
      • Zone.Zone - 读取
      • Zone.DNS - 编辑
    • 「区域资源」 部分,选择需要应用此令牌的特定域名(建议),或选择 「所有区域」
    • 确认配置后,点击 「创建令牌」
  2. 安全存储令牌 将生成的令牌妥善保存至服务器上的安全路径,并严格限制其访问权限。此令牌仅显示一次。

    sudo mkdir -p /etc/cloudflare-ddns
    echo "您的_API_令牌_字符串" | sudo tee /etc/cloudflare-ddns/token
    sudo chmod 600 /etc/cloudflare-ddns/token
安全须知
API 令牌等同于密码。/etc/cloudflare-ddns/token 文件权限应仅为 root 可读 (chmod 600)。切勿将其提交至版本控制系统或嵌入公开脚本。

第二部分:部署智能 DDNS 更新脚本

此脚本的设计遵循了运维最佳实践:幂等性(无论运行多少次,结果一致)、完善的错误处理以及清晰的日志输出

  1. 创建脚本文件/usr/local/bin/ 目录下创建可执行脚本,这是存放本地自定义命令的标准位置。

    sudo nano /usr/local/bin/cloudflare-ddns.sh
  2. 脚本内容如下 将以下脚本内容复制到文件中。请务必替换 ZONE_NAMERECORD_NAME 变量为您自己的域名和记录。

    #!/usr/bin/env bash
    # cloudflare-ddns.sh
    # example:把 tw.19910812.xyz 的 A 记录更新为当前公网 IPv4(若不存在则创建)
    # 依赖:curl, jq
    set -euo pipefail
    
    CF_API="https://api.cloudflare.com/client/v4"
    ZONE_NAME="your_zone_id" # example: 19910812.xyz
    RECORD_NAME="your_record_name.example.com" # example: tw.19910812.xyz
    TOKEN_FILE="/etc/cloudflare-ddns/token"
    
    if [[ ! -r "$TOKEN_FILE" ]]; then
      echo "Token file $TOKEN_FILE 不存在或不可读" >&2
      exit 1
    fi
    CF_TOKEN=$(cat "$TOKEN_FILE")
    
    # 获取当前公网 IPv4(可替换为其它服务)
    PUBLIC_IP=$(curl -fsS https://api.ipify.org)
    if [[ -z "$PUBLIC_IP" ]]; then
      echo "无法获取公网 IP" >&2
      exit 1
    fi
    
    # 1) 获取 zone id
    ZONE_ID=$(curl -fsS -X GET "$CF_API/zones?name=$ZONE_NAME" \
      -H "Authorization: Bearer $CF_TOKEN" \
      -H "Content-Type: application/json" \
      | jq -r '.result[0].id // empty')
    
    if [[ -z "$ZONE_ID" ]]; then
      echo "未找到 zone $ZONE_NAME,请确认 token 是否有权限或 zone 名称是否正确" >&2
      exit 1
    fi
    
    # 2) 查询目标记录(A)
    RECORD_JSON=$(curl -fsS -X GET "$CF_API/zones/$ZONE_ID/dns_records?name=$RECORD_NAME&type=A" \
      -H "Authorization: Bearer $CF_TOKEN" \
      -H "Content-Type: application/json")
    
    RECORD_ID=$(echo "$RECORD_JSON" | jq -r '.result[0].id // empty')
    DNS_IP=$(echo "$RECORD_JSON" | jq -r '.result[0].content // empty')
    
    # 如果记录存在,比较 IP;如果不同则更新
    if [[ -n "$RECORD_ID" ]]; then
      if [[ "$PUBLIC_IP" == "$DNS_IP" ]]; then
        echo "$(date +'%F %T') IP 未变 ($PUBLIC_IP),不需要更新"
        exit 0
      else
        echo "$(date +'%F %T') IP 变化:$DNS_IP -> $PUBLIC_IP,正在更新..."
        UPDATE_PAYLOAD=$(jq -n --arg t "A" --arg n "$RECORD_NAME" --arg c "$PUBLIC_IP" '{type:$t,name:$n,content:$c,ttl:120,proxied:false}')
        RESP=$(curl -fsS -X PUT "$CF_API/zones/$ZONE_ID/dns_records/$RECORD_ID" \
          -H "Authorization: Bearer $CF_TOKEN" \
          -H "Content-Type: application/json" \
          --data "$UPDATE_PAYLOAD")
        OK=$(echo "$RESP" | jq -r '.success')
        if [[ "$OK" == "true" ]]; then
          echo "$(date +'%F %T') 更新成功: $PUBLIC_IP"
          exit 0
        else
          echo "$(date +'%F %T') 更新失败: $RESP" >&2
          exit 1
        fi
      fi
    else
      # 记录不存在 -> 创建
      echo "$(date +'%F %T') 记录不存在,正在创建 A 记录 $RECORD_NAME -> $PUBLIC_IP"
      CREATE_PAYLOAD=$(jq -n --arg t "A" --arg n "$RECORD_NAME" --arg c "$PUBLIC_IP" '{type:$t,name:$n,content:$c,ttl:120,proxied:false}')
      RESP=$(curl -fsS -X POST "$CF_API/zones/$ZONE_ID/dns_records" \
        -H "Authorization: Bearer $CF_TOKEN" \
        -H "Content-Type: application/json" \
        --data "$CREATE_PAYLOAD")
      OK=$(echo "$RESP" | jq -r '.success')
      if [[ "$OK" == "true" ]]; then
        echo "$(date +'%F %T') 创建成功: $PUBLIC_IP"
        exit 0
      else
        echo "$(date +'%F %T') 创建失败: $RESP" >&2
        exit 1
      fi
    fi
  3. 设置脚本权限并首次测试

    sudo chmod +x /usr/local/bin/cloudflare-ddns.sh
    sudo /usr/local/bin/cloudflare-ddns.sh

    执行后,观察输出。成功的话,您的Cloudflare DNS区域中将立即出现或更新对应的A记录。


第三部分:使用 Systemd Timer 实现可靠定时任务

相较于传统的Cron,Systemd Timer 提供更精细的调度控制、更好的日志集成(通过 journalctl)以及与系统服务的依赖管理。

  1. 创建 Service 单元文件 (/etc/systemd/system/cloudflare-ddns.service) 此文件定义了 “要执行的任务”

    [Unit]
    Description=Cloudflare DDNS Updater Service
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=oneshot
    User=root
    ExecStart=/usr/local/bin/cloudflare-ddns.sh
    # 增强日志记录,将输出同时送入系统日志
    StandardOutput=journal
    StandardError=journal

    After=network-online.target 确保了仅在网络就绪后执行脚本。

  2. 创建 Timer 单元文件 (/etc/systemd/system/cloudflare-ddns.timer) 此文件定义了 “何时执行任务”

    [Unit]
    Description=Run Cloudflare DDNS Updater every 5 minutes
    
    [Timer]
    OnBootSec=1min
    OnUnitActiveSec=5min
    Persistent=true
    # 可添加随机延迟,避免所有客户端同时请求
    RandomizedDelaySec=30s
    
    [Install]
    WantedBy=timers.target
    • OnBootSec=1min: 系统启动后1分钟运行第一次。
    • OnUnitActiveSec=5min: 在上次任务激活成功后,每5分钟运行一次。
    • Persistent=true: 如果服务器在计划运行时间点处于关机状态,开机后会尽快补执行一次。
  3. 启用并启动定时器

    sudo systemctl daemon-reload
    sudo systemctl enable --now cloudflare-ddns.timer
  4. 验证定时器状态

    # 查看定时器状态
    sudo systemctl status cloudflare-ddns.timer
    # 查看最近的服务执行日志
    sudo journalctl -u cloudflare-ddns.service -n 20 -f

总结与高级应用场景

本方案已在 Debian 12 等主流 Linux 发行版上经过长期验证,稳定可靠。

核心应用场景包括:

  1. 动态公网IPv4环境:如HKT、HiNet等提供的NAT VPS,其出口IP可能周期性变化。本方案可确保域名始终指向有效的IP。
  2. 无缝机房迁移:对于使用 搬瓦工多机房切换 等服务的用户,在切换数据中心导致IP变更后,DDNS脚本能自动将域名解析至新IP,实现用户无感知的迁移,极大提升服务的可用性。
  3. 混合云及边缘计算:在IP不固定的边缘节点部署服务,通过DDNS提供统一的访问入口。

运维价值:

  • 自动化:免除手动更新DNS记录的操作负担与人为失误风险。
  • 高可用:分钟级的IP同步延迟,最大化服务在线时间。
  • 可观测性:通过Systemd Journal,所有执行记录、IP变更历史清晰可查。

您可以根据实际需求,调整脚本中的 TTL 值或Timer的执行频率