클라우드 내 인프라를 On-Demand로 제어하면서도 복잡하지 않게 만들고 싶은 어려운 결정 때문에 여러 도구를 PoC 해보다가 어느 정도 윤곽을 잡는데 도움을 주는 조합을 찾게 되어서 간단한 사용기를 공유해봅니다. 바로 .NET Aspire 이야기인데요,
프로토타이핑해보고 있는 프로젝트 구성은 이렇습니다.
- AppHost: .NET Aspire의 뼈대가 되는 프로젝트. IDE에서는 시작 프로젝트로 활용하는 부분. 캐싱 서버로는 MS에서 런칭한 Redis 호환 구현체이자 순수 .NET으로 동작하는 Garnet을, DB는 PostgreSQL 컨테이너를 사용하도록 설정했습니다.
- Data: EF Core 모델과 컨텍스트만 담되, 특정 DB에 관련된 설정은 여기에 포함시키지 않았습니다.
- ServiceDefaults: Aspire 프로젝트를 만들면 자동으로 따라붙는 클래스 라이브러리 프로젝트로, 앱 호스트 타입의 닷넷 프로젝트 (ASP.NET 포함)에 서비스 디스커버리 관련 초기화 코드가 각 애플리케이션마다 반복해서 쓰이므로 이런 내용을 담는 확장 메서드 AddServiceDefaults, MapDefaultEndpoints를 참조하기 위해 이 프로젝트를 참조로 넣습니다.
- ServiceNames: 클래스 라이브러리가 아닌 공유 프로젝트로, AppHost에서는 컨테이너/서비스의 이름을 지정하고, 각 애플리케이션에서는 여기에 선언된 문자열 상수 필드를 써서 이름을 참조하게 합니다. 이렇게 하면 앱 호스트부터 닷넷 애플리케이션에 이르기까지 모든 구간에서 컴파일러 수준에서 서비스들의 이름이 일관될 수 있게 보장하는게 가능합니다.
- Web: PoC 성격으로 만들어보고 있는 ASP.NET Core 프로젝트입니다.
그리고 Aspire 그 자체로 개발 환경은 완성형이지만, 진짜 중요한 것은 이후에 어떻게 프로덕션 환경으로 내보낼 것인가에 대한 부분입니다.
처음에 살펴본 바로는 Aspirate (Aspir8)을 이용하여 K8s로만 내보내는 것이 가능한 것으로 알고 있었지만, 다시 살펴보니 docker-compose로 내보내는 것도 가능했습니다. 그래서 K8s에 대한 관리 포인트가 부담인 경우 충분히 docker-compose를 사용할 수 있어 실용성이 확보가 되는 것 같습니다.
각 코드에서 인상깊었던 부분들은 대략 다음과 같습니다.
AppHost
AppHost 메인 메서드는 다음과 같이 정의해보았습니다.
var builder = DistributedApplication.CreateBuilder(args);
// Caching Server (Microsoft Garnet, Redis compatible)
var cachingServer = builder.AddGarnet(ServiceNames.CachingServer);
// Database Server (PostgreSQL)
var databaseServer = builder.AddPostgres(ServiceNames.DatabaseServer)
.WithDataVolume(ServiceNames.DatabaseVolume, isReadOnly: false);
// Web Frontend
builder
.AddProject<Projects.AspireTestDrive_Web>(ServiceNames.WebApp)
.WithExternalHttpEndpoints()
.WithReference(cachingServer)
.WithReference(databaseServer);
var app = builder.Build();
await app.RunAsync(cancellationToken);
Redis와 호환되면서, Redis의 라이선스 이슈를 피하고 더 빠른 성능을 제공하는 순수 닷넷 구현체인 Garnet을 이제는 직접 Aspire에 포함시켜 사용할 수 있습니다. 그리고 데이터베이스는 추가 라이선스 사용료가 발생하지 않는 것이 확실하면서, 역시 오랜 세월동안 검증된 오픈 소스 데이터베이스인 PostgreSql을 사용하고, 데이터 영속성을 위해 볼륨을 연동하도록 설정했습니다.
이렇게 볼륨을 설정해두면 로컬 개발 환경에서는 Docker나 Podman 로컬 개발 환경에 정식으로 볼륨이 생성되며, 이 볼륨이 계속 유지되므로 프로젝트를 띄울 때마다 데이터를 유지하면서 개발할 수 있습니다. 또한, 프로젝트가 실행 중일 때에만 DB 서버가 유지되니 노트북에서 개발할 때도 가볍게 개발 환경을 유지할 수 있는 것이 장점입니다.
이런 개별 연동 요소들은 독립적인 개체로 반환받을 수 있고, 이것을 프로젝트와 연결짓는 것도 손쉽게 할 수 있습니다. 참고로 프로젝트에 대한 참조는 AppHost 프로젝트 타입 고유의 기능으로, 프로젝트 참조를 추가해두면 자동으로 코드 상에도 상수 필드가 추가되어 코드 수준에서 각 연동 요소들 간의 관계를 코드 수준으로 선언할 수 있게 되어있습니다.
앱 호스트 프로젝트의 패키지 레퍼런스는 다음과 같이 구성했습니다.
<PackageReference Include="Aspire.Hosting.Garnet" Version="8.2.0" />
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="8.2.0" />
패키지 이름들 중, Aspire.Hosting.~
으로 시작하는 패키지들이 주로 앱 호스트용 패키지들로, Garnet과 PostgreSQL DB를 선언하기 위해 앱 호스트에 패키지를 추가했습니다.
ServiceDefaults
Aspire에 올릴 서비스들이 공동으로 사용할 코드는 이 프로젝트에 추가할 수 있습니다. 그래서 EF Core나 Redis에 관련된 종속성 주입을 여기서 해두면 초기화 코드를 줄일 수 있습니다.
Extensions 클래스의 AddServiceDefaults 메서드의 끝 부분에는 다음과 같이 코드를 추가했습니다.
builder.AddRedisDistributedCache(ServiceNames.CachingServer);
builder.AddNpgsqlDataSource(ServiceNames.DatabaseServer);
본래 코드에서는 AppHost에 지정한 연동 요소 이름을 문자열로 넣으면 되지만, 이런 이름들까지도 일관성을 맞추기 위해 앞서 언급한대로 공유 프로젝트로 문자열 상수를 정의해서, 프로젝트 타입에 관계없이 전역적으로 쓸 수 있는 공통 요소를 만들어 위의 코드처럼 넣었습니다.
프로젝트 상의 nuget 패키지 참조는 다음과 같이 설정했습니다.
<PackageReference Include="Aspire.Npgsql" Version="8.2.0" />
<PackageReference Include="Aspire.StackExchange.Redis.DistributedCaching" Version="8.2.0" />
Aspire.Hosting
으로 시작하지 않는 다른 모든 패키지들은 주로 앱 호스트용 패키지가 아닌 경우가 많은데, 이들 패키지는 Aspire의 환경을 인식해서 연결 문자열이나 서버 이름 같은 부분들을 직접 코딩하는 부담을 줄여주기 위해 쓰이는 편입니다.
위의 패키지들은 IServiceProvider가 아닌 IAppHostBuilder 류 인터페이스에 부착되는 확장 메서드를 제공하면서, .NET IoC, 그리고 개별 패키지 (예: StackExchange Redis, Npgsql)를 이어주는 브릿지 역할을 합니다.
그리고 닷넷 코어의 NuGet 패키지의 특성 중 하나인 전이적 패키지 전파 특성을 이용하기 위해, ServiceDefaults 자체에서는 사용하는 패키지가 아니지만, EF Core를 필요한 곳에서 모두 사용할 목적으로 아래 패키지도 추가했습니다.
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.2.0" />
ServiceNames
지금까지 반복적으로 등장한 ServiceNames 클래스는 서비스 디스커버리를 할 때 사용할 기준 이름들이었습니다.
internal static class ServiceNames
{
public static readonly string CachingServer = "caching";
public static readonly string DatabaseServer = "sqldb";
public static readonly string DatabaseVolume = "sqldb-data";
public static readonly string WebApp = "webapp";
}
정말 보잘것없이 단순한 코드들이지만, 여기서 이름이 변경되면 앱 호스트부터 각 애플리케이션 프로젝트까지 빠짐없이 전파가 이루어지게 되고, 결국 Aspire 메타데이터, K8s, docker-compose까지 바뀐 이름이나 설정이 온전히 반영되는 것을 보장할 수 있습니다.
웹 프로젝트
마지막으로 웹 프로젝트에서는 다음과 같이 초기화 코드를 쓸 수 있습니다.
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddNpgsqlDbContext<AspireTestDriveDbContext>(ServiceNames.DatabaseServer);
var app = builder.Build();
app.MapDefaultEndpoints();
app.MapGet("/", WebsiteRoot)
.Produces(200, contentType: "text/plain");
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<AspireTestDriveDbContext>();
await dbContext.Database.EnsureCreatedAsync();
}
await app.RunAsync(cancellationToken).ConfigureAwait(false);
.NET Aspire 런타임을 수용하기 위해 AddServiceDefaults 확장 메서드를 부른 후, EF Context를 복잡한 연결 문자열 없이 서비스 이름만으로 찾도록 지정해줍니다. 그리고 DB 초기 스캐폴딩도 바로 수행합니다. MapDefaultEndpoints 확장 메서드에서는 OpenTelemetry나 여러 관측 용이성 기능 지원에 필요한 엔드포인트를 .NET Aspire와 이어주는 기능을 제공하므로 붙여주는 것이 좋습니다.
그리고 실제 애플리케이션의 코드는 아래처럼 쓸 수 있겠습니다.
static async Task<IResult> WebsiteRoot(
[FromServices] IConnectionMultiplexer cache,
[FromServices] AspireTestDriveDbContext dbContext)
{
var database = cache.GetDatabase();
await database.StringSetAsync("LastAccess", DateTime.UtcNow.ToString()).ConfigureAwait(false);
await dbContext.Users.AddAsync(new Data.Models.Users
{
UserEmail = $"test_{DateTime.UtcNow.Ticks}@example.com",
UserName = $"TestUser_{DateTime.UtcNow.Ticks}",
EncryptedUserPassword = Guid.NewGuid().ToString(),
EncryptionAlgorithm = "SHA256",
EncryptionSalt = Guid.NewGuid().ToString(),
CreatedDateTimeUtc = DateTime.UtcNow,
LastModifiedDateTimeUtc = DateTime.UtcNow,
});
await dbContext.SaveChangesAsync();
return Results.Content($"Hello, World! {await database.StringGetAsync("LastAccess")}, " +
$"Total {await dbContext.Users.CountAsync()} users have been registered.");
}
Garnet (Redis), PostgreSQL, EF Core를 모두 사용하고, ASP.NET Core Minimal API 스타일로 메서드 하나로 끝나는 예시를 이와 같이 작성할 수 있습니다.
감상평
지금까지 보여준 코드를 종합해서 로컬에서 실행할 때는, 본래 docker-compose.yml 파일을 직접 작성하고 관리하는 수고를 들여야 했고, K8s로 배포할 때에는 Helm 차트도 직접 개발해야 했습니다.
하지만 개발 단계부터 배포 시나리오를 걱정할 필요 없이 Visual Studio에서 Docker나 Podman과 함께 완성된 개발 환경을 이용해서 개발에만 집중할 수 있어서 좋고, 프로덕션용으로 내보내는 시점에서는 각 프로젝트를 GitHub Action 등의 CI/CD 툴과 GitHub Packages 등의 아티팩트 리포지터리에 컨테이너 이미지를 게시한 후, Ops에서 사용할 docker-compose.yml이나 Helm 차트를 내보내는 과정 자체를 자동화할 수 있으니 휴먼 에러를 줄이는데 도움이 됩니다.
지금 살펴본 내용 말고도, Azure나 AWS (NuGet Gallery | Aspire.Hosting.AWS 8.2.0-preview.1.24428.5)에 한해서 Aspire 관리 환경 내에서 클라우드 리소스를 같이 놓고 쓸 수도 있고, 기존에 따로 만들어둔 컨테이너 이미지도 사용 가능하니 클라우드 지원을 추가하는 것도 어렵지 않게 달성할 수 있을 것으로 보입니다. (@iamjinseok 님께서 예전에 소개하셨던 Java 애플리케이션 통합이나 NodeJS 애플리케이션 통합도 어렵지 않게 생각할 수 있습니다.)
당분간 Aspire를 더 깊이있게 써보면서 많이 배워보려 합니다.
ps. AWS + Aspire에 대한 자세한 내용은 aspire/src/Aspire.Hosting.AWS/README.md at main · dotnet/aspire · GitHub 에서 보실 수 있습니다.