はじめに
Langfuseは、LLMを利用したアプリケーションを観測・分析するためのオープンソースのプラットフォームだ。
Strands Agentsのようなフレームワークを使っている場合、アプリにほんのひと手間加えるだけでトレースとメトリクスが簡単に取得できるようになる。
また、環境構築がお手軽にできるのも特徴だ。
だが、真面目に構築すると、常時起動リソースが必要になったりで少なからずクラウド利用料がかかる。
ちょっとした検証をするときにはDockerを使ってセルフホストするというのも手段だが、残念ながら世の中にはDockerが容易に使えない環境もある(そんなのある?と俄かに信じられない方もいると思うが、あると言ったらあるのだ)。
そこで、今回は、Amazon ECS+AWS Fargateを使ってセルフホスト相当のコンテナをシングルタスクで起動する。
ALBといった常時起動リソースも一切不要なので、気軽にタスクのDesired countを変更して上げ下げをすれば良い。かかる料金は、Fargateを起動する時間だけだ!(厳密にはAmazon ECRやAmazon CloudWatch Logsの料金も多少かかるが、僅かで済むはず)
AWS Fargateのシングルタスクなので、当然ながらストレージの永続性はない(ストレージはデフォルトのエフェメラルストレージを利用する)。永続性を考慮するのであれば、おとなしくまともな環境を作るのが良いだろう。今回の構成はあくまでも個人のデバッグ目的程度のものと考えていただきたい。
前準備
IPアドレスを動的に取得するコンテナイメージの準備
FargateにはパブリックIPアドレスを取得することができるが、起動時にアタッチされるため、環境変数でコンテナに渡すことができない。そこで、コンテナ起動後に動的にIPアドレスを取得して、Langfuseの起動時にホストするURLを設定する必要がある。
AL2023のminimalなコンテナイメージにはJQがインストールされていないため、jqを追加したイメージを作りECRにPUSHしておこう。
FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal
RUN dnf install -y \
aws-cli \
jq-1.6 \
&& dnf clean all
ENTRYPOINT [ "sh", "-c" ]
resource "aws_ecr_repository" "aws_cli_minimal" {
name = local.ecr_repository_name
image_tag_mutability = "MUTABLE"
}
data "aws_ecr_authorization_token" "token" {}
resource "terraform_data" "image_push" {
provisioner "local-exec" {
command = <<-EOF
docker build ./ -t ${aws_ecr_repository.aws_cli_minimal.repository_url}:latest; \
docker login -u AWS -p ${data.aws_ecr_authorization_token.token.password} ${data.aws_ecr_authorization_token.token.proxy_endpoint}; \
docker push ${aws_ecr_repository.aws_cli_minimal.repository_url}:latest
EOF
}
}
IAM/Amazon CloudWatch Logといった足回りのリソース
タスクロールとタスク実行ロール、それぞれのECSとタスクのログを保存するAmazon CloudWatch Logsのロググループを定義しておこう。
タスク実行ロールでは、今回、LangfuseのキーやユーザID/パスワードをAWS Systems Manager Parameter Storeで保持するため、アクセス権を付与しておく。
タスクロールで特殊なのは、ec2:DescribeNetworkInterfaces, ecs:DescribeTasksの権限だ。
IPアドレスを動的に取得するために必要な権限なのでタスクロールに設定しておこう。
resource "aws_cloudwatch_log_group" "ecs_log_group" {
name = local.logs_ecs_loggroup_name
retention_in_days = 3
}
resource "aws_cloudwatch_log_group" "ecstask_log_group" {
name = local.logs_ecs_task_loggroup_name
retention_in_days = 3
}
resource "aws_ssm_parameter" "langfuse_project_public_key" {
name = "/${var.prefix}/langfuse/project_public_key"
type = "SecureString"
value = "pk-lf-${uuid()}"
lifecycle {
ignore_changes = [ value ]
}
}
resource "aws_ssm_parameter" "langfuse_project_secret_key" {
name = "/${var.prefix}/langfuse/project_secret_key"
type = "SecureString"
value = "sk-lf-${uuid()}"
lifecycle {
ignore_changes = [ value ]
}
}
resource "aws_ssm_parameter" "langfuse_user_email" {
name = "/${var.prefix}/langfuse/user_email"
type = "SecureString"
value = local.langfuse_user_email
}
resource "aws_ssm_parameter" "langfuse_user_password" {
name = "/${var.prefix}/langfuse/user_password"
type = "SecureString"
value = local.langfuse_user_password
}
################################################################################
# ECS Task Execution #
################################################################################
resource "aws_iam_role" "ecs_task_execution" {
name = local.iam_ecs_task_execution_role_name
assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_assume.json
}
data "aws_iam_policy_document" "ecs_task_execution_assume" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = [
"ecs-tasks.amazonaws.com",
]
}
actions = [
"sts:AssumeRole",
]
}
}
resource "aws_iam_role_policy" "ecs_task_execution" {
name = local.iam_ecs_task_execution_policy_name
role = aws_iam_role.ecs_task_execution.id
policy = data.aws_iam_policy_document.ecs_task_execution_custom.json
}
data "aws_iam_policy_document" "ecs_task_execution_custom" {
statement {
effect = "Allow"
actions = [
"ecr:GetAuthorizationToken",
]
resources = ["*"]
}
statement {
effect = "Allow"
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage",
]
resources = [
aws_ecr_repository.aws_cli_minimal.arn
]
}
statement {
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
aws_cloudwatch_log_group.ecstask_log_group.arn,
"${aws_cloudwatch_log_group.ecstask_log_group.arn}:*"
]
}
statement {
effect = "Allow"
actions = [
"ssm:GetParameter",
"ssm:GetParameters",
]
resources = [
aws_ssm_parameter.langfuse_project_public_key.arn,
aws_ssm_parameter.langfuse_project_secret_key.arn,
aws_ssm_parameter.langfuse_user_email.arn,
aws_ssm_parameter.langfuse_user_password.arn,
]
}
}
################################################################################
# ECS Tasks #
################################################################################
resource "aws_iam_role" "ecs_task" {
name = local.iam_ecs_task_role_name
assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json
}
data "aws_iam_policy_document" "ecs_task_assume" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = [
"ecs-tasks.amazonaws.com",
]
}
actions = [
"sts:AssumeRole",
]
}
}
resource "aws_iam_role_policy" "ecs_task" {
name = local.iam_ecs_task_policy_name
role = aws_iam_role.ecs_task.id
policy = data.aws_iam_policy_document.ecs_task_custom.json
}
data "aws_iam_policy_document" "ecs_task_custom" {
statement {
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
aws_cloudwatch_log_group.ecs_log_group.arn,
"${aws_cloudwatch_log_group.ecs_log_group.arn}:*"
]
}
statement {
effect = "Allow"
actions = [
"ec2:DescribeNetworkInterfaces",
"ecs:DescribeTasks",
]
resources = ["*"]
}
}
また、セキュリティグループも、以下の通り設定しておく。
resource "aws_security_group" "langfuse" {
name = local.security_group_langfuse_name
description = "For Langfuse"
vpc_id = data.aws_vpc.langfuse.id
ingress {
description = "HTTP"
from_port = 3000
to_port = 3000
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
ECS Fargateの設定
ECS ClusterとService
以下のように設定する。
パブリックIPアドレスをアタッチするために、aws_ecs_service.langfuse.assign_public_ipをtrueに設定しておこう。
aws_ecs_task_definition.langfuse.volumeの設定については、LangfuseのGitHubのdocker-compose.ymlの中で定義されているものを一通り設定する。それに加えて、事前に設定したIPアドレス取得用コンテナから、取得した情報をファイルでLangfuseのコンテナに渡すためのshareボリュームを設定する。
resource "aws_ecs_cluster" "langfuse" {
name = local.ecs_cluster_name
}
resource "aws_ecs_service" "langfuse" {
name = local.ecs_service_name
cluster = aws_ecs_cluster.langfuse.id
launch_type = "FARGATE"
task_definition = aws_ecs_task_definition.langfuse.arn
desired_count = 1
network_configuration {
subnets = flatten([data.aws_subnets.langfuse.ids])
security_groups = [
aws_security_group.langfuse.id,
]
assign_public_ip = "true"
}
}
resource "aws_ecs_task_definition" "langfuse" {
family = local.ecs_task_family_name
execution_role_arn = aws_iam_role.ecs_task_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
requires_compatibilities = [
"FARGATE",
]
network_mode = "awsvpc"
cpu = "1024"
memory = "4096"
volume {
name = "postgres-data"
}
volume {
name = "clickhouse-data"
}
volume {
name = "clickhouse-logs"
}
volume {
name = "minio-data"
}
volume {
name = "share"
}
container_definitions = templatefile("./taskdef.json", {
AWS_REGION = data.aws_region.current.name
LOG_GROUP_NAME = aws_cloudwatch_log_group.ecstask_log_group.name
ECR_REPOSITORY_URL = aws_ecr_repository.aws_cli_minimal.repository_url
# Langfuse Settings
LANGFUSE_ORG_ID = local.langfuse_org_id
LANGFUSE_ORG_NAME = local.langfuse_org_name
LANGFUSE_PROJECT_ID = local.langfuse_project_id
LANGFUSE_PROJECT_NAME = local.langfuse_project_name
LANGFUSE_PROJECT_PUBLIC_KEY = local.langfuse_public_key
LANGFUSE_PROJECT_SECRET_KEY = local.langfuse_secret_key
LANGFUSE_USER_EMAIL = local.langfuse_user_email
LANGFUSE_USER_PASSWORD = local.langfuse_user_password
})
}
コンテナ定義
コンテナ定義は以下のJSONで設定する。
かなり長いため、読みたい方だけ読んでいただければと思う。
基本的には、docker-compose.ymlからの移植だ。
taskdef.json
[
{
"name": "langfuse-web",
"image": "langfuse/langfuse:3",
"essential": true,
"portMappings": [
{
"containerPort": 3000,
"hostPort": 3000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "DATABASE_URL",
"value": "postgresql://postgres:postgres@localhost:5432/postgres"
},
{
"name": "SALT",
"value": "mysalt"
},
{
"name": "ENCRYPTION_KEY",
"value": "0000000000000000000000000000000000000000000000000000000000000000"
},
{
"name": "TELEMETRY_ENABLED",
"value": "true"
},
{
"name": "LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES",
"value": "true"
},
{
"name": "CLICKHOUSE_MIGRATION_URL",
"value": "clickhouse://localhost:9000"
},
{
"name": "CLICKHOUSE_URL",
"value": "http://localhost:8123"
},
{
"name": "CLICKHOUSE_USER",
"value": "clickhouse"
},
{
"name": "CLICKHOUSE_PASSWORD",
"value": "clickhouse"
},
{
"name": "CLICKHOUSE_CLUSTER_ENABLED",
"value": "false"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_BUCKET",
"value": "langfuse"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_REGION",
"value": "auto"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID",
"value": "minio"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY",
"value": "miniosecret"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT",
"value": "http://localhost:9090"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE",
"value": "true"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_PREFIX",
"value": "events/"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_BUCKET",
"value": "langfuse"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_REGION",
"value": "auto"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID",
"value": "minio"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY",
"value": "miniosecret"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT",
"value": "http://localhost:9090"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE",
"value": "true"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_PREFIX",
"value": "media/"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_ENABLED",
"value": "false"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_BUCKET",
"value": "langfuse"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_PREFIX",
"value": "exports/"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_REGION",
"value": "auto"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_ENDPOINT",
"value": "http://localhost:9090"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID",
"value": "minio"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY",
"value": "miniosecret"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE",
"value": "true"
},
{
"name": "REDIS_HOST",
"value": "localhost"
},
{
"name": "REDIS_PORT",
"value": "6379"
},
{
"name": "REDIS_AUTH",
"value": "myredissecret"
},
{
"name": "REDIS_TLS_ENABLED",
"value": "false"
},
{
"name": "NEXTAUTH_SECRET",
"value": "mysecret"
},
{
"name": "LANGFUSE_INIT_ORG_ID",
"value": "${LANGFUSE_ORG_ID}"
},
{
"name": "LANGFUSE_INIT_ORG_NAME",
"value": "${LANGFUSE_ORG_NAME}"
},
{
"name": "LANGFUSE_INIT_PROJECT_ID",
"value": "${LANGFUSE_PROJECT_ID}"
},
{
"name": "LANGFUSE_INIT_PROJECT_NAME",
"value": "${LANGFUSE_PROJECT_NAME}"
},
{
"name": "AUTH_DISABLE_SIGNUP",
"value": "true"
}
],
"secrets": [
{
"name": "LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
"valueFrom": "${SSM_PARAMETER_LANGFUSE_PROJECT_PUBLIC_KEY_ARN}"
},
{
"name": "LANGFUSE_INIT_PROJECT_SECRET_KEY",
"valueFrom": "${SSM_PARAMETER_LANGFUSE_PROJECT_SECRET_KEY_ARN}"
},
{
"name": "LANGFUSE_INIT_USER_EMAIL",
"valueFrom": "${SSM_PARAMETER_LANGFUSE_USER_EMAIL_ARN}"
},
{
"name": "LANGFUSE_INIT_USER_PASSWORD",
"valueFrom": "${SSM_PARAMETER_LANGFUSE_USER_PASSWORD_ARN}"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${LOG_GROUP_NAME}",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "web"
},
"secretOptions": null
},
"mountPoints": [
{
"sourceVolume": "share",
"containerPath": "/share",
"readOnly": true
}
],
"dependsOn": [
{
"containerName": "langfuse-worker",
"condition": "START"
},
{
"containerName": "postgres",
"condition": "HEALTHY"
},
{
"containerName": "clickhouse",
"condition": "HEALTHY"
},
{
"containerName": "minio",
"condition": "HEALTHY"
},
{
"containerName": "redis",
"condition": "HEALTHY"
},
{
"containerName": "get-public-ip",
"condition": "SUCCESS"
}
],
"entryPoint": [
"dumb-init", "--", "sh", "-c", "NEXTAUTH_URL=http://$(cat /share/ipaddr.txt):3000 LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT=http://$(cat /share/ipaddr.txt):9090 exec ./web/entrypoint.sh \"$@\"", "_"
],
"command": [
"/bin/sh", "-c", "if [ -n \"$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION\" ]; then node --import dd-trace/initialize.mjs ./web/server.js --keepAliveTimeout 110000; else node ./web/server.js --keepAliveTimeout 110000; fi"
]
},
{
"name": "langfuse-worker",
"image": "langfuse/langfuse-worker:3",
"essential": true,
"portMappings": [
{
"containerPort": 3030,
"hostPort": 3030,
"protocol": "tcp"
}
],
"environment": [
{
"name": "DATABASE_URL",
"value": "postgresql://postgres:postgres@localhost:5432/postgres"
},
{
"name": "SALT",
"value": "mysalt"
},
{
"name": "ENCRYPTION_KEY",
"value": "0000000000000000000000000000000000000000000000000000000000000000"
},
{
"name": "TELEMETRY_ENABLED",
"value": "true"
},
{
"name": "LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES",
"value": "true"
},
{
"name": "CLICKHOUSE_MIGRATION_URL",
"value": "clickhouse://localhost:9000"
},
{
"name": "CLICKHOUSE_URL",
"value": "http://localhost:8123"
},
{
"name": "CLICKHOUSE_USER",
"value": "clickhouse"
},
{
"name": "CLICKHOUSE_PASSWORD",
"value": "clickhouse"
},
{
"name": "CLICKHOUSE_CLUSTER_ENABLED",
"value": "false"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_BUCKET",
"value": "langfuse"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_REGION",
"value": "auto"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID",
"value": "minio"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY",
"value": "miniosecret"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT",
"value": "http://localhost:9090"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE",
"value": "true"
},
{
"name": "LANGFUSE_S3_EVENT_UPLOAD_PREFIX",
"value": "events/"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_BUCKET",
"value": "langfuse"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_REGION",
"value": "auto"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID",
"value": "minio"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY",
"value": "miniosecret"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT",
"value": "http://localhost:9090"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE",
"value": "true"
},
{
"name": "LANGFUSE_S3_MEDIA_UPLOAD_PREFIX",
"value": "media/"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_ENABLED",
"value": "false"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_BUCKET",
"value": "langfuse"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_PREFIX",
"value": "exports/"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_REGION",
"value": "auto"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_ENDPOINT",
"value": "http://localhost:9090 "
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID",
"value": "minio"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY",
"value": "miniosecret"
},
{
"name": "LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE",
"value": "true"
},
{
"name": "REDIS_HOST",
"value": "localhost"
},
{
"name": "REDIS_PORT",
"value": "6379"
},
{
"name": "REDIS_AUTH",
"value": "myredissecret"
},
{
"name": "REDIS_TLS_ENABLED",
"value": "false"
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${LOG_GROUP_NAME}",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "worker"
},
"secretOptions": null
},
"mountPoints": [
{
"sourceVolume": "share",
"containerPath": "/share",
"readOnly": true
}
],
"dependsOn": [
{
"containerName": "postgres",
"condition": "HEALTHY"
},
{
"containerName": "clickhouse",
"condition": "HEALTHY"
},
{
"containerName": "minio",
"condition": "HEALTHY"
},
{
"containerName": "redis",
"condition": "HEALTHY"
},
{
"containerName": "get-public-ip",
"condition": "SUCCESS"
}
],
"entryPoint": [
"dumb-init", "--", "sh", "-c", "LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT=http://$(cat /share/ipaddr.txt):9090 exec ./worker/entrypoint.sh \"$@\"", "_"
],
"command": [
"node", "worker/dist/index.js"
]
},
{
"name": "clickhouse",
"image": "clickhouse/clickhouse-server",
"essential": true,
"user": "0:0",
"portMappings": [
{
"containerPort": 8123,
"hostPort": 8123,
"protocol": "tcp"
},
{
"containerPort": 9000,
"hostPort": 9000,
"protocol": "tcp"
}
],
"environment": [
{
"name": "CLICKHOUSE_DB",
"value": "default"
},
{
"name": "CLICKHOUSE_USER",
"value": "clickhouse"
},
{
"name": "CLICKHOUSE_PASSWORD",
"value": "clickhouse"
}
],
"mountPoints": [
{
"sourceVolume": "clickhouse-data",
"containerPath": "/var/lib/clickhouse",
"readOnly": false
},
{
"sourceVolume": "clickhouse-logs",
"containerPath": "/var/log/clickhouse-server",
"readOnly": false
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${LOG_GROUP_NAME}",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "clickhouse"
},
"secretOptions": null
},
"entryPoint": [
"bash",
"-c",
"mkdir -p /var/log/clickhouse-server && chown -R 101:101 /var/log/clickhouse-server /var/lib/clickhouse && su -s /bin/bash clickhouse -c '/entrypoint.sh'"
],
"healthCheck": {
"command": [
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"
],
"interval": 5,
"timeout": 5,
"retries": 10,
"startPeriod": 10
}
},
{
"name": "minio",
"image": "minio/minio",
"essential": true,
"portMappings": [
{
"containerPort": 9090,
"hostPort": 9090,
"protocol": "tcp"
},
{
"containerPort": 9001,
"hostPort": 9001,
"protocol": "tcp"
}
],
"environment": [
{
"name": "MINIO_ROOT_USER",
"value": "minio"
},
{
"name": "MINIO_ROOT_PASSWORD",
"value": "miniosecret"
}
],
"entryPoint": [
"sh"
],
"command": [
"-c",
"mkdir -p /data/langfuse && minio server --address \":9090\" --console-address \":9001\" /data"
],
"mountPoints": [
{
"sourceVolume": "minio-data",
"containerPath": "/data",
"readOnly": false
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${LOG_GROUP_NAME}",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "minio"
},
"secretOptions": null
},
"healthCheck": {
"command": [
"CMD",
"curl",
"-f",
"http://localhost:9090/minio/health/live"
],
"interval": 5,
"timeout": 5,
"retries": 5,
"startPeriod": 10
}
},
{
"name": "redis",
"image": "redis:7",
"essential": true,
"portMappings": [
{
"containerPort": 6379,
"hostPort": 6379,
"protocol": "tcp"
}
],
"command": [
"--requirepass",
"myredissecret"
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${LOG_GROUP_NAME}",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "redis"
},
"secretOptions": null
},
"healthCheck": {
"command": [
"CMD",
"redis-cli",
"ping"
],
"interval": 5,
"timeout": 10,
"retries": 10
}
},
{
"name": "postgres",
"image": "postgres:17",
"essential": true,
"portMappings": [
{
"containerPort": 5432,
"hostPort": 5432,
"protocol": "tcp"
}
],
"environment": [
{
"name": "POSTGRES_USER",
"value": "postgres"
},
{
"name": "POSTGRES_PASSWORD",
"value": "postgres"
},
{
"name": "POSTGRES_DB",
"value": "postgres"
}
],
"mountPoints": [
{
"sourceVolume": "postgres-data",
"containerPath": "/var/lib/postgresql/data",
"readOnly": false
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${LOG_GROUP_NAME}",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "postgres"
},
"secretOptions": null
},
"healthCheck": {
"command": [
"CMD-SHELL",
"pg_isready -U postgres"
],
"interval": 5,
"timeout": 10,
"retries": 10
}
},
{
"name": "get-public-ip",
"image": "${ECR_REPOSITORY_URL}:latest",
"essential": false,
"mountPoints": [
{
"sourceVolume": "share",
"containerPath": "/share",
"readOnly": false
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${LOG_GROUP_NAME}",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "get-public-ip"
},
"secretOptions": null
},
"entryPoint": [
"sh",
"-c",
"set -euo pipefail; TASK_JSON=$(curl -s \"$ECS_CONTAINER_METADATA_URI_V4/task\"); CLUSTER=$(echo $TASK_JSON | jq -r '.Cluster'); TASKARN=$(echo $TASK_JSON | jq -r '.TaskARN'); ENI=$(aws ecs describe-tasks --cluster $CLUSTER --tasks $TASKARN --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' --output text); PUBLIC_IP=$(aws ec2 describe-network-interfaces --network-interface-ids $ENI --query 'NetworkInterfaces[0].Association.PublicIp' --output text); echo Task IPAdress: $PUBLIC_IP; echo -n $PUBLIC_IP > /share/ipaddr.txt"
]
}
]
以下にポイントとなる部分を記載していく。
get-public-ipコンテナ
今回のキモになる部分。
shareのボリュームをマウントして、entryPointに記載のスクリプトでIPアドレスをファイルに出力して取得する。
このコンテナはIPアドレスを記載したファイルをshare配下に置いたらお役御免なので、"essential": falseを設定する。
(前略)
{
"name": "get-public-ip",
"image": "${ECR_REPOSITORY_URL}:latest",
"essential": false,
"mountPoints": [
{
"sourceVolume": "share",
"containerPath": "/share",
"readOnly": false
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "${LOG_GROUP_NAME}",
"awslogs-region": "${AWS_REGION}",
"awslogs-stream-prefix": "get-public-ip"
},
"secretOptions": null
},
"entryPoint": [
"sh",
"-c",
"set -euo pipefail; TASK_JSON=$(curl -s \"$ECS_CONTAINER_METADATA_URI_V4/task\"); CLUSTER=$(echo $TASK_JSON | jq -r '.Cluster'); TASKARN=$(echo $TASK_JSON | jq -r '.TaskARN'); ENI=$(aws ecs describe-tasks --cluster $CLUSTER --tasks $TASKARN --query 'tasks[0].attachments[0].details[?name==`networkInterfaceId`].value' --output text); PUBLIC_IP=$(aws ec2 describe-network-interfaces --network-interface-ids $ENI --query 'NetworkInterfaces[0].Association.PublicIp' --output text); echo Task IPAdress: $PUBLIC_IP; echo -n $PUBLIC_IP > /share/ipaddr.txt"
]
}
(以下略)
langfuse-web コンテナ
IPアドレスの取得後に起動をしたいので、dependsOn属性に以下を追加しよう。
コンテナの正常終了後に起動するには、"condition": "SUCCESS"を設定すれば良い。
"dependsOn": [
(中略)
{
"containerName": "get-public-ip",
"condition": "SUCCESS"
}
]
また、このコンテナでもshareをマウントする。こちらはファイルを読むだけなので、"readOnly": trueを設定する。
"mountPoints": [
{
"sourceVolume": "share",
"containerPath": "/share",
"readOnly": true
}
]
また、langfuse-webコンテナのentryPoint属性およびcommand属性で以下を設定する。
これで、get-public-ipコンテナがshareに格納したファイルを読み込んで、環境変数を上書きして起動ができる。
entryPoint属性を上書きすると、command属性の再定義が必要になるため、元の設定を再度ここに記載している。
"entryPoint": [
"dumb-init", "--", "sh", "-c", "NEXTAUTH_URL=http://$(cat /share/ipaddr.txt):3000 LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT=http://$(cat /share/ipaddr.txt):9090 exec ./web/entrypoint.sh \"$@\"", "_"
],
"command": [
"/bin/sh", "-c", "if [ -n \"$NEXT_PUBLIC_LANGFUSE_CLOUD_REGION\" ]; then node --import dd-trace/initialize.mjs ./web/server.js --keepAliveTimeout 110000; else node ./web/server.js --keepAliveTimeout 110000; fi"
]
langfuse-worker コンテナ
langfuse-webコンテナ同様にmountPointsおよびdependsOnを設定する。
以下のようにentryPoint属性とcommand属性を上書きする。
"entryPoint": [
"dumb-init", "--", "sh", "-c", "LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT=http://$(cat /share/ipaddr.txt):9090 exec ./worker/entrypoint.sh \"$@\"", "_"
],
"command": [
"node", "worker/dist/index.js"
]
起動時にデフォルトのOrganizationとProjectを作るように設定する&少しだけセキュアにする
aws_ecs_task_definitionで以下のように設定した通り、Langfuseは起動時にデフォルトでOrganizationとProjectとユーザとキーを作成する環境変数がある。いずれの設定もlangfuse-webコンテナで必要になる設定だ。
事前準備で、LangfuseのキーやユーザID/パスワードの情報はAWS Systems Manager Parameter StoreにSecureStringで作っているため、taskdef.jsonではsecrets属性を使って値をJSONに埋め込まないようにしておk
AUTH_DISABLE_SIGNUP環境変数は、新規のサインアップを行わないようにする設定だ。
今回、あまりセキュリティを堅牢にしていないため、万が一アクセスされた時に容易にユーザ作成をできないように念のため設定しておこう。
(前略)
resource "aws_ecs_task_definition" "langfuse" {
(中略)
container_definitions = templatefile("./taskdef.json", {
AWS_REGION = data.aws_region.current.name
LOG_GROUP_NAME = aws_cloudwatch_log_group.ecstask_log_group.name
ECR_REPOSITORY_URL = aws_ecr_repository.aws_cli_minimal.repository_url
# Langfuse Settings
LANGFUSE_ORG_ID = local.langfuse_org_id
LANGFUSE_ORG_NAME = local.langfuse_org_name
LANGFUSE_PROJECT_ID = local.langfuse_project_id
LANGFUSE_PROJECT_NAME = local.langfuse_project_name
SSM_PARAMETER_LANGFUSE_PROJECT_PUBLIC_KEY_ARN = aws_ssm_parameter.langfuse_project_public_key.arn
SSM_PARAMETER_LANGFUSE_PROJECT_SECRET_KEY_ARN = aws_ssm_parameter.langfuse_project_secret_key.arn
SSM_PARAMETER_LANGFUSE_USER_EMAIL_ARN = aws_ssm_parameter.langfuse_user_email.arn
SSM_PARAMETER_LANGFUSE_USER_PASSWORD_ARN = aws_ssm_parameter.langfuse_user_password.arn
})
}
[
{
"name": "langfuse-web",
(中略)
"environment": [
(中略)
{
"name": "LANGFUSE_INIT_ORG_ID",
"value": "${LANGFUSE_ORG_ID}"
},
{
"name": "LANGFUSE_INIT_ORG_NAME",
"value": "${LANGFUSE_ORG_NAME}"
},
{
"name": "LANGFUSE_INIT_PROJECT_ID",
"value": "${LANGFUSE_PROJECT_ID}"
},
{
"name": "LANGFUSE_INIT_PROJECT_NAME",
"value": "${LANGFUSE_PROJECT_NAME}"
},
{
"name": "AUTH_DISABLE_SIGNUP",
"value": "true"
}
],
"secrets": [
{
"name": "LANGFUSE_INIT_PROJECT_PUBLIC_KEY",
"valueFrom": "${SSM_PARAMETER_LANGFUSE_PROJECT_PUBLIC_KEY_ARN}"
},
{
"name": "LANGFUSE_INIT_PROJECT_SECRET_KEY",
"valueFrom": "${SSM_PARAMETER_LANGFUSE_PROJECT_SECRET_KEY_ARN}"
},
{
"name": "LANGFUSE_INIT_USER_EMAIL",
"valueFrom": "${SSM_PARAMETER_LANGFUSE_USER_EMAIL_ARN}"
},
{
"name": "LANGFUSE_INIT_USER_PASSWORD",
"valueFrom": "${SSM_PARAMETER_LANGFUSE_USER_PASSWORD_ARN}"
}
],
(以下略)
参考までに、今回、localsには以下のように設定している。
locals {
langfuse_org_id = "example-org"
langfuse_org_name = "Example Organization"
langfuse_project_id = "example-project"
langfuse_project_name = "Example Project"
langfuse_user_email = "neruneruo@example.com"
langfuse_user_password = "**********"
}
いざ、動かす!
さて、これでterraform applyして、http://(払い出されたパブリックIPアドレス):3000/にアクセスしよう。
しっかりと、サインアップがディアクティベートされた状態で起動している。
ここで予め設定したメールアドレスとパスワードでログインすると……
ちゃんと事前に設定したOrganizationとProjectが見えている。
もちろん、払い出したキーでトレーシングを送ってみたら記録もできていた!
これで、お手軽にLLMをデバッグする環境が整った

