Зачем вообще использовать 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-доступа через переменную окружения
KRB5CCNAMEKubernetes 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"]
Проверка интеграции:
curl http://gmsa-dotnet-app/health - жив ли Kerberos билет
curl http://gmsa-dotnet-app/sql/test - подключение к SQL Server с помощью gMSA
Листинг файловых шар можно реализовать аналогично через вызов smbclient из кода .NET
Важные аспекты безопасности:
init-контейнер работает от root только на этапе инициализации билета — основной .NET контейнер работает под обычным пользователем
Kerberos билет живет только в временном in-memory-томе и автоматически обновляется credentials-fetcher'ом по мере необходимости
минимум доступных секретов в поде
Спасибо всем, кто добрался до этого места! Статья получилась больше похожей на какой-то поток сознания, но для первого раза сойдёт. Надеюсь, этот опыт кому-то пригодится.
