适用场景

CLIProxyAPI 的 usage 统计默认在内存中,进程重启后会丢失。本方案基于 /v0/management/usage/export 与 /v0/management/usage/import 实现可恢复持久化。

1. 方案原因

  1. usage 统计是内存态,服务重启会清空。
  2. 官方有 management API 的 export/import,适合做外部持久化。
  3. 仅靠停机导出仍有风险(断电、kill -9 不触发 ExecStop),因此增加周期导出。

2. 方案说明

2.1 设计目标

  1. 启动后自动导入历史 usage。
  2. 停止前自动导出最新 usage。
  3. 运行中每 5 分钟自动导出一次,降低异常中断损失。

2.2 文件职责

  1. /etc/systemd/system/cli-proxy-api.service
  • 主服务 unit(通过 ExecStartPost 与 ExecStop 提供启停钩子)。
  1. /opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh
  • 实际执行 export/import。
  • 从 /opt/cli-proxy-api/config/.management-key 读取管理密钥。
  • 备份文件写入 /opt/cli-proxy-api/export/usage-stats-backup.json。
  1. /etc/systemd/system/cli-proxy-usage-export.service
  • oneshot 导出服务。
  1. /etc/systemd/system/cli-proxy-usage-export.timer
  • 每 5 分钟触发一次导出服务。

3. 部署配置(完整原文)

3.1 主服务 unit(服务启停钩子)

/etc/systemd/system/cli-proxy-api.service

[Unit]
Description=CLI Proxy API - OpenAI/Gemini/Claude Compatible Proxy Server
After=network-online.target
Wants=network-online.target

[Service]
Environment="ALL_PROXY=http://192.168.114.9:7890"
Environment="HTTP_PROXY=http://192.168.114.9:7890"
Environment="HTTPS_PROXY=http://192.168.114.9:7890"
Type=simple
ExecStart=/opt/cli-proxy-api/bin/cli-proxy-api -config /opt/cli-proxy-api/config/config.yaml
ExecStartPost=-/opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh import
ExecStop=-/opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh export
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/cli-proxy-api/config /opt/cli-proxy-api/export
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

3.2 启停同步脚本

/opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh

#!/usr/bin/env bash
set -u

MODE="${1:-}"
BASE_URL="https://127.0.0.1:8317"
KEY_FILE="/opt/cli-proxy-api/config/.management-key"
BACKUP_DIR="/opt/cli-proxy-api/export"
BACKUP_FILE="${BACKUP_DIR}/usage-stats-backup.json"
TMP_FILE="${BACKUP_FILE}.tmp"

log() {
  logger -t cli-proxy-usage-sync -- "$*"
  echo "[cli-proxy-usage-sync] $*" >&2
}

read_key() {
  if [! -r "$KEY_FILE"](/posts/!--r-"$key_file"/); then
    log "key file not found: $KEY_FILE"
    return 1
  fi
  KEY="$(tr -d '\r\n' < "$KEY_FILE")"
  if [-z "$KEY"](/posts/-z-"$key"/); then
    log "key is empty"
    return 1
  fi
  return 0
}

do_export() {
  mkdir -p "$BACKUP_DIR"

  local code
  code="$(curl -k -sS --connect-timeout 2 --max-time 5 \
    -H "X-Management-Key: $KEY" \
    -o "$TMP_FILE" -w "%{http_code}" \
    "$BASE_URL/v0/management/usage/export" || echo 000)"

  if ["$code" == "200"](/posts/"$code"-==-"200"/); then
    mv -f "$TMP_FILE" "$BACKUP_FILE"
    chmod 600 "$BACKUP_FILE" || true
    log "export ok -> $BACKUP_FILE"
  else
    rm -f "$TMP_FILE"
    log "export failed, http=$code"
  fi
}

do_import() {
  if [! -s "$BACKUP_FILE"](/posts/!--s-"$backup_file"/); then
    log "backup not found, skip import"
    return 0
  fi

  local i code
  for i in $(seq 1 20); do
    code="$(curl -k -sS --connect-timeout 2 --max-time 5 \
      -X POST \
      -H "X-Management-Key: $KEY" \
      -H "Content-Type: application/json" \
      --data-binary "@$BACKUP_FILE" \
      -o /dev/null -w "%{http_code}" \
      "$BASE_URL/v0/management/usage/import" || echo 000)"
    if ["$code" == "200"](/posts/"$code"-==-"200"/); then
      log "import ok from $BACKUP_FILE"
      return 0
    fi
    sleep 1
  done

  log "import failed, last_http=$code"
}

if ! read_key; then
  exit 0
fi

case "$MODE" in
  export) do_export ;;
  import) do_import ;;
  *) log "unknown mode: $MODE" ;;
esac

exit 0

3.2.1 export:从本地 management API 下载 usage 快照到文件

do_export() 的核心目标,是把当前内存中的 usage 统计,通过 HTTPS 接口导出成一个本地 JSON 备份文件。

执行流程如下:

  1. mkdir -p "$BACKUP_DIR"

    • 先确保备份目录存在,避免 curl -o 写文件时报错。
  2. 调用 curl 请求导出接口:

code="$(curl -k -sS --connect-timeout 2 --max-time 5 \
  -H "X-Management-Key: $KEY" \
  -o "$TMP_FILE" -w "%{http_code}" \
  "$BASE_URL/v0/management/usage/export" || echo 000)"

这里的含义可以拆开看:

  • 请求地址:$BASE_URL/v0/management/usage/export

    • 也就是 https://127.0.0.1:8317/v0/management/usage/export。
    • 这是本机上的 management API,不是从公网下载文件,而是从本地服务导出 usage 数据。
  • -k

    • 允许 curl 在 TLS 场景下跳过服务端证书校验。
    • 当前脚本用于本机回环地址调用自签证书接口,因此使用 -k 保证导入导出流程稳定执行。
  • 没有显式写 -X GET

    • 因为 curl 在没有上传数据时默认就是 GET 请求。
    • 所以这里本质上是:向 export 接口发起 GET,请服务返回 usage JSON。
  • -o "$TMP_FILE"

    • 把服务返回的响应体直接写到临时文件。
    • 也就是说,导出的 JSON 内容不会打印到终端,而是先落盘到 ${BACKUP_FILE}.tmp。
  • -w "%{http_code}"

    • 让 curl 在请求结束后,额外把 HTTP 状态码输出到标准输出。
    • 这样 shell 可以把它捕获到变量 code 中,例如 200、401、500。
  • || echo 000

    • 如果 curl 本身执行失败(例如连接不上、超时、被拒绝),就不会拿到正常 HTTP 状态码。
    • 这时补一个 000,用于表示“不是 HTTP 业务失败,而是传输层/连接层失败”。
  1. 根据返回码判断是否覆盖正式备份:
  • code == 200

    • 说明导出成功。
    • 执行 mv -f "$TMP_FILE" "$BACKUP_FILE",把临时文件原子替换成正式备份文件。
    • 这样做比直接写正式文件更稳妥,能避免导出过程中断时留下半截文件。
  • 其他状态码

    • 说明导出失败。
    • 执行 rm -f "$TMP_FILE" 删除临时文件,避免误把失败响应当成备份。
  1. chmod 600 "$BACKUP_FILE"
    • 把备份文件权限收紧为仅所有者可读写。
    • 因为 usage 数据通常属于管理面信息,建议不要对其他用户开放。

3.2.2 import:把本地 JSON 备份文件上传回服务

do_import() 的核心目标,是在服务启动后,把上一次保存的 usage 统计重新导入到内存中。

执行流程如下:

  1. 先检查备份文件是否存在且非空:
if [! -s "$BACKUP_FILE"](/posts/!--s-"$backup_file"/); then
  log "backup not found, skip import"
  return 0
fi
  • -s 表示文件存在且大小大于 0。
  • 如果没有可用备份,就直接跳过导入,避免空请求。
  1. 启动重试循环:
for i in $(seq 1 20); do
  ...
  sleep 1
done
  • 这里最多重试 20 次,每次间隔 1 秒。
  • 这样设计的原因是:ExecStartPost 触发时,主进程虽然已经启动,但 management API 端口未必立刻可用。
  • 因此需要给服务一点“预热时间”。
  1. 调用 curl 请求导入接口:
code="$(curl -k -sS --connect-timeout 2 --max-time 5 \
  -X POST \
  -H "X-Management-Key: $KEY" \
  -H "Content-Type: application/json" \
  --data-binary "@$BACKUP_FILE" \
  -o /dev/null -w "%{http_code}" \
  "$BASE_URL/v0/management/usage/import" || echo 000)"

这个请求与 export 的区别在于:

  • export 是“从接口取数据到文件”。
  • import 是“把文件内容作为请求体发回接口”。

具体含义如下:

  • -X POST

    • 明确指定使用 POST。
    • 因为这里是把数据提交给服务,语义上属于导入/写入操作。
  • -H "Content-Type: application/json"

    • 告诉服务端:这次请求体里发送的是 JSON 数据。
    • 服务端通常会按这个类型解析导入内容。
  • --data-binary "@$BACKUP_FILE"

    • 把本地文件内容原样读取出来,作为 HTTP 请求体发送。
    • @文件名 是 curl 的文件上传语法。
    • 这里使用 --data-binary 而不是普通 -d,是为了尽量按原始字节发送文件内容,避免对换行等内容做不必要处理,更适合发 JSON 备份文件。
  • -o /dev/null

    • 丢弃响应体。
    • 因为这里真正关心的是“导入是否成功”,而不是接口返回正文内容。
  • -w "%{http_code}"

    • 同样把状态码单独输出出来,供脚本判断是否成功。
  1. 成功后立即结束,失败则继续重试:
  • 如果返回 200

    • 说明服务已成功导入备份数据。
    • 记录日志后直接 return 0。
  • 如果不是 200

    • 继续等待 1 秒,再重试下一轮。
  • 20 次后仍失败

    • 记录 import failed, last_http=$code。
    • 但脚本本身仍然 exit 0,避免因为 usage 导入失败而阻塞主服务启动。

3.2.3 本脚本里用到的 curl 参数说明

参数出现位置作用这里为什么要用
-kexport / import跳过 TLS 证书校验本机访问 https://127.0.0.1:8317 且证书为自签场景,避免证书校验失败导致同步中断
-sSexport / import-s 开启 silent 模式,不显示进度条;-S 在 silent 模式下若出错仍显示错误信息适合 systemd/脚本场景,日志更干净,但异常时仍能看到错误
--connect-timeout 2export / import连接建立超时时间设为 2 秒避免本地接口异常时长时间卡住
--max-time 5export / import整个请求最多执行 5 秒本机回环调用无需长等待,快速失败可以缩短 systemd 钩子阻塞时间
-H "X-Management-Key: $KEY"export / import添加自定义 HTTP 请求头给 management API 传递鉴权密钥
-o "$TMP_FILE"export把响应体写入文件将导出的 usage JSON 直接保存到临时文件
-o /dev/nullimport丢弃响应体导入时只关心返回码,不关心正文
-w "%{http_code}"export / import在请求完成后输出 HTTP 状态码便于 shell 脚本做成功/失败判断
-X POSTimport指定请求方法为 POST导入属于提交数据,不再使用默认 GET
-H "Content-Type: application/json"import声明请求体类型是 JSON帮助服务端按 JSON 解析导入文件
--data-binary "@$BACKUP_FILE"import把文件内容作为请求体原样发送适合把 JSON 备份文件完整上传到 import 接口

3.2.4 手工调试时可直接使用的 curl 示例

如果你想绕过脚本、直接在命令行验证接口,可以使用下面两条命令。

  1. 手工导出 usage 到文件:
curl -k -sS \
  -H "X-Management-Key: <MANAGEMENT_KEY>" \
  -o usage-stats-backup.json \
  https://127.0.0.1:8317/v0/management/usage/export
  1. 手工把备份文件导回服务:
curl -k -sS -X POST \
  -H "X-Management-Key: <MANAGEMENT_KEY>" \
  -H "Content-Type: application/json" \
  --data-binary "@usage-stats-backup.json" \
  https://127.0.0.1:8317/v0/management/usage/import

> 上面两条命令更适合人工排障或联调;而脚本版本额外加入了超时、临时文件、状态码判断、失败清理和重试逻辑,更适合 systemd 自动化场景。

3.3 周期导出 service

/etc/systemd/system/cli-proxy-usage-export.service

[Unit]
Description=CLIProxyAPI usage statistics periodic export
After=network-online.target cli-proxy-api.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh export

3.4 周期导出 timer

/etc/systemd/system/cli-proxy-usage-export.timer

[Unit]
Description=Run CLIProxyAPI usage export every 5 minutes

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s
Persistent=true
Unit=cli-proxy-usage-export.service

[Install]
WantedBy=timers.target

4. 从零部署命令(可复用)

请替换 <MANAGEMENT_KEY> 为你的 management 明文密钥

# 1) 写主服务 unit(配置启停钩子)
cat >/etc/systemd/system/cli-proxy-api.service <<'UNIT'
[Unit]
Description=CLI Proxy API - OpenAI/Gemini/Claude Compatible Proxy Server
After=network-online.target
Wants=network-online.target

[Service]
Environment="ALL_PROXY=http://192.168.114.9:7890"
Environment="HTTP_PROXY=http://192.168.114.9:7890"
Environment="HTTPS_PROXY=http://192.168.114.9:7890"
Type=simple
ExecStart=/opt/cli-proxy-api/bin/cli-proxy-api -config /opt/cli-proxy-api/config/config.yaml
ExecStartPost=-/opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh import
ExecStop=-/opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh export
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/cli-proxy-api/config /opt/cli-proxy-api/export
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target
UNIT

# 2) 写脚本
install -m 755 /tmp/cli-proxy-usage-sync.sh /opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh

# 3) 写 timer 单元
install -m 644 /tmp/cli-proxy-usage-export.service /etc/systemd/system/cli-proxy-usage-export.service
install -m 644 /tmp/cli-proxy-usage-export.timer /etc/systemd/system/cli-proxy-usage-export.timer

# 4) 保存 management key(脚本读取)
printf '%s' '<MANAGEMENT_KEY>' >/opt/cli-proxy-api/config/.management-key
chmod 600 /opt/cli-proxy-api/config/.management-key
chown root:root /opt/cli-proxy-api/config/.management-key

# 5) 如果曾用 drop-in,删除旧配置
rm -f /etc/systemd/system/cli-proxy-api.service.d/usage-sync.conf

# 6) 生效并启用
systemctl daemon-reload
systemctl enable --now cli-proxy-usage-export.timer
systemctl restart cli-proxy-api.service

5. 验证命令

# 主服务是否加载启停钩子配置
systemctl status cli-proxy-api.service --no-pager -l
cat /etc/systemd/system/cli-proxy-api.service

# timer 是否生效
systemctl status cli-proxy-usage-export.timer --no-pager -l
systemctl list-timers cli-proxy-usage-export.timer --no-pager

# 手动触发一次导出并查看日志
systemctl start cli-proxy-usage-export.service
journalctl -t cli-proxy-usage-sync -n 50 --no-pager

# 检查备份文件
ls -lh /opt/cli-proxy-api/export/usage-stats-backup.json

6. 回滚方案

# 1) 关闭周期导出
systemctl disable --now cli-proxy-usage-export.timer
rm -f /etc/systemd/system/cli-proxy-usage-export.timer
rm -f /etc/systemd/system/cli-proxy-usage-export.service

# 2) 从服务配置移除启停钩子(恢复不含启停钩子的版本)
#    删除这两行:
#    ExecStartPost=-/opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh import
#    ExecStop=-/opt/cli-proxy-api/bin/cli-proxy-usage-sync.sh export

# 3) 生效
systemctl daemon-reload
systemctl restart cli-proxy-api.service

7. 注意事项

  1. ExecStop 仅覆盖正常 stop/restart,不覆盖断电或 kill -9。
  2. 周期导出越频繁,损失窗口越小,但管理 API 调用更频繁。
  3. .management-key 建议严格 600 权限。
  4. 如果端口或路径变更,需同步更新脚本中的 BASE_URL、KEY_FILE、BACKUP_FILE。

8. 总结

本方案通过“服务启停钩子 + timer 周期导出”实现 usage 统计可恢复:

  1. 启动自动恢复历史统计。
  2. 停止自动导出最新统计。
  3. 运行期间持续快照,减少异常中断数据损失。

适合长期运行、频繁重启、需要保留 usage 趋势数据的 CLIProxyAPI 部署。