Dotnet 6 Web API в Yandex Cloud Serverless Container

Nikolay Matrosov
6 min readJan 31, 2023

Для начала создадим проект на основе примера Web API.

Базовое приложение

Используем один из шаблонов в Rider’е.

Отлично. Теперь пройдемся по изменениям, которые нужно внести в проект, чтобы он «взлетел» в Serverless Containers.

Как я писал в посте про запуск контейнера по триггеру на необходимо реализовать интерфейс HTTP сервера. К счастью, dotnet core Web API именно его и реализует и нам остается только добавить несколько настроек, чтобы выполнить главное условие — запускать сервер на порту, переданном приложение через переменную окружения PORT. Для этого в Program.cs добавим следующее:

var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseKestrel(
serverOptions =>
{
serverOptions.ListenAnyIP(int.Parse(port));
});

Теперь, нам нужно удалить еще одну настройку:

app.UseHttpsRedirection();

Она нам не понадобится так как вызов контейнера идет по HTTP, а в логах мы будем видеть следующие предупреждения, ведь порт для HTTPS мы не задаем:

warn: Microsoft.AspNetCore.HttpsPolicy.HttpsRedirectionMiddleware[3]
Failed to determine the https port for redirect.

Еще я бы убрал из Dockerfile строки EXPOSE, чтобы не вводить в заблуждение, ведь наше приложение больше не использует эти порты.

EXPOSE 80
EXPOSE 443

Также можно почистить %project%/Properties/launchSettings.json от лишних параметров.

Теперь можно развернуть приложение и убедиться, что все работает. Я репозитории с примером буду делать это при помощи Terraform CDK. Работа с ним выходит за рамки этого туториала, но у меня есть отдельная статья про TF CDK. И так я перейду в папку cdk и выполню следующую команду.

TF_VAR_folderId=b1g*** TF_VAR_token=`yc iam create-token` \
cdktf deploy --output ../cdktf.out

В результате будет собран docker image, запушен в облачный репозиторий и из него будет развернут serverless контейнер.

Все работает

Авторизация

Если вы попробуете добавить авторизацию в ваше приложение развернутое в YC Serverless Containers вас ждет неприятный сюрприз: как сказано в документации заголовки Authorization и Cookie из запроса вырезаются, как и некоторые другие.

Что же делать? Ставить контейнер за API Gateway. Кроме возможности использовать авторизацию нормально, он также, например, позволит использовать свой собственный домен.

API Gateway

Для того чтобы создать API GW, нам понадобится OpenAPI спецификация описывающая наше API. Её можно написать руками, а можно сгенерировать на основе кода. Тем более в базовом примере и так уже используется пакет Swashbuckle для генерации спеки для Swagger. Установим CLI которую, будем вызывать при билде.

dotnet tool install SwashBuckle.AspNetCore.Cli --global

Теперь мы можем добавить в csproj команду для вызова этой утилиты.

<Target Name="OpenAPI" AfterTargets="Build" Condition="'$(Configuration)' == 'Debug'">
<Exec Command="swagger tofile --output ./swagger.yaml --yaml $(OutputPath)$(AssemblyName).dll v1" WorkingDirectory="$(ProjectDir)" />
</Target>

В итоге мы получим swagger.yaml где будет OpenAPI 3.0 документ, описывающий наше API. Чтобы использовать его в API Gateway нам понадобится лишь одна доработка: нам нужно разметить объекты операций расширением спецификации x-yc-apigateway-integration, которе скажет гейтвею, что для этого запроса нужно вызвать контейнер.

Для этого нам понадобится написать класс имплементирующий интерфейс IOperationFilter. Его метод Apply будет вызываться для каждого объекта операций в OpenAPI спецификации и в нем мы сможем модифицировать операцию как захотим.

using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace ServerlessContainerDemo.OpenAPI.Filters;

public class YcExtensionsFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
operation.Extensions.Add("x-yc-apigateway-integration", new OpenApiObject()
{
["type"] = new OpenApiString("serverless_containers"),
["container_id"] = new OpenApiString("${container_id}"),
["service_account_id"] = new OpenApiString("${container_service_account_id}"),
});
}
}

Значения ${container_id} и ${container_service_account_id} мы подставим в шаблонизаторе в Terraform.

Теперь зарегистрируем фильтр в Program.cs.

builder.Services.AddSwaggerGen(c =>
{
c.EnableAnnotations();
c.SwaggerDoc("v1", new OpenApiInfo() {Title = "Serverless Container Demo", Version = "v1"});
c.OperationFilter<YcExtensionsFilter>();
});

Добавляем авторизацию

Этот хэндлер приведен только для демонстрационных целей.

namespace ServerlessContainerDemo.Auth;

using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public BasicAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock
) : base(options, logger, encoder, clock)
{
}

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authHeader = Request.Headers["Authorization"].ToString();
if (authHeader != null && authHeader.StartsWith("basic", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader.Substring("Basic ".Length).Trim();
System.Console.WriteLine(token);
var credentialString = Encoding.UTF8.GetString(Convert.FromBase64String(token));
var credentials = credentialString.Split(':');
if (credentials[0] == "admin" && credentials[1] == "admin")
{
var claims = new[] {new Claim("name", credentials[0]), new Claim(ClaimTypes.Role, "Admin")};
var identity = new ClaimsIdentity(claims, "Basic");
var claimsPrincipal = new ClaimsPrincipal(identity);
return Task.FromResult(
AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name)));
}

Response.StatusCode = 401;
Response.Headers.Add("WWW-Authenticate", "Basic");
return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
}
else
{
Response.StatusCode = 401;
Response.Headers.Add("WWW-Authenticate", "Basic");
return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
}
}
}

Теперь добавим опции в конфигурацию приложения.

builder.Services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>
("BasicAuthentication", null);
builder.Services.AddAuthorization(options =>
{
var basicAuthenticationSchemePolicyBuilder = new AuthorizationPolicyBuilder("BasicAuthentication");
options.AddPolicy("BasicAuthentication", basicAuthenticationSchemePolicyBuilder
.RequireClaim(ClaimTypes.Role, "Admin")
.Build());
});

И после этого мы можем использовать аннотацию Authorize на хэндлерах нашего контроллера:

[Authorize(Policy = "BasicAuthentication")]
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()

Теперь если мы вызовем эндпоинт с неправильной авторизацией то получим:

HTTP/2 401
date: Mon, 30 Jan 2023 22:15:54 GMT
content-length: 0
access-control-allow-origin: *
x-content-type-options: nosniff
x-serverless-gateway-path: /WeatherForecast
x-yf-remapped-date: Mon, 30 Jan 2023 22:15:54 GMT
x-yf-remapped-server: Kestrel
x-yf-remapped-www-authenticate: Basic
server: Yandex-API-Gateway/1.0

К сожалению вместо WWW-Authenticate в ответе API Gateway мы получим x-yf-remapped-www-authenticate. Это не позволит браузеру узнать, что ресурс требует Basic-авторизацию и отобразить окно ввода логина и пароля.

Асинхронные задачи

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

Есть возможность выполнения асинхронных задач через помещение их в очередь и разбор очереди при помощи триггера.

Подробнее про асинхронность в серверлесс средах Облака можно прочитать в отдельном посте.

Логирование

Пользовательские логи, которые пишет контейнер в стандартный поток вывода (stdout) и стандартный поток вывода ошибок (stderr) могут быть простыми и структурированными.

По умолчанию Web API приложение будет писать логи в стандартном формате, что будет восприниматься сервисом Cloud Logging как простые текстовые логи. В таком случае мы не имеем возможности фильтровать логи по уровню критичности, а также передавать дополнительные параметры. Чтобы это все заработало, нужно писать логи в JSON-формате. Тогда поля message или msg будут восприниматься, поле содержащее основной текст записи лога, поле level — как поле, указывающее уровень логирования.

Для того чтобы удобно это настроить мы можем воспользоваться библиотекой Serilog или Nlog. Я выбрал вторую.

И так для того, чтобы добавить библиотеку, нам нужно будет установить пакет NLog.Web.AspNetCore — он обеспечивает легкую интеграцию и конфигурирование логгера в Web API приложении.

builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Debug);
builder.Host.UseNLog();

Теперь добавим в корень проекта файл конфигурации логгера nlog.config, именно по этому стандартному пути конфиг и будет найден при вызовеUseNLog().

<?xml version="1.0" encoding="utf-8"?>

<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

<!-- куда писать -->
<targets>
<target xsi:type="Console" name="ConsoleTarget">
<!-- Важно добавить includeEventProperties="true".
Так в логи попадут все дополнительные параметры, что мы передадим.
Больше инфы тут: https://github.com/NLog/NLog/wiki/JsonLayout
-->
<layout xsi:type="JsonLayout" includeEventProperties="true" includeScopeProperties="true">
<attribute name="time" layout="${longdate}" />
<!-- Эти два поля нужны, чтобы cloud logging подхватил наши
структурированные логи -->
<attribute name="level" layout="${level:upperCase=true}" />
<attribute name="message" layout="${message}" />
<!-- Если хотите, чтобы в логи попадали стектрейсы. -->
<attribute name='exception' layout='${exception}' />
</layout>
</target>
</targets>

<!-- правила сопоставления логгера и таргета -->
<rules>
<!--Output hosting lifetime messages to console target for faster startup detection -->
<logger name="Microsoft.Hosting.Lifetime" minlevel="Info" writeTo="ConsoleTarget" final="true" />

<!--Skip non-critical Microsoft logs and so log only own logs (BlackHole) -->
<logger name="Microsoft.*" maxlevel="Info" final="true" />
<logger name="System.Net.Http.*" maxlevel="Info" final="true" />

<logger name="*" minlevel="Trace" writeTo="ConsoleTarget" />
</rules>
</nlog>

Теперь о том как получить логгер. Есть два пути:

Logger log = LogManager.GetCurrentClassLogger();

Использовать NLog напрямую. Плюсы этого решения:

  • Наилучшая производительность
  • Больше опций для взаимодействия с API логгера, например Logger.WithProperty(..)
  • Работает со всеми платформами
  • Не требует Dependency Injection

Второй вариант — Microsoft.Extensions.Logging

ILogger<Т> logger
  • Решение полностью интегрировано с ASP.NET Core, то есть логи которые пишутся фреймворком тоже будут обработаны и/или отфильтрованы NLog.
  • Не придется писать абстракции связанные с логированием
  • Отлично работает с .NET Core Dependency injection
  • Можно загрузить конфигурацию NLog из appsettings.json вместо XML файла NLog.config

Использование

using (_logger.BeginScope("{reminder}", reminder))
{
if (value % 10 == reminder)
{
throw new Exception("Random exception");
}

_logger.LogInformation("valid {value}", value);
}

Хоть строка message в логе и выглядит как шаблон для подстановки значений, при логировании интерполяция строк не будет производиться. На самом деле значения из фигурных скобок будут использованы как ключи для аргументов переданных после message при записи в объект структурированного лога.

{
"message": "valid value"
"value": 3666638874307776000,
"reminder": 7
}

Как видно выше мы можем задавать именованные области через _logger.BeginScope, рамках которых определять дополнительные параметры для записей лога.

Репозиторий с примером. https://github.com/nikolaymatrosov/yc-sls-container-csharp

--

--