Ubuntu 14.04 Droplet自动扩缩容实战:Shell级轻量方案

Ubuntu 14.04 Droplet自动扩缩容实战:Shell级轻量方案

1. 为什么在 Ubuntu 14.04 Droplet 上做自动扩缩容,本质上是在和时间赛跑

你点开 DigitalOcean 控制台,新建一台 Ubuntu 14.04 的 Droplet,选了 1GB 内存、1 核 CPU,装上 Nginx、Passenger、PostgreSQL,再部署一个 Ruby on Rails 应用——一切顺利。直到某天凌晨三点,你被 Slack 里炸锅的告警叫醒:应用响应时间飙升到 8 秒,502 错误刷屏,用户反馈“页面打不开”。你连上服务器,top一敲,ruby进程占满 CPU,free -h显示内存只剩 64MB,swap 正在疯狂抖动。你手忙脚乱重启 Passenger、清缓存、临时扩容到 2GB……问题暂时压下去了。但第二天复盘时你意识到:这不是故障,是系统性失能——你正在用人工操作对抗指数级增长的请求压力。

这就是 Ubuntu 14.04 Droplet 自动扩缩容的核心语境:它不是锦上添花的“高级功能”,而是对一个特定技术栈组合(老旧 OS + 动态语言 + 无状态 Web 层)实施生存级运维的底线手段。Ubuntu 14.04 于 2014 年 4 月发布,2019 年 4 月已结束标准支持,2022 年 4 月终止扩展安全维护(ESM)。这意味着它不再接收内核补丁、glibc 更新、OpenSSL 升级——任何依赖现代 TLS 握手、cgroup v2 或 memory pressure 检测机制的现代扩缩容工具(如 Kubernetes HPA、Prometheus+Keda)根本无法在其上编译或稳定运行。你不能指望kubectl top pods在一个连systemd都没完全替代upstart的系统里返回有效指标。

而 Ruby 生态在此环境下的脆弱性被进一步放大。Passenger 4.x 是当时主流版本,其进程管理模型依赖mod_rails的 prefork 模式,每个 worker 固定占用 80–120MB 内存;当并发请求激增,Passenger 不会像 Go 或 Node.js 那样轻量创建协程,而是硬生生 fork 新进程——这直接触发 OOM Killer 杀死最“肥”的进程(通常是数据库连接池或缓存客户端),形成雪崩。此时,任何基于应用层指标(如 Rails 日志中的Completed 200 OK计数)的扩缩容都是马后炮:等日志写完、指标采集到、决策下发,服务早已不可用。

所以,“How To Automate the Scaling”这个标题的真实含义是:在操作系统内核、C 库、Ruby 解释器、Web 服务器全部锁定在 2014–2016 年技术代际的前提下,仅利用cronbashcurlpsfree等 POSIX 兼容基础工具,构建一套能在 90 秒内完成检测→决策→执行→验证闭环的轻量级扩缩容流水线。它不追求优雅,只求可靠;不依赖新特性,只榨干旧工具链的最后一丝能力。我当年在一家 SaaS 初创公司维护 37 台 Ubuntu 14.04 Droplet 时,就是靠这套方案扛过了 Black Friday 流量洪峰——没有一行 Ruby 代码参与决策逻辑,所有判断都在 Shell 脚本里用awk字符串匹配和bc浮点计算完成。下面,我们就从最底层的监控锚点开始,一层层搭起这座“技术考古现场”的自动扩缩容塔。

2. 监控锚点:为什么不用 CPU 百分比,而用ps aux --sort=-%mem | head -n 20做核心指标

几乎所有教程都会告诉你:“看 CPU 使用率超过 70% 就扩容”。但在 Ubuntu 14.04 + Ruby 场景下,这是个致命陷阱。原因有三:

第一,top/proc/stat中的 CPU 百分比是采样统计值,受sysctl vm.stat_interval(默认 1 秒)影响,在高负载下采样窗口可能错过瞬时尖峰。更关键的是,Ruby 的 GIL(全局解释器锁)导致多线程 CPU 利用率呈现“锯齿状”波动——同一秒内可能从 15% 跳到 95% 再跌回 20%,单纯阈值触发会造成频繁误扩缩。我实测过,将 CPU 阈值设为 70%,在模拟 200 QPS 的 Rails API 负载下,30 分钟内触发了 17 次无意义扩容,每次扩容后 2 分钟内又因 CPU 回落而缩容,Droplet 数量在 2–5 台间疯狂震荡,成本翻倍且服务稳定性反而下降。

第二,Ubuntu 14.04 的ps命令不支持--no-headers参数(该参数在 procps-ng 3.3.10 后才加入,而 14.04 默认是 3.3.9),导致解析输出必须处理表头行。但ps aux的列宽是动态的,当用户名过长或命令行含空格时,列对齐会错位,用cut -d' ' -f3提取 PID 会失败。这是新手最容易栽跟头的地方——脚本看似运行成功,实则监控数据全错。

第三,也是最根本的:Ruby 应用的瓶颈从来不在 CPU,而在内存与 I/O 等待。Passenger worker 进程启动后,大部分时间阻塞在read()系统调用(等待数据库响应)或write()(向客户端发包),%cpu显示很低,但RSS(常驻内存集)却持续增长。我们曾遇到一个 Bug:某个 ActiveRecord 关联查询未加.includes,导致 N+1 查询,单个请求生成 200 个数据库连接,每个连接占用 4MB 内存,10 个并发请求就吃掉 8GB 内存——此时 CPU 使用率不到 30%,但服务已彻底卡死。

因此,我们放弃 CPU 百分比,转而采用“Top 20 内存消耗进程的 RSS 总和”作为核心监控锚点。具体实现如下:

# 获取当前所有 ruby 进程的 RSS(KB)并求和 RUBY_RSS_SUM=$(ps aux --sort=-%mem 2>/dev/null | \ awk '$11 ~ /ruby/ && $10 > 100 {sum += $6} END {print sum+0}') # 获取系统总内存(KB) TOTAL_MEM=$(grep MemTotal /proc/meminfo | awk '{print $2}') # 计算 Ruby 进程内存占用率 if [ "$RUBY_RSS_SUM" -gt 0 ] && [ "$TOTAL_MEM" -gt 0 ]; then RUBY_MEM_PERCENT=$(echo "scale=2; $RUBY_RSS_SUM * 100 / $TOTAL_MEM" | bc -l) else RUBY_MEM_PERCENT=0.00 fi

这段脚本的关键细节在于:

  • ps aux --sort=-%mem按内存使用率降序排列,确保ruby进程大概率出现在前 20 行;
  • awk '$11 ~ /ruby/ && $10 > 100'精准匹配第 11 列(COMMAND)含ruby且第 10 列(%MEM)大于 100(即真实内存占用超 100MB)的进程,过滤掉ruby -v这类短命进程;
  • sum += $6累加第 6 列(RSS,单位 KB),END {print sum+0}确保空结果返回 0 而非报错;
  • bc -l进行浮点计算,避免expr的整数截断。

我们设定的扩缩容阈值是:RUBY_MEM_PERCENT >= 65.00时触发扩容;当<= 40.00时触发缩容。这个数值不是拍脑袋定的——通过连续 72 小时压测得出:Ubuntu 14.04 的swappiness=60默认值下,当 Ruby 进程 RSS 占比超 65%,kswapd0内核线程开始高频唤醒,pgmajfault(主缺页中断)每秒超 200 次,应用延迟直线上升;而低于 40% 时,即使缩容 1 台,剩余 Droplet 的平均 RSS 占比仍稳定在 55% 以下,无性能劣化。

提示:不要用free -m | awk 'NR==2{print $3/$2*100}'计算内存使用率。free显示的used包含 cache/buffer,而 Passenger worker 的内存是真实 RSS,两者不可比。我曾因误用此法,导致扩缩容决策延迟 4 分钟,最终用户投诉激增。

3. 执行引擎:为什么用 DigitalOcean API v2 +curl而非doctl,以及如何绕过 OAuth 令牌过期陷阱

DigitalOcean 在 2015 年初就推出了 v2 REST API,而doctl命令行工具直到 2017 年才正式发布。在 Ubuntu 14.04 环境下,doctl的二进制依赖 glibc 2.17+,而 14.04 自带的是 2.19——看似满足,实则doctl的静态链接库在 14.04 的ldconfig路径下找不到libstdc++.so.6的兼容版本,强行安装会破坏系统apt工具链。我们试过用patchelf修改 rpath,但后续发现doctl的 Droplet 创建 API 调用会触发getaddrinfoDNS 缓存 bug,导致 10% 的请求超时。最终,我们回归最原始也最可靠的方案:纯curl调用 DigitalOcean API v2。

API 认证采用 Personal Access Token(PAT),而非 OAuth。原因很现实:OAuth 流程需要浏览器重定向、state 参数防 CSRF、token refresh 机制——这些在无 GUI 的服务器环境中无法实现。而 PAT 是一个长生命周期(可设永不过期)的字符串,直接放在 HTTP Header 中,零依赖、零状态、零维护。

但 PAT 本身有个隐形陷阱:DigitalOcean 的 PAT 有“作用域”限制,而创建 Droplet 必须同时拥有droplets:read,droplets:write,regions:read,images:read四个权限。很多工程师只勾选了droplets:write,结果 API 返回 403 Forbidden,排查半天才发现是权限不足。我们的 PAT 生成流程强制要求:登录 DigitalOcean 控制台 → API → Generate New Token → 勾选全部四个复选框 → 复制后立即存入~/.digitalocean/token文件(权限600)。

创建 Droplet 的核心curl命令如下:

# 读取 token TOKEN=$(cat ~/.digitalocean/token) # 构建 JSON payload(关键字段详解见下文) PAYLOAD=$(cat <<EOF { "name": "rails-app-$(date +%s)", "region": "sfo2", "size": "s-1vcpu-2gb", "image": "ubuntu-14-04-x64", "ssh_keys": ["YOUR_SSH_KEY_FINGERPRINT"], "backups": false, "ipv6": true, "user_data": "$(base64 -w0 ./bootstrap.sh)" } EOF ) # 调用 API curl -X POST "https://api.digitalocean.com/v2/droplets" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d "$PAYLOAD" \ -o /tmp/do_create_resp.json 2>/dev/null # 解析响应,提取 droplet ID 和 IP DROPLET_ID=$(jq -r '.droplet.id' /tmp/do_create_resp.json) DROPLET_IP=$(jq -r '.droplet.networks.v4[0].ip_address' /tmp/do_create_resp.json)

这里有几个必须深挖的细节:

user_data字段是成败关键。它允许你在 Droplet 启动时自动执行脚本,相当于云平台的“无人值守安装”。但 Ubuntu 14.04 的 cloud-init 版本是 0.7.5,不支持#cloud-configYAML 语法的高级特性(如runcmd中的数组嵌套)。我们必须用最原始的#!/bin/bash方式编写bootstrap.sh,且首行必须是#!/bin/bash(不能是#!/usr/bin/env bash,因为/usr/bin/env在最小化安装中可能不存在)。bootstrap.sh的核心任务是:

  1. apt-get update && apt-get install -y nginx passenger ruby2.1(固定 Ruby 版本,避免apt upgrade升级破坏环境);
  2. cp /tmp/app.tar.gz /var/www/myapp && tar -xzf /var/www/myapp.tar.gz -C /var/www/myapp(从对象存储拉取预打包应用);
  3. sed -i "s/localhost/$DROPLET_IP/g" /etc/nginx/sites-enabled/myapp(动态注入本机 IP);
  4. service nginx restart && passenger start --daemonize --port 3000 --environment production

DNS 解析超时问题。DigitalOcean API 域名api.digitalocean.com的 TTL 是 60 秒,但 Ubuntu 14.04 的resolvconf服务在某些内核版本下会缓存 DNS 结果长达 5 分钟。我们遇到过curl请求卡在Resolving host阶段 30 秒以上。解决方案是在curl命令中强制指定 DNS 服务器:-H "Host: api.digitalocean.com" --resolve "api.digitalocean.com:443:192.0.2.1"192.0.2.1是 DigitalOcean 官方 DNS,实际使用192.0.2.1替换),并添加--connect-timeout 10 --max-time 30严格控制超时。

错误重试的幂等性设计。API 调用可能因网络抖动失败,但重复创建同名 Droplet 会返回 422 Unprocessable Entity。我们不依赖name去重,而是用curl-f参数捕获 HTTP 错误码,并在失败时生成新时间戳$(date +%s%N)保证名称唯一,同时记录last_attempt_time到本地文件,避免 1 分钟内重复尝试。

注意:jq工具在 Ubuntu 14.04 的默认源中不可用,需手动安装:wget -qO - https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64 | sudo tee /usr/local/bin/jq && sudo chmod +x /usr/local/bin/jq。别用apt-get install jq,那个版本是 1.3,不支持-r参数。

4. 负载均衡中枢:HAProxy 的零配置热重载与会话保持失效的真相

当新 Droplet 创建完成,IP 地址拿到手,下一步是让流量流过去。你可能会想:“加一台 Nginx 反向代理做负载均衡不就行了?”——这是典型的新手误区。Nginx 的 upstream 配置修改后必须nginx -s reload,这个信号会杀死旧 worker 进程,导致正在传输的大文件(如图片上传)中断,HTTP 连接重置。在 Ubuntu 14.04 上,nginx -s reload的平均耗时是 120ms,而 HAProxy 的reload只需 8ms,且支持无缝切换(seamless reload)。

HAProxy 1.5(Ubuntu 14.04 默认版本)的热重载原理是:新进程启动后,通过SO_REUSEPORT套接字选项与旧进程共享监听端口;旧进程处理完已有连接后优雅退出。整个过程对客户端完全透明,TCP 连接不断开。我们实测,在 5000 QPS 下,HAProxy reload 期间 0 个连接被重置,而 Nginx reload 导致 0.3% 的请求失败。

但 HAProxy 的配置绝非“抄个模板”就能用。针对 Ruby on Rails 应用,我们必须解决两个核心问题:会话保持(Session Persistence)与健康检查(Health Check)

先说会话保持。Rails 默认使用 Cookie-based Session,理论上无需服务端粘性。但现实中,Passenger 的sticky_sessions选项在 14.04 上有 bug:当启用balance source时,HAProxy 会把同一客户端 IP 的所有请求固定到一台后端,而移动网络下用户 IP 经常变化,导致登录态丢失。我们的解法是禁用balance source,改用cookie SERVERID insert indirect nocache,并在 Rails 应用的config/environments/production.rb中添加:

config.session_store :cookie_store, key: '_myapp_session', expire_after: 1.hour, secure: true, httponly: true, same_site: :lax

这样,HAProxy 从响应头Set-Cookie中提取SERVERID值(如SERVERID=web-001),后续请求携带该 Cookie 即路由到对应后端。indirect表示只在后端首次响应时插入 Cookie,nocache防止 CDN 缓存带 Cookie 的响应。

健康检查的配置更是魔鬼在细节里。默认的option httpchk GET /health会触发 Rails 的完整请求栈,消耗大量资源。我们改为option httpchk HEAD /,并在 Nginx 配置中添加:

location = / { return 200 "OK"; add_header Content-Type text/plain; }

这样健康检查只走 Nginx,不进 Rails,毫秒级响应。同时,http-check expect status 200确保只有返回 200 才认为健康,避免 Rails 报错页面(500)被误判为正常。

最关键的热重载脚本haproxy-reload.sh如下:

#!/bin/bash # 备份旧配置 cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak.$(date +%s) # 生成新配置(从模板注入新 Droplet IP) envsubst < /etc/haproxy/haproxy.cfg.tmpl > /etc/haproxy/haproxy.cfg # 语法检查 haproxy -c -f /etc/haproxy/haproxy.cfg if [ $? -ne 0 ]; then echo "HAProxy config syntax error, restoring backup" cp /etc/haproxy/haproxy.cfg.bak.$(date +%s) /etc/haproxy/haproxy.cfg exit 1 fi # 发送 USR2 信号触发无缝重载 kill -USR2 $(cat /var/run/haproxy.pid)

这里envsubst是核心:它用 shell 环境变量替换模板中的${BACKENDS}占位符。haproxy.cfg.tmpl中定义:

backend rails_servers balance roundrobin cookie SERVERID insert indirect nocache option httpchk HEAD / http-check expect status 200 ${BACKENDS}

而调用脚本时,先执行export BACKENDS="server web-001 192.0.2.101:80 check cookie web-001 server web-002 192.0.2.102:80 check cookie web-002",再运行envsubst。这样,每次扩容只需更新BACKENDS变量,无需解析 XML 或 JSON,极致轻量。

提示:HAProxy 的check参数默认间隔 2000ms,但我们将其设为inter 1000 rise 2 fall 3——1 秒检查一次,连续 2 次成功认为恢复,3 次失败才下线。这比默认值快一倍,能更快发现故障节点。

5. 缩容安全阀:为什么必须用passenger-status --show=requests而非ps aux | grep ruby

扩容是加法,缩容是减法——而减法一旦出错,就是服务中断。很多自动化脚本在缩容时简单粗暴地curl -X DELETE "https://api.digitalocean.com/v2/droplets/$ID",然后祈祷后端没人在处理请求。这是灾难的开端。

Ubuntu 14.04 的 Passenger 4.0.59 有一个鲜为人知的特性:passenger-status --show=requests命令会输出当前所有 worker 进程正在处理的 HTTP 请求详情,包括请求方法、URL、处理时长、内存占用。这才是缩容前必须检查的“生命体征”。

我们编写的缩容前检查脚本pre-shutdown-check.sh核心逻辑是:

# 获取所有 passenger worker 的 PID PIDS=$(passenger-status | awk '/PID:/ {print $2}') # 对每个 PID,检查其处理的请求数 for pid in $PIDS; do # 使用 /proc/$pid/cmdline 获取进程命令行(Ubuntu 14.04 兼容) CMDLINE=$(tr '\0' ' ' < /proc/$pid/cmdline 2>/dev/null | cut -d' ' -f1) # 如果是 passenger-core 或 ruby 进程,检查其 requests if [[ "$CMDLINE" == *"passenger-core"* ]] || [[ "$CMDLINE" == *"ruby"* ]]; then REQUESTS=$(passenger-status --show=requests 2>/dev/null | \ awk -v pid="$pid" '$1 == pid {count++} END {print count+0}') if [ "$REQUESTS" -gt 0 ]; then echo "PID $pid has $REQUESTS active requests, delaying shutdown" exit 1 fi fi done echo "All workers idle, safe to shutdown"

这段脚本的精妙之处在于:

  • 不依赖passenger-status的 JSON 输出(14.04 版本不支持--format=json),而是用awk解析人类可读格式;
  • /proc/$pid/cmdline替代ps aux,因为ps在高负载下可能卡住,而/proc是内核实时接口;
  • tr '\0' ' '将 cmdline 中的 null 字节替换为空格,cut -d' ' -f1提取第一个单词,精准识别进程类型。

我们设定的安全策略是:如果任意一个 worker 有活跃请求,缩容操作暂停 30 秒后重试;最多重试 5 次(2.5 分钟),超时则强制缩容并告警。这个窗口足够让一个慢查询(如报表导出)完成,又不会无限等待。

另一个关键安全阀是连接 draining。在发送DELETEAPI 请求前,我们先调用 HAProxy 的 stats socket,将目标 Droplet 标记为drain状态:

echo "set server rails_servers/web-001 state drain" | \ socat stdio unix-connect:/var/run/haproxy.sock

drain状态意味着:新连接不再分配给该后端,但已有连接继续服务直至完成。socat是 Ubuntu 14.04 默认安装的工具,比nc更可靠。haproxy.cfg中必须启用 stats socket:stats socket /var/run/haproxy.sock mode 600 level admin

最后,缩容 API 调用不是简单的DELETE,而是带force=true参数的软删除:

curl -X DELETE "https://api.digitalocean.com/v2/droplets/$DROPLET_ID?force=true" \ -H "Authorization: Bearer $TOKEN"

force=true确保即使 Droplet 处于new状态(如创建失败残留),也能被强制清理,避免资源泄漏。

注意:socat的 socket 路径/var/run/haproxy.sock在 Ubuntu 14.04 上默认不存在,需在 HAProxy 启动脚本中添加mkdir -p /var/run/haproxy && chown haproxy:haproxy /var/run/haproxy,否则socat会报 “No such file or directory”。

6. 全链路验证:从curl -Iab -n 1000 -c 100的七层穿透测试

自动化脚本写完,不等于系统可靠。我们必须建立一套覆盖“创建→配置→接入→服务→销毁”全生命周期的验证体系。这套体系不依赖外部监控,全部用 Ubuntu 14.04 自带工具完成,确保在任何网络隔离环境下都能自检。

第一层:Droplet 创建验证(L3/L4)
脚本创建 Droplet 后,立即执行:

# 等待 SSH 端口开放(最多 60 秒) timeout 60 bash -c 'until nc -z $0 $1; do sleep 2; done' $DROPLET_IP 22 # 验证 SSH 登录(使用预置密钥) ssh -o ConnectTimeout=10 -o BatchMode=yes -i ~/.ssh/id_rsa root@$DROPLET_IP "echo 'SSH OK'" 2>/dev/null

nc -z检查端口连通性,ssh -o BatchMode=yes避免交互式提示,-o ConnectTimeout=10防止卡死。如果任一失败,脚本标记该 Droplet 为unhealthy,不加入 HAProxy,进入人工排查队列。

第二层:应用服务验证(L7)
Droplet SSH 通后,远程执行:

ssh -i ~/.ssh/id_rsa root@$DROPLET_IP " curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/health " | grep -q "200"

这里http://localhost:3000/health是 Rails 应用内置的健康检查端点,返回{"status":"ok","timestamp":1234567890}curl -w '%{http_code}'只输出 HTTP 状态码,grep -q "200"静默检查,避免日志污染。

第三层:负载均衡验证(End-to-End)
HAProxy 配置重载后,从跳板机执行:

# 获取 HAProxy VIP(如 192.0.2.200) HAPROXY_IP="192.0.2.200" # 发送 5 个请求,检查是否轮询到新 Droplet for i in {1..5}; do curl -s -I http://$HAPROXY_IP/health 2>/dev/null | \ grep "X-Runtime" | awk -F': ' '{print $2}' | tr -d '\r\n' echo -n " " done | sort | uniq -c

X-Runtime是 Rails 自动注入的响应头,值为处理时长(如0.023456)。如果新 Droplet 已接入,输出中应出现至少一个来自新 IP 的X-Runtime值。sort | uniq -c统计各值出现次数,确认轮询生效。

第四层:压力验证(Production-like Load)
最后,用 Apache Bench 模拟真实流量:

# 对 HAProxy VIP 发起 1000 次请求,100 并发 ab -n 1000 -c 100 http://$HAPROXY_IP/health > /tmp/ab_result.txt 2>&1 # 检查关键指标 SUCCESS_RATE=$(grep "Failed requests:" /tmp/ab_result.txt | awk '{print $3}') AVG_TIME=$(grep "Time per request:" /tmp/ab_result.txt | head -1 | awk '{print $4}') if [ "$(echo "$SUCCESS_RATE < 1" | bc -l)" = "1" ] && \ [ "$(echo "$AVG_TIME < 200" | bc -l)" = "1" ]; then echo "Load test PASSED" else echo "Load test FAILED: $SUCCESS_RATE failed, avg $AVG_TIME ms" fi

ab是 Ubuntu 14.04 默认安装的压测工具。我们关注两个硬指标:失败请求数< 1(即 0 失败),平均响应时间< 200ms。如果失败,说明 HAProxy 后端配置有误或新 Droplet 服务未就绪;如果超时,说明 Passenger worker 数不足或数据库连接池饱和。

整套验证流程嵌入自动化脚本,任何一层失败都会触发告警(邮件 + Slack webhook),并暂停后续操作。我们曾用这套流程在上线前发现一个致命问题:bootstrap.sh中的apt-get install命令因源服务器临时不可用而超时,导致 Nginx 未安装,但curl http://localhost:3000/health却返回 200(因为 Passenger 直接监听了 3000 端口,绕过了 Nginx)。正是第三层的curl -I检查X-Runtime头,暴露了该问题——新 Droplet 的响应头中没有X-Runtime,因为请求根本没进 Rails。

提示:ab-c 100参数在 Ubuntu 14.04 上需配合ulimit -n 1024使用,否则会报 “socket: Too many open files”。在脚本开头添加ulimit -n 2048即可。

7. 运维铁律:日志审计、成本熔断与三年未重启的 Droplet 真相

自动化系统上线后,真正的挑战才开始:如何确保它长期稳定、成本可控、问题可溯?我们制定了三条铁律,每一条都源于血泪教训。

第一条:所有操作必须原子化日志,且日志留存 365 天
我们不用loggersyslog,因为它们在 14.04 上的rsyslog配置复杂,且日志轮转可能丢失关键事件。我们采用最原始的>>追加模式,但做了三重加固:

# 每次操作前,生成带毫秒的时间戳 TIMESTAMP=$(date +"%Y-%m-%d %H:%M:%S.%3N") # 记录到专用日志文件(按天分割) LOG_FILE="/var/log/autoscale/$(date +%Y-%m-%d).log" # 原子化写入:先写临时文件,再 mv(避免并发写入损坏) echo "[$TIMESTAMP] SCALE_UP: Droplet $DROPLET_ID ($DROPLET_IP) created, backend added" >> "$LOG_FILE.tmp" mv "$LOG_FILE.tmp" "$LOG_FILE" # 设置日志权限,防止未授权读取 chmod 600 "$LOG_FILE"

关键点在于date +"%Y-%m-%d %H:%M:%S.%3N"—— Ubuntu 14.04 的date命令不支持%3N(毫秒),但我们用python -c "import time; print(time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime()) + '.' + str(time.time() % 1)[2:5])"替代,确保时间精度。日志内容包含操作类型(SCALE_UP/SCALE_DOWN)、Droplet ID、IP、后端状态,便于审计。

第二条:成本熔断开关,硬编码在脚本中
DigitalOcean 的按小时计费模式下,失控的自动扩容可能一夜烧掉数千美元。我们在所有扩容脚本顶部加入:

# 成本熔断:每日最大 Droplet 数(生产环境设为 20) MAX_DROPLETS=20 # 获取当前运行中 Droplet 数 CURRENT_COUNT=$(curl -s -H "Authorization: Bearer $TOKEN" \ "https://api.digitalocean.com/v2/droplets?per_page=1&tag_name=rails-app" | \ jq -r '.meta.total') if [ "$CURRENT_COUNT" -ge "$MAX_DROPLETS" ]; then echo "Cost limit reached: $CURRENT_COUNT >= $MAX_DROPLETS, aborting scale-up" exit 0 fi

tag_name=rails-app是我们给所有业务 Droplet 打的标签,per_page=1配合meta.total可以零成本获取总数,无需遍历全部 Droplet。这个熔断是“软性”的——它不阻止扩容,而是记录告警并退出,留给运维人员干预窗口。

第三条:Droplet 生命周期管理,拒绝“僵尸实例”
我们曾有一台 Droplet 因磁盘满(/var/log未轮转)而失联,但它仍在计费。为此,我们建立了“心跳-复活-清理”机制:

  • 每 5 分钟,主控机curl -I http://$DROPLET_IP/health,超时 3 次标记为unhealthy
  • unhealthy状态持续 30 分钟,自动触发rebootAPI(curl -X POST .../actions);
  • reboot后 10 分钟仍不健康,则执行DELETE并告