Skip to content

Kubernetes 怎么做地域访问控制?Ingress 白名单只能写 CIDR,用 IP 归属地 + 外部鉴权服务(Go 实现) ​

需求经常是这么来的:活动页只在某几个省开放,或者新业务先对境外用户放量;也可能是合规要求,某个接口只允许境内访问。在传统的 Nginx 上这件事不难,geo 模块加一行规则就完了。搬到 Kubernetes 上,第一步就卡住了——

ingress-nginx 能做的 IP 访问控制,只有 whitelist-source-range,填的是 CIDR。 你没法在注解里写"只允许江苏和广东"。要把整省的地址段抄进去?国内 IP 段每天在变,抄一次维护一年,而且中间换手的段会直接把业务打挂。

能真正解决这个问题的位置,是 Ingress 的外部鉴权(auth-url):nginx 在转发请求前,先拿客户端 IP 去问一个自己写的服务,服务返回 200 就放行、403 就拒绝。这篇讲怎么把这个服务写出来,包括 K8s 里最容易踩的源 IP 丢失的坑。

一、为什么不用其他几种做法 ​

先把几条看起来可行、实际上会后悔的路排除掉:

在 ConfigMap 里塞 server-snippet 写 geo 规则。 理论上能做,但片段是全局的,规则与业务耦合在一个大配置块里,改一次要等控制器 reload,多团队共用一个 Ingress 时极易冲突。而且归属地数据你总得从某个地方来,还是要调接口。

上 WAF。 云 WAF 确实有地域封禁能力,成熟也省事。但如果你的诉求只是"某几个环境/某个内部域名按省收录",为它引入一套 WAF、改 DNS、加一层七层代理,成本和链路长度都不划算。该用 WAF 的场景用 WAF,别为了一个地域判断把架构改重。

自己写 controller。 维护成本远超收益。

外部鉴权的好处是把"判断"从 Ingress 里拿出来了:规则是你的代码,可以热更新、可以灰度、可以打日志、可以只记录不拦截。Ingress 只负责转发这个动作。

二、方案设计 ​

整体链路:

用户 → Ingress(nginx) --auth-url--> 鉴权服务(/authz)
                            200 → 转发到后端,并把归属地透传成请求头
                            403 → 直接返回 403,请求不进后端

有几个设计点想清楚再动手:

鉴权服务的返回码只有三个选择。 nginx 的 auth_request 语义是:200/2xx 放行,401/403 按对应状态码拒绝,其他状态码一律当成错误(客户端会看到 500)。所以服务里永远只返回 200 或 403,别用 500 表达"我查不出来"——那会把一次数据缺失变成一次线上报错。

查不到归属地时的策略要显式配置。 失败模式有两种,业务含义完全不同:fail-open(查不到就放行,保证可用性)适合对外业务;fail-closed(查不到就拒绝,保证边界)适合内部系统、灰度环境。把它做成环境变量,别写死在代码里。

归属地要透传给后端。 用 auth-response-headers 把 X-Geo-Prov、X-Geo-City 传到后端,业务和日志系统就都能用到,避免每个服务各查一遍接口。

缓存是刚需,不是优化。 免费接口 60 次/分钟的量级,一个稍有流量的站点,热点 IP 一分钟就能把额度打满。同一个 IP 的归属地几天内不会变,缓存 10 分钟到几小时都合理;多副本部署时缓存各自独立,如果想统一控制(比如运营后台要一键改白名单),就把规则和缓存放 Redis。

先记录、后拦截。 上线第一周把 ALLOW_REGIONS 开成"全量允许 + 打日志",看看真实流量里各地区的分布,确认规则没伤到正常用户,再切到真拦。这一步能避免 90% 的线上事故。

三、Go 实现 ​

服务只用标准库,go build 直接出二进制,扔进镜像里就是个十几 MB 的容器。

go
// Ingress 外部鉴权服务:Nginx Ingress 通过 auth-url 调用本服务,
// 按访客 IP 归属地决定放行还是拦截。只依赖标准库。
package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net"
	"net/http"
	"os"
	"strings"
	"sync"
	"time"
)

var (
	apiURL   = env("IP9_API", "https://ip9.com.cn/get")
	ua       = "Mozilla/5.0 (X11; Linux x86_64) ip9-authz/1.0" // 必须带正常 UA
	failMode = env("FAIL_MODE", "closed")                      // open=查不到就放行,closed=拒绝
	allowRaw = env("ALLOW_REGIONS", "广东,广州,深圳")            // 允许的省/市,逗号分隔
	allow    = strings.Split(allowRaw, ",")
	ttl      = 10 * time.Minute
	client   = &http.Client{Timeout: 3 * time.Second}
)

func env(k, def string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return def
}

type geo struct {
	Prov, City string
	OK         bool
}

type entry struct {
	g  geo
	at time.Time
}

var (
	mu    sync.RWMutex
	cache = map[string]entry{}
)

func lookup(ip string) geo {
	mu.RLock()
	if e, ok := cache[ip]; ok && time.Since(e.at) < ttl {
		mu.RUnlock()
		return e.g
	}
	mu.RUnlock()

	g := geo{}
	req, _ := http.NewRequest("GET", apiURL+"?ip="+ip, nil)
	req.Header.Set("User-Agent", ua)
	resp, err := client.Do(req)
	if err == nil {
		defer resp.Body.Close()
		var body struct {
			Ret  int `json:"ret"`
			Data struct {
				Prov string `json:"prov"`
				City string `json:"city"`
			} `json:"data"`
		}
		if json.NewDecoder(resp.Body).Decode(&body) == nil && body.Ret == 200 {
			g = geo{Prov: body.Data.Prov, City: body.Data.City, OK: body.Data.Prov != "" || body.Data.City != ""}
		}
	}

	mu.Lock()
	cache[ip] = entry{g: g, at: time.Now()}
	mu.Unlock()
	return g
}

// 从请求头取真实客户端 IP:Ingress 已经把 XFF 处理成「客户端, 中间代理…」
func clientIP(r *http.Request) string {
	xff := r.Header.Get("X-Forwarded-For")
	if xff != "" {
		first := strings.TrimSpace(strings.Split(xff, ",")[0])
		if net.ParseIP(first) != nil {
			return first
		}
	}
	// 兜底:取远端地址(源 IP 没保住时就是这一层,排查时看日志能发现)
	host, _, err := net.SplitHostPort(r.RemoteAddr)
	if err == nil {
		return host
	}
	return r.RemoteAddr
}

func matched(g geo) bool {
	for _, a := range allow {
		a = strings.TrimSpace(a)
		if a == "" {
			continue
		}
		if g.Prov == a || g.City == a {
			return true
		}
		if strings.HasPrefix(g.Prov, a) || strings.HasPrefix(g.City, a) {
			return true // 省名带后缀的情况:广东 vs 广东省
		}
	}
	return false
}

func authz(w http.ResponseWriter, r *http.Request) {
	ip := clientIP(r)
	g := lookup(ip)

	// 查不到归属地:按失败策略处理,别把正常用户一刀切拦掉
	if !g.OK {
		if failMode == "open" {
			w.Header().Set("X-Geo-Decision", "unknown-allow")
			w.WriteHeader(http.StatusOK)
			return
		}
		w.Header().Set("X-Geo-Decision", "unknown-deny")
		w.WriteHeader(http.StatusForbidden)
		return
	}

	w.Header().Set("X-Geo-Prov", g.Prov)
	w.Header().Set("X-Geo-City", g.City)
	if matched(g) {
		w.Header().Set("X-Geo-Decision", "allow")
		w.WriteHeader(http.StatusOK)
		return
	}
	w.Header().Set("X-Geo-Decision", "deny")
	w.WriteHeader(http.StatusForbidden)
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/authz", authz)
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	log.Printf("listening :9099 allow=%s failMode=%s", allowRaw, failMode)
	log.Fatal(http.ListenAndServe(":9099", mux))
}

本地跑起来验证了一遍(ALLOW_REGIONS=江苏,南京):

== X-Forwarded-For: 114.114.114.114   -> HTTP 200,X-Geo-Prov: 江苏,X-Geo-City: 南京,X-Geo-Decision: allow
== X-Forwarded-For: 101.226.4.6       -> HTTP 403,X-Geo-Prov: 上海,X-Geo-Decision: deny
== X-Forwarded-For: 192.168.1.1       -> HTTP 403,X-Geo-Decision: unknown-deny   (FAIL_MODE=closed)
== 同上,改成 FAIL_MODE=open          -> HTTP 200,X-Geo-Decision: unknown-allow
== GET /healthz                       -> HTTP 200

(踩坑记录一条:本机 8080 被别的服务占着的时候,这个鉴权服务起不来会直接退出,Ingress 侧表现为后端 404/503 一类的报错。容器里也一样——探针必须配,不然 Pod 在反复重启,你只会看到业务 5xx。)

部署到集群里,配 Ingress 注解:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: activity-page
  annotations:
    # 关键注解:nginx 在转发前先请求鉴权服务
    nginx.ingress.kubernetes.io/auth-url: "http://ip9-authz.default.svc.cluster.local:8080/authz"
    # 把归属地透传给后端和日志
    nginx.ingress.kubernetes.io/auth-response-headers: "X-Geo-Prov,X-Geo-City,X-Geo-Decision"
spec:
  ingressClassName: nginx
  rules:
    - host: activity.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: activity-svc
                port:
                  number: 80

部署鉴权服务时,别忘了两件事:Deployment 里配 readinessProbe/livenessProbe 指向 /healthz;Service 端口写 8080(和容器内监听保持一致,我上面的示例里为了本地验证改成了 9099,容器里按 8080 来更符合习惯)。

四、K8s 上最容易踩的坑:客户端 IP 丢了 ​

这套方案的成败全在鉴权服务拿到的 IP 是不是真客户端 IP。K8s 里它丢得比你想的容易:

Service 默认的 externalTrafficPolicy: Cluster 会改写源 IP。 流量从节点转发到 Pod 时做了一次 SNAT,Pod 看到的是节点 IP。解决方式是把 ingress-nginx 的 Service 改成 externalTrafficPolicy: Local,代价是流量只落到有 Ingress Pod 的节点上(节点数要够)。这一步不做,鉴权服务永远看到的是集群内部地址,规则等于失效。

前面还有一层 LB/CDN 的话,XFF 里第一个地址才可能是客户端。 但前提是 Ingress 控制器被明确配置成信任上游代理:ingress-nginx 的 ConfigMap 里要用 use-forwarded-headers: "true"、compute-full-forwarded-for: "true",并把可信来源限定住(七层负载走 proxy protocol 时另有一套开关)。键名和取值以你用的控制器版本文档为准,别照抄别的版本。

XFF 是可以伪造的。 如果信任链没配好,攻击者随手加一个 X-Forwarded-For: 1.2.3.4,你的地域规则就被绕过了。所以鉴权服务里要区分两件事:XFF 第一个地址(客户端,前提是信任链干净)和 RemoteAddr(最后一跳)。生产环境建议在服务里把两者都打进日志,出问题时一眼看出是链路配错还是有人在伪造——我上面的代码在 clientIP() 里就是这么处理的。

多副本 + 内存缓存 = 规则不一致。 三个副本各自缓存各自的,运营改了白名单,得等 TTL 到期才全量生效。要"改完立刻生效",把规则和归属地缓存放 Redis,配一个短 TTL。

别忘了接口额度。 每个未命中的 IP 都要发一次外部请求,缓存没起作用的时候(比如大量短连接、爬虫扫站)很可能顶到 60 次/分钟的限额,接口会返回 429 甚至被前置防护拦成 403。要在服务里给"未知结果"加负缓存(30 秒即可),否则接口一抖,所有请求会一起打过去。

合规别忽略。 归属地属于个人信息,鉴权服务只是判定用,日志里建议记省份不记原始 IP,保留期限跟你们的数据留存策略对齐。

总结 ​

Kubernetes 里做地域访问控制,路劲其实很清楚:Ingress 管转发,鉴权服务管判断。Ingress 只给 whitelist-source-range 这种 CIDR 能力,按省市拦截就得靠 auth-url 把请求交给自己的服务;服务返回 200/403,查不到就按显式配置的失败策略走,归属地用响应头透传给后端。

真正容易出事的不是代码,而是源 IP 链路:externalTrafficPolicy 改了没、上游 LB 的 XFF 信任链配没配、攻击者能不能伪造 XFF。上线前建议拿一个已知 IP 的请求从外网打一遍,把鉴权服务日志里的 IP 打印出来对一遍——这一步比后面所有调试都省时间。如果你们还停在传统 Nginx,本站《网站按地区限制访问怎么做?IP白名单和区域封锁实战》讲的就是配置文件层面那套做法;网关层的缓存分层策略可以看《OpenResty 网关怎么用 IP 归属地做地域风控》。

示例里的归属地查询用的是 IP9 的免费接口 https://ip9.com.cn/get?ip=<IP>,不传 ip 参数则返回调用方自己的归属地,IPv4/IPv6 都支持,免费版 60 次/分钟、无需注册;按量更大或需要区县、ip_type(ISP/BUS/IDC)、ip_asn 这些字段时换 VIP 版即可。字段说明、额度与私有化部署在官网 https://www.ip9.com.cn 。