TL;DR

Статья показывает, как организовать локальную разработку .NET-сервиса с интеграцией AWS без моков и без постоянных походов в реальный AWS — за счёт связки .NET Aspire + LocalStack (переключение управляется конфигурацией).

Что делается:

  • В AppHost подключается LocalStack.Aspire.Hosting, задаётся общий AWS-конфиг (профиль/регион) и включается флаг LocalStack:UseLocalStack, чтобы режим «локально/прод» переключался конфигурацией.

  • Через AddLocalStack(...) хост Aspire поднимает LocalStack в контейнере, управляет временем жизни (Session/Persistent) и логированием для отладки.

  • Инфраструктура описывается как код через AWS CDK на C#: создаётся S3-бакет со статическим хостингом и политикой доступа.

  • Выходные значения стека (например, BucketName и BucketWebsiteUrl) регистрируются типобезопасно и прокидываются в сервис через переменные окружения (Storage__*).

  • Одна строка builder.UseLocalStack(...) включает автоматическую конфигурацию AWS-ресурсов под LocalStack, обнаружение CDK/CloudFormation (включая bootstrap при необходимости) и правильный порядок зависимостей.

  • В API-сервисе подключаются LocalStack.Client и AWS SDK, регистрируется IAmazonS3, после чего тот же код ходит либо в LocalStack, либо в AWS — в зависимости от конфигурации.

  • В конце — минимальный пример API с эндпоинтами /upload (загрузка в S3) и /download/{key}, плюс проверка через curl и AWS CLI с --endpoint-url.

Разработка .NET-приложения, использующего сервисы AWS, сопровождается множеством сложностей. Либо мы постоянно ходим в настоящий AWS и наблюдаем, как растёт счёт, либо всё замокаем и не уверены, что код вообще работает. Команды часто делят один dev-стенд, мешая друг другу данными и отладкой. Разработчики регулярно оказываются в ситуации, когда приходится ждать, пока «освободится дев-окружение», потому что кто-то другой как раз тестирует свою интеграцию. .NET Aspire помогает с оркестрацией микросервисов, но не решает проблему разработки именно с AWS.

Здесь в игру вступает LocalStack. По сути, это AWS, который запускается у нас на машине: S3, Lambda, DynamoDB, SQS, SNS и т.д. Community-версия бесплатна и покрывает почти всё, что нужно для типовой разработки. Больше не нужно поднимать отдельные AWS-окружения для каждого разработчика или разбираться с очисткой ресурсов в разных аккаунтах. К тому же, мы получаем более быстрые циклы обратной связи, возможность работать офлайн и возможность полностью «обнулить» окружение простым перезапуском контейнера.

В этой статье мы рассмотрим практический пример, с которым постоянно сталкиваются разработчики: API-сервис, который загружает файлы в Amazon S3 и раздаёт их через статический хостинг сайта. Такой паттерн встречается повсюду: загрузка аватарок, хранение документов, медиагалереи и т.п. Мы настроим всё через .NET Aspire и LocalStack так, чтобы один и тот же код без изменений работал и локально, и в продакшене.

Прежде чем переходить к настройке, зафиксируем исходную точку. У нас есть приложение .NET Aspire с одним API-сервисом. Сервис предоставляет HTTP-эндпоинты. Структура решения выглядит так (стандартная структура Aspire):

AppHost — проект хоста Aspire
Program.cs — точка входа хоста Aspire
appsettings.json — файл конфигурации хоста Aspire
другие файлы хоста Aspire...


Api
Program.cs — точка входа API-сервиса
appsettings.json — файл конфигурации API-сервиса
другие файлы API-сервиса...


ServiceDefaults — общая конфигурация сервисов
Extensions.cs — общие расширения для сервисов

AppHost/Program.cs:

var builder = DistributedApplication.CreateBuilder(args);

builder.AddProject<Projects.Api>("api")
    .WithHttpHealthCheck("/health");

await builder.Build().RunAsync();

Api/Program.cs:

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

var app = builder.Build();

app.MapDefaultEndpoints();

// Endpoint для проверки состояния
app.MapGet("/health", () => "Healthy");

app.Run();

С такой структурой мы можем расширить AppHost и интегрировать в него LocalStack и сервисы AWS. Для начала нужно добавить в проект AppHost пакет Aspire для работы с LocalStack — LocalStack.Aspire.Hosting — через NuGet. В файле AppHost.csproj это выглядит так:

<PackageReference Include="LocalStack.Aspire.Hosting" />

Этот пакет включает всё необходимое: управление контейнером LocalStack, интеграцию с AWS CDK и оркестрацию через CloudFormation.

Дальше нам нужно настроить AWS SDK, добавив общий контекст конфигурации AWS:

var awsConfig = builder.AddAWSSDKConfig()
    .WithProfile("default")
    .WithRegion(RegionEndpoint.EUCentral1);

Профиль использует стандартную цепочку учётных данных AWS: для локальной разработки можно применять профили AWS CLI, а в продакшене — роли IAM. Регион гарантирует, что будут использованы корректные эндпоинты. Большей гибкости можно достичь за счёт конфигурации:

var awsConfig = builder.AddAWSSDKConfig()
    .WithProfile(builder.Configuration["AWS:Profile"] ?? "default")
    .WithRegion(RegionEndpoint.GetBySystemName(builder.Configuration["AWS:Region"] ?? "eu-central-1"));

Далее нужно настроить сам LocalStack. Мы скажем Aspire, чтобы он управлял контейнером LocalStack за нас, настроим время жизни контейнера и уровни логирования для отладки:

var awsLocal = builder.AddLocalStack("aws-local", // Имя инстанса LocalStack
    awsConfig: awsConfig, // Ссылка на конфигурацию AWS, созданную выше
    configureContainer: c => // Настройка параметров контейнера LocalStack
    {
        c.Lifetime = ContainerLifetime.Session; // Сбрасывать контейнер при каждом рестарте приложения
        c.DebugLevel = 1; // Включить детализированное логирование
        c.LogLevel = LocalStackLogLevel.Debug; // Показывать отладочную информацию
    });

Ключевые настройки здесь:

  • Lifetime (время жизни):
    - Session: контейнер пересоздаётся при каждом рестарте приложения (подходит по умолчанию для тестов)
    - Persistent: контейнер переживает перезапуски приложения, сохраняя данные

  • LogLevel: позволяет видеть, что именно происходит при вызовах AWS

  • DebugLevel: значение 1 — детализированные логи запросов/ответов для отладки

Режим Persistent (постоянное время жизни контейнера) важен: мы не хотим заново создавать S3-бакеты каждый раз, когда перезапускаем отладку. Пока Session-режим удобен для тестовых стендов, Persistent лучше подходит для повседневной разработки.

Важная часть здесь — файл конфигурации AppHost. Метод AddLocalStack использует настройку LocalStack:UseLocalStack, чтобы понять, нужно ли работать в локальном режиме. Он ищет это значение в конфигурации (appsettings.json, переменная окружения LocalStack__UseLocalStack и т.д.). Поэтому в AppHost/appsettings.json можно добавить:

{
  "LocalStack": {
    "UseLocalStack": true
  }
}

Без этой настройки интеграция с LocalStack будет отключена, и наши сервисы попытаются подключаться к реальному AWS. Такой подход упрощает переключение между локальной разработкой и продакшеном без изменений в коде.

После этого мы можем начать описывать инфраструктуру AWS. Мы можем определить инфраструктуру AWS как код, и она будет работать как с LocalStack, так и с реальным AWS. Для этого мы будем использовать AWS CDK (Cloud Development Kit). Проще всего думать о нём как о способе описывать инфраструктуру на привычных языках программирования, а не в YAML или JSON. CDK позволяет объявлять ресурсы AWS — такие как S3-бакеты, очереди SQS или функции Lambda — с помощью C#-классов, с поддержкой IntelliSense, типобезопасностью и всеми плюсами полноценного языка. При сборке CDK-кода генерируются шаблоны CloudFormation, которые затем может разворачивать AWS (или LocalStack).

var awsStack = builder.AddAWSCDKStack("aws-stack", s => new AwsStack(s))
    .WithReference(awsConfig); // Связать со сконфигурированным AWS
var awsStackOutputs = AwsStackOutputs.Create(awsStack); // Получить типобезопасный доступ к выходным значениям стека

AwsStack — это кастомный класс, в котором мы описываем ресурсы AWS. Чтобы задать реальную инфраструктуру, вот как можно создать бакет Amazon S3 с включённым статическим хостингом сайта:

public sealed class AwsStack : Amazon.CDK.Stack
{
    // Пробрасываем S3-бакет как свойство, чтобы использовать его в выходных значениях
    public IBucket Bucket { get; }

    public AwsStack(Constructs.Construct scope)
        : base(scope, "bucket")
    {
        // Создаём S3-бакет с включённым статическим хостингом
        Bucket = new Bucket(this,
            "bucket",
            new BucketProps
            {
                BucketName = "test-data-bucket",
                WebsiteIndexDocument = "index.html" // Включаем статический хостинг сайта
            });

        // Разрешаем публичное чтение объектов для статического хостинга
        Bucket.AddToResourcePolicy(new PolicyStatement(new PolicyStatementProps
        {
            Actions = ["s3:GetObject"], // Разрешить публичное чтение объектов
            Effect = Effect.ALLOW, // Разрешить доступ
            Principals = [new AnyPrincipal()], // Любой клиент может обратиться
            Resources = [Bucket.ArnForObjects("*")] // Все объекты в бакете
        }));
    }
    
}

// Класс AwsStackOutputs даёт типобезопасный доступ к ресурсам стека.
// Вместо того чтобы работать с «сырыми» строками в Program.cs, мы получаем проверку на этапе компиляции
// и защищаемся от опечаток, которые могли бы привести к ошибкам уже во время выполнения.
public sealed class AwsStackOutputs
{
    private readonly IResourceBuilder<IStackResource<AwsStack>> _stack;

    private AwsStackOutputs(IResourceBuilder<IStackResource<AwsStack>> stack)
    {
        _stack = stack;
    }
        
    // Выходное значение с именем бакета
    public StackOutputReference BucketName => _stack.GetOutput(nameof(BucketName));
    
    // Выходное значение с URL сайта на базе бакета
    public StackOutputReference BucketWebsiteUrl => _stack.GetOutput(nameof(BucketWebsiteUrl));

    // Фабричный метод для создания AwsStackOutputs и регистрации выходных значений
    public static AwsStackOutputs Create(IResourceBuilder<IStackResource<AwsStack>> stack)
    {
        stack.AddOutput(nameof(BucketName), s => s.Bucket.BucketName);
        stack.AddOutput(nameof(BucketWebsiteUrl), s => s.Bucket.BucketWebsiteUrl);
        return new AwsStackOutputs(stack);
    }
}

Конфигурация бакета настраивает статический хостинг сайта с публичным доступом к контенту. Политика доступа к ресурсу позволяет публичный доступ к объектам, при этом сам бакет остаётся приватным. Аналогично мы можем определять и другие ресурсы AWS по мере необходимости. Например, для очередей Amazon SQS мы можем вызвать new Queue(this, "queue", new QueueProps { ... }) и отдать наружу такие значения, как QueueUrl и/или QueueArn. Для таблиц Amazon DynamoDB мы можем определить new Table(this, "table", new TableProps { ... }) и экспортировать, например, TableName.

Когда инфраструктура определена, мы можем сослаться на эти ресурсы в наших сервисах. Вот как настроить API-сервис для использования созданного нами бакета Amazon S3:

var apiService = builder.AddProject<Projects.ApiService>("api")
    .WithHttpHealthCheck("/health")
    .WithReference(awsStack) // Ссылаемся на AWS-стек для автоматической конфигурации
    .WithEnvironment("Storage__BucketName", awsStackOutputs.BucketName) // Прокидываем имя бакета в конфиг
    .WithEnvironment("Storage__PublicBaseUrl", awsStackOutputs.BucketWebsiteUrl); // Прокидываем URL бакета в конфиг

Вызовы WithEnvironment(...) пробрасывают эти значения как переменные окружения. Storage__BucketName в конфигурации сервиса превращается в Storage:BucketName.

Важно отметить, что WithReference(awsStack) автоматически добавит выходные значения ресурсов AWS в секцию конфигурации AWS__Resources__*, так что мы тоже можем обращаться к ним напрямую. Однако явная конфигурация вроде Storage:BucketName в самом сервисе часто предпочтительнее, чем опора на «общее» хранилище выходных значений. Такой подход дополнительно развязывает конфигурацию сервиса и конкретную реализацию инфраструктуры, позволяя менять инфраструктуру, не затрагивая код сервиса.

Непосредственно перед сборкой хоста нам нужно включить интеграцию с LocalStack:

// Enable LocalStack integration for all services which reference AWS resources
builder.UseLocalStack(awsLocal);

Эта одна строка проделывает очень большую работу. Она настраивает все ресурсы AWS в приложении так, чтобы они использовали указанный инстанс LocalStack, автоматически обнаруживает шаблоны CloudFormation и CDK-стеки и при необходимости выполняет CDK bootstrap. Этот метод сканирует все ресурсы в приложении и автоматически конфигурирует ресурсы AWS и проекты, которые на них ссылаются, для работы с LocalStack в локальной среде. Он:

  • Обнаруживает все шаблоны CloudFormation и ресурсы CDK-стеков

  • Автоматически создаёт ресурс CDK bootstrap, если присутствуют CDK-стеки

  • Настраивает все ресурсы AWS на использование эндпоинтов LocalStack

  • Выстраивает правильный порядок зависимостей для CDK bootstrap

  • Автоматически конфигурирует проекты, которые ссылаются на ресурсы AWS

  • Добавляет аннотации, чтобы избежать повторной конфигурации ресурсов

Вот здесь начинается самое интересное. Когда мы вызываем WithReference(awsStack) и включаем LocalStack, Aspire автоматически подставляет конфигурацию в наши сервисы. Нам не приходится управлять этим вручную.

Сервис автоматически получает все эти переменные окружения:

Конфигурация LocalStack (пример):

LocalStack__UseLocalStack = True
LocalStack__Config__EdgePort = 33895
LocalStack__Config__LocalStackHost = localhost
LocalStack__Config__UseLegacyPorts = False
LocalStack__Config__UseSsl = False
LocalStack__Session__AwsAccessKey = secretKey
LocalStack__Session__AwsAccessKeyId = accessKey
LocalStack__Session__AwsSessionToken = token
LocalStack__Session__RegionName = eu-central-1

Ссылки на ресурсы AWS (пример):

AWS__Resources__ProfileBucketName = test-data-bucket
AWS__Resources__ProfileBucketWebsiteUrl = http://test-data-bucket.s3-website.localhost:33895

Конфигурация приложения (пример):

Storage__BucketName = test-data-bucket
Storage__PublicBaseUrl = http://test-data-bucket.s3-website.localhost:33895

Всё это отображается в эквивалентную JSON-конфигурацию:

{
  "LocalStack": {
    "UseLocalStack": true,
    "Config": {
      "EdgePort": 33895,
      "LocalStackHost": "localhost",
      "UseLegacyPorts": false,
      "UseSsl": false
    },
    "Session": {
      "AwsAccessKey": "secretKey",
      "AwsAccessKeyId": "accessKey", 
      "AwsSessionToken": "token",
      "RegionName": "eu-central-1"
    }
  },
  "AWS": {
    "Resources": {
      "ProfileBucketName": "test-data-bucket",
      "ProfileBucketWebsiteUrl": "http://test-data-bucket.s3-website.localhost:33895"
    }
  },
    "Storage": {
    "BucketName": "test-data-bucket",
    "PublicBaseUrl": "http://test-data-bucket.s3-website.localhost:33895"
  }
}

Интересно и то, что LocalStack автоматически обрабатывает переписывание URL для website-эндпоинтов Amazon S3. Когда мы обращаемся, например, по адресу http://test-data-bucket.s3-website.localhost:33895, LocalStack понимает, к какому S3-бакету в контейнере нужно направить запрос. Поэтому если приложение загружает объект в S3, а затем формирует URL, используя website-URL бакета, всё будет прозрачно работать с LocalStack без дополнительной конфигурации. Аналогично мы можем использовать Amazon CloudFront поверх S3, создав объект Distribution — это также будет работать с LocalStack.

Итак, после всех этих настроек финальная версия Program.cs в нашем AppHost может выглядеть так:

using Amazon;
using Amazon.CDK.AWS.IAM;
using Amazon.CDK.AWS.S3;
using Aspire.Hosting.AWS.CDK;
using Aspire.Hosting.AWS.CloudFormation;
using Aspire.Hosting.LocalStack.Container;

var builder = DistributedApplication.CreateBuilder(args);

// Ресурсы AWS

var awsConfig = builder.AddAWSSDKConfig()
    .WithProfile("default")
    .WithRegion(RegionEndpoint.EUCentral1);

var awsLocal = builder.AddLocalStack("aws-local",
    awsConfig: awsConfig,
    configureContainer: c =>
    {
        c.Lifetime = ContainerLifetime.Session;
        c.DebugLevel = 1;
        c.LogLevel = LocalStackLogLevel.Debug;
    });

var awsStack = builder.AddAWSCDKStack("aws-stack", s => new AwsStack(s))
    .WithReference(awsConfig);
var awsStackOutputs = AwsStackOutputs.Create(awsStack);

// Сервисы

builder.AddProject<Projects.Api>("api")
    .WithHttpHealthCheck("/health")
    .WithReference(awsStack)
    .WithEnvironment("Storage__BucketName", awsStackOutputs.BucketName)
    .WithEnvironment("Storage__PublicBaseUrl", awsStackOutputs.BucketWebsiteUrl);

// Запуск

builder.UseLocalStack(awsLocal);

await builder.Build().RunAsync();

public sealed class AwsStack : Amazon.CDK.Stack
{
    public IBucket Bucket { get; }

    public AwsStack(Constructs.Construct scope)
        : base(scope, "bucket")
    {
        Bucket = new Bucket(this,
            "bucket",
            new BucketProps
            {
                BucketName = "test-data-bucket",
                WebsiteIndexDocument = "index.html" // Включаем статический хостинг сайта
            });

        Bucket.AddToResourcePolicy(new PolicyStatement(new PolicyStatementProps
        {
            Actions = ["s3:GetObject"],
            Effect = Effect.ALLOW,
            Principals = [new AnyPrincipal()],
            Resources = [Bucket.ArnForObjects("*")]
        }));
    }
}

public sealed class AwsStackOutputs
{
    private readonly IResourceBuilder<IStackResource<AwsStack>> _stack;

    private AwsStackOutputs(IResourceBuilder<IStackResource<AwsStack>> stack)
    {
        _stack = stack;
    }
        
    public StackOutputReference BucketName => _stack.GetOutput(nameof(BucketName));
    
    public StackOutputReference BucketWebsiteUrl => _stack.GetOutput(nameof(BucketWebsiteUrl));

    public static AwsStackOutputs Create(IResourceBuilder<IStackResource<AwsStack>> stack)
    {
        stack.AddOutput(nameof(BucketName), s => s.Bucket.BucketName);
        stack.AddOutput(nameof(BucketWebsiteUrl), s => s.Bucket.BucketWebsiteUrl);
        return new AwsStackOutputs(stack);
    }
}

А теперь, когда мы запускаем AppHost, Aspire поднимает LocalStack, разворачивает в нём CDK-стек и прокидывает всю необходимую конфигурацию в API-сервис. На скриншоте дашборда Aspire можно увидеть запущенный LocalStack и задеплоенный CDK-стек.

Aspire Dashboard with LocalStack

Теперь AppHost настроен. Пришло время настроить сам сервис для работы с AWS SDK через LocalStack. Сначала добавим в сервис необходимые зависимости: LocalStack.Client, LocalStack.Client.Extensions, AWSSDK.S3. В файле Api.csproj это выглядит так:

<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="LocalStack.Client" />
<PackageReference Include="LocalStack.Client.Extensions" />

Затем в Program.cs API-сервиса добавляем интеграцию с LocalStack и регистрируем клиент S3:

// Настраиваем интеграцию LocalStack и AWS SDK
builder.Services.AddLocalStack(builder.Configuration); // Читаем конфигурацию LocalStack, которую подставил Aspire
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions()); // Настраиваем AWS SDK
builder.Services.AddAwsService<IAmazonS3>(); // Регистрируем клиент S3 в контейнере внедрения зависимостей

Вот и всё. Метод AddLocalStack(...) читает всю конфигурацию, которую Aspire уже подставил, и настраивает AWS SDK так, чтобы он ходил в LocalStack. Клиент S3 теперь автоматически указывает на LocalStack. Дополнительно регистрация клиента AWS SDK через AddAwsService<IAmazonS3>() использует сконфигурированные AWS-опции, которые уже включают эндпоинты LocalStack.

Вот как выглядит код простого API-сервиса (у нас есть два эндпоинта: один для загрузки файлов в Amazon S3 и второй — для скачивания файлов из S3; это упрощённый тестовый функционал, демонстрирующий интеграцию с S3):

// УПРОЩЁННЫЙ ТЕСТОВЫЙ КОД — НЕ ИСПОЛЬЗОВАТЬ В ПРОДАКШЕНЕ
// ПРЕДНАЗНАЧЕН ТОЛЬКО ДЛЯ ДЕМОНСТРАЦИИ ИНТЕГРАЦИИ

using Amazon.S3;
using Amazon.S3.Model;
using LocalStack.Client.Extensions;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

// Настраиваем интеграцию LocalStack и AWS SDK
builder.Services.AddLocalStack(builder.Configuration);
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
builder.Services.AddAwsService<IAmazonS3>();

// Регистрируем конфигурацию хранилища
builder.Services.Configure<StorageOptions>(builder.Configuration.GetSection("Storage"));

var app = builder.Build();

app.MapDefaultEndpoints();

// Эндпоинт проверки состояния
app.MapGet("/health", () => "Healthy");

// Эндпоинт загрузки файла
app.MapPost("/upload", async (
    IFormFile file,
    IAmazonS3 s3Client,
    IOptions<StorageOptions> storageOptions) =>
{
    var bucketName = storageOptions.Value.BucketName;

    // Для простоты используем исходное имя файла как ключ в S3 (НЕБЕЗОПАСНО для продакшена)
    var key = file.FileName;

    // Открываем поток исходного файла
    await using var stream = file.OpenReadStream();

    // Загружаем файл в S3
    await s3Client.PutObjectAsync(new PutObjectRequest
    {
        BucketName = bucketName,
        Key = key,
        InputStream = stream,
        ContentType = file.ContentType
    });
    
    return Results.Ok(new
    {
        Url = new Uri(storageOptions.Value.PublicBaseUrl, key)
    });
})
// В этом примере отключаем защиту от подделки запросов (antiforgery) для упрощения
.DisableAntiforgery();

// Эндпоинт скачивания файла
app.MapGet("/download/{key}", async (
    string key,
    IAmazonS3 s3Client,
    IOptions<StorageOptions> storageOptions) =>
{
    var bucketName = storageOptions.Value.BucketName;

    try
    {
        var response = await s3Client.GetObjectAsync(bucketName, key);
        return Results.File(response.ResponseStream, response.Headers["Content-Type"], key);
    }
    catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
    {
        return Results.NotFound();
    }
});

app.Run();

// Класс конфигурации хранилища
// Соответствует секции Storage в конфигурации:
// Storage:BucketName и Storage:PublicBaseUrl
public sealed class StorageOptions
{
    public string BucketName { get; init; } = null!;
    public Uri PublicBaseUrl { get; init; } = null!;
}

Вся прелесть здесь в том, что клиент LocalStack автоматически переключается на реальный AWS, когда LocalStack не включён. Нам не нужны никакие условные регистрации — просто везде используем AddAwsService<...>(), а уже конфигурация решает, куда пойдут вызовы. LocalStack Client использует подставленные настройки (параметры с префиксом LocalStack__ из примера выше, особенно LocalStack__UseLocalStack), чтобы направлять запросы либо в LocalStack, если он включён, либо в настоящий AWS — если нет.

Теперь мы можем снова запустить наше приложение на Aspire и протестировать API-сервис. Для загрузки и скачивания файлов подойдут инструменты вроде Postman или curl. Поскольку LocalStack запущен, все операции с Amazon S3 выполняются локально, не затрагивая реальный AWS. Мы даже можем воспользоваться AWS CLI, чтобы посмотреть содержимое S3-бакета в LocalStack. В моём случае это выглядит так:

# Загрузить файл с помощью curl
curl -v "http://localhost:22456/upload" -F "file=@index.html"
  # *   Trying 127.0.0.1:22456...
  # * Connected to localhost (127.0.0.1) port 22456 (#0)
  # > POST /upload HTTP/1.1
  # > Host: localhost:22456
  # > User-Agent: curl/7.87.0
  # > Accept: */*
  # > Content-Length: 326
  # > Content-Type: multipart/form-data; boundary=------------------------90474ff5b4693aae
  # > 
  # * Файл полностью загружен, всё в порядке
  # * Помечаем этот набор как не поддерживающий повторное использование соединения
  # < HTTP/1.1 200 OK
  # < Content-Type: application/json; charset=utf-8
  # < Date: Wed, 02 Nov 2025 16:44:05 GMT
  # < Server: Kestrel
  # < Transfer-Encoding: chunked
  # < 
  # * Соединение #0 с хостом localhost оставлено открытым
  # {"url":"http://test-data-bucket.s3-website.localhost:33895/index.html"}%  

# Посмотреть список объектов в S3-бакете с помощью AWS CLI
aws --endpoint-url="http://localhost:33895" s3 ls s3://test-data-bucket
  # 2025-11-02 17:44:05        139 index.html

# Скачать файл с помощью curl
curl -v "http://localhost:22456/download/index.html" -o downloaded_index.html
Полезные ссылки
Освойте серверную разработку на C# с нуля до Middle на специализации C# Developer
Освойте серверную разработку на C# с нуля до Middle на специализации C# Developer

Если Aspire и LocalStack закрывают «как запустить», то дальше обычно упираешься в границы сервисов, контракты, отказоустойчивость и наблюдаемость. На курсе Microservice Architecture разбираем паттерны и инструменты микросервисов на реальных распределённых системах. Чтобы узнать, подойдет ли вам программа, пройдите вступительный тест.

Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:

  • 14 января, 20:00. «IaC: Тестирование инфраструктуры — как внедрить инженерные практики и перестать бояться изменений». Записаться

  • 21 января, 20:00. «Мониторинг: как понять, что твой сервис болен». Записаться

  • 22 января, 19:00. «eBPF: рентгеновское зрение для production. Видим сеть, безопасность и узкие места на уровне ядра Linux». Записаться