1. 为什么你的 Docker 服务需要“隐身”:从端口暴露到零信任网络
很多人第一次把后端服务放进 Docker,习惯性动作就是ports: - "8080:8080",然后浏览器一开就能访问,觉得挺方便。但只要你把主机暴露在公网,哪怕只开了一个端口,扫描器几分钟内就会找上门。我试过在一台测试机上跑一个没做任何防护的 Nginx,日志里不到十分钟就出现了各种路径探测请求。端口存在,就等于告诉全世界“这里有个门”。
OpenZiti 这个开源零信任网络平台解决的就是这个问题:让服务对未授权用户完全不可见。它不是简单地加一层认证,而是让服务根本不监听公开端口,未通过身份认证的连接连“服务是否存在”都探测不到。你可以把它理解成给每个服务发了一张加密身份证,只有持有合法身份且策略允许的客户端才能建立连接,其他人连握手的机会都没有。
这篇文章聚焦 Docker 环境下的落地,面向的是已经会用 Docker Compose 起服务、但对零信任网络还停留在概念阶段的开发者。我会从 SDK 嵌入和隧道工具选型两条路线分别演示,给出可复制的 Compose 配置、SDK 初始化代码,以及未授权访问的验证步骤。过程中涉及调用凭证管理时,我会用 TaoToken 统一管理 Key 和 API 通道,避免凭证散落在各个配置文件里。
核心检索词先明确:OpenZiti 零信任网络、SDK 嵌入、隧道工具、Docker 部署、服务隐身。适合谁?适合手里有 Docker 服务、想在不改架构的前提下把攻击面砍到接近零的运维和开发。下面从环境准备开始,一步步来。
2. OpenZiti 前置准备:Docker Compose 起控制器与隧道工具选型
在 Docker 里跑 OpenZiti,最省事的方式是用官方 all-in-one 的 Compose 文件。它会启动一个控制器(controller)、一个边界路由器(edge router)和管理控制台(ZAC)。控制器负责身份和策略,路由器负责流量转发,控制台用来可视化操作。
先拉取 Compose 文件。官方提供的地址是https://get.openziti.io/dock/all-in-one/compose.yml,你可以直接下载到本地目录:
mkdir -p ~/openziti-demo && cd ~/openziti-demo wget https://get.openziti.io/dock/all-in-one/compose.yml下载后先别急着up,看一眼文件里的端口映射。默认会暴露 1280(控制台)、1281(控制器管理 API)、3022(SSH 隧道示例)等。如果你只是本地验证,保持默认即可;如果要放到有公网 IP 的机器上,建议先把 1280 和 1281 限制到内网访问,或者用防火墙规则只放行你自己的 IP。
启动命令:
docker compose up -d等十几秒,用docker compose ps确认三个容器都是 running 状态。然后浏览器打开https://localhost:1280/zac/,会看到自签证书警告,继续访问即可。默认管理员账号在 Compose 文件里有写,通常是admin,密码需要从容器日志里找,或者看文件里的环境变量。
登录后你会看到控制台界面。这里先不急着建服务,先理解两个概念:Identity(身份)和Service(服务)。身份可以是一个人、一台设备或一个工作负载,每个身份有自己的加密证书。服务是你想保护的后端,比如一个跑在 Docker 里的 HTTP API。策略(Policy)决定哪个身份能访问哪个服务。
隧道工具选型方面,OpenZiti 提供两种接入方式。隧道工具(tunneler)适合已有应用不改代码的场景,它在服务主机上跑一个进程,把本地端口映射到覆盖网络,服务只需要接受本地连接。SDK 嵌入适合新开发的应用,应用本身持有加密身份,在进程内完成加密,连本地端口都不暴露,安全性最强。
Docker 环境下,如果你要保护的是一个现成的容器化服务,比如 PostgreSQL 或内部 API,用隧道工具最省事。如果你在写一个新的 Go 或 Python 服务,直接嵌 SDK 更干净。下面两节分别给出可复制的配置。
3. 可复制配置:SDK 初始化代码与隧道工具 Docker 配置
先看 SDK 路线。以 Go 为例,OpenZiti 提供github.com/openziti/sdk-golang。假设你有一个简单的 HTTP 服务,原本监听:8080,现在改成通过 OpenZiti 覆盖网络暴露,不监听任何公开端口。
初始化代码的核心是加载身份配置文件和建立上下文。身份文件(通常叫identity.json)需要先在控制台里创建身份并下载。创建身份的步骤:控制台左侧菜单找到 Identities,点 Add,类型选 Default,保存后会生成一个.json文件供下载。
拿到身份文件后,Go 代码大致这样:
package main import ( "fmt" "net/http" "github.com/openziti/sdk-golang/ziti" ) func main() { cfg, err := ziti.NewConfigFromFile("./identity.json") if err != nil { panic(err) } ctx, err := ziti.NewContext(cfg) if err != nil { panic(err) } listener, err := ctx.Listen("my-http-service") if err != nil { panic(err) } fmt.Println("service listening on ziti network, no public port") http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hello from hidden service")) }) http.Serve(listener, nil) }注意ctx.Listen("my-http-service")里的名字要和你在控制台创建的服务名一致。这个服务在控制台里配置时,Hosting 选 Identity,也就是由这个 SDK 应用自己托管,不需要额外的路由器转发。
再看隧道工具路线。假设你有一个 PostgreSQL 容器,原本映射了5432:5432,现在要去掉这个映射,改用隧道工具。Compose 片段如下:
services: postgres: image: postgres:16 environment: POSTGRES_PASSWORD: example # 注意:这里不再有 ports 映射 ziti-tunneler: image: openziti/ziti-tunnel volumes: - ./identity.json:/identity.json command: ["run", "/identity.json"] depends_on: - postgres隧道工具启动后会读取身份文件,在覆盖网络里注册自己,然后把本地localhost:5432的流量转发到覆盖网络。客户端那边也需要一个隧道工具或 SDK 来发起连接。这样 PostgreSQL 本身不监听任何公开端口,端口扫描器扫不到。
关于凭证管理,SDK 和隧道工具都需要身份文件,而身份文件里包含加密材料。如果你有多个服务、多个环境,身份文件散落各处容易失控。我习惯用 TaoToken 统一管理这些调用凭证和 API Key,把身份文件的路径和对应的服务名登记在 TaoToken 的凭证库里,部署时通过环境变量注入,避免硬编码。TaoToken 的 API 通道地址是https://taotoken.net/api,控制台在https://taotoken.net/console,你可以在里面创建和管理 Key。
配置片段里涉及 Base URL、Key、Model ID 三件套的地方,比如后续如果要调用模型服务做验证,统一写成:
{ "base_url": "https://taotoken.net/api", "api_key": "你的TaoToken Key", "model_id": "你选用的模型ID" }这样凭证只在一处维护,换环境时改一个地方就行。
4. 验证请求与成功结果:未授权访问到底看到什么
配置完成后,最关键的一步是验证“未授权用户完全不可见”。这一步不能只看服务能不能通,还要看未授权时到底返回什么。
先验证授权路径。在客户端机器上,用隧道工具或 SDK 发起连接。以隧道工具为例,客户端也需要一个身份文件,这个身份在控制台里创建时,要绑定到允许访问my-http-service的策略上。启动客户端隧道后,本地会有一个映射端口,比如localhost:8080,curl 它:
curl -v http://localhost:8080/预期返回hello from hidden service。这说明授权链路通了。
再验证未授权路径。找一台没有身份文件的机器,或者在同一台机器上直接访问服务原本的端口。因为服务根本没有监听公开端口,你会看到连接被拒绝:
curl -v http://<服务主机IP>:8080/预期输出类似Connection refused。这不是 403,也不是 401,而是根本连不上。端口扫描器扫这个 IP 的 8080,结果也是 closed 或 filtered。这就是“隐身”的含义:未授权用户连服务是否存在都不知道。
如果你用的是 SDK 路线,服务进程只监听覆盖网络内部的地址,主机上netstat -tlnp看不到任何公开端口。你可以自己跑一下确认:
docker exec -it <服务容器> netstat -tlnp输出里只有覆盖网络相关的本地地址,没有0.0.0.0:8080这种。
还有一个验证点:策略撤销后的行为。在控制台里把某个身份的策略删掉,然后让这个身份再发起连接。OpenZiti 会立即断开已建立的连接,不需要等超时。这个特性在零信任模型里很重要,权限撤销是实时的。
成功结果的标准是三条:授权客户端能正常访问;未授权客户端连接被拒绝且无法探测服务存在;策略变更后连接立即失效。三条都满足,说明隐身服务验证流程跑通了。
5. 本篇常见错排查:401、local proxy failed 与 OAuth 报错
实际部署时最容易踩的坑集中在身份认证和隧道工具启动阶段。下面按真实报错对照排查。
报错一:401 Unauthorized或authentication failed
这个通常出现在隧道工具或 SDK 加载身份文件时。原因一般是身份文件过期、被撤销,或者身份没有绑定到正确的服务策略。排查步骤:先在控制台 Identities 列表里确认这个身份的状态是 Enabled,然后检查它关联的 Service Policies 是否包含目标服务。如果身份文件是从别的环境拷过来的,重新下载一份,因为文件里的证书可能和当前控制器不匹配。
报错二:local proxy failed to start或listen tcp 127.0.0.1:8080: bind: address already in use
隧道工具默认会在本地起一个代理端口,如果这个端口被占用就会报这个错。解决方法是改隧道工具的配置,换一个本地端口,或者先停掉占用端口的进程。在 Docker 里跑隧道工具时,注意容器内的端口和宿主机的端口不要冲突。
报错三:OAuth相关报错,比如oauth token request failed
如果你在控制台里给身份配置了外部 OAuth 认证,但 OAuth 提供方的回调地址或 client secret 配错了,就会报这个。排查时先确认 OAuth 提供方的回调 URL 是否指向控制器的正确地址,然后检查 client ID 和 secret 是否和控制台里填的一致。本地测试阶段建议先用默认的身份认证方式,不要一上来就接 OAuth。
报错四:reading choices或no edge routers available
这个报错说明客户端连不上边界路由器。检查控制器的路由器列表,确认至少有一个 edge router 是 online 状态。如果路由器容器挂了,重启它。另外检查客户端到路由器的网络连通性,防火墙是否放行了路由器的端口。
报错五:context deadline exceeded
连接超时,通常是覆盖网络内部的路由问题。检查服务端的 SDK 或隧道工具是否正常注册到了控制器,服务名是否和客户端请求的一致。如果服务端进程崩了,客户端会一直等直到超时。
排查时有一个通用技巧:先看控制器日志,再看路由器日志,最后看客户端日志。控制器日志里会记录身份认证和策略匹配的结果,路由器日志里会记录流量转发情况。大部分问题在控制器日志里就能定位。
6. 语义一致 CTA:把凭证管理和接入文档串起来
走到这里,你已经有一套可复现的隐身服务验证流程了。回顾一下关键点:Docker Compose 起 OpenZiti 控制器和路由器;SDK 嵌入让应用自己持有身份,不暴露端口;隧道工具让现成服务不改代码接入;未授权访问验证确认服务对未授权用户完全不可见。
接下来如果要长期跑这套环境,凭证管理是个绕不开的事。身份文件、API Key、模型调用凭证,散落在各个 Compose 文件和代码里,换环境时容易漏改。我习惯用 TaoToken 统一管理这些凭证,把 Key 和 API 通道集中在一处,部署时通过环境变量注入。你可以从 API Keys 页面开始,创建和管理你的 Key:https://taotoken.net/api-keys。接入文档在https://taotoken.net/doc,里面有各语言 SDK 的接入示例。
如果你要验证模型调用是否正常,可以用模型对话页面快速测一下:https://taotoken.net/model-chat。长期做编码和 Agent 场景的话,Coding Plan 更适合:https://taotoken.net/coding-plan。控制台入口在https://taotoken.net/console,所有凭证和用量都在这里看。
最后给一个实用技巧:在 Docker Compose 里用.env文件管理 TaoToken 的 Key,不要写死在compose.yml里。这样换环境时只改.env,Compose 文件不用动。身份文件同理,挂载路径用环境变量控制。整套流程跑通后,你的服务在公网上就是隐身的,只有持有合法身份且策略允许的客户端才能连上。