我们面临一个典型的技术演进困境。一个稳定运行多年的Ruby on Rails单体应用承载着核心业务逻辑,包括一套复杂且深度耦合的用户认证与授权体系。为了应对新的性能挑战和业务敏捷性需求,技术团队决定引入新的微服务,技术栈选型非常多样化:一个面向外部高并发场景的推送服务选用了基于Rust的Axum;一个内部数据处理服务采用了基于JVM的Quarkus以利用其丰富的Java生态和原生编译能力;还有一个轻量级的BFF层则选择了Node.js的Fastify。
所有外部流量都通过Kong API Gateway进行路由。最初的方案简单粗暴:
-- kong.conf snippet
services:
- name: legacy-rails-service
url: http://rails-app:3000
- name: new-axum-service
url: http://axum-app:8000
routes:
- name: rails-route
service: legacy-rails-service
paths:
- /rails
- name: axum-route
service: new-axum-service
paths:
- /axum-push
这种天真的代理模式直接将认证授权的责任推给了下游的各个服务。很快,问题就暴露了:难道要让Axum, Quarkus, Fastify的开发团队各自去实现一套逻辑,调用Rails的认证接口来验证用户身份吗?这不仅是重复劳动,更是将脆弱的耦合关系扩散到了整个系统,任何Rails认证逻辑的变更都将是一场灾难。
定义问题:统一认证与无缝迁移
架构决策的核心目标变得清晰:我们需要一个统一的认证授权层,它必须在API网关层面执行,对下游微服务透明。
具体要求如下:
- 集中处理: 所有进入微服务集群的请求,必须在Kong层面完成认证和授权检查。
- 兼容旧系统: 认证逻辑必须复用现有的Rails应用的会话或Token验证机制,避免在迁移初期重写核心认证模块。
- 高性能: 对于新建的高性能服务(尤其是Axum和Fastify),认证层引入的延迟必须尽可能低。
- 对下游透明: 认证成功后,下游服务应能以一种标准化的方式(例如,通过HTTP Header)获取到用户信息,而无需关心认证过程的细节。
- 高可用: Rails单体应用的任何抖动或临时不可用,不应导致整个认证系统瘫痪,尤其是不应影响已经建立会话的用户的访问。
方案A:各服务中间件自行校验
这是最容易想到的方案。每个微服务都在自己的框架内实现一个认证中间件。
例如,在Fastify服务中,代码可能如下:
// fastify-auth-middleware.js
const axios = require('axios');
const RAILS_AUTH_ENDPOINT = 'http://rails-app:3000/internal/auth/verify';
async function authMiddleware(request, reply) {
const authToken = request.headers['authorization'];
if (!authToken) {
reply.code(401).send({ error: 'Missing authorization token' });
return;
}
try {
// 对每个请求都发起一次到Rails的验证调用
const response = await axios.post(RAILS_AUTH_ENDPOINT, {}, {
headers: { 'Authorization': authToken }
});
if (response.status === 200 && response.data.userId) {
// 验证通过,将用户信息注入请求
request.user = { id: response.data.userId, roles: response.data.roles };
} else {
reply.code(401).send({ error: 'Invalid token' });
}
} catch (error) {
// Rails服务不可用或验证失败
console.error('Auth verification failed:', error.message);
reply.code(503).send({ error: 'Auth service unavailable' });
}
}
// 在应用中注册
// server.addHook('preHandler', authMiddleware);
优点:
- 实现直观,每个团队可以在自己熟悉的技术栈内完成工作。
缺点:
- 代码冗余: 每个服务都需要实现几乎相同的逻辑。
- 紧密耦合: 所有新服务都直接依赖于Rails的内部接口,形成了一个难以管理的依赖网络。
- 性能瓶颈: 每个外部请求都会在内部触发一次额外的网络调用(
Service -> Rails),在高并发下,这会急剧增加响应延迟并给Rails应用带来巨大压力。 - 策略不一致: 缓存、重试、超时等策略难以在不同语言和框架间保持统一,最终导致系统行为不一致。
这个方案在真实项目中是不可接受的,它将问题分解,但代价是复杂度的扩散。
方案B:独立的认证微服务
第二个方案是构建一个全新的、专门负责认证的微服务。这个服务可以封装对Rails认证逻辑的调用。
graph TD
subgraph "请求流程"
Client -->|Request with Token| Kong
Kong -->|Forward| Auth_Service
Auth_Service -->|Verify Token| Rails_App
Rails_App -->|Validation Result| Auth_Service
Auth_Service -->|User Info or 401| Kong
Kong -->|Proxy with User Header| Upstream_Service(Axum/Quarkus/Fastify)
end
优点:
- 职责分离: 认证逻辑被清晰地剥离出来,符合微服务单一职责原则。
- 解耦: 上游服务不再直接依赖Rails,而是依赖于定义良好的认证服务接口。
缺点:
- 运维复杂性: 引入了一个新的、高关键性的服务需要维护,其自身的高可用性成为整个系统的命门。
- 性能问题未根本解决: 依然存在额外的网络跳数 (
Client -> Kong -> Auth Service -> Upstream)。虽然比方案A少了一次到Rails的直接调用(可以在Auth Service内部缓存),但请求链路依然被拉长了。 - 迁移阵痛: 构建这个服务本身就需要投入开发资源,并且它依然需要一种方式(例如共享数据库或调用内部API)来与Rails的会话存储进行交互,这本身就是一个棘手的技术问题。
最终选择:基于Kong的自定义有状态插件
我们最终选择了在问题的源头——API网关——上解决它。通过为Kong编写一个自定义Lua插件,我们可以在流量入口处完成所有认证逻辑。
决策理由:
- 性能最优: 插件代码运行在Kong的Nginx工作进程中,使用LuaJIT执行,性能极高。它可以通过
ngx.location.capture等内部API与上游服务通信,这比完整的HTTP客户端调用要轻量得多。 - 中心化控制: 所有认证逻辑、缓存策略、超时设置都集中在一个地方,易于管理和审计。
- 对下游完全透明: 微服务开发者完全不需要关心认证细节,他们只需要信任Kong注入的
X-User-ID等头信息即可。 - 韧性设计: 插件内部可以实现强大的缓存机制。即使Rails认证服务暂时不可用,只要用户的认证信息在缓存中,请求依然可以成功处理,极大地提高了系统的韧性。
核心实现:kong-legacy-auth 插件
我们将插件命名为 kong-legacy-auth。一个Kong插件通常由 handler.lua (核心逻辑) 和 schema.lua (配置定义) 两个文件组成。
1. schema.lua:定义插件配置
这个文件定义了插件的可配置项,让运维人员可以在启用插件时动态传入参数。
-- kong/plugins/kong-legacy-auth/schema.lua
-- 详尽的注释是生产级代码的标志
return {
no_consumer = true, -- 这个插件是全局的,不绑定到特定的Consumer
fields = {
auth_url = {
type = "string",
required = true,
description = "用于验证Token或Session的内部Rails端点URL."
},
auth_method = {
type = "string",
default = "POST",
one_of = {"GET", "POST"},
description = "调用验证端点的HTTP方法."
},
timeout = {
type = "number",
default = 2000, -- 默认2秒超时
description = "调用验证端点的超时时间 (ms)."
},
cache_ttl = {
type = "number",
default = 300, -- 默认缓存5分钟
description = "认证成功结果的缓存时间 (seconds). 0表示禁用缓存."
},
user_id_header = {
type = "string",
default = "X-User-ID",
description = "注入到下游请求的表示用户ID的Header名称."
},
user_roles_header = {
type = "string",
default = "X-User-Roles",
description = "注入到下游请求的表示用户角色的Header名称 (逗号分隔)."
}
}
}
这份Schema清晰地定义了插件的行为,具备良好的可维护性。
2. handler.lua:核心认证逻辑
这是插件的心脏,所有的处理逻辑都在这里。我们主要利用access阶段,它在Kong将请求代理到上游服务之前执行。
-- kong/plugins/kong-legacy-auth/handler.lua
local BasePlugin = require "kong.plugins.base_plugin"
local http = require "resty.http"
local cjson = require "cjson.safe"
local LegacyAuthHandler = BasePlugin:extend()
LegacyAuthHandler.PRIORITY = 1000 -- 确保在其他插件之前执行
LegacyAuthHandler.VERSION = "1.0.0"
function LegacyAuthHandler:new()
LegacyAuthHandler.super.new(self, "kong-legacy-auth")
end
-- 核心的 access 阶段处理函数
function LegacyAuthHandler:access(conf)
LegacyAuthHandler.super.access(self)
local authorization_header = kong.request.get_header("Authorization")
if not authorization_header then
return kong.response.exit(401, { message = "Authorization header is missing" })
end
-- 1. 缓存优先策略
-- 这里的坑在于:缓存键必须是唯一的且无歧义的。直接使用token作为键。
local cache_key = "legacy_auth:" .. authorization_header
if conf.cache_ttl > 0 then
local cached_user_data_str = kong.cache:get(cache_key)
if cached_user_data_str then
local user_data, err = cjson.decode(cached_user_data_str)
if not err and user_data then
kong.log.debug("Auth cache hit for key: ", cache_key)
-- 从缓存中获取并设置headers
kong.service.request.set_header(conf.user_id_header, user_data.id)
if user_data.roles then
kong.service.request.set_header(conf.user_roles_header, table.concat(user_data.roles, ","))
end
return -- 认证通过,直接返回
end
end
end
-- 2. 缓存未命中,执行实时验证
kong.log.debug("Auth cache miss, proceeding with live validation for key: ", cache_key)
-- 使用resty.http进行网络调用,这是OpenResty的标准做法
local httpc, err = http.new()
if not httpc then
kong.log.err("failed to create http client: ", err)
return kong.response.exit(500, { message = "Internal Server Error: Cannot create HTTP client" })
end
httpc:set_timeout(conf.timeout)
-- 构造到Rails验证服务的请求
local res, err = httpc:request_uri(conf.auth_url, {
method = conf.auth_method,
headers = {
["Authorization"] = authorization_header,
["Content-Type"] = "application/json"
}
})
-- 3. 健壮的错误处理
if not res then
kong.log.err("failed to request auth service: ", err)
-- 在真实项目中,这里应该有更精细的错误分类,例如超时、连接拒绝等
return kong.response.exit(503, { message = "Authentication service unavailable" })
end
-- 关闭连接以释放资源
httpc:close()
-- 4. 处理验证结果
if res.status == 200 then
local body_str = res.body
local body, json_err = cjson.decode(body_str)
if json_err or not body.user_id then
kong.log.err("failed to decode auth response or missing user_id: ", json_err or "nil")
return kong.response.exit(500, { message = "Invalid response from authentication service" })
end
-- 认证成功,设置headers
kong.service.request.set_header(conf.user_id_header, body.user_id)
local roles_str = ""
if body.roles and type(body.roles) == "table" then
roles_str = table.concat(body.roles, ",")
kong.service.request.set_header(conf.user_roles_header, roles_str)
end
-- 5. 将成功结果写入缓存
if conf.cache_ttl > 0 then
local user_data_to_cache = {
id = body.user_id,
roles = body.roles or {}
}
local cache_val, cache_err = cjson.encode(user_data_to_cache)
if not cache_err then
local ok, err = kong.cache:set(cache_key, cache_val, conf.cache_ttl)
if not ok then
kong.log.warn("failed to set auth cache: ", err)
end
end
end
elseif res.status == 401 or res.status == 403 then
-- 明确的认证/授权失败
return kong.response.exit(res.status, { message = "Invalid credentials" })
else
-- 其他未知错误
kong.log.warn("auth service returned unexpected status: ", res.status)
return kong.response.exit(502, { message = "Bad response from authentication service" })
end
end
return LegacyAuthHandler
下游服务的简化
有了这个插件,下游微服务的代码变得极其简单。它们不再需要关心认证的复杂过程,只需信任网关传递过来的用户信息。
以Axum服务为例,获取用户ID的代码如下:
// axum-service/src/main.rs
use axum::{
async_trait,
extract::{FromRequestParts, State},
http::{request::Parts, HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
use std::net::SocketAddr;
// 定义一个Extractor来安全地提取用户信息
struct AuthenticatedUser {
id: i64,
roles: Vec<String>,
}
#[async_trait]
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let headers = &parts.headers;
// 从Kong插件设置的Header中提取用户ID
let user_id_str = headers
.get("X-User-ID")
.and_then(|value| value.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid X-User-ID header"))?;
let id = user_id_str.parse::<i64>().map_err(|_| {
(StatusCode::UNAUTHORIZED, "Invalid user ID format")
})?;
// 提取角色信息
let roles = headers
.get("X-User-Roles")
.and_then(|value| value.to_str().ok())
.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_else(Vec::new);
Ok(AuthenticatedUser { id, roles })
}
}
// 业务Handler
async fn protected_route(user: AuthenticatedUser) -> Response {
// 业务逻辑可以直接使用 user.id 和 user.roles
// 例如:检查用户是否有权限执行此操作
if !user.roles.contains(&"admin".to_string()) {
return (StatusCode::FORBIDDEN, "Admin role required").into_response();
}
format!("Hello, admin user with ID: {}!", user.id).into_response()
}
// ... main function to start the server
这种模式极大地降低了微服务开发的认知负担。
架构的局限性与未来演进
这个基于Kong自定义插件的方案,虽然优雅地解决了当前异构环境下的统一认证问题,但它并非银弹。
首先,Rails单体应用作为认证信息的唯一权威来源,其本身依然是系统的一个潜在瓶颈和故障点。我们的缓存策略(cache_ttl)有效缓解了这个问题,将对Rails的强依赖转变为弱依赖,但这是一种“容错”而非“根治”。当大量新用户同时认证或缓存集中失效时,Rails应用的压力仍然存在。
其次,将认证逻辑用Lua编写并部署在Kong中,引入了一定的技术栈复杂性。团队需要有能力开发、测试和维护Lua代码。复杂的业务授权逻辑(例如基于属性的访问控制 ABAC)如果全部实现在插件中,会让插件变得臃肿且难以调试。
长远的演进方向,应该是将Rails中的认证和用户模型逐步剥离出来,形成一个完全独立的、高可用的身份服务(Identity Provider)。届时,kong-legacy-auth插件的使命便告一段落,可以被标准的OIDC或JWT插件所取代。但在此之前,作为一个务实的、高性价比的过渡性架构,它为团队争取了宝贵的重构时间窗口,并保障了新业务的快速迭代。