谷歌云免费层级 Compute Engine
Xcho 炼气

Google Cloud 免费计划

具体步骤可参考这篇文章 谷歌云Free Tier长期免费云服务器

  • 1 non-preemptible e2-micro VM instance per month in one of the following US regions:
    • Oregon: us-west1
    • Iowa: us-central1
    • South Carolina: us-east1
  • 30 GB-months standard persistent disk (标准永久性磁盘HDD)
  • 1 GB of outbound data transfer from North America to all region destinations (excluding China and Australia) per month
  • 网络选择 标准网络层级 每月有200g的免费流量,

另外创建虚拟机实例页面有两个坑

数据保护中的备份要去掉,选择无备份
可观测性中 Ops Agent 安装要去掉,这是用于监控和日志录的,按量付费

创建交换分区

由于 e2-micro 只有1G 内存,创建交换分区可以缓解内存不足的状况

1
2
3
4
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

使交换空间永久生效,编辑 /etc/fstab 文件,添加以下内容:

1
/swapfile none swap sw 0 0

优化交换使用策略,编辑 /etc/sysctl.conf,添加:

1
vm.swappiness=10

Cloudflare ddns

按照免费计划创建的 e2-micro 还有一个缺陷就是公网 IP 是临时性的,关机后会更换 IP 地址。这个可以用 ddns 解决,将域名托管在 cloudflare ,通过cloudflare API 动态更新域名的解析地址,以后地址再怎么变也不怕

后面就可以安心用域名 ssh 登录,下面是脚本每次重启自动运行一次,推荐使用 systemd 服务而不用 crontab,systemd 服务可确保在网络准备好之后再运行脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
#!/bin/bash

# --- Configuration ---
AUTH_EMAIL="" # Optional: Older API key method
API_TOKEN="" # !!! 必须修改: Your Cloudflare API Token !!!
ZONE_ID="" # !!! 必须修改: Your Zone ID !!!
RECORD_TYPE="A" # !!! 必须修改: Record type ("A" or "AAAA") !!!
LOG_FILE="" # !!! 检查确认: Log file path and permissions !!!
IP_CACHE_FILE="" # !!! 检查确认: Cache file path and permissions !!!
SLEEP_BETWEEN_RECORDS=1 # Seconds to wait between processing records (0 to disable)

# !!! 必须修改: List of DNS records to update !!!
RECORD_NAMES=(
"baidu.com"
"sb.baidu.com"
# Add more records here...
)

# Public IP lookup services (primary and backups)
if [ "$RECORD_TYPE" == "A" ]; then
IP_SERVICES=("https://api.ipify.org" "https://icanhazip.com" "https://ipinfo.io/ip")
IP_TYPE_DESC="IPv4"
elif [ "$RECORD_TYPE" == "AAAA" ]; then
IP_SERVICES=("https://api64.ipify.org" "https://icanhazip.com" "https://ipinfo.io/ip")
IP_TYPE_DESC="IPv6"
else
# Log initial config error to file if possible, otherwise echo
err_msg="$(date '+%Y-%m-%d %H:%M:%S') - Error: Unsupported RECORD_TYPE: $RECORD_TYPE"
if [ -n "$LOG_FILE" ] && [ -d "$(dirname "$LOG_FILE")" ] && [ -w "$(dirname "$LOG_FILE")" ]; then
echo "$err_msg" >> "$LOG_FILE"
else
echo "$err_msg"
fi
exit 1
fi
# --- End Configuration ---

# --- Helper function for logging ---
log() {
local message="$1"
local log_entry="$(date '+%Y-%m-%d %H:%M:%S') - $message"
# Try to log to file, fallback to echo if file/dir not writable or not set
if [ -n "$LOG_FILE" ]; then
if ! echo "$log_entry" >> "$LOG_FILE" 2>/dev/null; then
echo "$log_entry (Error writing to log file: $LOG_FILE)"
fi
else
echo "$log_entry"
fi
}

# --- Function to get current public IP with redundancy ---
get_current_ip() {
local ip=""
for service in "${IP_SERVICES[@]}"; do
log "Attempting to get public IP from $service..."
ip=$(curl -s --connect-timeout 5 "$service")
if [[ -n "$ip" && "$ip" =~ ^[0-9a-fA-F.:]+$ ]]; then
log "Successfully retrieved IP: $ip from $service"
echo "$ip"
return 0
else
log "Failed or received invalid response from $service (Response: $ip)"
fi
done
log "Error: Could not retrieve current public IP from any service."
return 1
}

# --- Main Logic ---

# 1. Get current public IP address
CURRENT_IP=$(get_current_ip)
if [ $? -ne 0 ]; then
exit 1
fi
log "Current public $IP_TYPE_DESC IP is: $CURRENT_IP"

# 2. Compare with cached IP
CACHED_IP=""
if [ -f "$IP_CACHE_FILE" ]; then
CACHED_IP=$(cat "$IP_CACHE_FILE")
log "Last known IP from cache ($IP_CACHE_FILE): $CACHED_IP"
fi

if [ "$CURRENT_IP" == "$CACHED_IP" ]; then
log "Public IP ($CURRENT_IP) matches cached IP. No Cloudflare update needed."
exit 0
fi

log "Public IP ($CURRENT_IP) differs from cached IP ($CACHED_IP) or cache is empty. Proceeding with Cloudflare checks..."

# --- Loop through each record name ---
log "Starting DNS update process for ${#RECORD_NAMES[@]} record(s)..."
ALL_SUCCESS=true
UPDATES_PERFORMED=false

for RECORD_NAME in "${RECORD_NAMES[@]}"; do
log "--- Processing record: $RECORD_NAME ---"

# 3. Get the DNS Record ID, current IP, and **Proxied Status** for THIS record from Cloudflare
RECORD_INFO=$(curl -s --connect-timeout 10 -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=$RECORD_TYPE&name=$RECORD_NAME" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json")

RECORD_SUCCESS=$(echo "$RECORD_INFO" | jq -r '.success')
if [ "$RECORD_SUCCESS" != "true" ]; then
log "Error querying DNS record for '$RECORD_NAME'. API Response: $RECORD_INFO"
ALL_SUCCESS=false
continue
fi

RECORD_COUNT=$(echo "$RECORD_INFO" | jq '.result | length')
if [ "$RECORD_COUNT" -eq 0 ]; then
log "Error: DNS record '$RECORD_NAME' ($RECORD_TYPE) not found. Skipping."
ALL_SUCCESS=false
continue
fi
if [ "$RECORD_COUNT" -gt 1 ]; then
log "Warning: Multiple records found for '$RECORD_NAME' ($RECORD_TYPE). Using the first one."
fi

# Extract ID, IP, and Proxied Status
RECORD_ID=$(echo "$RECORD_INFO" | jq -r '.result[0].id')
RECORD_IP=$(echo "$RECORD_INFO" | jq -r '.result[0].content')
RECORD_PROXIED_STATUS=$(echo "$RECORD_INFO" | jq -r '.result[0].proxied') # <<< 添加: 获取代理状态

# Validate all extracted values
if [ -z "$RECORD_ID" ] || [ "$RECORD_ID" == "null" ] || \
[ -z "$RECORD_IP" ] || [ "$RECORD_IP" == "null" ] || \
[ -z "$RECORD_PROXIED_STATUS" ] || [ "$RECORD_PROXIED_STATUS" == "null" ]; then # <<< 添加: 验证代理状态
log "Error: Could not retrieve valid DNS Record ID, IP, or Proxied status for '$RECORD_NAME'. Response: $RECORD_INFO. Skipping."
ALL_SUCCESS=false
continue
fi

log "Cloudflare DNS IP for $RECORD_NAME is: $RECORD_IP, Proxied: $RECORD_PROXIED_STATUS" # <<< 修改: 日志包含代理状态

# 4. Compare IPs and update THIS record if necessary, preserving proxied status
if [ "$CURRENT_IP" != "$RECORD_IP" ]; then
log "IP has changed for $RECORD_NAME ($RECORD_IP -> $CURRENT_IP). Updating Cloudflare DNS record..."

UPDATE_RESPONSE=$(curl -s --connect-timeout 10 -X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/$RECORD_ID" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"type\":\"$RECORD_TYPE\",\"name\":\"$RECORD_NAME\",\"content\":\"$CURRENT_IP\",\"ttl\":1,\"proxied\":$RECORD_PROXIED_STATUS}") # <<< 修改: 使用变量 $RECORD_PROXIED_STATUS

UPDATE_SUCCESS=$(echo "$UPDATE_RESPONSE" | jq -r '.success')

if [ "$UPDATE_SUCCESS" == "true" ]; then
log "Successfully updated DNS record for $RECORD_NAME to $CURRENT_IP (Proxied status kept as $RECORD_PROXIED_STATUS)."
UPDATES_PERFORMED=true
else
log "Error updating DNS record for $RECORD_NAME. API Response: $UPDATE_RESPONSE"
ALL_SUCCESS=false
fi
else
log "Cloudflare IP for $RECORD_NAME already matches current public IP ($CURRENT_IP). No update needed."
fi

# Optional sleep between records
if [ "$SLEEP_BETWEEN_RECORDS" -gt 0 ]; then
log "Waiting ${SLEEP_BETWEEN_RECORDS}s before next record..."
sleep "$SLEEP_BETWEEN_RECORDS"
fi

done # --- End of loop for RECORD_NAMES ---

# 5. Update cache file if the process completed without critical errors preventing updates and IP changed
# (We update cache even if some non-critical errors occurred for individual records,
# as long as we successfully determined the new public IP differs from the old cache)
if [ "$CURRENT_IP" != "$CACHED_IP" ]; then
# Check if we should update the cache. We update if *any* update was attempted or if cache was initially empty.
# We might choose *not* to update if ALL attempts failed, but let's update if the public IP genuinely changed.
log "Updating IP cache file $IP_CACHE_FILE with new IP: $CURRENT_IP"
echo "$CURRENT_IP" > "$IP_CACHE_FILE"
# More conservative approach: only update cache if $ALL_SUCCESS is true AND $UPDATES_PERFORMED is true.
# if $ALL_SUCCESS && $UPDATES_PERFORMED; then ... echo ... fi
fi


log "Finished processing all records."
if $ALL_SUCCESS; then
exit 0
else
log "One or more errors occurred during the update process. Check logs above."
exit 1
fi

填入开头的变量,可遍历修改同个ID域的多个二级域名,其中会生成 log 文件,和 ip 缓存文件, ip 缓存文件用于比对 ip 免于频繁访问 API 触发风控。

/etc/systemd/system/ 目录下创建 systemd 服务单元文件 cloudflare-ddns.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Unit]
Description=Cloudflare DDNS Update Script
Wants=network-online.target # 需要网络连接后才运行
After=network-online.target # 在网络连接之后运行

[Service]
Type=oneshot # 只需要运行一次
ExecStart= # 脚本的完整路径
RemainAfterExit=yes # 即使 ExecStart 完成,服务也视为活动 (可选,对 oneshot 有用)
# 如果你的脚本需要以特定用户运行,可以取消下面一行的注释
# User=guest
# 注意:如果以特定用户运行,确保该用户对日志文件/目录有写入权限

[Install]
WantedBy=multi-user.target # 在多用户模式下启用

重新加载 systemd 配置,设置服务开机自启

1
2
sudo systemctl daemon-reload
sudo systemctl enable cloudflare-ddns.service

立即运行一次服务来测试它,检查服务状态(cloudflare-ddns.service需要将注释去掉,不然会报错)

1
2
sudo systemctl start cloudflare-ddns.service
sudo systemctl status cloudflare-ddns.service

安全防护 CrowdSec

服务器当然要对外服务,难免要开放个别端口,如果只是用来 ssh remote 完全可以安装 VPN 只对自己开放( IP 地址会经常被封),为了避免被反薅需要特别的安全防护,长亭雷池太吃资源所以选择了 CrowdSec ,有总比没有好

安装步骤

直接按照官网走就好,注册了账号会提示,其中安全组件,我只安装了防火墙和 Nginx,规则订阅的话因为没有会员选择了官方推荐的 Firehol greensnow.co list 和 Free Proxies list

网络达量停机

云主机最怕的就是被刷流量,一觉醒来一套房就没了,采用基于 vnstat 的流量监控和自动停机方案可以防止流量超标,

为云主机实现网络达量停机
上面是我参考的博客文章,不过里面的脚本也太简洁了,我还是让 ChatGPT 帮我另外写了一个脚本,每分钟执行一次 sudo crontab e,因为关机需要 sudo 操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#!/bin/bash
#
# 脚本名称: check_traffic_limit.sh
# 描述: 监控指定网络接口的月度流量,并在达到限制时关闭服务器。
# 作者: (根据用户需求编写)
# 日期: 2025-04-12
#
# 注意: 此脚本需要以 root 权限运行 (例如通过 sudo) 才能执行关机操作
# 并且需要确保 vnstat, jq 命令已安装。
# 建议通过 cron 定期执行此脚本。
#

# --- 配置项 ---
INTERFACE="ens4" # 要监控的网络接口名称
LIMIT_GB=200 # 流量限制 (单位: GB)
BYTES_PER_GB=1000000000 # 每 GB 的字节数 (10^9, SI 标准)
LOG_FILE="/var/log/traffic_limit_check.log" # 日志文件路径

# --- 计算字节单位的限制值 ---
LIMIT_BYTES=$((LIMIT_GB * BYTES_PER_GB))

# --- 命令路径 (自动查找) ---
# 使用 command -v 查找命令路径,更可靠
VNSTAT_CMD=$(command -v vnstat)
JQ_CMD=$(command -v jq)
SHUTDOWN_CMD="/sbin/shutdown" #crontab 可能没有 PATH 环境变量 使用 sudo which shutdown 来查找
AWK_CMD=$(command -v awk)
DATE_CMD=$(command -v date)

# --- 脚本选项 ---
# set -o pipefail # 让管道命令的返回值取最后一个非零返回值 (如果需要更严格的错误处理)

# --- 日志记录函数 ---
log_message() {
# 将带有时间戳的消息追加到日志文件
echo "$($DATE_CMD '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
}

# --- 依赖检查 ---
# 检查必要的命令是否存在
if [ -z "$VNSTAT_CMD" ]; then
log_message "错误: 未找到 'vnstat' 命令。请安装 vnstat 并确保其在 PATH 中。"
exit 1
fi
if [ -z "$JQ_CMD" ]; then
log_message "错误: 未找到 'jq' 命令。请安装 jq 并确保其在 PATH 中。"
exit 1
fi
if [ -z "$SHUTDOWN_CMD" ]; then
# 注意: shutdown 通常需要 root 权限才能找到或执行
log_message "错误: 未找到 'shutdown' 命令。请确保以 root 权限运行,或命令路径正确。"
exit 1
fi
if [ -z "$AWK_CMD" ]; then
log_message "错误: 未找到 'awk' 命令。"
exit 1
fi
if [ -z "$DATE_CMD" ]; then
# 这个几乎不可能找不到,但以防万一
log_message "错误: 未找到 'date' 命令。"
exit 1
fi

# --- 主要逻辑 ---
log_message "====== 开始检查接口 $INTERFACE 的流量 ======"
log_message "配置限制: $LIMIT_GB GB ($LIMIT_BYTES 字节)"

# 获取指定接口的月度流量数据 (JSON 格式)
# 使用 -i 指定接口,--json m 获取月度 json
JSON_DATA=$($VNSTAT_CMD --json m -i "$INTERFACE")
VNSTAT_EXIT_CODE=$?

# 检查 vnstat 命令是否成功执行
if [ $VNSTAT_EXIT_CODE -ne 0 ]; then
log_message "错误: vnstat 命令执行失败 (接口: $INTERFACE, 退出码: $VNSTAT_EXIT_CODE)。请检查 vnstat 是否正确配置并运行。"
# 可以选择记录 vnstat 可能的错误输出 (如果有的话)
# log_message "vnstat 输出: $JSON_DATA" # 注意 JSON_DATA 此时可能包含错误信息而非 JSON
exit 1
fi

# 使用 jq 解析 JSON 获取当月的 rx 和 tx 字节数
# **重要修正**: 使用 .name 选择接口,而不是 .id
# 使用 '.interfaces[] | select(.name == $iface) | .traffic.month[0]' 定位到当月数据
# 然后分别提取 .rx 和 .tx,使用 // 0 提供默认值以防字段缺失或为 null
CURRENT_MONTH_RX_BYTES=$(echo "$JSON_DATA" | $JQ_CMD --arg iface "$INTERFACE" '.interfaces[] | select(.name == $iface) | .traffic.month[0].rx // 0')
CURRENT_MONTH_TX_BYTES=$(echo "$JSON_DATA" | $JQ_CMD --arg iface "$INTERFACE" '.interfaces[] | select(.name == $iface) | .traffic.month[0].tx // 0')

# 验证 jq 是否成功提取了有效的数值 (必须是非负整数)
# 使用正则表达式检查变量是否只包含数字
if ! [[ "$CURRENT_MONTH_RX_BYTES" =~ ^[0-9]+$ ]]; then
log_message "错误: 未能从 $INTERFACE 的 vnstat JSON 中解析出有效的 RX 字节数。获取到的值: '$CURRENT_MONTH_RX_BYTES'"
log_message "请手动执行 'sudo vnstat --json m -i $INTERFACE' 检查 JSON 输出内容。"
# 如果需要调试,取消下面这行的注释以记录完整的 JSON 数据
# echo "$JSON_DATA" >> "$LOG_FILE"
exit 1
fi
if ! [[ "$CURRENT_MONTH_TX_BYTES" =~ ^[0-9]+$ ]]; then
log_message "错误: 未能从 $INTERFACE 的 vnstat JSON 中解析出有效的 TX 字节数。获取到的值: '$CURRENT_MONTH_TX_BYTES'"
log_message "请手动执行 'sudo vnstat --json m -i $INTERFACE' 检查 JSON 输出内容。"
# 如果需要调试,取消下面这行的注释以记录完整的 JSON 数据
# echo "$JSON_DATA" >> "$LOG_FILE"
exit 1
fi

# 计算当月总字节数 (RX + TX)
CURRENT_TOTAL_BYTES=$((CURRENT_MONTH_RX_BYTES + CURRENT_MONTH_TX_BYTES))

# 将当前总字节数转换为 GB (保留两位小数) 以便日志阅读
# 使用 awk 进行浮点数计算
CURRENT_TOTAL_GB=$($AWK_CMD "BEGIN {printf \"%.2f\", $CURRENT_TOTAL_BYTES / $BYTES_PER_GB}")

log_message "$INTERFACE 当前月度总用量: $CURRENT_TOTAL_GB GB ($CURRENT_TOTAL_BYTES 字节)。"

# 比较当前用量与限制值 (大于或等于)
if [ "$CURRENT_TOTAL_BYTES" -ge "$LIMIT_BYTES" ]; then
log_message "警告: 接口 $INTERFACE 的月度流量 ($CURRENT_TOTAL_GB GB) 已达到或超出限制 ($LIMIT_GB GB)。"
log_message "====== 正在执行关机操作! ======"
# 执行立即关机 - 需要 ROOT 权限
"$SHUTDOWN_CMD" -h now "系统关机:接口 $INTERFACE 的月度网络流量 ($CURRENT_TOTAL_GB GB) 已达到 $LIMIT_GB GB 的限制。"
# 关机命令发出后,脚本可能不会执行到 exit 0
exit 0 # 理论上关机后不会执行到这里
else
log_message "状态: 流量用量在限制范围内。"
log_message "====== 检查完成 ======"
exit 0 # 正常退出
fi

脚本每分钟执行一次产生的 log 文件,可以用日志轮转(logrotate)解决

谷歌官方超预算停机

查阅了谷歌云文档发现,官方可以实现超预算停机的效果,这不巧了吗

停用带通知的结算使用情况
监听 Pub/Sub 通知

image
这是官方的流程图很好理解,创建一个预算和提醒,当费用超过预算时,会发送邮件和 Pub/Sub 通知,而Pub/Sub 通知可以触发 Cloud run Function 来停用结算账号避免产生超额费用。

  1. 首先创建一个预算,勾选邮件提醒和关联 Pub/Sub 通知,关联 Pub/Sub 通知会要求你新建一个主题
  2. IAM 中新建服务账号,授予 roles/billing.admin 权限,作为 运行时服务账号
  3. 进入 Pub/Sub 界面找到刚创建的主题,在主题的 更多操作 中触发 Cloud Run 函数,其中代码直接粘贴文档代码,不设置环境变量话,其中 PROJECT_ID 修改为你实际的项目 ID
  4. Pub/Sub 主题中发布消息测试

走了一遍流程发现其中的难点在于权限

  • Pub/Sub 服务 (service-<PROJECT_NUMBER>@gcp-sa-pubsub.iam.gserviceaccount.com) 需要 Cloud Run Invoker 权限来触发/调用你的函数
  • 函数需要调用 Billing API 的权限: 当你的函数代码运行时,去调用 Cloud Billing API 来执行停用结算的操作。

要解决上面两个问题,在创建 Cloud run Function 时,直接用默认的Compute Engine default service account 为 触发器服务账号(默认),运行时服务账号 需要在 IAM 中新建,并授予其 Billing Account Administrator 角色

还有用文档中的 node.js ,用 Python 好多次没有成功。

信用卡限额

最好绑定一张虚拟信用卡,真实的信用卡的话一定要限制额度

 Comments
Comment plugin failed to load
Loading comment plugin
Powered by Hexo & Theme Keep
This site is deployed on