同一个网站在不同地区面对完全不同的约束:数据合规要求某些请求必须在境内处理,内容分发希望把用户导向最近的数据中心,风控需要识别异常地域的访问,运营则要给不同区域展示不同的默认语言与货币。这些需求的共同前提是——在请求进入系统的第一跳就判断出它来自哪里。Nginx 通过 GeoIP2 模块把这件事做在接入层,成本极低且对业务透明。本文从模块安装讲到策略落地,重点讲清 CDN 场景下真实 IP 的还原,因为那是地域判定出错最常见的原因。
1. 地域路由的场景与取舍
一句话总结: 地域路由把「用户在哪儿」变成可路由的属性,适合分流与合规拦截,但不适合做精细风控,因为它只基于 IP。
先明确 GeoIP 能做什么、不能做什么。它基于 IP 段与地理位置的映射表,因此判定的是「这个 IP 通常归属于哪里」,而不是「用户此刻在哪里」。
| 能做 | 不能做 |
|---|---|
| 国家与地区分流 | 精确到街道级定位 |
| 就近数据中心选择 | 识别 VPN 与代理后的真实位置 |
| 合规区域拦截 | 替代 GPS 或用户填写地址 |
| 默认语言与货币 | 作为风控的唯一依据 |
移动网络与 VPN 会让判定出现偏差:一个用境外节点的用户会被判为境外。因此地域策略应当是优化手段而非安全边界。需要强合规保证时,必须叠加用户申报信息与账号归属地校验。
# 策略设计原则:地域只做「默认值」与「优化」,最终决策交给业务
# 1. 默认语言:地域决定初始值,用户可覆盖
# 2. 就近接入:地域决定数据中心,失败可回退
# 3. 合规拦截:地域只做第一道,账号归属地做第二道
2. GeoIP2 模块安装与配置
一句话总结: GeoIP2 需要模块与数据库两个前提,模块靠编译或发行版包提供,数据库需自行获取并定期更新。
Nginx 官方仓库的包已包含 ngx_http_geoip2_module(基于 libmaxminddb),若从源码编译需要显式开启。
# 源码编译:需要先安装 libmaxminddb 开发库
apt-get install -y libmaxminddb-dev
./configure \
--with-http_geoip_module \
--add-dynamic-module=/path/to/ngx_http_geoip2_module \
--with-compat
make && make install
# 发行版包方式(Debian/Ubuntu)
apt-get install -y nginx-module-geoip2
数据库需要从 MaxMind 获取。免费版 GeoLite2 精度足够做国家级分流,付费版 GeoIP2 才提供城市级精度。
# 下载 GeoLite2 数据库(需要 MaxMind 账号与 License Key)
mkdir -p /etc/nginx/geoip
curl -sL "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=${MM_KEY}&suffix=tar.gz" \
-o /tmp/geoip.tar.gz
tar -xzf /tmp/geoip.tar.gz -C /tmp
cp /tmp/GeoLite2-City_*/GeoLite2-City.mmdb /etc/nginx/geoip/
ls -lh /etc/nginx/geoip/GeoLite2-City.mmdb
加载模块与数据库:
# 动态模块加载
load_module modules/ngx_http_geoip2_module.so;
http {
# 把数据库加载进内存映射,所有 worker 共享
geoip2 /etc/nginx/geoip/GeoLite2-City.mmdb {
auto_reload 24h; # 数据库变更后自动重载
$geoip2_data_country_code country iso_code;
$geoip2_data_country_name country names en;
$geoip2_data_city_name city names en;
$geoip2_data_continent_code continent code;
$geoip2_data_latitude location latitude;
$geoip2_data_longitude location longitude;
}
# 客户端 IP 从哪个变量取,默认 $remote_addr
geoip2_proxy 10.0.0.0/8; # 若前置了代理,声明可信来源
geoip2_proxy_recursive on;
}
geoip2_proxy 用于告诉 Nginx「这些地址是可信代理」,从 X-Forwarded-For 中还原真实客户端 IP。这个指令必须谨慎使用:一旦把不可信网段也加进去,攻击者就能伪造 X-Forwarded-For 来伪装地域。
3. 国家与城市判定
一句话总结: 国家判定可靠且开销小,城市判定依赖更精细的数据库且误差更大,二者都通过变量暴露给后续指令。
国家判定只需要 GeoLite2-Country 数据库,体积小、精度高,是最常用的粒度。
geoip2 /etc/nginx/geoip/GeoLite2-Country.mmdb {
$geo_country country iso_code; # 例如 CN、US、SG
}
server {
location / {
# 把判定结果透传给后端与日志
proxy_set_header X-Geo-Country $geo_country;
add_header X-Geo-Country $geo_country always;
proxy_pass http://backend;
}
}
城市判定需要 GeoLite2-City,能拿到城市名、经纬度、时区等字段:
geoip2 /etc/nginx/geoip/GeoLite2-City.mmdb {
$geo_country country iso_code;
$geo_city city names zh-CN; # 直接取中文城市名
$geo_region subdivisions 0 names en;
$geo_timezone location time_zone;
$geo_lat location latitude;
$geo_lon location longitude;
}
判定失败时的行为需要显式处理。IP 不在数据库中(内网地址、新分配段)时变量为空字符串,若直接用于 map 匹配会落到 default 分支。
# 判定失败时给一个明确的安全默认值
map $geo_country $effective_country {
default "UNKNOWN";
"" "UNKNOWN";
"~^[A-Z]{2}$" $geo_country;
}
# 内网请求单独标记
map $remote_addr $is_internal {
default 0;
"~^10\." 1;
"~^192\.168\." 1;
"~^172\.(1[6-9]|2[0-9]|3[01])\." 1;
}
# 用 mmdblookup 验证某个 IP 的判定结果,便于排错
mmdblookup --file /etc/nginx/geoip/GeoLite2-City.mmdb --ip 8.8.8.8 city names en
4. 地域策略实现
一句话总结: 地域策略的载体是 map 与变量,把「国家到策略」的映射集中在一处,location 里只引用结果。
把判定与决策分离是保持配置可维护的关键。所有「哪个国家走哪条路」的规则集中在一个 map 里。
# 策略一:按国家选择数据中心
map $effective_country $dc_pool {
default "dc_sg";
"CN" "dc_cn";
"HK" "dc_hk";
"TW" "dc_hk";
"US" "dc_us";
"~^(JP|KR)$" "dc_jp";
}
upstream dc_cn { server 10.1.0.11:8080; }
upstream dc_hk { server 10.2.0.11:8080; }
upstream dc_jp { server 10.3.0.11:8080; }
upstream dc_us { server 10.4.0.11:8080; }
upstream dc_sg { server 10.5.0.11:8080; }
server {
location /api/ {
proxy_pass http://$dc_pool;
add_header X-DC $dc_pool always;
}
}
# 策略二:按国家设置默认语言与货币
map $effective_country $default_lang {
default "en";
"CN" "zh-CN";
"TW" "zh-TW";
"JP" "ja";
"DE" "de";
}
server {
location = / {
# 未带语言 Cookie 时按地域重定向到对应语言首页
if ($cookie_lang = "") {
return 302 /$default_lang/;
}
root /var/www/app;
}
}
# 策略三:合规拦截,只允许特定区域访问敏感接口
map $effective_country $allowed_region {
default 0;
"CN" 1;
"HK" 1;
"MO" 1;
}
server {
location /api/personal-data/ {
if ($allowed_region = 0) {
return 451; # 451 表示因法律原因不可用
}
proxy_pass http://backend;
}
}
使用 451 状态码比 403 更准确,它明确表达「因法律原因不可用」,便于合规审计与用户理解。
地域策略还应考虑用户可覆盖。地域判定是推测,用户可能确实在别处。因此语言、货币这类体验类策略应允许 Cookie 覆盖,只有合规类策略才强制生效。
# 用户显式选择优先于地域推测
map $cookie_lang $final_lang {
default $default_lang;
"~^(zh-CN|en|ja)$" $cookie_lang;
}
5. 与 CDN 和真实 IP 协同
一句话总结: 经过 CDN 后 $remote_addr 是 CDN 节点地址,必须借助 real_ip 模块还原客户端 IP,且只信任 CDN 的回源网段。
这是地域路由出错最多的地方。启用 CDN 后,到达源站的请求来自 CDN 节点,$remote_addr 是节点 IP,GeoIP 判定出来的位置是节点机房所在地,而不是用户所在地。
# 错误示范:直接用 $remote_addr 做地域判定
# 结果是所有用户都被判为 CDN 节点所在的国家
geoip2 /etc/nginx/geoip/GeoLite2-City.mmdb {
$geo_country country iso_code;
}
server {
location / {
add_header X-Geo-Country $geo_country always; # 几乎全是节点所在地
}
}
正确做法是用 ngx_http_realip_module 从 X-Forwarded-For 中还原,并严格限定可信来源。
# 声明 CDN 回源网段为可信来源
set_real_ip_from 203.0.113.0/24; # CDN 节点网段示例
set_real_ip_from 198.51.100.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on; # 从右往左剥离可信代理,取第一个不可信地址
# 还原后 $remote_addr 即为真实客户端 IP
geoip2 /etc/nginx/geoip/GeoLite2-City.mmdb {
$geo_country country iso_code;
}
real_ip_recursive on 的含义是:从 X-Forwarded-For 最右侧开始,逐个剔除属于 set_real_ip_from 声明的地址,第一个不属于可信网段的地址就是真实客户端 IP。若 CDN 网段没写全,还原结果就会错误;若写多了(把公网大段写进去),攻击者可以伪造。
# 从 CDN 厂商获取最新的回源网段并生成配置
# 以 Cloudflare 为例(示例命令)
curl -s https://api.cloudflare.com/client/v4/ips \
| jq -r '.result.ipv4_cidrs[] | "set_real_ip_from \(.);"' \
> /etc/nginx/conf.d/cdn_realip.conf
nginx -t && nginx -s reload
多层代理的情况需要逐层还原。若链路是「用户 → CDN → 自建 LB → Nginx」,则 set_real_ip_from 必须包含 CDN 与自建 LB 的全部网段,否则还原会停在中转节点。
# 多层代理:把所有中间层的网段都声明为可信
set_real_ip_from 203.0.113.0/24; # CDN
set_real_ip_from 10.0.0.0/8; # 自建 LB
set_real_ip_from 172.16.0.0/12; # 内网 K8s Node
real_ip_header X-Forwarded-For;
real_ip_recursive on;
6. 数据更新与合规
一句话总结: GeoIP 数据库需要定期更新才能保持准确,同时地理位置属于个人数据,处理时必须考虑合规要求。
MaxMind 的免费数据库每月更新两次,付费版每周更新。不更新会导致新分配的 IP 段判定失败或错误。
# 用 systemd timer 定期更新数据库并平滑重载
# /etc/systemd/system/geoip-update.timer
[Unit]
Description=Run GeoIP update weekly
[Timer]
OnCalendar=weekly
Persistent=true
[Install]
WantedBy=timers.target
# 服务单元调用 /usr/local/bin/geoip-update.sh
systemctl enable --now geoip-update.timer
# /usr/local/bin/geoip-update.sh
#!/usr/bin/env bash
set -euo pipefail
DEST=/etc/nginx/geoip
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
curl -sL "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=${MM_KEY}&suffix=tar.gz" \
-o "$TMP/city.tar.gz"
tar -xzf "$TMP/city.tar.gz" -C "$TMP"
install -m 0644 "$TMP"/GeoLite2-City_*/GeoLite2-City.mmdb "$DEST/GeoLite2-City.mmdb"
# 校验配置无误后重载,让新数据库生效
nginx -t && nginx -s reload
echo "geoip database updated at $(date -Is)"
若配置中使用了 auto_reload,Nginx 会检测文件变更后自动重载,无需显式 reload;但显式 reload 更可控,且能在数据库损坏时及时回滚。
合规方面有三点必须留意:
第一,IP 地址属于个人数据。 在 GDPR 等法规下,把 IP 与用户标识一起记录日志可能构成个人数据处理,需要纳入隐私政策并控制保留期限。
第二,地域判定结果应避免长期存储。 能实时判定就不要落库;确需存储时应做聚合或匿名化。
第三,跨境路由本身可能触发合规要求。 把境内用户的数据路由到境外机房可能违反数据出境规定,因此地域策略必须与数据驻留策略一致。
# 在日志中只记录国家代码而非精确经纬度,降低数据敏感度
log_format geo_safe '$remote_addr - [$time_local] "$request" $status '
'country=$effective_country city=-';
7. 性能与陷阱
一句话总结: GeoIP 查询是内存映射的哈希查找,开销很小,真正的风险在可信代理配置与判定失败时的默认行为。
性能上,geoip2 模块把数据库文件通过 mmap 映射进内存,查询是一次 B 树查找,单次开销在微秒级。它不会成为性能瓶颈,因此不必为了性能而缓存判定结果。
# 压测对比:开启与关闭 GeoIP 的差异(示例方法)
ab -n 100000 -c 200 http://127.0.0.1/api/ping
# 差异通常在 1% 以内,若差异明显,问题多半在别处
常见陷阱:
陷阱一:geoip2_proxy 配错导致伪造。 只声明真实可信的代理网段。若不确定,宁可不配,改用 real_ip 模块统一还原。
陷阱二:判定失败时默认放行。 合规拦截场景下,未知地域应当拒绝而不是放行。
# 合规场景:未知地域一律拒绝
map $effective_country $allowed_region {
default 0; # UNKNOWN 也会落到这里
"CN" 1;
"HK" 1;
}
陷阱三:if 与 return 的组合在 location 中的边界。 地域拦截用 if ($x = 0) { return 451; } 是安全的,但不要在其中混入 proxy_pass。
陷阱四:城市级判定用于强合规。 城市级精度受 IP 分配与运营商影响,误差可能达到几十公里甚至跨城市,不可作为合规判据。
陷阱五:数据库文件权限过大。 mmdb 文件不应可写,且更新脚本的密钥不能出现在进程列表里。
# 保护数据库与密钥
chmod 0644 /etc/nginx/geoip/GeoLite2-City.mmdb
chmod 0600 /etc/default/geoip-update # 存放 MM_KEY
8. 总结
| 环节 | 要点 |
|---|---|
| 能力边界 | 地域判定是推测,适合分流与默认值,不适合做安全边界 |
| 模块准备 | 需要 geoip2 模块与 mmdb 数据库,二者都要提前部署 |
| 判定粒度 | 国家可靠且轻量,城市误差较大,合规场景不要用城市 |
| 策略组织 | 判定与决策分离,map 集中管理,location 只引用结果 |
| 真实 IP | CDN 场景必须用 real_ip 还原,且只信任回源网段 |
| 多层代理 | set_real_ip_from 必须覆盖全部中间层,否则还原中断 |
| 数据更新 | 定期更新数据库并校验后重载,保留回滚能力 |
| 合规 | IP 属个人数据,控制留存与跨境,日志避免记录精确位置 |
地域路由的价值在于把「用户在哪里」这个信息变成可执行的策略,它让就近接入、默认体验与合规拦截都变得简单。但它的精度上限决定了它只能是优化手段。下一篇我们把视角从「请求从哪来」转向「请求经历了什么」,讨论用 OpenTelemetry 打通从 Nginx 到后端的完整调用链。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。