Terraformを使ってLangfuseをECS＋Fargateのシングルタスクで一発起動する

# はじめに
Langfuseは、LLMを利用したアプリケーションを観測・分析するためのオープンソースのプラットフォームだ。
Strands Agentsのようなフレームワークを使っている場合、アプリにほんのひと手間加えるだけでトレースとメトリクスが簡単に取得できるようになる。
また、環境構築がお手軽にできるのも特徴だ。

https://langfuse.com/

https://strandsagents.com/latest/

だが、真面目に構築すると、常時起動リソースが必要になったりで少なからずクラウド利用料がかかる。
ちょっとした検証をするときにはDockerを使ってセルフホストするというのも手段だが、残念ながら世の中にはDockerが容易に使えない環境もある(そんなのある？と俄かに信じられない方もいると思うが、あると言ったらあるのだ)。

そこで、今回は、Amazon ECS＋AWS Fargateを使ってセルフホスト相当のコンテナをシングルタスクで起動する。
ALBといった常時起動リソースも一切不要なので、気軽にタスクのDesired countを変更して上げ下げをすれば良い。かかる料金は、Fargateを起動する時間だけだ！(厳密にはAmazon ECRやAmazon CloudWatch Logsの料金も多少かかるが、僅かで済むはず)

:::note warn
AWS Fargateのシングルタスクなので、当然ながらストレージの永続性はない(ストレージはデフォルトのエフェメラルストレージを利用する)。永続性を考慮するのであれば、おとなしくまともな環境を作るのが良いだろう。今回の構成はあくまでも個人のデバッグ目的程度のものと考えていただきたい。
:::

# 前準備
## IPアドレスを動的に取得するコンテナイメージの準備
FargateにはパブリックIPアドレスを取得することができるが、起動時にアタッチされるため、環境変数でコンテナに渡すことができない。そこで、コンテナ起動後に動的にIPアドレスを取得して、Langfuseの起動時にホストするURLを設定する必要がある。

AL2023のminimalなコンテナイメージにはJQがインストールされていないため、jqを追加したイメージを作りECRにPUSHしておこう。

```Dockerfile:Dockerfile
FROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal

RUN dnf install -y \
      aws-cli      \
      jq-1.6       \
  && dnf clean all

ENTRYPOINT [ "sh", "-c" ]
```

```HCL:ecr.tf
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アドレスを動的に取得するために必要な権限なのでタスクロールに設定しておこう。

```HCL:cloudtwatchlogs.tf
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
}
```

```HCL:ssmparameter.tf
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
}
```

```HCL:iam.tf
################################################################################
# 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 = ["*"]
  }
}
```

また、セキュリティグループも、以下の通り設定しておく。

```HCL:securitygroup.tf
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```ボリュームを設定する。

https://github.com/langfuse/langfuse

```hcl:ecs.tf
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```からの移植だ。

<details><summary>taskdef.json</summary>

```JSON: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:latest",
    "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"
    ]
  }
]
```

</details>

以下にポイントとなる部分を記載していく。

### get-public-ipコンテナ

今回のキモになる部分。
```share```のボリュームをマウントして、```entryPoint```に記載のスクリプトでIPアドレスをファイルに出力して取得する。
このコンテナはIPアドレスを記載したファイルを```share```配下に置いたらお役御免なので、```"essential": false```を設定する。

```JSON:taskdef.json
  (前略)
  {
    "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"```を設定すれば良い。

```JSON
    "dependsOn": [
      (中略)
      {
        "containerName": "get-public-ip",
        "condition": "SUCCESS"
      }
    ]
```

また、このコンテナでも```share```をマウントする。こちらはファイルを読むだけなので、```"readOnly": true```を設定する。

```JSON
    "mountPoints": [
      {
        "sourceVolume": "share",
        "containerPath": "/share",
        "readOnly": true
      }
    ]
```

また、```langfuse-web```コンテナの```entryPoint```属性および```command```属性で以下を設定する。
これで、```get-public-ip```コンテナが```share```に格納したファイルを読み込んで、環境変数を上書きして起動ができる。
```entryPoint```属性を上書きすると、```command```属性の再定義が必要になるため、元の設定を再度ここに記載している。

```JSON
    "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```属性を上書きする。

```JSON:taskdef.json抜粋
    "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に埋め込まないようにしておｋ

```AUTH_DISABLE_SIGNUP```環境変数は、新規のサインアップを行わないようにする設定だ。
今回、あまりセキュリティを堅牢にしていないため、万が一アクセスされた時に容易にユーザ作成をできないように念のため設定しておこう。

```HCL:ecs.tf
(前略)
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

  })
}
```

```JSON:taskdef.json
[
  {
    "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には以下のように設定している。

```HCL
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/にアクセスしよう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/2e39c13a-ac03-452d-9364-6dd1b47f54b6.png)

しっかりと、サインアップがディアクティベートされた状態で起動している。
ここで予め設定したメールアドレスとパスワードでログインすると……

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/cb245ede-0af9-430c-9376-a0aa8a345a14.png)

ちゃんと事前に設定したOrganizationとProjectが見えている。

もちろん、払い出したキーでトレーシングを送ってみたら記録もできていた！
これで、お手軽にLLMをデバッグする環境が整った