别再迷信“内网很安全”了:手搓一个 SPIFFE + mTLS + 网络策略的零信任小工程
Nov 9, 2026 · @Echo_Wish
先说句得罪人的话:内网,真没你想的那么安全
内网从来不是“自己人”的同义词,它只是“坏人还没进来”的另一种说法。
我干运维这些年,见过最离谱的一次事故,一点都不高大上,没有什么 0day。就是一台没人管的测试机被人拿下,然后顺着内网一路横着摸,摸到了生产数据库。复盘会上大家都沉默了,因为谁都说不出口:这个库为什么能被测试机直接连?答案很扎心——“都在内网里啊,一直都是这么通的。”
这就是传统边界安全的老毛病:城墙修得很高,城里却谁都能串门。只要有一个人翻进来,后面就是“进了村”。
零信任要解决的,就是这个问题。它的核心就三句话:不默认信任任何人,每一次访问都要验证,假设已经有人在里面了。
我先把观点亮出来:零信任不是一个能买回来的产品,它是一套习惯。 习惯就是——每个服务开口说话之前,先掏出身份证;每条网络路径,都得有人明确批准过才能走。
今天我带你用三样都是开源、都能落地的东西,搭一个能跑起来的小工程:
- SPIFFE / SPIRE:给每个工作负载发一张“电子身份证”。
- mTLS:双方互相验身份证,然后加密通信。
- NetworkPolicy:默认全部拒绝,只开必须的口子。
代码我尽量贴全,你拿去改改就能在测试集群里跑。
三层各管一摊:身份、认证、路,缺一不可
这三样东西不是重复建设,而是各管各的活儿,凑一起才叫纵深防御。
咱先立个小场景:集群里有三个服务,web(前端网关)调 order(订单),order 再调 payment(支付)。我们的目标很朴素——payment 只认 order 的调用,其他谁来都不行,包括同一个命名空间里的“兄弟”。
- SPIFFE 回答“你是谁”。 每个 Pod 拿到一个形如
spiffe://example.org/ns/shop/sa/order的身份,背后是短期自动轮换的证书(SVID)。身份跟 IP 没关系,Pod 漂到哪儿它都是它。 - mTLS 回答“我凭什么信你”。 双方都出示证书,互相验明正身,中间链路全程加密。不是只有客户端验服务端,服务端也得验客户端。
- NetworkPolicy 回答“你有没有资格走这条路”。 它在网络层先把大部分闲杂人等挡在门外,哪怕对方证书再漂亮,路不通就是不通。
有人会问:我只上 mTLS 不行吗?说实话,只靠 mTLS,坏人的包已经到你家门口了,你只是在门口审他;只靠 NetworkPolicy,路是堵上了,但路上走的是谁你并不知道。 两个加起来,才是“门口有保安,大门还上锁”。
第一步:先让每个服务有张“身份证”
身份发放是整个体系的地基,地基歪了,后面全是白搭。
SPIRE 是 SPIFFE 的参考实现,装起来比你想的轻松,官方有现成的 Helm Chart:
helm repo add spiffe https://spiffe.github.io/helm-charts-hardened/
# 先装 CRD,再装 SPIRE 本体
helm upgrade --install spire-crds spiffe/spire-crds -n spire-mgmt --create-namespace
helm upgrade --install spire spiffe/spire -n spire-mgmt \
--set global.spire.trustDomain=example.org
装好之后,关键就一件事:告诉 SPIRE 谁配拿什么身份。 我们用 ClusterSPIFFEID 这个 CRD,按命名空间和 ServiceAccount 自动生成身份,不用一个个手工注册:
apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
name: shop-workloads
spec:
# 身份 = 命名空间 + ServiceAccount,和 IP 没有半毛钱关系
spiffeIDTemplate: "spiffe://example.org/ns/{
{ .PodMeta.Namespace }}/sa/{
{ .PodSpec.ServiceAccountName }}"
podSelector:
matchLabels:
spiffe: enabled
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: shop
然后让业务 Pod 通过 CSI 把 Workload API 的 socket 挂进去,业务代码就能向 SPIRE 要证书了:
spec:
serviceAccountName: order # 一个服务一个 SA,别偷懒用 default
containers:
- name: order
env:
- name: SPIFFE_ENDPOINT_SOCKET
value: unix:///spiffe-workload-api/spire-agent.sock
volumeMounts:
- name: spiffe-workload-api
mountPath: /spiffe-workload-api
readOnly: true
volumes:
- name: spiffe-workload-api
csi:
driver: csi.spiffe.io
readOnly: true
这里我想啰嗦一句:“一个服务一个 ServiceAccount”这条,千万别省。 我见过太多集群,所有 Pod 都跑在 default 这个 SA 下面,那你发出去的身份全是同一个,等于给全公司的人都发了同一张工牌,后面的授权就没法做了。身份的粒度,决定了你后面能管得多细。
还有个好处我特别喜欢:SVID 默认是短命证书,自动轮换,不用你半夜被“证书过期”叫醒。以前这种告警,我可是没少挨。
第二步:服务之间用 mTLS 对暗号,只认指定的身份
mTLS 的重点不是“加密”,而是“双向验身份”,加密只是顺手的事。
有了身份证,下一步就是让服务互相验。我这里用 Go 的 go-spiffe 库举例,因为它把最麻烦的证书热更新全包了。先看服务端,也就是 payment:
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
"github.com/spiffe/go-spiffe/v2/svid/x509svid"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
func main() {
ctx := context.Background()
// 从 SPIRE 拿证书,后续轮换由库自动完成,业务代码无感知
source, err := workloadapi.NewX509Source(ctx)
if err != nil {
log.Fatalf("拿不到 SVID: %v", err)
}
defer source.Close()
// 核心:只放行 order 这一个身份
allowed := spiffeid.RequireFromString("spiffe://example.org/ns/shop/sa/order")
tlsCfg := tlsconfig.MTLSServerConfig(source, source, tlsconfig.AuthorizeID(allowed))
mux := http.NewServeMux()
mux.HandleFunc("/pay", func(w http.ResponseWriter, r *http.Request) {
caller, _ := x509svid.IDFromCert(r.TLS.PeerCertificates[0])
fmt.Fprintf(w, "收款成功,来电的是:%s\n", caller)
})
srv := &http.Server{
Addr: ":8443", Handler: mux, TLSConfig: tlsCfg}
log.Fatal(srv.ListenAndServeTLS("", ""))
}
再看客户端,也就是 order。注意它也要反过来验证对面真的是 payment,而不是一个冒牌货:
func newPaymentClient(ctx context.Context) (*http.Client, error) {
source, err := workloadapi.NewX509Source(ctx)
if err != nil {
return nil, err
}
serverID := spiffeid.RequireFromString("spiffe://example.org/ns/shop/sa/payment")
tlsCfg := tlsconfig.MTLSClientConfig(source, source, tlsconfig.AuthorizeID(serverID))
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsCfg},
}, nil
}
这段代码有个细节我挺想让你品一品:它校验的不是域名,也不是 IP,而是 SPIFFE ID。 以前我们总在纠结“这个域名的证书对不对”,现在问题变成“对面是不是我要找的那个服务”。思路一变,整个信任模型就清爽了。
另外多说一句。如果你的服务不是 Go 写的,或者压根不想改代码,也别慌:让 Envoy 当 sidecar,通过 SDS 从 SPIRE 拿证书,效果是一样的,只是代价换成了多一个 sidecar 的资源和排障复杂度。这笔账怎么算,得看你团队的实际情况,没有标准答案。
第三步:先把路全堵死,再一条一条开
默认拒绝是网络层零信任的灵魂:不是“谁不能访问就禁掉”,而是“谁能访问才放开”。
先上一条“全家都别动”的策略。注意前提:你的 CNI 得支持 NetworkPolicy,比如 Calico 或 Cilium,否则写了也是白写。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: shop
spec:
podSelector: {
} # 命名空间里所有 Pod
policyTypes: [Ingress, Egress]
---
# 堵死之后,DNS 得先放行,不然服务连名字都解析不了
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: shop
spec:
podSelector: {
}
policyTypes: [Egress]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- {
protocol: UDP, port: 53 }
- {
protocol: TCP, port: 53 }
然后只开我们真正需要的两条路:order 能出去找 payment,payment 只收 order 的单。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: payment-only-from-order
namespace: shop
spec:
podSelector:
matchLabels: {
app: payment }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: {
app: order }
ports:
- {
protocol: TCP, port: 8443 }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: order-to-payment
namespace: shop
spec:
podSelector:
matchLabels: {
app: order }
policyTypes: [Egress]
egress:
- to:
- podSelector:
matchLabels: {
app: payment }
ports:
- {
protocol: TCP, port: 8443 }
验证一下,别光写不测:
# order -> payment:应该通
kubectl -n shop exec deploy/order -- nc -zvw3 payment 8443
# web -> payment:应该被拦住(超时)
kubectl -n shop exec deploy/web -- nc -zvw3 payment 8443 || echo "被拦住了,很好"
我得提前跟你说句实话:默认拒绝刚上线的那几天,你大概率会很痛苦。 一堆“怎么突然不通了”的工单会涌过来。但我反而觉得这是好事,每一个“不通了”,背后都是一条你以前根本不知道的隐式依赖。这些依赖原来藏在“反正内网都通”的假象里,现在被一条条逼出来了。不通的那一刻,你才真正看清了自己的系统。
说点踩坑经验:这套东西,难的不在技术
这套方案真正的坑,几乎都不在原理上,而在那些“谁也没想到”的细节里。我把自己踩过、也见别人踩过的几个列一下:
身份发不下来。 别急着怀疑 SPIRE,先去 spire-server 里查一下注册条目,十有八九是 ServiceAccount 或标签没对上:
# 在 spire-server 的 Pod 里执行 spire-server entry show -spiffeID spiffe://example.org/ns/shop/sa/order健康检查被自己人拦了。 kubelet 的探针不带证书,开了 mTLS 之后,探针直接被服务端拒绝,Pod 就在那儿循环重启。常见解法是单开一个只读的健康检查端口,再用 NetworkPolicy 把它圈起来,或者改用 exec 探针。
启动时序。
NewX509Source会等到拿到证书才返回,如果 SPIRE agent 还没就绪,你的服务就会卡在启动阶段,探针和超时时间要留够余量。时钟漂移。 短期证书对时间很敏感,节点时钟差个几分钟,就会冒出一堆莫名其妙的“证书无效”。NTP 这种老生常谈的东西,在这里突然变得很重要。
还有一条,我觉得比上面所有技术坑加起来都重要:别想着一口气全铺开。 我的建议是挑一条最关键的链路,比如“订单到支付”,先试点。先观察,把流量摸清楚,再切到强制拦截。很多团队的零信任项目死掉,不是技术不行,而是一上来就把业务搞崩,然后被一句“这玩意儿太折腾了”一票否决。
说句心里话,这几年我越来越觉得,安全方案的成败,取决于它让开发的日子好过还是难过。 如果每个开发为了调个接口,得先研究半天证书,他们一定会想办法绕过去。所以你看,SPIFFE 最让我服气的地方,不是它多“安全”,而是它把证书这件脏活累活藏到了库的后面,开发几乎无感。好的安全,应该是让人想不到去绕它。
最后想问你一句
零信任落地之后,最大的变化不是多了几个组件,而是你看待“信任”的方式变了。
回头看今天这个小工程:SPIFFE 让每个服务有了不会变的身份,mTLS 让每次通话都要先验明正身,NetworkPolicy 让每条路都得有人点头。三层叠起来,就算哪天真有一台测试机被拿下,它也只能面对一堵堵“你是谁、你凭什么过来”的墙,而不是一条畅通无阻的内网大道。
当然,它不是银弹。身份签发服务本身要保护好,策略写错了照样会放行,人为配置失误才是最常见的漏洞。但至少,我们把“默认信任”这个最大的坑给填上了。
最后留个问题给你,也是留给我自己的:你现在的系统里,有多少服务之间的互相信任,是因为“有人认真评估过”,又有多少,只是因为“一直都这样”?
这个问题,值得你下周一上班第一件事,就去查一查。
我是 Echo_Wish,咱们下篇接着聊。