Построение zero-trust сети микросервисов на Kubernetes: политики, mTLS и identity без Service Mesh
Введение: почему zero-trust стал необходимостью в 2026 году
Модель безопасности zero-trust строится на принципе «не доверяй никому по умолчанию» — ни внешним запросам, ни внутренним сервисам в кластере. В 2026 году эта философия перестала быть архитектурной экзотикой и стала стандартом де-факто для production-окружений на Kubernetes. Причины просты: горизонтальное масштабирование микросервисов увеличивает поверхность атаки, а компрометация одного пода без должной изоляции открывает путь ко всей инфраструктуре.
Многие команды сразу тянутся к Istio или Linkerd, считая, что zero-trust невозможен без полноценного Service Mesh. Это заблуждение. Kubernetes предоставляет достаточно примитивов — NetworkPolicy, RBAC, ServiceAccount — а открытый стандарт SPIFFE и его реализация SPIRE закрывают задачу identity без дополнительного data plane. Взаимная TLS-аутентификация реализуется прямо в коде Go-сервиса. В этой статье разберём каждый из этих слоёв детально, с примерами YAML и Go-кода.
Проблемы традиционной периметровой безопасности в Kubernetes
Классический подход «защищённый периметр» предполагает, что всё внутри сети доверенно. В Kubernetes это означает, что любой под в кластере по умолчанию может обращаться к любому другому поду через Service DNS. Никаких ограничений нет, если не настроено иное.
Последствия очевидны:
- Скомпрометированный под
payment-serviceможет напрямую обратиться кuser-dbбез каких-либо проверок. - Отсутствие mutual TLS означает, что сервис не может быть уверен, с кем именно он общается — подлинный сервис или атакующий посередине.
- Горизонтальное движение (lateral movement) после взлома одного компонента становится тривиальным.
Zero-trust решает эти проблемы через три ортогональных механизма: сетевую изоляцию, криптографическую идентификацию и аудит каждого взаимодействия.
NetworkPolicy: изоляция трафика на уровне namespace и pod
NetworkPolicy — это Kubernetes-ресурс, который описывает правила входящего (ingress) и исходящего (egress) трафика для набора подов. Важно понимать: NetworkPolicy работает только если в кластере установлен CNI-плагин с поддержкой политик (Calico, Cilium, Weave Net). Flannel в базовой конфигурации NetworkPolicy не применяет.
Стратегия «deny-all by default» — первый шаг к zero-trust. Создаём политику, которая запрещает весь входящий трафик для всех подов в namespace payments:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: payments
spec:
podSelector: {}
policyTypes:
- Ingress
Затем явно разрешаем только нужные пути. Например, order-service из namespace orders должен обращаться к payment-service в namespace payments:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-order-to-payment
namespace: payments
spec:
podSelector:
matchLabels:
app: payment-service
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: orders
podSelector:
matchLabels:
app: order-service
ports:
- protocol: TCP
port: 8443
Обратите внимание: мы используем порт 8443 — это уже намёк на TLS. NetworkPolicy не проверяет содержимое трафика, она работает на L3/L4. Для L7-контроля нужен либо CNI с поддержкой L7 (Cilium с Hubble), либо application-layer проверка через mTLS.
Рекомендации по организации NetworkPolicy в production:
- Создавайте
default-denyдля ingress и egress в каждом namespace. - Явно разрешайте обращения к DNS (
kube-dns, порт 53 UDP/TCP). - Используйте отдельные labels для разных ролей подов (
role: api,role: worker). - Версионируйте NetworkPolicy в Git как часть IaC.
SPIFFE/SPIRE: выдача и ротация идентификаторов сервисов
SPIFFE (Secure Production Identity Framework for Everyone) — открытый стандарт, который определяет формат криптографических идентификаторов для рабочих нагрузок. Идентификатор имеет вид URI: spiffe://trust-domain/path. Например: spiffe://cluster.local/ns/payments/sa/payment-service.
SPIRE — референсная реализация SPIFFE, состоящая из двух компонентов:
- SPIRE Server — центральный CA, который выдаёт SVID (SPIFFE Verifiable Identity Document) в форме x.509-сертификатов.
- SPIRE Agent — DaemonSet на каждой ноде, аттестует поды через Kubernetes API и доставляет SVID через Unix-сокет.
Развёртывание SPIRE в кластере начинается с установки CRD и деплоймента Server. Минимальный ConfigMap для SPIRE Server:
apiVersion: v1
kind: ConfigMap
metadata:
name: spire-server
namespace: spire
data:
server.conf: |
server {
bind_address = "0.0.0.0"
bind_port = "8081"
trust_domain = "cluster.local"
data_dir = "/run/spire/server/data"
log_level = "INFO"
ca_ttl = "24h"
default_x509_svid_ttl = "1h"
}
plugins {
DataStore "sql" {
plugin_data {
database_type = "sqlite3"
connection_string = "/run/spire/server/data/datastore.sqlite3"
}
}
NodeAttestor "k8s_psat" {
plugin_data {
clusters = {
"my-cluster" = {
service_account_allow_list = ["spire:spire-agent"]
}
}
}
}
KeyManager "disk" {
plugin_data {
keys_path = "/run/spire/server/data/keys.json"
}
}
}
SPIRE Agent регистрируется на ноде через Projected Service Account Token (PSAT) — механизм аттестации, встроенный в Kubernetes. После успешной аттестации агент получает доступ к Workload API и раздаёт SVID подам через Unix-сокет /run/spire/sockets/agent.sock.
Регистрация рабочей нагрузки (workload registration entry) выполняется командой:
kubectl exec -n spire spire-server-0 -- \
spire-server entry create \
-spiffeID spiffe://cluster.local/ns/payments/sa/payment-service \
-parentID spiffe://cluster.local/spire/agent/k8s_psat/my-cluster/NODE_UID \
-selector k8s:ns:payments \
-selector k8s:sa:payment-service
SVID автоматически ротируются за 5 минут до истечения TTL. Сервис получает новый сертификат без перезапуска — это критично для long-running Go-сервисов.
mTLS между Go-сервисами: реализация без Service Mesh
Получив SVID от SPIRE Agent через Workload API, Go-сервис использует его для установки взаимной TLS-аутентификации с другими сервисами. Библиотека github.com/spiffe/go-spiffe/v2 абстрагирует работу с Workload API и предоставляет готовые tls.Config.
Пример Go-кода для сервера с mTLS через SPIFFE:
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/spiffetls"
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
func main() {
ctx := context.Background()
// Подключаемся к SPIRE Agent через Workload API
client, err := workloadapi.New(ctx,
workloadapi.WithAddr("unix:///run/spire/sockets/agent.sock"),
)
if err != nil {
log.Fatalf("unable to create workload API client: %v", err)
}
defer client.Close()
// Источник X.509 SVID с автоматической ротацией
source, err := workloadapi.NewX509Source(ctx,
workloadapi.WithClient(client),
)
if err != nil {
log.Fatalf("unable to create X509Source: %v", err)
}
defer source.Close()
// Разрешаем только order-service из нашего trust domain
allowedID := spiffeid.RequireFromString(
"spiffe://cluster.local/ns/orders/sa/order-service",
)
tlsCfg := tlsconfig.MTLSServerConfig(
source,
source,
tlsconfig.AuthorizeID(allowedID),
)
server := &http.Server{
Addr: ":8443",
TLSConfig: tlsCfg,
Handler: http.HandlerFunc(paymentHandler),
}
log.Println("payment-service listening on :8443 with mTLS")
if err := server.ListenAndServeTLS("", ""); err != nil {
log.Fatalf("server error: %v", err)
}
}
func paymentHandler(w http.ResponseWriter, r *http.Request) {
// SPIFFE ID клиента доступен через TLS peer certificates
if len(r.TLS.PeerCertificates) > 0 {
log.Printf("authenticated caller SPIFFE ID: %s",
r.TLS.PeerCertificates[0].URIs[0])
}
fmt.Fprintln(w, `{"status": "ok"}`)
}
Клиентский код для order-service, обращающегося к payment-service:
package main
import (
"context"
"io"
"log"
"net/http"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/spiffetls/tlsconfig"
"github.com/spiffe/go-spiffe/v2/workloadapi"
)
func main() {
ctx := context.Background()
source, err := workloadapi.NewX509Source(ctx,
workloadapi.WithClientOptions(
workloadapi.WithAddr("unix:///run/spire/sockets/agent.sock"),
),
)
if err != nil {
log.Fatalf("X509Source error: %v", err)
}
defer source.Close()
serverID := spiffeid.RequireFromString(
"spiffe://cluster.local/ns/payments/sa/payment-service",
)
tlsCfg := tlsconfig.MTLSClientConfig(
source,
source,
tlsconfig.AuthorizeID(serverID),
)
client := &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsCfg},
}
resp, err := client.Get("https://payment-service.payments.svc.cluster.local:8443/charge")
if err != nil {
log.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
log.Printf("response: %s", body)
}
Ключевые моменты реализации:
- Функции
tlsconfig.AuthorizeIDпроверяют SPIFFE ID собеседника — если ID не совпадает с ожидаемым, TLS handshake завершится ошибкой. X509Sourceавтоматически обновляет сертификаты в фоне через watch на Workload API.- Сокет SPIRE Agent монтируется в под через
hostPathvolume или CSI driver.
Volume mount для пода:
volumes:
- name: spire-agent-socket
hostPath:
path: /run/spire/sockets
type: Directory
containers:
- name: payment-service
image: payment-service:latest
volumeMounts:
- name: spire-agent-socket
mountPath: /run/spire/sockets
readOnly: true
Аудит и логирование событий безопасности
Zero-trust без аудита — это половина работы. Необходимо фиксировать каждое значимое событие: кто обратился к кому, когда, с каким результатом.
На уровне Kubernetes включите Audit Policy для API Server. Пример политики, которая логирует изменения NetworkPolicy и секретов:
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
- level: RequestResponse
resources:
- group: "networking.k8s.io"
resources: ["networkpolicies"]
- level: Metadata
resources:
- group: ""
resources: ["secrets", "serviceaccounts"]
- level: None
users: ["system:nodes"]
verbs: ["get", "list", "watch"]
На уровне приложения каждый Go-сервис должен логировать SPIFFE ID входящих соединений. Используйте структурированное логирование (slog или zap) и отправляйте логи в централизованное хранилище (Loki, Elasticsearch). Пример middleware для HTTP-сервера:
func auditMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callerID := "unknown"
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
for _, uri := range r.TLS.PeerCertificates[0].URIs {
callerID = uri.String()
break
}
}
log.Printf(`{"event":"request","caller":"%s","path":"%s","method":"%s"}`,
callerID, r.URL.Path, r.Method)
next.ServeHTTP(w, r)
})
}
Дополнительно рассмотрите интеграцию с Falco — runtime security tool, который умеет обнаруживать аномальные syscall-паттерны и сетевые соединения прямо в подах.
Практический пример: защищённая коммуникация трёх микросервисов
Рассмотрим сценарий: api-gateway (namespace gateway) → order-service (namespace orders) → payment-service (namespace payments). Каждый hop должен быть защищён mTLS с проверкой SPIFFE ID.
Архитектура идентификаторов:
spiffe://cluster.local/ns/gateway/sa/api-gatewayspiffe://cluster.local/ns/orders/sa/order-servicespiffe://cluster.local/ns/payments/sa/payment-service
NetworkPolicy для order-service разрешает входящий трафик только от api-gateway:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-gateway-to-orders
namespace: orders
spec:
podSelector:
matchLabels:
app: order-service
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: gateway
podSelector:
matchLabels:
app: api-gateway
ports:
- protocol: TCP
port: 8443
policyTypes:
- Ingress
На уровне приложения order-service дополнительно проверяет, что SPIFFE ID клиента — именно api-gateway. Даже если NetworkPolicy обойдена (например, через compromised ноду), mTLS с проверкой identity не позволит неавторизованному сервису установить соединение.
Цепочка защиты выглядит так:
- NetworkPolicy блокирует нежелательный L3/L4 трафик ещё до того, как пакет достигнет пода.
- TLS handshake с проверкой сертификата обеспечивает шифрование и аутентификацию на L4.
- Проверка SPIFFE ID в Go-коде гарантирует авторизацию на уровне приложения.
- Аудит-лог фиксирует каждое успешное и неуспешное взаимодействие.
Такая многоуровневая защита реализует принцип defense-in-depth без единой точки отказа.
Выводы
Zero-trust безопасность в Kubernetes — это не продукт, который можно «установить и забыть». Это архитектурный подход, реализуемый через комбинацию механизмов:
- NetworkPolicy обеспечивает сетевую изоляцию на L3/L4 без изменения кода сервисов.
- SPIFFE/SPIRE дают каждому сервису криптографически верифицированный идентификатор с автоматической ротацией.
- mTLS в Go с проверкой SPIFFE ID закрывает авторизацию на уровне приложения.
- Аудит и логирование обеспечивают видимость и возможность forensics.
Описанный стек не требует Istio или Linkerd и хорошо вписывается в существующие Kubernetes-кластеры. Отсутствие Service Mesh означает меньше операционной сложности, меньше компонентов, которые могут упасть, и прямой контроль над каждым аспектом безопасности. Для команд, которые хотят полного понимания своей security-модели, это часто лучший выбор, чем непрозрачный sidecar-proxy.
Начните с малого: введите default-deny NetworkPolicy в одном namespace, задеплойте SPIRE, добавьте mTLS в один сервис на Go. Этот инкрементальный путь позволит команде накопить экспертизу, не рискуя стабильностью production.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →