Nginx 地域路由与 GeoIP:GeoIP2 模块、城市判定与 CDN 协同

基于 GeoIP2 模块实现 Nginx 地域路由,覆盖国家与城市判定、地域策略落地、CDN 真实 IP 还原以及数据更新与合规要点。

同一个网站在不同地区面对完全不同的约束:数据合规要求某些请求必须在境内处理,内容分发希望把用户导向最近的数据中心,风控需要识别异常地域的访问,运营则要给不同区域展示不同的默认语言与货币。这些需求的共同前提是——在请求进入系统的第一跳就判断出它来自哪里。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 只引用结果
真实 IPCDN 场景必须用 real_ip 还原,且只信任回源网段
多层代理set_real_ip_from 必须覆盖全部中间层,否则还原中断
数据更新定期更新数据库并校验后重载,保留回滚能力
合规IP 属个人数据,控制留存与跨境,日志避免记录精确位置

地域路由的价值在于把「用户在哪里」这个信息变成可执行的策略,它让就近接入、默认体验与合规拦截都变得简单。但它的精度上限决定了它只能是优化手段。下一篇我们把视角从「请求从哪来」转向「请求经历了什么」,讨论用 OpenTelemetry 打通从 Nginx 到后端的完整调用链。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nginx」更多文章

  1. Nginx 在服务网格中的角色:边车代理、mTLS 与 Envoy 取舍
  2. Nginx 大文件上传与请求体处理:缓冲、临时文件与断点续传
  3. Nginx 证书自动化与 ACME:certbot、DNS-01 通配符与自动续期