Зачем вообще использовать 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"]
Проверка интеграции:
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'ом по мере необходимости
минимум доступных секретов в поде
Спасибо всем, кто добрался до этого места! Статья получилась больше похожей на какой-то поток сознания, но для первого раза сойдёт. Надеюсь, этот опыт кому-то пригодится.