tech.guitarrapc.cóm

Technical updates

AWSがC#のファイルベースLambda関数をサポートしました

2025年11月に.NET 10がリリースされましたが、2026年1月6日にAWS Lambdaが.NET 10をサポートしました。 これに伴いファイルベースプログラムのC# Lambda関数もサポートされ、.csファイルだけ用意すればLambda関数をデプロイできるようになりました。

AWS LambdaでC#を書く体験が変わったので、今回はその魅力を紹介します。

ファイルベースプログラムのC# Lambda関数について、以前書いた記事も参考にしてください。

はじめに

ファイルベースプログラムのC# Lambda関数は、.csファイルだけでLambda関数をデプロイできます。

これまでは.csproj + .csファイルを用意 → nugetパッケージを追加 → ビルド → zip化 → デプロイ...といった手順が必要でしたが、簡単になりました。 IaCでは引き続きビルドが必要ですが、dotnet toolでデプロイする分にはビルドステップも不要で、体験的にはPythonやNode.jsのようにスクリプトファイルをそのままデプロイするのに近い感覚です。

  • dotnet toolでデプロイ: .csファイルだけ用意したらデプロイコマンド実行
  • IaCでデプロイ: .csファイルをコンパイル、バイナリを指定してデプロイ

次のコードはコメントを抜いたミニマムなサンプルC#コードで、入力された文字列を大文字に変換して返すだけの関数です。

#:package Amazon.Lambda.Core@2.8.0
#:package Amazon.Lambda.RuntimeSupport@1.14.1
#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4
#:property TargetFramework=net10.0

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// The function handler
var handler = (string input, ILambdaContext context) =>
{
    return input.ToUpper();
};

await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaSerializerContext>())
  .Build()
  .RunAsync();

[JsonSerializable(typeof(string))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

これをToUpper.csという名前で保存し、dotnet lambda deploy-functionコマンドでデプロイするだけで、Lambda関数が作成されます。

File-based C# Lambda関数をデプロイする

もう少し詳しく見ていきましょう。

C#コードはAmazon.Lambda.Templatesで追加できるlambda.FileBasedテンプレートをベースに開始できますし、そんなのを入れなくてもコピー&ペーストでも大丈夫1です。

テンプレートを使う場合は、次のコマンドでプロジェクトを作成します。

dotnet new lambda.FileBased -n ToUpper

あるいは、空のディレクトリを作成して、.csファイルを作成します。今回はToUpper.csという名前にします。

$ tree
.
└── ToUpper.cs

C#コードを用意する

テンプレートから生成されるToUpper.csファイルの中身は先のコードとほぼ同じです。 コード全体を改めて示します。

ToUpper.csのコード全体

// C# file-based Lambda functions can be deployed to Lambda using the
// .NET Tool Amazon.Lambda.Tools version 6.0.0 or later.
//
// Command to install Amazon.Lambda.Tools
//   dotnet tool install -g Amazon.Lambda.Tools
//
// Command to deploy function
//    dotnet lambda deploy-function <lambda-function-name> MyLambdaFunction.cs
//
// Command to package function
//    dotnet lambda package MyLambdaFunction.zip MyLambdaFunction.cs


#:package Amazon.Lambda.Core@2.8.0
#:package Amazon.Lambda.RuntimeSupport@1.14.1
#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4

// Explicitly setting TargetFramework here is done to avoid
// having to specify it when packaging the function with Amazon.Lambda.Tools
#:property TargetFramework=net10.0

// By default File-based C# apps publish as Native AOT. When packaging Lambda function
// unless the host machine is Amazon Linux a container build will be required.
// Amazon.Lambda.Tools will automatically initate a container build if docker is installed.
// Native AOT also requires the code and dependencies be Native AOT compatible.
//
// To disable Native AOT uncomment the following line to add the .NET build directive
// that disables Native AOT.
//#:property PublishAot=false

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// The function handler that will be called for each Lambda event
var handler = (string input, ILambdaContext context) =>
{
    return input.ToUpper();
};

// Build the Lambda runtime client passing in the handler to call for each
// event and the JSON serializer to use for translating Lambda JSON documents
// to .NET types.
await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaSerializerContext>())
  .Build()
  .RunAsync();

// Since Native AOT is used by default with C# file-based Lambda functions the source generator
// based Lambda serializer is used. Ensure the input type and return type used by the function
// handler are registered on the JsonSerializerContext using the JsonSerializable attribute.
[JsonSerializable(typeof(string))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

コードはC#のトップレベルステートメントを使ったExecutable assembly handlersスタイル名になっているので、Mainメソッドは不要で代わりにawait LambdaBootstrapBuilder.Create()が必要です。

ハンドラーのシグネチャはpublic async Task<string> HandleRequest(T input, ILambdaContext context)のようになります。第一引数が受け取るイベントデータの型で、第二引数がILambdaContextです。↓のコードは、文字列を受け取り大文字に変換して返すので、(string input, ILambdaContext context) => { ... }となっています。 要するに、ハンドラーは第一引数に受け取る型、第二引数にILambdaContextを指定すれば任意の非同期メソッドにできると考えればいいでしょう。

整理すると、開発者が書く部分は2か所です。

ハンドラーでどのような処理をするかの実装を書く

Lambdaで処理したい内容はこのハンドラー内に書きます。

var handler = (string input, ILambdaContext context) =>
{
    // ここに処理を書く
};

Lambdaで受け取る入力をシリアライズコンテキストに登録する

Lambdaへの入力をC#オブジェクトに変換するため、シリアライズコンテキストに登録します。 今回は入力が文字列想定なのでJsonSerializable(typeof(string))でシリアライズコンテキストに登録、ハンドラーに来た文字列をToUpper()で大文字に変換してレスポンスを返します。

[JsonSerializable(typeof(string))] // ここで受け取る型のシリアライズを登録する
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

デプロイする

Lambdaに直接.csファイルをデプロイするdotnet toolがあるので、これを使うと楽でしょう。 とりあえずAmazon.Lambda.Toolsをインストールします。

$ dotnet tool install -g Amazon.Lambda.Tools
You can invoke the tool using the following command: dotnet-lambda
Tool 'amazon.lambda.tools' (version '6.0.3') was successfully installed.

AWS認証を取得しておきます。

$ aws sso login --profile your-profile

Lambda関数をデプロイします。

ここが従来に比べて大きく変わった点で、.csprojファイルを用意せずに.csファイルだけでデプロイできるようになっています。 これならGitHub ActionsなどのCI/CD環境でデプロイするのも簡単です。

$ dotnet lambda deploy-function ToUpper ToUpper.cs --function-runtime dotnet10 --function-role arn:aws:iam::123456789012:role/lambda-function-Role --function-memory-size 256 --function-timeout 10 --profile your-profile
Amazon Lambda Tools for .NET Core applications (6.0.3)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet

Architecture not provided, defaulting to x86_64 for container build image.
Executing publish command
Starting container for native AOT build using build image: mcr.microsoft.com/dotnet/sdk:10.0-aot.
... invoking 'docker run --name tempLambdaBuildContainer-3fb48080-a444-499e-b6a4-16413be8c5d0 --rm --volume "C:\github\Lambda\DotnetFileAppFunction":/tmp/source/ -i mcr.microsoft.com/dotnet/sdk:10.0-aot dotnet publish "/tmp/source/ToUpper.cs" --output "/tmp/source\artifacts\ToUpper" --configuration "Release" --framework "net10.0" /p:GenerateRuntimeConfigurationFiles=true --runtime linux-x64 --self-contained True  /p:StripSymbols=true' from directory C:\github\Lambda\DotnetFileAppFunction
... docker run: Unable to find image 'mcr.microsoft.com/dotnet/sdk:10.0-aot' locally
... docker run: 10.0-aot: Pulling from dotnet/sdk
... docker run: 59287b3c3c70: Pulling fs layer
... docker run: 06762f394a85: Pulling fs layer
... docker run: e26f93cf9c70: Pulling fs layer
... docker run: 69c84e01b5c0: Pulling fs layer
... docker run: 47849234c411: Pulling fs layer
... docker run: 505db3b3094b: Pulling fs layer
... docker run: 95c4e06fe864: Pulling fs layer
... docker run: a3629ac5b9f4: Pulling fs layer
... docker run: 60fc5ac8adb0: Pulling fs layer
... docker run: da1a80ccb2fc: Pulling fs layer
... docker run: 46f592c23ae7: Pulling fs layer
... docker run: e26f93cf9c70: Download complete
... docker run: 95c4e06fe864: Download complete
... docker run: da1a80ccb2fc: Download complete
... docker run: 59287b3c3c70: Download complete
... docker run: 47849234c411: Download complete
... docker run: a3629ac5b9f4: Download complete
... docker run: 06762f394a85: Download complete
... docker run: 60fc5ac8adb0: Download complete
... docker run: 46f592c23ae7: Download complete
... docker run: a3629ac5b9f4: Pull complete
... docker run: 47849234c411: Pull complete
... docker run: 95c4e06fe864: Pull complete
... docker run: 46f592c23ae7: Pull complete
... docker run: e26f93cf9c70: Pull complete
... docker run: 59287b3c3c70: Pull complete
... docker run: 60fc5ac8adb0: Pull complete
... docker run: 505db3b3094b: Download complete
... docker run: 69c84e01b5c0: Download complete
... docker run: 69c84e01b5c0: Pull complete
... docker run: da1a80ccb2fc: Pull complete
... docker run: 06762f394a85: Pull complete
... docker run: 505db3b3094b: Pull complete
... docker run: Digest: sha256:d68a5e260330b659f7eae596a255bddbdc4e406e3579eb2d85d718ac58dd7dcb
... docker run: Status: Downloaded newer image for mcr.microsoft.com/dotnet/sdk:10.0-aot
... docker run:   Determining projects to restore...
... docker run:   Restored /tmp/source/ToUpper.csproj (in 13.86 sec).
... docker run:   ToUpper -> /root/.local/share/dotnet/runfile/ToUpper-6855d7fa7559aae751b6be03e6497d359004d36f9f6dae455063950209971e3d/bin/release_linux-x64/ToUpper.dll
... docker run:   Generating native code
... docker run:   ToUpper -> /tmp/source/artifacts/ToUpper/
Zipping publish folder C:\github\Lambda\DotnetFileAppFunction\artifacts\ToUpper to C:\github\Lambda\DotnetFileAppFunction\artifacts\ToUpper.zip
... zipping: ToUpper
Created publish archive (C:\github\Lambda\DotnetFileAppFunction\artifacts\ToUpper.zip).
Creating new Lambda function ToUpper
New Lambda function created

arm64も行けますが、NativeAOTでのarm64デプロイはarm64マシンが必要です。また、引数を最後に持っていくとFunction名がarm64になるので注意です2

# x86_64マシンでarm64デプロイしようとするとエラーになる
$ dotnet lambda deploy-function ToUpper-arm64 ToUpper.cs --function-architecture arm64 --function-runtime dotnet10 --function-role arn:aws:iam::123456789012:role/lambda-function-Role --function-memory-size 256 --function-timeout 10
Amazon Lambda Tools for .NET Core applications (6.0.3)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet
Host machine architecture (X64) differs from Lambda architecture (Arm64). Building Native AOT Lambda functions require the host and lambda architectures to match.

ビルド~デプロイ処理を見てみると、mcr.microsoft.com/dotnet/sdk:10.0-aotコンテナを使ってNative AOTビルドしています。 また、ビルドしたバイナリToUpperをzip化してLambda関数を作成しています。つまり、File-baed C#ですが、Lambda関数としては従来通りzip化されたバイナリをアップロードする形です。

デプロイ後のファイルツリーを見てみると、artifactsディレクトリにアップロードしたToUpper.zipがあり、ToUpperディレクトリにNative AOTでビルドされたバイナリが入っています。

.
│  ToUpper.cs
│
└─artifacts
    │  ToUpper.zip
    │
    └─ToUpper
            ToUpper
            ToUpper.dbg

ToUpper.zipの中身はToUpperバイナリだけが入っています。

$ unzip -l artifacts/ToUpper.zip
Archive:  artifacts/ToUpper.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
  6925632  1980-00-00 00:00   ToUpper
---------                     -------
  6925632                     1 file

動作確認する

デプロイされた結果です。

Lambdaコンソール

Lambda HandlerはToUpperに設定されます。通常C#の場合はNamespace.ClassName::MethodNameの形式ですが、ファイルベースの場合は名前空間やクラス名が省略され直接バイナリ名になります。

Lambda HandlerはToUpper

実行してみると、ToUpper関数が動作していることがわかります。テストイベントを以下のように設定します。

"foobar"

実行すると、以下のように返ってきます。

"FOOBAR"

Functionの実行結果

いい感じですね。

JSONを入力する

先ほどの例では文字列を受け取る単純なケースでしたが、実際のLambda関数ではJSONオブジェクトを扱うことが多いでしょう。 現在のコードのままJSONを与えると例外が発生します。

{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}

Lambdaを実行すると例外ログが出ます。

START RequestId: 08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9 Version: $LATEST
2026-01-14T06:09:44.113Z    08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9    fail    Amazon.Lambda.Serialization.SystemTextJson.JsonSerializerException: Error converting the Lambda event JSON payload to type System.String: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
 ---> System.Text.Json.JsonException: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
 ---> System.InvalidOperationException: Cannot get the value of a token type 'StartObject' as a string.
   at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_ExpectedString(JsonTokenType) + 0x19
   at System.Text.Json.Utf8JsonReader.GetString() + 0xa5
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader&, Type, JsonSerializerOptions, ReadStack&, T&, Boolean&) + 0x1e8
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader&, T&, JsonSerializerOptions, ReadStack&) + 0x81
   --- End of inner exception stack trace ---
   at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack&, Utf8JsonReader&, Exception) + 0x48
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader&, T&, JsonSerializerOptions, ReadStack&) + 0x21b
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.Deserialize(Utf8JsonReader&, ReadStack&) + 0x26
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1, JsonTypeInfo`1, Nullable`1) + 0xd6
   at Amazon.Lambda.Serialization.SystemTextJson.SourceGeneratorLambdaJsonSerializer`1.InternalDeserialize[T](Byte[]) + 0x78
   at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream) + 0x183
   --- End of inner exception stack trace ---
   at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream) + 0x229
   at Amazon.Lambda.RuntimeSupport.HandlerWrapper.<>c__DisplayClass44_0`2.<GetHandlerWrapper>b__0(InvocationRequest invocation) + 0x45
   at Amazon.Lambda.RuntimeSupport.LambdaBootstrap.<>c__DisplayClass26_0.<<InvokeOnceAsync>b__0>d.MoveNext() + 0x1d5
END RequestId: 08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9
REPORT RequestId: 08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9  Duration: 55.06 ms  Billed Duration: 56 ms  Memory Size: 256 MB Max Memory Used: 37 MB

これに対応するには、入力JSONを表すC#クラスを用意し、シリアライズコンテキストに登録します。

[JsonSerializable(typeof(HelloWorldEvent))]
//[JsonSerializable(typeof(string))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

public record HelloWorldEvent
{
    [JsonPropertyName("key1")]
    public required string Key1 { get; init; }
    [JsonPropertyName("key2")]
    public required string Key2 { get; init; }
    [JsonPropertyName("key3")]
    public required string Key3 { get; init; }
}

受け取ったHelloWorldEventオブジェクトを使うようにハンドラーを書き換えます。

var handler = (HelloWorldEvent input, ILambdaContext context) =>
{
    return $"{input.Key1}, {input.Key2}, {input.Key3}".ToUpper();
};

変更後のToUpper.csのコードは次の通りです。

#:package Amazon.Lambda.Core@2.8.0
#:package Amazon.Lambda.RuntimeSupport@1.14.1
#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4

#:property TargetFramework=net10.0

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// HelloWorldEventを受け取るように書き換える
var handler = (HelloWorldEvent input, ILambdaContext context) =>
{
    return $"{input.Key1}, {input.Key2}, {input.Key3}".ToUpper();
};

await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaSerializerContext>())
    .Build()
    .RunAsync();

// シリアライズコンテキストにHelloWorldEventを登録する
[JsonSerializable(typeof(HelloWorldEvent))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

// JSONを表すC#クラス
public record HelloWorldEvent
{
    [JsonPropertyName("key1")]
    public required string Key1 { get; init; }
    [JsonPropertyName("key2")]
    public required string Key2 { get; init; }
    [JsonPropertyName("key3")]
    public required string Key3 { get; init; }
}

これで受け取ったJSONの値を大文字に変換して返せます。

{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}

JSONを受け取って大文字で返す

IaCからデプロイする

PulumiのAWS SDKでもファイルベースプログラムのC# Lambda関数をデプロイできます。 ただ、dotnet toolからのデプロイと違って明示的に事前ビルドが必要なので微妙です。

ガワだけIaCで作っておいてCodeはIgnore、関数自体は別途デプロイしたほうが扱いやすいです。

var name = "dotnet-file-based-lambda";
var functionName = "ToUpper";
var handlerName = "ToUpper";
var functionCodePath = "Lambda/DotnetFileAppFunction/artifacts/ToUpper/ToUpper"; // 事前にビルドしておいたバイナリのパス
var roleArn = "arn:aws:iam::123456789012:role/lambda-function-Role";

var lambdaHash = HashHelper.CreateHashSha256(functionCodePath);

var cloudwatchLogs = new Pulumi.Aws.CloudWatch.LogGroup($"{name}-lambda-loggroup", new()
{
    Name = $"/aws/lambda/{functionName}",
    RetentionInDays = 7,
}, opt);

var lambda = new Pulumi.Aws.Lambda.Function($"{name}-lambda-function", new()
{
    Name = functionName,
    Description = AwsConstants.Descriptions.Default,
    Code = new FileArchive(Directory.GetParent(functionCodePath)!.FullName.NormalizePath()),
    Role = roleArn,
    Runtime = "dotnet10",
    Handler = handlerName,
    Timeout = 10,
    SourceCodeHash = lambdaHash,
}, opt);

パフォーマンスについて

AWS Blogで、.NET 8に比べて.NET 10がパフォーマンス低下するケース(#120288)について言及されています。これに関しては、.NET 10.0.2で解消したとのことなので、安心できそうです。

.NET 10.0.2でパフォーマンス低下が改善

まとめ

AWS Lambdaが.NET 10をサポートしたことで、ファイルベースプログラムのC# Lambda関数が使えるようになりました。 .csファイルだけでLambda関数をデプロイできるので、C#でのLambda開発がかなり手軽になります。

C#のラムダ関数は、PythonやNode.jsに比べてビルドが必要で、C#を意識するのはお手軽じゃないと感じていました。PythonやNode.jsのような手軽さでC#が書けるのは嬉しい変化です。 .NET 10におけるファイルベースプログラムは割と良い感じなので、ぜひ試してみてください。

参考


  1. 私はコピー&ペーストで書いてる
  2. コマンドの途中に--function-architecture arm64を入れると回避できます

リポジトリの.gitignoreを触らず自分だけのファイルを無視したい

最近、.git/info/excludeを使う方法を紹介している記事を読みました。 実際、.ideaや自分だけの設定ファイルなどは、うっかりコミットしそうになりますよね。 とはいえ、リポジトリの.gitignoreを触るとチームメンバーに影響が出るため、触りたくないことも多いでしょう。

同じような動機ですが、私が普段使っている他の方法も紹介します。

はじめに

gitは無視したいファイルを.gitignoreに書くことで、Gitの管理対象から外せます。 しかし、.gitignoreはリポジトリごとに存在し、チームメンバー全員に影響を与えます。自分だけの無視設定を書きたい場合、リポジトリの.gitignoreを変更するのは避けたくなります。

今回は、リポジトリの.gitignoreを触らずに自分だけの無視設定を書く方法を4つ紹介します。

  • そのリポジトリだけでファイルを無視する
  • リポジトリに無視ファイル設定を置く
    • リポジトリの.gitignoreに無視するファイルを書く
    • グローバルの.gitignoreに、リポジトリごとの無視ファイルを書く <- おすすめ!
  • グローバルの.gitignoreに直接無視設定を書く

方法1. そのリポジトリだけでファイルを無視する

そのリポジトリでだけファイルを無視するなら、.git/info/excludeを使えば自分だけの無視設定ができます。

$ echo ".idea/" >> .git/info/exclude

.git/info/excludeはリポジトリごとに存在するため、他のリポジトリには影響しません。 また、.gitignoreと同じ書式で無視設定ができて使い勝手が良いです。

一方で、.git/info/excludeはリポジトリごとに存在するため、複数のリポジトリで同じ設定をしたい場合は、各リポジトリで設定することになります。 また、リポジトリをクローンしなおしたときに設定が消えてしまう1ため、その点は注意が必要です。

これらが嫌で私は余り使わないようにしています。

方法2. リポジトリに無視ファイル設定を置く

リポジトリに.gitignore.local(ファイル名は何でもいい)を置き、そこに自分だけの無視設定を書く方法もあります。 これは2つの方法があります。

2-1. リポジトリの.gitignoreに無視するファイルを書く

リポジトリの.gitignoreに、.gitignore.localを一度だけ書いてしまうのもいいでしょう。 チーム全員が「リポジトリで自分だけの無視ファイル設定は.gitignore.localに書く」というルールを敷くスタイルです。 初回だけリポジトリの.gitignoreを触る必要がありますが、その後は各自が.gitignore.localに自分だけの無視設定を書けます。

$ echo ".gitignore.local" >> .gitignore

こうすることで、チームメンバー全員が、各自.gitignore.localに自分だけの無視設定を書けます。

$ echo ".idea/" >> .gitignore.local

無視されているか確認します。

$ git check-ignore -v -- .gitignore.local
.gitignore:139:.gitignore.local .gitignore.local

いい感じですね。

2-2. グローバルの.gitignoreに、リポジトリごとの無視ファイルを書く

リポジトリの.gitignoreを触りたくない場合、グローバルgitignoreに.gitignore.localを指定しておく方法もあります。 これなら、リポジトリの.gitignoreを一切触らずに自分だけの無視設定ができます。私はこれが好きです。

グローバルgitignoreの設定は、XDG_CONFIG_HOMEを使っているなら$XDG_CONFIG_HOME/git/ignoregit config --global core.excludesFileデフォルト設定です。 このパスにファイルを置けばexcludesFileにパスを設定しなくても有効になります。

XDG_CONFIG_HOMEは、Linux/macOS/Windows共通で設定ファイルを配置する場所を示す環境変数です。通常は$HOME/.configに設定されています。

例えば、XDG_CONFIG_HOME$HOME/.configなら、$HOME/.config/git/ignoreになります。

$ env | grep XDG_CONFIG_HOME
XDG_CONFIG_HOME=/home/foobar/.config

$ echo ".gitignore.local" >> "$XDG_CONFIG_HOME/git/ignore"

これで、リポジトリに.gitignore.localを置けば自分だけの無視設定を書けます。

$ echo .idea/ >> .gitignore.local

無視されているか確認します。

$ git check-ignore -v -- .gitignore.local
/home/foobar/.config/git/ignore:2:.gitignore.local       .gitignore.local

いい感じですね。

方法3. グローバルgitignoreを使う方法

リポジトリごとに無視設定を書くのが面倒な場合、グローバルgitignoreに直接無視したいファイルを書いておく方法もあります。

このやり方は特定のリポジトリだけ例外にできないので、あまり好きではありませんが、OS固有のファイルなどを無視するのに便利です。 グローバルにignoreする内容はgithub/gitignoreにあるテンプレートを参考にすると良いです。便利。

以下は、私が使っているグローバルgitignoreの例です。

$ cat "$XDG_CONFIG_HOME/git/ignore"

# --- git ---
.gitignore.local

# Gibo
.gitignore-boilerplates
.gitconfig.local

# --- macOS ---

# General
.DS_Store
__MACOSX/
.AppleDouble
.LSOverride
Icon[]

# Thumbnails
._*

# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent

# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

# --- Windows ---

# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db

# Dump file
*.stackdump

# Folder config file
[Dd]esktop.ini

# Recycle Bin used on file shares
$RECYCLE.BIN/

# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp

# Windows shortcuts
*.lnk

まとめ

リポジトリの.gitignoreを触らずに自分だけの無視設定を書く方法を4つ紹介しました。

方法 メリット デメリット
.git/info/exclude 設定が簡単 クローンしなおすと消える
.gitignore.local(リポジトリ) チーム全体で統一できる 一度だけ.gitignoreを触る必要がある
.gitignore.local(グローバル) リポジトリを一切触らない グローバル設定が必要
グローバルgitignore直接 OS固有ファイルに便利 例外設定ができない

リポジトリの.gitignoreは極力触りたくない場合、グローバルgitignoreにリポジトリごとの無視ファイルを指定する方法がおすすめです。

参考


  1. Unityプロジェクトだと、時々クローンしなおしたくなるのでありがちなんですよね

コンテナでGUIアプリケーションを動かしてホストからブラウザで操作する

前回の記事で、コンテナのGUIアプリケーションをホストからRDP/VNCで操作する方法を紹介しました。

今回は、それを一歩進めてブラウザ経由でコンテナのGUIアプリケーションにアクセスする方法を紹介します。引き続き、guitarrapc/docker-jmeter-guiリポジトリのDockerfileを利用します。

はじめに

前回まとめたコンテナのGUIアプリケーションをホストから操作する方法のうち、ブラウザ経由で接続する方法が今回です。RDP/VNCと違ってブラウザだけでアクセスできるため、かなり手軽ですし迷わず使えるのが嬉しいです。

方法 クロスプラットフォーム GPU不要 概要
X11転送 X O ホストにXサーバーをインストールし、コンテナからX11プロトコルで接続する
VNC O O コンテナ内にVNCサーバーをインストールし、ホストからVNCクライアントで接続する
RDP O O コンテナ内にRDPサーバーをインストールし、ホストからRDPクライアントで接続する
ブラウザ経由 O O コンテナのVNCをWebSocket化して差分ビットマップを送信、ホストからブラウザで接続する
WebRTC経由 O X コンテナをWebRTC化して動画ストリーム配信、ホストからブラウザで接続する

ブラウザ経由でのアクセスが他の方法と比べて優れている点は、次のようなものがあります。

  • クライアントソフト不要: RDPクライアントやVNCクライアントのインストールが不要
  • どこからでもアクセス: ブラウザさえあれば、OSを問わずアクセスできる
  • ファイアウォール的な扱いやすさ: HTTP/HTTPSポートを使うため、企業ネットワークでも接続しやすい

コンテナにブラウザ経由でアクセスできるようにする

まず、ホストマシンからブラウザで接続する様子を示します。ホストマシンからブラウザで接続するとデスクトップが表示、操作できます。ほしいのはこれだった。

Ubuntu 24.04 Alpine
Ubuntu24.04コンテナへのアクセスの様子 Alpine3.23コンテナへのアクセスの様子

コンテナ構成

Ubuntu 24.04ベース(KasmVNC)とAlpine Linuxベース(noVNC)で異なる方法を用意しています。いずれもブラウザでアクセスできるようにする手法は同じで、VNCサーバーをWebSocket化しています1

構築して動かした感じだと、KasmVNCは起動時の依存が少なく動かしやすいため好みですが通信帯域が大きめです。そう意味ではnoVNCはもう少し軽量なのでよい感じもします。

イメージサイズは次の通りです。

$ docker image ls
REPOSITORY  TAG                 IMAGE ID       CREATED          SIZE
jmeter-gui  5.6.3-alpine3.23    73616f724564   4 minutes ago    1.31GB
jmeter-gui  5.6.3-ubuntu24.04   6f04998c4e51   12 minutes ago   1.45GB
alpine      3.23                865b95f46d98   3 weeks ago      26.5MB
ubuntu      24.04               c35e29c94501   2 months ago     257MB

Ubuntu 24.04

Ubuntu 24.04ベースのコンテナは、KasmVNCを利用しています。ファイル配置は次の通りです。

$ tree
.
├── Dockerfile
└── kasmvnc.yaml

Dockerfileを示します。以下を考慮しています。

  • JMeterのインストールをマルチステージで分離
  • linux/amd64linux/arm64のマルチアーキテクチャ対応
  • バイナリダウンロード時にSHA512で整合性検証
FROM ubuntu:24.04 AS builder

ARG TARGETARCH

ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_PLUGINS_MANAGER_VERSION="1.10"
# SHA512 checksums for integrity verification
ENV JMETER_SHA512="5978a1a35edb5a7d428e270564ff49d2b1b257a65e17a759d259a9283fc17093e522fe46f474a043864aea6910683486340706d745fcdf3db1505fd71e689083"
ENV JMETER_PLUGINS_MANAGER_SHA512="38af806a7c78473c032ba93c7a2e522674871f01616985a0b0522483977d58afdc444d18bd5590b8036c344ccf11d2fe61be807501d5edb6d4bdebc9050c43ae"

RUN apt-get update \
    && apt-get install -y --no-install-recommends wget ca-certificates \
    # Download JMeter
    && wget https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz -O /tmp/jmeter.tgz \
    # Verify JMeter checksum
    && echo "${JMETER_SHA512}  /tmp/jmeter.tgz" | sha512sum -c - \
    && tar -xzf /tmp/jmeter.tgz -C /opt \
    # Download JMeter Plugins Manager
    && wget https://repo1.maven.org/maven2/kg/apc/jmeter-plugins-manager/${JMETER_PLUGINS_MANAGER_VERSION}/jmeter-plugins-manager-${JMETER_PLUGINS_MANAGER_VERSION}.jar -O ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar \
    # Verify Plugins Manager checksum
    && echo "${JMETER_PLUGINS_MANAGER_SHA512}  ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar" | sha512sum -c - \
    && rm -rf ${JMETER_HOME}/docs ${JMETER_HOME}/printable_docs /tmp/jmeter.tgz

FROM ubuntu:24.04

ARG TARGETARCH

LABEL version="5.6.3"
LABEL description="An Ubuntu based docker image contains Apache JMeter GUI to configure scenario. Enable connect container with browser."
LABEL maintainer="3856350+guitarrapc@users.noreply.github.com"

ENV DEBIAN_FRONTEND=noninteractive
ENV KASMVNC_VERSION="1.4.0"
ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_BIN=${JMETER_HOME}/bin
ENV PATH=${JMETER_BIN}:$PATH
ENV DISPLAY=":99"

# Install minimal packages
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates fluxbox xterm openjdk-11-jre wget \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# Install KasmVNC
RUN wget https://github.com/kasmtech/KasmVNC/releases/download/v${KASMVNC_VERSION}/kasmvncserver_jammy_${KASMVNC_VERSION}_${TARGETARCH}.deb -O /tmp/kasmvnc.deb \
    && apt-get update \
    && apt-get install -y --no-install-recommends /tmp/kasmvnc.deb \
    && rm /tmp/kasmvnc.deb \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /opt/apache-jmeter-${JMETER_VERSION} /opt/apache-jmeter-${JMETER_VERSION}

# Configure KasmVNC
RUN mkdir -p /root/.vnc \
    && echo '#!/bin/sh' > /root/.vnc/xstartup \
    && echo 'fluxbox &' >> /root/.vnc/xstartup \
    && echo 'jmeter -Jjmeter.laf=CrossPlatform' >> /root/.vnc/xstartup \
    && chmod +x /root/.vnc/xstartup

COPY kasmvnc.yaml /root/.vnc/kasmvnc.yaml

EXPOSE 8080

WORKDIR /root

CMD ["bash", "-c", "echo '2' | vncserver ${DISPLAY} -DisableBasicAuth -select-de manual -fg"]

kasmvnc.yamlは以下の通りです。コマンドライン引数で指定もできるのですが、設定ファイルでまとめておくと見通しがよく、利用時にオーバーライドしやすいのでいい感じです。

desktop:
  resolution:
    width: 1366
    height: 768
  allow_resize: true
  pixel_depth: 24

network:
  interface: 0.0.0.0
  websocket_port: 8080
  ssl:
    pem_certificate:
    pem_key:
    require_ssl: false

encoding:
  max_frame_rate: 30

以下のコマンドでdockerイメージをビルドします。

docker buildx build --platform linux/amd64,linux/arm64 -t jmeter-gui:5.6.3-ubuntu24.04 .

以下のコマンドでコンテナを起動します。

docker run -it --rm -v ${PWD}/scenarios:/root/jmeter/ -p 8080:8080 jmeter-gui:5.6.3-ubuntu24.04

コンテナが起動したら、ブラウザで http://localhost:8080 にアクセスすることで、JMeterのGUI画面が表示されます。

Alpine Linux

Alpine Linuxベースのコンテナは、noVNCを利用しています。ファイル配置は次の通りです。こっちは依存が多いため、supervisordでプロセス管理しています。

$ tree
.
├── Dockerfile
└── supervisord.conf

Dockerfileを示します。以下を考慮しています。

  • JMeterのインストールをマルチステージで分離
  • linux/amd64linux/arm64のマルチアーキテクチャ対応
  • バイナリダウンロード時にSHA512で整合性検証
  • supervisordでプロセス管理
FROM alpine:3.23 AS builder

ARG TARGETARCH

ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_PLUGINS_MANAGER_VERSION="1.10"
# SHA512 checksums for integrity verification
ENV JMETER_SHA512="5978a1a35edb5a7d428e270564ff49d2b1b257a65e17a759d259a9283fc17093e522fe46f474a043864aea6910683486340706d745fcdf3db1505fd71e689083"
ENV JMETER_PLUGINS_MANAGER_SHA512="38af806a7c78473c032ba93c7a2e522674871f01616985a0b0522483977d58afdc444d18bd5590b8036c344ccf11d2fe61be807501d5edb6d4bdebc9050c43ae"

RUN apk add --no-cache curl \
    # Download JMeter
    && curl -L https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz -o /tmp/jmeter.tgz \
    # Verify JMeter checksum
    && echo "${JMETER_SHA512}  /tmp/jmeter.tgz" | sha512sum -c - \
    && tar -xvf /tmp/jmeter.tgz -C /opt \
    # Download JMeter Plugins Manager
    && curl -L https://repo1.maven.org/maven2/kg/apc/jmeter-plugins-manager/${JMETER_PLUGINS_MANAGER_VERSION}/jmeter-plugins-manager-${JMETER_PLUGINS_MANAGER_VERSION}.jar -o ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar \
    # Verify Plugins Manager checksum
    && echo "${JMETER_PLUGINS_MANAGER_SHA512}  ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar" | sha512sum -c - \
    && rm -rf ${JMETER_HOME}/docs ${JMETER_HOME}/printable_docs /tmp/jmeter.tgz

FROM alpine:3.23

ARG TARGETARCH

LABEL version="5.6.3"
LABEL description="An Alpine based docker image contains Apache JMeter GUI to configure scenario. Enable connect container with browser."
LABEL maintainer="3856350+guitarrapc@users.noreply.github.com"

ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_BIN=${JMETER_HOME}/bin
ENV PATH=${JMETER_BIN}:$PATH
ENV DISPLAY=":99"

# Install minimal packages
RUN apk add --no-cache \
    ca-certificates bash fluxbox xterm x11vnc xvfb ttf-dejavu supervisor openjdk11-jre novnc \
    && ln -s /usr/share/novnc/vnc.html /usr/share/novnc/index.html \
    && rm -rf /var/cache/apk/*

COPY --from=builder /opt/apache-jmeter-${JMETER_VERSION} /opt/apache-jmeter-${JMETER_VERSION}

# Configure VNC
RUN mkdir -p /root/.vnc \
    && echo '#!/bin/sh' > /root/.vnc/xstartup \
    && echo 'fluxbox &' >> /root/.vnc/xstartup \
    && echo 'jmeter -Jjmeter.laf=CrossPlatform' >> /root/.vnc/xstartup \
    && chmod +x /root/.vnc/xstartup

# Configure supervisord
RUN mkdir -p /var/log/supervisor
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf

EXPOSE 8080

WORKDIR /root

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

supervisord.confは以下の通りです。

[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
user=root

[program:xvfb]
command=/usr/bin/Xvfb :99 -screen 0 1280x1024x24 -ac +extension GLX +render -noreset
autorestart=true
priority=10
stdout_logfile=/var/log/supervisor/xvfb.log
stderr_logfile=/var/log/supervisor/xvfb.err
startretries=3

[program:x11vnc]
command=/usr/bin/x11vnc -display :99 -forever -shared -nopw -wait 5
autorestart=true
priority=20
stdout_logfile=/var/log/supervisor/x11vnc.log
stderr_logfile=/var/log/supervisor/x11vnc.err
startretries=3

[program:novnc]
command=/usr/bin/novnc_server --vnc localhost:5900 --listen 8080
autorestart=true
priority=30
stdout_logfile=/var/log/supervisor/novnc.log
stderr_logfile=/var/log/supervisor/novnc.err
startretries=3

[program:fluxbox]
command=/usr/bin/fluxbox
environment=DISPLAY=":99"
autorestart=true
priority=40
stdout_logfile=/var/log/supervisor/fluxbox.log
stderr_logfile=/var/log/supervisor/fluxbox.err
startretries=3

[program:jmeter]
command=/opt/apache-jmeter-5.6.3/bin/jmeter -Jjmeter.laf=CrossPlatform
environment=DISPLAY=":99"
autorestart=false
priority=50
stdout_logfile=/var/log/supervisor/jmeter.log
stderr_logfile=/var/log/supervisor/jmeter.err
startretries=0

以下のコマンドでdockerイメージをビルドします。

docker buildx build --platform linux/amd64,linux/arm64 -t jmeter-gui:5.6.3-alpine3.23 .

以下のコマンドでコンテナを起動します。

docker run -it --rm -v ${PWD}/scenarios:/root/jmeter/ -p 8080:8080 jmeter-gui:5.6.3-alpine3.23

コンテナが起動したら、ブラウザで http://localhost:8080 にアクセスすることで、JMeterのGUI画面が表示されます。

コンテナへの接続フロー

ホストからコンテナへの接続フローは次の通りです。前回のVNC接続のフローの前にWebSocket変換ブリッジが入っただけですが、これが体験に大きな差をもたらします。ブラウザアクセスは本当に手軽です。

クリックで接続フローのMermaidを開く

graph LR
  subgraph ホストマシン
    A[ブラウザ]
  end
  subgraph コンテナ内
    B[WebSocket<br/>接続]
    C[VNCサーバー<br/>KasmVNC/noVNC]
    E[仮想Xサーバー<br/>Xvfb :99]
    F[デスクトップ環境<br/>fluxbox]
    G[JMeter GUI]
  end

  A -->|Port 8080| B
  B -->|VNCプロトコル| C
  C -->|X11プロトコル| E
  E --> F
  F --> G

ブラウザ接続フロー

パッケージ一覧

Dockerfileで使用しているパッケージ一覧を説明します。これらを使い、GUI環境を持たないDockerコンテナ内でJMeterのGUIを実行し、外部から接続を受け付けています。記載以外のパッケージは、前回の記事で説明しています。

KasmVNC

VNCサーバーがHTTP/HTTPSとWebSocketを直接サーブでき、追加のwebsockifyが不要です。1コンポーネントで完結しやすいのが利点です。

noVNC

websockify(WSプロキシ)と組み合わせて、VNCをWebSocket化する機能を持っています。VNCをウェブ化といううたい文句そのままを提供します。

supervisord

AlpineでnoVNCを動かす際に利用しています。起動時にXvfb、x11vnc、noVNCサーバー、デスクトップ環境、JMeterを起動するのですが、それぞれに依存関係があるためCMDでは扱いきれません。supervisordでプロセス管理することで、起動順序や再起動ポリシーを人間が扱える程度に整理できます。

まとめ

コンテナのGUIをホストから操作する方法として、ブラウザで接続する方法を紹介しました。この方法には、クロスプラットフォームで動作する、GPU不要である、ブラウザだけでアクセスできるといった利点があります。

2026年現在、GUIコンテナと一緒に提供するならブラウザ経由なのは当然という感じがあります。イメージサイズが膨れがちなのは微妙ですが、マルチアーキテクチャでも提供できますし、利便性を考えると十分に許容範囲内です。

次にやりたくなるのはWebRTCって感じですが、これはまた別の機会にまとめます。

参考


  1. WebRTCベースだとさらに低遅延で動画ストリームを配信できますが、低遅延は不要なので選択していません。

コンテナでGUIアプリケーションを動かしてホストからVNC/RDPで操作する

JMeterはJavaで動作する負荷試験ツールです。その特徴はGUIでシナリオを作成できることです。しかし、JMeter GUIを動作させるためにはJava環境が必要であり、セットアップが面倒ですしインストールも避けたいものがあります。

これを解決するため、Dockerコンテナ上でJMeter GUIを動作させるコンテナイメージguitarrapc/docker-jmeter-guiを公開しています。このコンテナイメージを作成する過程で、コンテナのGUIをホストから操作する方法をいくつか試してみました。

今回はホストからVNC/RDPでコンテナに接続してGUI操作する方法を紹介します。次回はブラウザ経由で接続する方法を紹介します。

はじめに

コンテナのGUIをホストから操作する方法はいくつかあります。

方法 クロスプラットフォーム GPU不要 概要
X11転送 X O ホストにXサーバーをインストールし、コンテナからX11プロトコルで接続する1
VNC O O コンテナ内にVNCサーバーをインストールし、ホストからVNCクライアントで接続する
RDP O O コンテナ内にRDPサーバーをインストールし、ホストからRDPクライアントで接続する
ブラウザ経由 O O コンテナのVNCをWebSocket化して差分ビットマップを送信、ホストからブラウザで接続する
WebRTC経由 O X コンテナをWebRTC化して動画ストリーム配信、ホストからブラウザで接続する

コンテナでどう構成するにしても、ホストからなるべく使いやすいのは絶対条件です。このため、X11転送は候補になりえません。VNCやRDPはクライアントソフトが必要ですが、Windows(RDP)やmacOS(VNC)には標準でクライアントが付属しているため、比較的使いやすいです。ブラウザ経由・WebRTC経由はブラウザさえあればよいため、最も使いやすい方法です。

WebRTCはGPUがないと体験が悪く、GPUがない環境でもコンテナは使われえるのでWebRTCは除外します。

このため、クロスプラットフォームでの利用を考えると、VNC/RDPやブラウザアクセスできるようにするのがいい感じです。

すべてを書いていると記事が長くなるので、この記事ではVNC/RDPで接続する方法を紹介します。

コンテナにVNC/RDPでアクセスできるようにする

まず、ホストマシンからRDP・VNCクライアントで接続する様子を示します。ホストマシンからRDPで接続するとX Serverログイン画面が表示されるのでパスワードを入力します。ホストマシンからVNCで接続するとVNCパスワード入力画面が表示されます。

パスワードをrootに固定しています。今回はローカルコンテナであるという割り切りです。ご了承ください。

状態 RDPクライアント VNCクライアント
接続画面 WindowsからRemote Desktop Connectionで接続する VNCで接続する
ログイン画面 XServerログイン画面 VNCパスワードの入力

接続に成功するとJMeter GUIが表示されます。

JMeterの画面

コンテナ構成

VNC/RDPでアクセスできるようにするには、コンテナ内にデスクトップ環境とVNCサーバーまたはRDPサーバーをインストールします。ファイル配置は次の通りです。

$ tree
.
├── Dockerfile
└── xrdp.ini

Dockerfile2を示します。

FROM ubuntu:24.04

LABEL version="5.6.3"
LABEL description="An Ubuntu based docker image contains Apache JMeter GUI to configure scenario. Enable connect container with VNC and RDP."
LABEL maintainer="3856350+guitarrapc@users.noreply.github.com"

ENV DEBIAN_FRONTEND=noninteractive
ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_BIN=${JMETER_HOME}/bin
ENV JMETER_PLUGINS_MANAGER_VERSION="1.10"
ENV PATH=${JMETER_BIN}:$PATH
ENV DISPLAY=":99" \
    RESOLUTION="1366x768x24" \
    PASS="root"

# Install minimal packages
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
    wget ca-certificates \
    xvfb x11vnc \
    xrdp xorgxrdp \
    fluxbox xterm \
    openjdk-11-jre \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# Download JMeter
RUN wget https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz -O /tmp/jmeter.tgz \
    && tar -xzf /tmp/jmeter.tgz -C /opt \
    && rm /tmp/jmeter.tgz \
    && rm -rf ${JMETER_HOME}/docs ${JMETER_HOME}/printable_docs
RUN wget https://repo1.maven.org/maven2/kg/apc/jmeter-plugins-manager/${JMETER_PLUGINS_MANAGER_VERSION}/jmeter-plugins-manager-${JMETER_PLUGINS_MANAGER_VERSION}.jar -O ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar

# Configure VNC
RUN x11vnc -storepasswd ${PASS} /etc/x11vnc.pass

# Set root password for RDP login
RUN echo "root:${PASS}" | chpasswd

# Configure RDP
COPY xrdp.ini /etc/xrdp/xrdp.ini

EXPOSE 5900 3389

WORKDIR /root

CMD ["bash", "-c", "rm -f /tmp/.X99-lock /var/run/xrdp.pid /var/run/xrdp-sesman.pid \
    && /usr/bin/Xvfb ${DISPLAY} -screen 0 ${RESOLUTION} -ac +extension GLX +render -noreset & \
    sleep 3 \
    && fluxbox & \
    sleep 2 \
    && jmeter -Jjmeter.laf=CrossPlatform & \
    sleep 3 \
    && x11vnc -xkb -noxrecord -noxfixes -noxdamage -display ${DISPLAY} -forever -rfbport 5900 -rfbauth /etc/x11vnc.pass -shared & \
    sleep 2 \
    && xrdp-sesman & \
    sleep 1 \
    && xrdp --nodaemon"]

xrdp.iniは以下の通りです。

[Globals]
bitmap_cache=true
bitmap_compression=true
autorun=jmeter
port=3389

[jmeter]
name=jmeter
lib=libvnc.so
ip=127.0.0.1
port=5900
username=na
password=ask

以下のコマンドでdockerイメージをビルドします。イメージサイズは約1.1GBです。Docker Hubでは300MB程度です。JavaランタイムとJMeter本体が大きいことに加え、GUIライブラリも入れているのである程度のサイズになるのは仕方ないです。

$ docker build -t docker-jmeter-gui .
$ docker image ls
docker image ls
REPOSITORY         TAG       IMAGE ID       CREATED              SIZE
docker-jmeter-gui  latest    cd1a471f7207   About a minute ago   1.13GB
ubuntu             24.04     c35e29c94501   2 months ago         117MB

以下のコマンドでコンテナを起動します。

docker run -it --rm -v ${PWD}/scenarios:/root/jmeter/ -p 5900:5900 -p 3389:3389 docker-jmeter-gui

注意: この例では開発・検証用途を想定しており、パスワードをrootに固定しています。本番環境や公開ネットワークでの使用は避けてください。

コンテナへの接続フロー

ホストからコンテナへの接続フローを示します。

本Dockerfileでは、RDPクライアントから接続してもVNCで接続したときと同じJMeter GUIを表示させたいため、xrdp.iniでlibvnc.soモジュールを利用しています。libvnc.soを指定することで、RDPクライアントから接続してもx11vncサーバーが提供するXvfbの仮想ディスプレイにアクセスします。libvnc.soモジュールを使わずにxorgxrdpで直接Xvfbに接続すると、RDP接続時に別のセッションが作成され、VNC接続時とは異なるデスクトップ環境が表示されます。3

クリックでコンテナのVNC/RDP接続フローのMermaidを開く

graph LR
  subgraph ホストマシン
    A[VNCクライアント]
    B[RDPクライアント]
  end
  subgraph コンテナ内
    C[VNCサーバー<br/>x11vnc]
    D[RDPサーバー<br/>xrdp]
    D1[libvnc.so<br/>モジュール]
    E[仮想Xサーバー<br/>Xvfb :99]
    F[デスクトップ環境<br/>fluxbox]
    G[JMeter GUI]
  end

  A -->|Port 5900| C
  B -->|Port 3389| D
  D --> D1
  D1 -->|VNCプロトコル| C
  C -->|X11プロトコル| E
  E --> F
  F --> G

VNC/RDP接続フロー

パッケージ一覧

Dockerfileで使用しているパッケージ一覧を説明します。これらを使い、GUI環境を持たないDockerコンテナ内でJMeterのGUIを実行し、外部から接続を受け付けています。

xvfb (X Virtual FrameBuffer)

仮想ディスプレイを提供するXサーバーです。物理的なモニターがなくてもGUIアプリケーション(JMeter GUI)を動作させます。DISPLAY=:99で仮想ディスプレイを作成していますが、他の番号でも構いません。

x11vnc

Xサーバーの画面を外部に公開する、VNCサーバーです。VNCクライアントからの接続を受け付け、Xvfbの仮想ディスプレイをVNCプロトコルで提供します。 ポート5900で待ち受け、パスワード認証を設定しています。

xrdp + xorgxrdp

Xサーバーの画面を外部に公開する、RDP(Remote Desktop Protocol)サーバーです。RDPクライアントからの接続を受け付け、Xvfbの仮想ディスプレイをRDPプロトコルで提供します。xorgxrdpはXサーバーとxrdpを接続するドライバです。

ポート3389で待ち受け、ログイン時にユーザー認証します。

fluxbox

軽量なウィンドウマネージャーです。JMeterのウィンドウを管理・表示するために必要です。これがないとGUIウィンドウが正しく表示されません。

ウィンドウマネージャーはxfce4、lxde、mateなどがあり、好きなものを選択できます。以前はxfce4を使っていましたが、fluxboxのほうが軽量なので変更しました。

xterm

端末エミュレータです。RDPやVNC接続時にコマンドラインを使えるようにします。

端末エミュレータは他にもgnome-terminal、lxterminal、xfce4-terminalなどがあります。以前は、xfce4-terminalを使っていましたが、fluxboxに合わせて軽量なxtermに変更しました。

まとめ

コンテナのGUIをホストから操作する方法として、VNC/RDPで接続する方法を紹介しました。この方法には、クロスプラットフォームで動作する、GPU不要である、既存のVNC/RDPクライアントをそのまま使えるといったメリットがあります。一方、VNCやRDPが必要になるのが明確な欠点です。

しばらく公開して自分でも使っていて感じるのですが、2026年現在、ブラウザ経由でないアクセスは使い勝手がいまいちです。ブラウザ経由であれば、ホストに特別なクライアントソフトをインストールする必要がなく、接続も簡単です。現代においてほとんどの操作はブラウザで完結するため、ブラウザ経由での接続を提供するほうがいいなぁというのが正直な感想です。

ということで、次回はブラウザ経由で接続する方法を紹介します。

参考


  1. LinuxホストであればXサーバーは構成しやすいですが、WindowsやMac OSではXサーバーのセットアップが必要です。
  2. 現在はブラウザベースに移行しているため、構成が変わっています。
  3. 5年前はAlpineで構成していたのですが、現在のAlpine Linuxのxrdpではlibvnc.soモジュールがうまく動作しなかったため、Ubuntuベースに変更しました。

terraform-provider-sopsとEphemeral valuesを使ってTerraformでシークレットを安全に扱う

AWSにはSSM Parameter StoreやSecrets Managerなど、機密情報を安全に管理するためのサービスが提供されています。しかし、TerraformでAWS環境を構築する際、SSM Parameter StoreやSecrets Managerに機密情報を登録する方法は悩ましいものがありました。Stateファイルに平文で保存されてしまう懸念から、手動で登録するパターンを使っているケースも多く見かけます。

この記事では、Terraform 1.10.0で導入されたEphemeral resourceterraform-provider-sopsを組み合わせて、機密情報を安全にコード管理する方法を紹介します。この方法には次のメリットがあります。

  • 機密情報をSOPSで暗号化してGit管理できる
  • TerraformのStateファイルに機密情報が保存されない
  • 機密情報もTerraformコードベースで把握できる

Ephemeral resourceについては、使い方の記事を以前書きました。合わせてどうぞ。

はじめに

2025年にリリースされたTerraform 1.10.0でEphemeral resourceが導入され、その値はStateファイルに残らなくなりました。Ephemeral resourceが利用できるかはリソースの対応を待つ必要があるのですが、2025年10月にリリースされたterraform-provider-sops 1.3.0でEphemeral resource対応しました。

これにより、SOPSで暗号化したファイルをGit管理しつつ、TerraformのStateファイルから機密情報を排除する構成をとれます。これまで難しかったTerraformでのシークレット管理がコード管理できるようになりました。

従来の方法と課題

TerraformでAWS環境を構築する際、SSM Parameter StoreやSecrets Managerに機密情報を登録する方法として、従来はTerraformでダミーの値を登録し、後からAWSコンソールやaws cliで機密情報に置き換える方法がよく使われていました。

resource "aws_ssm_parameter" "main" {
  name  = "my_secret_parameter"
  type  = "String"
  value = "DUMMY_VALUE"  # 後でAWSコンソールやaws cliで置き換える

  lifecycle {
    ignore_changes = [value]  # valueの変更を無視する
  }
}

運用でカバーする方法としては悪くないのですが、2つ課題があります。

AWSコンソールやaws cliで直接機密情報を登録する課題

Ephemeral resourceが登場するまでは、Terraformでこれらの値を入れるとStateファイルに平文で保存されてしまうため、AWSコンソールやaws cliで直接登録する方法がよく使われていました。しかしこの方法には以下のような問題点があります。

  • 機密情報の登録を手動で行う必要があり、環境構築の自動化が難しい
  • 誰がいつどのような値を登録したかの履歴がCloud Trailで確認するしかない
  • どのような値が登録されているかをコードベースで把握できない
  • 権限管理が必要なため、できる人、できない人が発生する

いずれもIaCが解決するべき課題であり、Terraformで機密情報を管理したいニーズは高いです。 Terraform Sateファイルに平文で保存されない方法があれば、Terraformで機密情報を管理することが可能になります。このため、Ephemeral valuesの登場は非常に大きな意味があります。

Terraformで機密情報を登録する課題

Ephemeral resourceが登場しても、機密情報をTerraformに渡す方法が課題でした。いくつか方法がありますが、代表的なものとして以下が挙げられます。

  • 実行環境から環境変数経由で渡す
  • Terraform実行時にコマンドライン引数で渡す
  • 暗号化されたファイルから読み込む

環境変数経由やコマンドライン引数で渡す方法は、実行環境に機密情報を保持する必要があり、実行環境の管理が煩雑になる問題があります。また、どのような値を渡しているかをコードベースで把握できない問題もあります。

暗号化されたファイルから読み込む方法は、コードベースで把握できます。しかし、terraform-provider-sopsはEphemeral resourceを実装していなかったため、dataリソースで読み込んだシークレットがTerraform Stateファイルに平文で保存されてしまう問題がありました。

terraform-provider-sopsがEphemeral resourceに対応したことで、SOPSで暗号化したファイルをgitに置きつつ、TerraformのStateファイルには機密情報を保存しない構成をとれます。

terraform-provider-sopsとEphemeral valuesを使って機密情報を安全に扱う

シークレットをコード管理しつつ、TerraformのStateファイルに平文で保存しない。両者の課題を解決できるのが、terraform-provider-sopsとEphemeral valuesを組み合わせた方法です。今回は、AWS SSM Parameter Storeに機密情報を登録する例1を紹介します。

SOPSをTerraformで使う手順を見てみましょう。1-3までは事前準備なので、一度構成してしまえば以降は再実施不要です。普段の運用は、いつも通りTerraformリソースを書いて実行するだけです。

  1. KMSキーの作成 (事前設定)
  2. SOPSで暗号化されたファイルの作成 (事前設定)
  3. TerraformでSOPSプロバイダーの設定 (事前設定)
  4. Terraformリソースの定義
  5. Terraformの実行

実行環境

次の実行環境を用います。

事前にTerraformとSOPSをインストールしておいてください。 sopsは、scoopやHomebrew、aquaなどでインストールできます。

KMSキーの作成

SOPSの暗号化・復号に使用するKMSキーを作成します。KMSを用いることで、sopsで暗号化されたファイルへのアクセス権限を、KMSアクセスできるIAM RoleやIAM User単位で管理できます。 以下のTerraformコードでKMSキーを作成します。

data "aws_caller_identity" "current" {}

resource "aws_kms_key" "main" {
  description             = "Terraform managed."
  deletion_window_in_days = 7
  enable_key_rotation     = true
  key_usage               = "ENCRYPT_DECRYPT"
  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : "Enable IAM User Permissions",
        "Effect" : "Allow",
        "Principal" : {
          "AWS" : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        },
        "Action" : [
          "kms:*"
        ],
        "Resource" : "*"
      }
    ]
  })
}

resource "aws_kms_alias" "main" {
  name          = "alias/terraform-provider-sops"
  target_key_id = aws_kms_key.main.key_id
}

output "arn" {
  description = "KMS Key arn"
  value       = aws_kms_key.main.arn
}

KMS ARNは後でSOPSの設定で使用するため、出力しておきます。

次に、SOPSを実行するIAM RoleやIAM Userに、このKMSキーを使用する権限を付与します。以下のポリシーはKMS暗号化・復号に必要な権限を付与します。このポリシーを付けたIAM Role/ユーザーがSOPSでシークレットを暗号化・復号できます。

{
  "Sid": "Allow use of the key",
  "Effect": "Allow",
  "Action": [
    "kms:Encrypt",
    "kms:Decrypt",
    "kms:DescribeKey"
  ],
  "Resource": "*",
  "Principal": {
    "AWS": [
      "arn:aws:iam::123456789012:role/sops-dev-xyz"
    ]
  }
}

SOPSで暗号化されたファイルの作成

SOPSで暗号化されたファイル.sops.yamlを作成します。今回はfooフォルダでTerraformリソースを定義していると仮定して、./foo/secrets.yamlファイルを作成します。secrets.yamlにSOPSで暗号化・復号するシークレットを設定、指定したKMS鍵で暗号化・復号するように設定します。

  • path: 暗号化するシークレットが書かれたファイルパス
  • kms: 先ほど作成したKMSキーのARN
creation_rules:
  - path: ./foo/secrets.yaml
    kms: >-
      arn:aws:kms:ap-northeast-1:123456789012:key/01234567-1234-abcd-abcd-1234567890ab

SOPSで暗号化・復号操作する前に、事前にAWSアカウントの認証を取得しておきましょう。KMSキーへのアクセス権限を持つプロファイルを使用してください。

# Identity Center (SSO) を使っている場合
aws sso login --profile YOUR_PROFILE

# aws loginを使っている場合
aws login --profile YOUR_PROFILE

sops editコマンドを使うと、指定したファイルを自動的に復号してエディタで開きます。編集後に保存すると、自動的に暗号化されます。secrets.yamlファイルがなくても作成してくれるので、初回から使えて万能です。

AWS_PROFILE=YOUR_PROFILE sops edit ./foo/secrets.yaml

コマンドを実行すると、初期状態なら次のような内容でエディタが開きます。

hello: Welcome to SOPS! Edit this file as you please!
example_key: example_value
# Example comment
example_array:
    - example_value1
    - example_value2
example_number: 1234.56789
example_booleans:
    - true
    - false

雑に編集して、次のようにfooとbarの2キーを持つYAMLファイルにします。

foo: thisisasecretvalue
bar: 123456789

保存するとsecrets.yamlファイルが暗号化されます。内容を確認してみましょう。

$ cat ./foo/secrets.yaml
foo: ENC[AES256_GCM,data:Gm/K9H+V+tM8TFrO329WYlHh,iv:75K+UGidrBPFGGdseZvvoWkGRNG32LvTDAy59O2ZvsI=,tag:nueHHxln7AWZWVuE+JGlgw==,type:str]
bar: ENC[AES256_GCM,data:VAKgrrKJQzpK,iv:ZDiCPV247Om6xIV6EBLUFRaQuXoBazvogckLWoX+1Vo=,tag:nXiLbNX0qwWL4UgxYS/5DQ==,type:int]
sops:
    kms:
        - arn: arn:aws:kms:ap-northeast-1:123456789012:key/01234567-1234-abcd-abcd-1234567890ab
          created_at: "2026-01-06T16:57:47Z"
          enc: AQICAHiU+HnowUKfgMmUO2S0Jj9ScLimZ37vJyD1AVgeLYaUSgFIHwSL2H1eF7HzbrHGSAmSAAAAfjB8BgkqhkiG9w0BBwagbzBtAgEAMGgGCSqGSIb3DQEHATAeBglghkgBZQMEAS4wEQQMhgseKZ423Pb8ehrdAgEQgDuTdy3uTRjrppPambOmjwKH2eIS3JK6+6LhD/scB2K2eaSAq6+sNpI3p6fepoAS3EjeaCMTd7n5ieYFkg==
          aws_profile: ""
    lastmodified: "2026-01-06T16:59:12Z"
    mac: ENC[AES256_GCM,data:gpVk67OIWAWg+iyWUu9q7cmI/DcviR70f5e+cI7kvCnBj0bVPNKPrIs2zDg5eD1OnCo+PbQXexX8YTBt0uqFx2Wm5iJ3Dpa6zCru4F3si92lVCUswNyfDeHHp7eGoGO9CpDGYi5HkBpf6cqOLS7F/rigqmIEtkF0clFbpOMj1ak=,iv:SLzttBfFg4lNjI/XpRbHokUcJTe4PrsGqqRi2Fz6UyY=,tag:1IbgK2p2S6V8bUWawLdY9Q==,type:str]
    unencrypted_suffix: _unencrypted
    version: 3.11.0

TerraformでSOPSプロバイダーの設定

TerraformでSOPSを扱えるterraform-provider-sopsプロバイダーを設定しましょう。sopsプロバイダーv1.3.0以降でEphemeral valuesに対応しています。 Terraformの設定ファイルにcarlpett/sopsプロバイダーを追加します。

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 6.27.0"
    }
    sops = {
      source = "carlpett/sops"
      version = "= 1.3.0"
    }
  }
  required_version = "~> 1.14.0"
}

terraform initを実行すると、sopsプロバイダーがダウンロードされます。

$ cd foo
$ terraform init
... 省略
Initializing provider plugins...
- Finding carlpett/sops versions matching "1.3.0"...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Installing carlpett/sops v1.3.0...
- Installed carlpett/sops v1.3.0 (self-signed, key ID 1468AC14E6819667)
- Using previously-installed hashicorp/aws v6.27.0
... 省略

これでTerraform実行時に自動的にsopsで復号されます。

Terraformリソースの定義

暗号化したシークレットファイルsecrets.yamlから値を取得し、SSM Parameter Storeに登録するTerraformリソースを定義します。SOPSプロバイダーにv1.3.0で追加されたエフェメラルリソースephemeral "sops_file"を用いると、Stateファイルに値が保存されません。従来のdata "sops_file"リソースはStateファイルに値が保存されてしまうため避けましょう。

aws_ssm_parameterはEphemeral valuesに対応しているため、ephemeralリソースから取得した値をそのまま渡せます。今回はわかりやすいよう、secrets.yamlの内容を復号したものをそのままSSM Parameter Storeに登録します。

ephemeralリソースから取得した値はraw属性でアクセスできます。rawしかないので個別の値へのアクセスはAPIとして提供されていません。 aws_ssm_parameterにEphemeral valuesを渡すには、value_wo属性を使います。value_woは"write-only"の略で、この属性に渡した値はStateファイルに保存されません。値を変更したときはvalue_wo_versionも変更すると、Terraformが更新を検知します。

ephemeral "sops_file" "secrets" {
  source_file = "secrets.yaml"
}

resource "aws_ssm_parameter" "main" {
  name  = "sops_secrets"
  type  = "String"
  value_wo = ephemeral.sops_file.secrets.raw
  value_wo_version = 1
}

terraform planを実行して、問題ないことを確認します。

$ terraform plan

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_ssm_parameter.main will be created
  + resource "aws_ssm_parameter" "main" {
      + arn              = (known after apply)
      + data_type        = (known after apply)
      + has_value_wo     = (known after apply)
      + id               = (known after apply)
      + insecure_value   = (known after apply)
      + key_id           = (known after apply)
      + name             = "sops_secrets"
      + region           = "ap-northeast-1"
      + tier             = (known after apply)
      + type             = "String"
      + value            = (sensitive value)
      + value_wo         = (write-only attribute)
      + value_wo_version = 1
      + version          = (known after apply)
    }

terraform applyすると、SSM Parameter Storeに機密情報が登録されます。値が、secrets.yamlの内容と同じであることを確認しましょう。

foo: thisisasecretvalue
bar: 123456789

SSM Parameterにsecrets.yamlの内容が入る

tfstateファイルを確認してみましょう。ephemeralリソースの値がvaluevalue_woに保存されていないことがわかります。valueは空文字列、value_woはnullと、機密情報が除外されています。これがEphemeral valuesの利点です。

{
  "mode": "managed",
  "type": "aws_ssm_parameter",
  "name": "main",
  "provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
  "instances": [
    {
      "schema_version": 0,
      "attributes": {
        "allowed_pattern": "",
        "arn": "arn:aws:ssm:ap-northeast-1:123456789012:parameter/sops_secrets",
        "data_type": "text",
        "description": "",
        "has_value_wo": true,
        "id": "sops_secrets",
        "insecure_value": null,
        "key_id": "",
        "name": "sops_secrets",
        "overwrite": null,
        "region": "ap-northeast-1",
        "tags": null,
        "tier": "Standard",
        "type": "String",
        "value": "",
        "value_wo": null,
        "value_wo_version": 1,
        "version": 1
      },
      "sensitive_attributes": [
        [
          {
            "type": "get_attr",
            "value": "value"
          }
        ],
        [
          {
            "type": "get_attr",
            "value": "value_wo"
          }
        ]
      ],
      "identity_schema_version": 0,
      "identity": {
        "account_id": "123456789012",
        "name": "sops_secrets",
        "region": "ap-northeast-1"
      },
      "private": "bnVsbA==",
      "dependencies": [
        "ephemeral.sops_file.secrets"
      ]
    }
  ]
}

個別の値を登録したい

terraform-provider-sopsのephemeralリソースはraw属性しか提供していないため、個別の値に直接アクセスできません。そこで、yamldecodejsondecode関数を使って、rawで取得したYAML/JSONコンテンツを分解します。

先ほどのsecrets.yamlは、YAMLフォーマットでfoobarのキーを持っています。それぞれを個別のSSM Parameterとして登録したい場合、yamldecode関数を使います。この方法なら、secrets.yamlに複数の値を持たせつつ、必要な値だけを個別のParameterとして登録できます。アプリケーション側で必要な値だけを取得できるので、アクセス権限が管理しやすくなります。

ephemeral "sops_file" "secrets" {
  source_file = "secrets.yaml"
}

resource "aws_ssm_parameter" "foo" {
  name  = "sops_foo"
  type  = "String"
  value_wo = yamldecode(ephemeral.sops_file.secrets.raw).foo
  value_wo_version = 1
}

resource "aws_ssm_parameter" "bar" {
  name  = "sops_bar"
  type  = "String"
  value_wo = yamldecode(ephemeral.sops_file.secrets.raw).bar
  value_wo_version = 1
}

terraform applyを実行すると、SSM Parameter Storeに個別の値が登録されます。

sops_fooには、secrets.yamlのfooの値が入っています。

fooの値が入っていることが確認できる

sops_barには、secrets.yamlのbarの値が入っています。

barの値が入っていることが確認できる

value_wo_versionを自動化する

value_wo_version属性は、値を変更したときに更新を検知するために使います。value_wo_versionを手動で管理していると、値を更新したときにバージョンを上げ忘れることがあります。私は一度やらかしたので、自動化を推奨します。

値が変更されたら自動的にversionも変わるように、ハッシュ値を使った自動化が良いでしょう。ただ、次のように復号した値ハッシュ計算に使おうとすると実行時怒られます。

locals {
  sops_secrets = yamldecode(ephemeral.sops_file.secrets.raw)
  sops_secrets_hash = sha256(local.sops_secrets) # これはダメ
}

復号した値を計算式に直接渡すことはできないため、シークレットファイル全体のハッシュ値を用います。シークレットを更新するときにファイルハッシュが書き変わります。SSM Parameter Storeに登録されている値を更新しても特に影響がないので、対象のシークレット以外もTerraformで差分検出されても大きな問題はありません。

ephemeral "sops_file" "secrets" {
  source_file = local.sops_secrets_path
}

locals {
  sops_secrets_path = "secrets.yaml"
  sops_secrets_hash = substr(filesha256(local.sops_secrets_path), 0, 8)
}

resource "aws_ssm_parameter" "main" {
  name  = "sops_secrets"
  type  = "String"
  value_wo = local.sops_secrets
  value_wo_version = local.sops_secrets_hash
}

resource "aws_ssm_parameter" "foo" {
  name  = "sops_foo"
  type  = "String"
  value_wo = local.sops_secrets.foo
  value_wo_version = local.sops_secrets_hash
}

resource "aws_ssm_parameter" "bar" {
  name  = "sops_bar"
  type  = "String"
  value_wo = local.sops_secrets.bar
  value_wo_version = local.sops_secrets_hash
}

シークレットファイルのハッシュを使う方法の注意点

1つの値を更新したり、KMSキーのローテーションでもシークレットファイルは書き変わり、全シークレットが更新されます。これが気になる場合、個別にephemeralリソースを作成して分割する方法もあります。シークレットファイルが多数増えるのは運用しにくいのでオススメしにくいですが、要件次第ではよいでしょう。

あるいは、あきらめてvalue_wo_versionを手動で管理する方法もあります。Copilot Agentを使って、シークレットの更新に合わせてバージョンも更新させるのは悪くないでしょう。

運用上の注意

この方法の鍵はAWS KMSのアクセス権限管理です。KMSキーのkms:Decryptkms:Encryptにアクセスできるユーザー・ロールが、SOPSで暗号化されたファイルを閲覧、操作できるユーザーです。もちろんTerraform実行環境もKMSキーにアクセスできる必要があります。

また、Ephemeral valuesはStateに残りませんが、Stateファイル自体の管理は引き続き注意が必要です。構成が丸見えになるため、Stateファイルの保存先やアクセス権限管理は適切に行いましょう。

まとめ

terraformにおけるシークレット管理は、Ephemeral valuesの登場で大きく変わりました。terraform-sops-providerがEphemeral resourceに対応したことで、SOPSで暗号化されたファイルをコードベースで管理しつつ、TerraformのStateファイルに平文で保存しない構成がとれます。

インフラのコード化を進める上で、シークレット管理は避けて通れない課題です。今回の方法は、万が一Gitリポジトリが漏洩しても、KMSキーで保護されたシークレットファイルが守られるため、安全にコードベースでシークレットを管理できます。

私もこの構成をとってしばらく経ちますが、運用しやすくいい感じです。

参考

GitHub


  1. Secrets Managerも同様ですが、簡単のためSSM Parameter Storeを使います。