两层,两种职责
Ingress 只回答一个问题:这个 HTTP 请求应该交给哪个内部服务。它按 host 和 path 匹配规则、终止 TLS 并转发流量,但不关心调用方是谁、购买了什么套餐、契约允许多少请求。
API 网关回答的是另一个问题:这个消费者能否调用该 API、速率多少、契约是什么。它负责认证、配额、公私 schema 转换,并按消费者输出分析数据。例如移动端调用 /v1/orders,该路由背后可能有三项内部服务。
重叠之处
两者都会终止 TLS、按路径路由,也可能重试。这种重叠容易让人把它们合并成一个组件,小型平台往往也确实应该合并。风险出现在 Ingress 开始承载业务策略时:消费者等级、API key、按租户的配额变成了平台团队的集群配置,而平台团队并不了解产品。
归属才是真正的边界
Ingress 属于基础设施,随集群一起版本化,由平台或网络团队维护。API 网关更贴近产品:路由、套餐和契约随 API 变化,通常每周都会调整。即使同一个团队同时负责两者,也应保持职责分离,因为变更频率和影响范围完全不同。
如何选择
只需要把 HTTP 流量路由进集群并终止 TLS 时,用 Ingress 即可。当外部消费者需要身份、配额、契约版本或用量分析时,再引入 API 网关。与只部署网关相比,这种分层让集群网络保持稳定,而产品策略可以快速演进;成熟平台通常两者并用,网关在前,Ingress 在后。
代码示例
import hashlib
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional
class IdempotencyConflictError(ValueError):
"""Raised when a key is reused with a different payload."""
@dataclass(frozen=True)
class Entry:
payload_hash: str
response: Any
stored_at: float
def payload_fingerprint(payload: str) -> str:
"""Return a stable fingerprint for a request payload."""
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
class IdempotencyStore:
"""Replay recorded responses for duplicate keys; reject payload conflicts."""
def __init__(self, ttl_s: float = 3600.0, clock: Any = time.monotonic) -> None:
if ttl_s <= 0:
raise ValueError("ttl_s must be positive")
self._ttl_s = ttl_s
self._clock = clock
self._entries: Dict[str, Entry] = {}
def execute(self, key: str, payload: str, produce: Any) -> Any:
"""Return the recorded response, or run produce and record it."""
if not key:
raise ValueError("idempotency key must be non-empty")
fingerprint = payload_fingerprint(payload)
existing = self._get_live(key)
if existing is not None:
if existing.payload_hash != fingerprint:
raise IdempotencyConflictError(
f"key {key!r} was already used with a different payload"
)
return existing.response
response = produce()
self._entries[key] = Entry(
payload_hash=fingerprint,
response=response,
stored_at=self._clock(),
)
return response
def _get_live(self, key: str) -> Optional[Entry]:
entry = self._entries.get(key)
if entry is None:
return None
if self._clock() - entry.stored_at > self._ttl_s:
del self._entries[key]
return None
return entry
use std::collections::HashMap;
use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize)]
struct Item {
id: u32,
name: String,
price_cents: u32,
}
#[derive(Debug, Deserialize)]
struct ListQuery {
limit: Option<usize>,
offset: Option<usize>,
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
#[derive(Debug)]
enum ApiError {
NotFound(u32),
BadRequest(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::NotFound(id) => (StatusCode::NOT_FOUND, format!("item {id} not found")),
ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
};
(status, Json(ErrorBody { error: message })).into_response()
}
}
type AppState = Arc<HashMap<u32, Item>>;
async fn get_item(
State(items): State<AppState>,
Path(id): Path<u32>,
) -> Result<Json<Item>, ApiError> {
items
.get(&id)
.cloned()
.map(Json)
.ok_or(ApiError::NotFound(id))
}
async fn list_items(
State(items): State<AppState>,
Query(query): Query<ListQuery>,
) -> Result<Json<Vec<Item>>, ApiError> {
let limit = query.limit.unwrap_or(10);
if !(1..=50).contains(&limit) {
return Err(ApiError::BadRequest("limit must be between 1 and 50".to_string()));
}
let offset = query.offset.unwrap_or(0);
let mut all: Vec<Item> = items.values().cloned().collect();
all.sort_by_key(|item| item.id);
Ok(Json(all.into_iter().skip(offset).take(limit).collect()))
}
#[tokio::main]
async fn main() {
let mut catalog = HashMap::new();
catalog.insert(1, Item { id: 1, name: "Keyboard".to_string(), price_cents: 4_900 });
catalog.insert(2, Item { id: 2, name: "Mouse".to_string(), price_cents: 2_500 });
let app = Router::new()
.route("/items", get(list_items))
.route("/items/{id}", get(get_item))
.with_state(Arc::new(catalog));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
println!("listening on http://{}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}