Amazon Web Services ブログ

モノリシックな .NET REST API を AWS Lambdaに移行する

この記事は、クラウド インフラストラクチャ アーキテクトの James Eastham によって書かれました。

.NET アプリケーションを AWS にデプロイする方法は、EC2 インスタンスでホストされる単一プロセスの ASP.NET Core Web API から、AWS Lambda に支えられたサーバーレス API まで多数あります。この記事では、モノリスからサーバーレスへの移行を簡単にするための重要なトピックについて説明します。

.NET Framework は 2002 年に公開されました。つまり、サーバーレスアーキテクチャへの移行によってメリットを得られる既存の.NET アプリケーションコードが何年も存在することになります。Porting Assistant for .NETAWS Microservices Extractor for .NET のリリースにより、AWS のツールはこのモダナイゼーションを直接支援できるようになりました。

これらのツールはモダナイゼーションに役立ちますが、コンピューティングレイヤーを従来のサーバーからサーバーレステクノロジーに移行させることはできません。

ヘキサゴナルアーキテクチャ

ヘキサゴナルアーキテクチャパターンは、システムを疎結合で交換可能なコンポーネントに分割することを目的としています。アプリケーションとビジネスロジックはアプリケーションの中核にあります。

Layers of a hexagonal architecture

次のレイヤーは、コアビジネスロジックレイヤーからの双方向通信を処理するインターフェイスのセットです。実装の詳細は外部に移動されます。入力 (API コントローラー、UI、コンソール、テストスクリプト) と出力 (データベース実装、メッセージバス操作) は境界にあります。

選択したコンピューティングレイヤーは、システムの中核部分ではなく、実装の詳細になります。これにより、フロントエンドからコンピューティングレイヤーや基盤となるデータベースエンジンに至るまで、あらゆるインテグレーションを移行するプロセスがより簡潔になります。

コードサンプル

GitHub リポジトリには、この投稿のコード例と、移行したサーバーレスアプリケーションのデプロイ手順が記載されています。

このリポジトリには、.NET Core REST API が含まれています。データベースエンジンには MySQL を使用し、ビジネスロジックの一部として外部 API を利用しています。また、 AWS アカウントにデプロイできる同じアプリケーションの移行済みサーバーレスバージョンも含まれています。これは、AWS Cloud Development Kit (CDK)AWS Serverless Application Model (AWS SAM) CLI の組み合わせを使用します。

デプロイされたモノリシックアプリケーションのアーキテクチャは次のとおりです。

Architecture of the deployed monolithic application

アプリケーションを Lambda に移行した後のアーキテクチャは以下のようになります。

Architecture after migrating

インテグレーション

モダンウェブアプリケーションは、データベース、ファイルシステム、さらには他のアプリケーションに依存しています。.NET Core では依存性の注入を充分サポートしているため、これらの統合の管理が簡単になります。

次のコードスニペットは BookingController.cs ファイルから抜粋したものです。必要なインターフェースがコントローラーのコンストラクターにどのように注入されるかを示しています。コントローラーメソッドの1つは、注入されたインターフェースを使用して、BookingRepository からの予約を一覧表示します。

    [ApiController]
    [Route("[controller]")]
    public class BookingController : ControllerBase
    {
        private readonly ILogger<BookingController> _logger;
        private readonly IBookingRepository _bookingRepository;
        private readonly ICustomerService _customerService;

        public BookingController(ILogger<BookingController> logger,
            IBookingRepository bookingRepository,
            ICustomerService customerService)
        {
            this._logger = logger;
            this._bookingRepository = bookingRepository;
            this._customerService = customerService;
        }

        /// <summary>
        /// HTTP GET endpoint to list all bookings for a customer.
        /// </summary>
        /// <param name="customerId">The customer id to list for.</param>
        /// <returns>All <see cref="Booking"/> for the given customer.</returns>
        [HttpGet("customer/{customerId}")]
        public async Task<IActionResult> ListForCustomer(string customerId)
        {
            this._logger.LogInformation($"Received request to list bookings for {customerId}");

            return this.Ok(await this._bookingRepository.ListForCustomer(customerId));
        }
}

IBookingRepository の実装は、起動時に Startup.cs ファイル内の依存性の注入を使用して設定されます。

services.AddTransient<IBookingRepository, BookingRepository>();

フレームワークは複雑さと構成の多くを抽象化するため、ASP.NET Core Web API プロジェクトを使用する場合、この方法は有効です。ただし、Lambda で実行されている .NET Core コードにも同じ方法を適用することは可能です。

AWS Lambda での依存性の注入の設定

スタートアップロジックは、スタンドアロンの DotnetToLambda.Serverless.Config ライブラリに移動されます。これにより、複数の Lambda 関数間で依存性の注入の設定を共有できます。このライブラリには、ServerlessConfig という名前の静的クラスが 1 つ含まれています。

このファイルと Startup.cs ファイルにはほとんど違いはありません。

public void ConfigureServices(IServiceCollection services)
{
	var databaseConnection =
		new DatabaseConnection(this.Configuration.GetConnectionString("DatabaseConnection"));
	
	services.AddSingleton<DatabaseConnection>(databaseConnection);
	
	services.AddDbContext<BookingContext>(options =>
		options.UseMySQL(databaseConnection.ToString()));

	services.AddTransient<IBookingRepository, BookingRepository>();
	services.AddHttpClient<ICustomerService, CustomerService>();
	
	services.AddControllers();
}

そして、ServerlessConfig クラスのコンフィグレーションメソッドは以下のとおりです。

public static void ConfigureServices()
{
	var client = new AmazonSecretsManagerClient();
	
	var serviceCollection = new ServiceCollection();

	var connectionDetails = LoadDatabaseSecret(client);

	serviceCollection.AddDbContext<BookingContext>(options =>
		options.UseMySQL(connectionDetails.ToString()));
	
	serviceCollection.AddHttpClient<ICustomerService, CustomerService>();
	serviceCollection.AddTransient<IBookingRepository, BookingRepository>();
	serviceCollection.AddSingleton<DatabaseConnection>(connectionDetails);
	serviceCollection.AddSingleton<IConfiguration>(LoadAppConfiguration());

	serviceCollection.AddLogging(logging =>
	{
		logging.AddLambdaLogger();
		logging.SetMinimumLevel(LogLevel.Debug);
	});

	Services = serviceCollection.BuildServiceProvider();
}

主な追加点は、27 行目で ServiceCollection オブジェクトを手動で作成し、45 行目で BuildServiceProvider を呼び出すことです。.NET Core では、この手動によるオブジェクトの初期化はフレームワークによって抽象化されます。作成された ServiceProvider は、ServerlessConfig クラスの読み取り専用プロパティとして公開されます。これまで行ってきたのは、ASP.NET Core Web API がバックグラウンドで実行する定型コードを取り込んでフォアグラウンドに導入することだけです。

これにより、起動時の設定の大部分をウェブ API から直接コピーして貼り付け、Lambda 関数で再利用できます。

Lambda API コントローラー

関数コードについても同様の手順に従います。たとえば、Lambda 用に書き直された ListForCustomer エンドポイントは次のとおりです。

public class Function
{
	private readonly IBookingRepository _bookingRepository;
	private readonly ILogger<Function> _logger;
	
	public Function()
	{
		ServerlessConfig.ConfigureServices();

		this._bookingRepository = ServerlessConfig.Services.GetRequiredService<IBookingRepository>();
		this._logger = ServerlessConfig.Services.GetRequiredService<ILogger<Function>>();
	}
	
	public async Task<APIGatewayProxyResponse> FunctionHandler(APIGatewayProxyRequest apigProxyEvent, ILambdaContext context)
	{
		if (!apigProxyEvent.PathParameters.ContainsKey("customerId"))
		{
			return new APIGatewayProxyResponse
			{
				StatusCode = 400,
				Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
			};
		}

		var customerId = apigProxyEvent.PathParameters["customerId"];
		
		this._logger.LogInformation($"Received request to list bookings for: {customerId}");

		var customerBookings = await this._bookingRepository.ListForCustomer(customerId);
		
		return new APIGatewayProxyResponse
		{
			Body = JsonSerializer.Serialize(customerBookings),
			StatusCode = 200,
			Headers = new Dictionary<string, string> { { "Content-Type", "application/json" } }
		};
	}
}

関数コンストラクターでは、起動時の設定を呼び出します。これにより、Lambda 実行環境がアクティブな状態でも初期設定を再利用できます。サービスを構成すると、ServerlessConfig クラスのサービスプロパティから必要なインターフェイスを取得できます。

2 つ目の主な違いは、インバウンドのリクエストとレスポンスの API Gateway へのマッピングです。HTTP リクエストはイベントとして送信されるため、未処理の HTTP データから内容を手動で解析する必要があります。同じことがHTTPレスポンスにも当てはまり、手動で作成する必要があります。この2つの違い以外は、元のBookingControllerからのコピーです。

アプリケーション構成

ASP.NET Core Web API には、ランタイム固有の構成を含む appsettings.json ファイルが含まれています。このフレームワークは、このファイルを読み込み、注入可能な IConfiguration インターフェイスとして公開します。また、環境変数から設定を読み込むこともできます。

これは Lambda を使用している場合でも可能です。appsettings.json ファイルをコンパイル済みコードと一緒にパッケージ化し、実行時に手動でロードできます。ただし、Lambda をコンピューティングレイヤーとして使用する場合、設定を管理するための AWS 固有のオプションがあります。

環境変数

template.yaml ファイルに示すように、Lambda 環境変数を使用してランタイム構成を追加します。

Environment:
	Variables:
		SERVICE: bookings
		DATABASE_CONNECTION_SECRET_ID: !Ref SecretArn

この AWS SAM 設定では、DATABASE_CONNECTION_SECRET_IDという名前の環境変数を追加します。Lambda では、どの C# アプリケーションでも環境変数にアクセスするのと同じ方法でこれにアクセスします。

var databaseConnectionSecret = client.GetSecretValueAsync(new GetSecretValueRequest()
            {
                SecretId = Environment.GetEnvironmentVariable("DATABASE_CONNECTION_SECRET_ID"),
            }).Result;

これはランタイム構成を追加する最も簡単な方法です。変数はプレーンテキストで保存され、変更には再デプロイまたは手動操作が必要です。

外部構成サービス

AWS には、アプリケーション設定を関数コードの外部に移動できるサービスがあります。これらには、AWS Systems Manager Parameter StoreAWS AppConfig、および AWS Secrets Manager が含まれます。

Parameter Store を使用してプレーンテキストのパラメータを保存できます。このパラメータは、 AWS Key Management Service を使用して暗号化することもできます。ASP.NET Core API の appsettings.json ファイルの内容は、パラメータ文字列に直接コピーされ、AWS CDK を使用してデプロイされます。

var parameter = new StringParameter(this, "dev-configuration", new StringParameterProps()
{
	ParameterName = "dotnet-to-lambda-dev",
	StringValue = "{\"CustomerApiEndpoint\": \"https://jsonplaceholder.typicode.com/users\"}",
	DataType = ParameterDataType.TEXT,
	Tier = ParameterTier.STANDARD,
	Type = ParameterType.STRING,
	Description = "Dev configuration for dotnet to lambda",
});

この JSON データは、起動時の設定の一部としてロードされます。次に、パラメータ文字列を使用して IConfiguration の実装を手動で構築します。

private static IConfiguration LoadAppConfiguration()
{
	var client = new AmazonSimpleSystemsManagementClient();
	var param = client.GetParameterAsync(new GetParameterRequest()
	{
		Name = "dotnet-to-lambda-dev"
	}).Result;
	
	return new ConfigurationBuilder()
		.AddJsonStream(new MemoryStream(Encoding.ASCII.GetBytes(param.Parameter.Value)))
		.Build();

2 番目の設定メカニズムは Secrets Manager です。これにより、シークレットが保護され、データベース認証情報のローテーションと管理が容易になります。

Amazon RDS は Secrets Manager と統合されています。新しい RDS インスタンスを作成すると、データベース接続の詳細が自動的に暗号化され、シークレットとして保持されます。MySQL インスタンスの詳細は Secrets Manager に保存され、公開されません。これらの接続の詳細には、Secrets Manager SDK を使用して起動時の設定の一部としてアクセスできます。

private static DatabaseConnection LoadDatabaseSecret(AmazonSecretsManagerClient client)
{
	var databaseConnectionSecret = client.GetSecretValueAsync(new GetSecretValueRequest()
	{
		SecretId = Environment.GetEnvironmentVariable("DATABASE_CONNECTION_SECRET_ID"),
	}).Result;

	return JsonSerializer
		.Deserialize<DatabaseConnection>(databaseConnectionSecret.SecretString);
}

Lambda 関数には、Secrets Manager と Parameter Store の両方にアクセスするための IAM 権限が必要です。AWS SAM には、テンプレートに追加できる定義済みのポリシーテンプレートが含まれています。4 行の YAML では、必要な Secrets Manager と SSM 権限が適用されます。

Policies:
	- AWSSecretsManagerGetSecretValuePolicy:
		SecretArn: !Ref SecretArn
	- SSMParameterReadPolicy:
		ParameterName: dotnet-to-lambda-dev

詳細なリストについては、ポリシーテンプレートのリストを参照してください。

ネットワーキング

最後のアーキテクチャコンポーネントはネットワークです。Lambda 関数は、サービスが所有する VPC にデプロイされます。この関数は、他の AWS サービス、API 用の HTTPS エンドポイント、AWS 外部のサービスやエンドポイントなど、パブリックインターネット上で利用可能なあらゆるものにアクセスできます。その場合、この関数は VPC 内のプライベートリソースに接続する方法がありません。

RDS インスタンスを AWS にデプロイする場合、データベースは外部からの入力があるプライベートサブネットに配置するのがベストプラクティスです。Lambda が RDS を使用する場合は、Lambda サービス の VPC と RDS の VPC との 間の接続を作成する必要があります。このネットワークコンポーネントの詳細は、このブログ記事に記載されています。

AWS SAM テンプレートは、このネットワーク設定を定義します。

VpcConfig:
	SubnetIds:
	  - !Ref PrivateSubnet1
	  - !Ref PrivateSubnet2
	SecurityGroupIds:
	  - !Ref SecurityGroup

この例では、ネットワーク構成はグローバルに適用されます。つまり、テンプレート内のすべての Lambda 関数に同じ設定が適用されます。この関数は 2 つのサブネットと 1 つのセキュリティグループにデプロイされます。RDS アクセス用のサブネットとセキュリティグループを設定する手順の詳細については、この記事を参照してください。

サブネットとセキュリティグループの特定の値は、環境変数から取得されます。ローカルで実行する場合、これらの変数を手動で指定できます。CICD 経由でデプロイする場合、これらの変数はパイプラインのステージに基づいて動的に変更できます。

 PrivateSubnet1:
	Description: 'Required. Private subnet 1. Output from cdk deploy'
	Type: 'String'
PrivateSubnet2:
	Description: 'Required. Private subnet 2. Output from cdk deploy'
	Type: 'String'
SecurityGroup:
	Description: 'Required. Security group. Output from cdk deploy'
	Type: 'String'

まとめ

このブログ記事では、.NET Core REST API を AWS Lambda に移行する際に必要な考慮事項を示しています。これで、既存のコードベースを見直して、Lambda が自分に適しているかどうかを十分な情報に基づいて判断できます。適切な抽象化と構成があれば、.NET Core API をコピーアンドペーストで Lambda に移行できます。

サーバーレスのラーニングリソースの詳細については、Serverless Land をご覧ください。

この記事の翻訳はソリューションアーキテクトの平良允俊が担当しました。原文はこちらです。