Дайджесты новостей
Объемная визуализация взаимной аутентификации микросервисов в Kubernetes через audience-bound токен ServiceAccount и TokenReview API.

Аутентификация микросервисов через identity в Kubernetes: Service Accounts и TokenReview API

Внутри любого кластера Kubernetes живет опасная иллюзия безопасности: раз сервисы запущены в единой виртуальной сети за общим внешним шлюзом (Ingress), значит, вокруг свои. Однако сетевая модель Kubernetes по умолчанию устроена по принципу открытого офиса — любой под может отправить TCP-пакет на IP-адрес соседа. Если вспомогательный сервис скомпрометирован через уязвимость в зависимостях, злоумышленник получает доступ ко всем внутренним портам. Базовые правила NetworkPolicy изолируют сетевые маршруты на уровне L3/L4, но ничего не знают о контексте приложения: внутреннему сервису базы данных или хранилищу data-store необходимо железобетонно знать, кто именно к нему обращается.

Обычно команды выбирают между двумя крайностями. Первая — сложить статические токены в Kubernetes Secret и передать сервисам через переменные окружения. Это быстро превращается в проблему: секреты не ротируются годами, а утечка одного ключа требует ручной замены настроек по всему кластеру. Вторая крайность — развернуть выделенный Identity Provider вроде Keycloak или внедрить Service Mesh (Istio или Linkerd) с взаимным mTLS. Но поднимать тяжелый сервер авторизации с отдельной СУБД ради закрытого контура избыточно, а подсаживать sidecar-прокси к каждому микросервису означает отдавать ощутимую долю памяти и CPU на инфраструктурный оверхед.

В самом ядре Kubernetes уже заложен нативный и легковесный механизм взаимного удостоверения сервисов: связка сервисных аккаунтов (ServiceAccount), проецируемых токенов ограниченной аудитории и системного интерфейса TokenReview API.

Как Kubernetes превращается в доверенный удостоверяющий центр

Каждый под в кластере выполняется от имени учетной записи ServiceAccount. Это цифровая машинная идентичность приложения. В ранних версиях платформы к аккаунту генерировался бессрочный статический токен в формате секрета: если такой токен утекал, злоумышленник мог бесконечно обращаться от имени пода к любым API.

Ситуация изменилась с появлением проекции токенов — Service Account Token Volume Projection (TokenRequest API). Kubelet на рабочей ноде самостоятельно запрашивает у API криптографически подписанный JWT и монтирует его прямо в файловую систему пода. Этот токен обладает тремя ключевыми свойствами:

  1. Ограниченный срок жизни (expirationSeconds): токен регулярно обновляется на диске kubelet'ом без перезапуска контейнера.
  2. Привязка к экземпляру пода: при удалении пода выданный ему токен аннулируется.
  3. Ограничение целевой аудитории (audience-bound): токен содержит поле aud, определяющее, для кого именно он выпущен.

Представьте служебный пропуск. Обычный статический токен похож на универсальный мастер-ключ от всех дверей здания без срока давности. Спроецированный токен с привязкой к аудитории — это временный талон, на котором напечатано: «Действителен 60 минут, разрешает вход исключительно в хранилище data-store». Если злоумышленник перехватит такой талон и попытается предъявить его на центральном сервере Kubernetes API или в сервисе биллинга, проверка провалится: аудитория не совпадет.

Проекция целевого токена в файловую систему вызывающего пода

Чтобы микросервис api-service мог обращаться к хранилищу data-store, ему не нужны сторонние генераторы JWT. Достаточно описать в манифесте Deployment монтирование проецируемого тома с целевым сервисом в поле audience.

Манифест вызывающего пода настраивает автоматическую проекцию токена с часовым временем жизни:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-service
  namespace: frontend
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api-service
  template:
    metadata:
      labels:
        app: api-service
    spec:
      serviceAccountName: api-sa
      containers:
        - name: app
          image: internal/api:v1.0
          volumeMounts:
            - name: bound-token
              mountPath: /var/run/secrets/tokens
              readOnly: true
      volumes:
        - name: bound-token
          projected:
            sources:
              - serviceAccountToken:
                  path: data-store-token
                  expirationSeconds: 3600
                  audience: data-store

Внутри контейнера по пути /var/run/secrets/tokens/data-store-token появляется текстовый файл с актуальным JWT. Вызывающий сервис читает его при формировании исходящего HTTP-запроса и добавляет в заголовок Authorization: Bearer <token>.

Выдача мандата на проверку: роль system:auth-delegator

Запрос с токеном прибывает в принимающий сервис data-store. Сервису необходимо проверить подлинность подписи, срок действия и принадлежность токена аккаунту api-service.

Сервис обращается к системному валидатору — TokenReview API на сервере Kubernetes (/apis/authentication.k8s.io/v1/tokenreviews). В модели безопасности Kubernetes поды не имеют права проверять чужие токены без явного разрешения. Чтобы сервис получил право делегированной проверки, его сервисному аккаунту назначается встроенная роль system:auth-delegator.

Для этого объявляется манифест ClusterRoleBinding:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: datastore-tokenreview-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:auth-delegator
subjects:
  - kind: ServiceAccount
    name: datastore-sa
    namespace: backend

Сервис data-store под аккаунтом datastore-sa получает право отправлять запросы в TokenReview API.

Проверка токена на стороне сервиса: TokenReview на Go

Принимающий сервис перехватывает заголовок Authorization, формирует структуру TokenReview и отправляет ее в kube-apiserver.

Схема валидации токена в Go-сервисе через отправку структуры TokenReview в kube-apiserver и авторизацию идентичности сервисного аккаунта.

Сервер API проверяет криптографическую подпись своим открытым ключом, проверяет аудиторию (data-store) и возвращает регистрационные данные: имя аккаунта, пространство имен (namespace) и UID.

Практическая реализация валидатора на языке Go с использованием client-go:

package main

import (
    "context"
    "fmt"
    "net/http"
    "strings"

    authenticationv1 "k8s.io/api/authentication/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
)

type TokenValidator struct {
    clientset *kubernetes.Clientset
    audience  string
}

func NewTokenValidator(audience string) (*TokenValidator, error) {
    config, err := rest.InClusterConfig()
    if err != nil {
        return nil, fmt.Errorf("ошибка получения внутрикластерного конфига: %w", err)
    }
    clientset, err := kubernetes.NewForConfig(config)
    if err != nil {
        return nil, fmt.Errorf("ошибка инициализации clientset: %w", err)
    }
    return &TokenValidator{clientset: clientset, audience: audience}, nil
}

func (tv *TokenValidator) ValidateToken(ctx context.Context, token string) (*authenticationv1.UserInfo, error) {
    review := &authenticationv1.TokenReview{
        Spec: authenticationv1.TokenReviewSpec{
            Token:     token,
            Audiences: []string{tv.audience},
        },
    }

    result, err := tv.clientset.AuthenticationV1().TokenReviews().Create(ctx, review, metav1.CreateOptions{})
    if err != nil {
        return nil, fmt.Errorf("сбой обращения к TokenReview API: %w", err)
    }

    if !result.Status.Authenticated {
        return nil, fmt.Errorf("токен отклонен кластером: %s", result.Status.Error)
    }

    return &result.Status.User, nil
}

func authMiddleware(tv *TokenValidator, allowedIdentity string, next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        token := strings.TrimPrefix(authHeader, "Bearer ")
        if token == "" || token == authHeader {
            http.Error(w, "отсутствует bearer-токен", http.StatusUnauthorized)
            return
        }

        user, err := tv.ValidateToken(r.Context(), token)
        if err != nil {
            http.Error(w, "ошибка авторизации: "+err.Error(), http.StatusForbidden)
            return
        }

        // Проверяем машинную идентичность формата system:serviceaccount:<ns>:<name>
        if user.Username != allowedIdentity {
            http.Error(w, "доступ для данного аккаунта запрещен", http.StatusForbidden)
            return
        }

        next(w, r)
    }
}

Middleware проверяет точное совпадение user.Username с доверенной строкой вида system:serviceaccount:frontend:api-sa. Запрос от любого другого пода отсекается с кодом 403 Forbidden.

Накладные расходы, локальное кэширование и транспортная безопасность

Синхронный вызов к kube-apiserver на каждый входящий запрос добавляет сетевую задержку от 2 до 10 мс. При потоке в тысячи RPS это перегрузит мастер-ноды кластера.

Решение задачи — локальное кэширование проверенных токенов в памяти (in-memory LRU cache). Поскольку токены действительны от 10 до 60 минут, сервис может кэшировать результат проверки на 1–5 минут. Повторные запросы валидируются мгновенно, а пятиминутное окно инвалидации безопасно для большинства внутренних сервисов.

Второй фактор — защита сетевого канала. TokenReview API обеспечивает строгую аутентификацию на прикладном уровне L7, но не шифрует сетевой трафик. Чтобы предотвратить перехват пакетов в оверлейной сети, взаимодействие должно идти поверх внутрикластерного HTTPS или шифроваться средствами CNI (WireGuard в Cilium).

Итоговый вердикт: когда использовать нативную аутентификацию

Паттерн на базе Service Accounts и TokenReview API закрывает потребности микросервисов в пределах одного кластера без развертывания сторонних платформ.

Вам не нужен Keycloak, если задача сводится к разграничению прав внутренних сервисов. Вам не нужен тяжелый Service Mesh, если от него требуется только проверка идентичности вызывающей стороны. Ядро Kubernetes предоставляет готовый, проверенный временем механизм аутентификации из коробки.