Зачем вообще использовать gMSA в контейнерах?

Group Managed Service Accounts (gMSA) решает проблему хранения и обновления сервисных паролей: пароль хранится только в AD и регулярно обновляется автоматически. Использование gMSA позволяет не менять уже настроенные ACL и роли на файловых шарах и SQL-серверах - приложения продолжают работать с прежними правами через корпоративные Kerberos/SPN-механизмы. Такая интеграция обеспечивает прозрачный и контролируемый переход классических приложений в контейнерную инфраструктуру Kubernetes.

Посмотрим как это работает на примере простого кроссплатформенного dotnet-приложения.

Верхнеуровнево модель выглядит следующим образом:

  • Active Directory - хранит gMSA-аккаунты, генерирует/обновляет пароли

  • Kubernetes Secret - содержит учётные данные AD для domainless-режима

  • Init container - с помощью утилиты от AWS credentials-fetcher получает на старте билеты Kerberos для gMSA и копирует их в shared volume

  • .NET-контейнер - использует полученный билет для SMB/MSSQL-доступа через переменную окружения KRB5CCNAME

  • Kubernetes RBAC - выдает права init-контейнеру только на чтение необходимых Secret и ConfigMap

Теперь перейдём к конкретике. Нам понадобится gMSA (в моём примере это WebApp01), настройку домена для использования gMSA пропустим - считаем, что они уже есть и используются. С помощью PS-модуля CredentialSpec получаем информацию для заполнения манифеста:

apiVersion: v1
kind: ConfigMap
metadata:
  name: gmsa-credspec
data:
  credspec.json: |
    {
      "CmsPlugins": ["ActiveDirectory"],
      "DomainJoinConfig": {
        "Sid": "S-1-5-21-4217655605-3681839426-3493040985",
        "MachineAccountName": "WebApp01",
        "Guid": "af602f85-d754-4eea-9fa8-fd76810485f1",
        "DnsTreeName": "contoso.com",
        "DnsName": "contoso.com",
        "NetBiosName": "contoso"
      },
      "ActiveDirectoryConfig": {
        "GroupManagedServiceAccounts": [
          { "Name": "WebApp01", "Scope": "contoso.com" }
        ]
      }
    }
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: krb5-config
data:
  krb5.conf: |
    [libdefaults]
      dns_lookup_realm = false
      ticket_lifetime = 24h
      renew_lifetime  = 7d
      forwardable     = true
      default_realm   = CONTOSO.COM
    [realms]
      CONTOSO.COM = {
        kdc = dc1.contoso.com
        admin_server = dc1.contoso.com
      }
    [domain_realm]
      .contoso.com = CONTOSO.COM

Подробнее про настройку gMSA и PS-модуль можно почитать тут.
Также нам понадобится доменная учётка (в примере это admin), состоящая в security-группе gMSA. От её имени мы и будем в конечном итоге получать билеты:

apiVersion: v1
kind: Secret
metadata:
  name: ad-credentials
type: Opaque
data:
  username: YWRtaW5pc3RyYXRvcg==    # admin (base64)
  password: UGFzc3cwcmQxMjM=        # Passw0rd123 (base64)
  domain: Y29udG9zby5jb20=          # contoso.com (base64)

В итоговом пайплайне мы опишем наш контейнер с тестовым dotnet-приложением, а также init-контейнер, который будет запускаться до старта основного контейнера и получать golden ticket:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: gmsa-dotnet-app
  labels:
    app: gmsa-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: gmsa-demo
  template:
    metadata:
      labels:
        app: gmsa-demo
    spec:
      serviceAccountName: credentials-fetcher-sa
      initContainers:
      - name: credentials-fetcher-init
        image: ghcr.io/aws/credentials-fetcher:latest
        securityContext:
          runAsUser: 0
          capabilities:
            add: [ NET_ADMIN ]
        env:
        - name: CF_CRED_SPEC_FILE
          value: "/var/credentials-fetcher/credspec.json"
        - name: DOMAIN_USER
          valueFrom: { secretKeyRef: { name: ad-credentials, key: username } }
        - name: DOMAIN_PASSWORD
          valueFrom: { secretKeyRef: { name: ad-credentials, key: password } }
        - name: DOMAIN_NAME
          valueFrom: { secretKeyRef: { name: ad-credentials, key: domain } }
        volumeMounts:
        - name: credspec-volume
          mountPath: /var/credentials-fetcher
        - name: kerberos-tickets
          mountPath: /krb5
        command: ["/bin/bash", "-c"]
        args:
        - |
          apt-get update && apt-get install -y grpc-cli
          mkdir -p /var/credentials-fetcher/krbdir /var/credentials-fetcher/socket
          /usr/bin/credentials-fetcherd --domainless-mode --credspec-file /var/credentials-fetcher/credspec.json &
          sleep 15
          grpc_cli call unix:/var/credentials-fetcher/socket/credentials_fetcher.sock AddNonDomainJoinedKerberosLease \
            "credspec_contents: '$(cat /var/credentials-fetcher/credspec.json)', username: '$DOMAIN_USER', password: '$DOMAIN_PASSWORD', domain: '$DOMAIN_NAME'"
          TICKET_PATH=$(find /var/credentials-fetcher/krbdir -name "krb5cc" -type f | head -1)
          if [ -n "$TICKET_PATH" ]; then
            cp "$TICKET_PATH" /krb5/krb5cc
            echo "export KRB5CCNAME=FILE:/krb5/krb5cc" > /krb5/krb_env
            chmod 644 /krb5/krb5cc
          fi
      containers:
      - name: dotnet-app
        image: my-dotnet-app:latest
        env:
        - name: KRB5CCNAME
          value: "FILE:/krb5/krb5cc"
        - name: ConnectionStrings__DefaultConnection
          value: "Server=sqlserver.contoso.com;Database=MyApp;Integrated Security=true;TrustServerCertificate=true;"
        - name: FileShare__Path
          value: "//fileserver.contoso.com/shared"
        volumeMounts:
        - name: kerberos-tickets
          mountPath: /krb5
          readOnly: true
        - name: krb5-config
          mountPath: /etc/krb5.conf
          subPath: krb5.conf
        command: ["/bin/bash", "-c"]
        args:
        - |
          if [ -f /krb5/krb_env ]; then source /krb5/krb_env; fi
          klist -c $KRB5CCNAME || echo "No valid ticket"
          exec dotnet MyApp.dll
  volumes:
  - name: credspec-volume
    configMap:
      name: gmsa-credspec
  - name: kerberos-tickets
    emptyDir: {}
  - name: krb5-config
    configMap:
      name: krb5-config

Для теста используем простейшее dotnet(8)-приложение (Program.cs) с поддержкой Kerberos:

using Microsoft.AspNetCore.Authentication.Negotiate;
using Microsoft.Data.SqlClient;
using System.Diagnostics;

var builder = WebApplication.CreateBuilder(args);
// --- Authentication ---
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
    .AddNegotiate();
builder.Services.AddAuthorization(options => { options.FallbackPolicy = options.DefaultPolicy; });
builder.Services.AddControllers();
builder.Services.AddHealthChecks().AddCheck<KerberosHealthCheck>("kerberos");
// --- App ---
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health");
app.MapGet("/sql/test", async (HttpContext context) =>
{
    var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
    using var connection = new SqlConnection(connectionString);
    await connection.OpenAsync();
    using var cmd = new SqlCommand("SELECT @@VERSION, SYSTEM_USER, SUSER_NAME()", connection);
    using var reader = await cmd.ExecuteReaderAsync();
    var values = new List<string>();
    while (await reader.ReadAsync()) values.Add(reader[^0].ToString());
    return Results.Ok(values);
});
// ... остальной код
app.Run();
public class KerberosHealthCheck : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext ctx, CancellationToken ct = default)
    {
        var cache = Environment.GetEnvironmentVariable("KRB5CCNAME");
        if (string.IsNullOrEmpty(cache)) return Task.FromResult(HealthCheckResult.Unhealthy("KRB5CCNAME is undefined"));
        var proc = new Process { StartInfo = new ProcessStartInfo("klist", $"-c {cache.Replace("FILE:", "")}") { RedirectStandardOutput = true } };
        proc.Start();
        proc.WaitForExit();
        return Task.FromResult(proc.ExitCode == 0 ? HealthCheckResult.Healthy() : HealthCheckResult.Unhealthy("No ticket"));
    }
}

Ну и docker-файл, куда ж без него:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 80
RUN apt-get update && apt-get install -y krb5-user libkrb5-dev smbclient cifs-utils && rm -rf /var/lib/apt/lists/*
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApp.csproj", "."]
RUN dotnet restore "./MyApp.csproj"
COPY . .
RUN dotnet build "MyApp.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "MyApp.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN useradd -m -u 1001 appuser && mkdir -p /krb5 && chown appuser:appuser /krb5
USER appuser
ENTRYPOINT ["dotnet", "MyApp.dll"]

Проверка интеграции:

Листинг файловых шар можно реализовать аналогично через вызов smbclient из кода .NET

Важные аспекты безопасности:

  • init-контейнер работает от root только на этапе инициализации билета — основной .NET контейнер работает под обычным пользователем

  • Kerberos билет живет только в временном in-memory-томе и автоматически обновляется credentials-fetcher'ом по мере необходимости

  • минимум доступных секретов в поде

Спасибо всем, кто добрался до этого места! Статья получилась больше похожей на какой-то поток сознания, но для первого раза сойдёт. Надеюсь, этот опыт кому-то пригодится.