every Tech Blog

株式会社エブリーのTech Blogです。

AWSを活用したスケーラブルなログ収集基盤の構築

はじめに

こんにちは。 開発本部 開発1部 デリッシュリサーチチームでデータエンジニアをしている吉田です。

エブリーではサイネージ端末を利用した広告配信サービスを提供しており、サイネージ端末からのログを収集しています。
従来はTreasureData-SDKを利用して端末からTreasureData上のテーブルにログを送信していましたが、ログ収集基盤を移行することになりました。
今回、AWSのマネージドサービスを活用して、スケーラブルなログ収集基盤を構築したので、その内容を紹介します。

背景

従来のログ収集基盤では、TreasureData-SDKを利用して端末からTreasureData上のテーブルにログを送信していました。
これはコストや処理の面で非常に便利でしたが、ログの送信がSDKに依存しているため、仮にTreasureData以外のログ収集基盤へ移行する場合には、アプリケーションのコードを変更する必要がありました。
サイネージ端末の都合上、アプリケーションのアップデートを極力行わずにログ収集基盤を変更できるようにしたい、という要望がありました。

そこで、アプリケーションの改修をすることなく、バックエンドをいつでも自由に変更できる、より柔軟なログ収集基盤の構築を目指しました。

アーキテクチャ

新しく構築したログ収集基盤は以下のコンポーネントで構成されています。

  1. API Gateway: アプリケーションからのログ送信用エンドポイント
  2. SQS: ログメッセージをバッファリング
  3. Lambda: SQSからメッセージを取得し、処理を行う
  4. Firehose: 処理されたログをS3に配信
  5. S3: ログの長期保存

この構成により、以下のメリットを実現しています:

  • スケーラビリティ: 突発的な大量のログにも対応可能
  • 信頼性: SQS によるメッセージの永続化とデッドレターキューによる失敗処理
  • 運用負荷の軽減: マネージドサービスの活用

全体のアーキテクチャは以下のようになります。

アーキテクチャ

コスト面では、リクエスト数に応じて課金されるAPI Gatewayの料金が、この基盤全体の大部分を占めることになります。
しかし今回は、そのコストよりも、将来的にログ収集基盤を柔軟に変更できるというメリットを重視し、この構成を採用しました。

詳細

API Gateway

API Gatewayはアプリケーションからログを受け取るエンドポイントを提供するとともに、SQSにメッセージを送信する役割を担います。
API Gateway から SQS へのインテグレーションでは、マッピングテンプレートを使用して、受信したJSONペイロードを SQS メッセージ形式に変換します。

terraformのコードは以下のようになります。

resource "aws_api_gateway_integration" "logs_post_integration" {
  rest_api_id             = aws_api_gateway_rest_api.this.id
  resource_id             = aws_api_gateway_resource.logs.id
  http_method             = aws_api_gateway_method.logs_post.http_method
  integration_http_method = "POST"
  credentials             = aws_iam_role.api_gateway.arn

  type = "AWS"
  uri  = "arn:aws:apigateway:${var.aws_region}:sqs:path/${var.aws_account_id}/${aws_sqs_queue.primary.name}"

  request_parameters = {
    "integration.request.header.Content-Type" = "'application/x-www-form-urlencoded'"
  }

  request_templates = {
    "application/json" = "Action=SendMessage&MessageBody=$util.urlEncode($input.body)"
  }

  passthrough_behavior = "WHEN_NO_TEMPLATES"
}

SQS

SQSはログメッセージをバッファリングし、Lambda関数による処理を待ちます。
API GatewayとLambdaの間にSQSを挟むことで、Lambdaの処理が失敗した場合でもメッセージを保持し、後で再処理できるようになります。
また、SQSのデッドレターキューを設定し、指定回数処理に失敗したメッセージを別のキューへ送信して処理させることで、エラーの確認を容易にするとともに、ログの喪失を防ぎます。

terraformのコードは以下のようになります。

resource "aws_sqs_queue" "dead_letter" {}

resource "aws_sqs_queue" "primary" {
  name = "${var.prefix}-primary-queue"

  delay_seconds              = 0                # 配信遅延秒
  visibility_timeout_seconds = 30               # メッセージの可視性タイムアウト秒
  max_message_size           = 262144           # 256KB
  message_retention_seconds  = 3 * 24 * 60 * 60 # メッセージの保持期間 (最大14日)
  receive_wait_time_seconds  = 20               # メッセージ受信待機時間秒

  sqs_managed_sse_enabled = true

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.dead_letter.arn
    maxReceiveCount     = 3 # メッセージがDLQに移動するまでの最大受信回数
  })
}

resource "aws_sqs_queue_redrive_allow_policy" "this" {
  queue_url = aws_sqs_queue.dead_letter.id

  redrive_allow_policy = jsonencode({
    redrivePermission = "byQueue",
    sourceQueueArns   = [aws_sqs_queue.primary.arn]
  })
}

redrive_policyは、メッセージがデッドレターキューに移動する条件を設定します。
今回は最大3回の受信失敗でデッドレターキューに移動するように設定しています。

Lambda

Lambda関数はSQSからメッセージを取得し、処理を行います。
トリガーとしてSQSを設定し、メッセージがキューに追加されると自動的に起動します。

terraformのコードは以下のようになります。

resource "aws_lambda_function" "primary" {
  function_name = "${var.prefix}-log-sender"
  role          = aws_iam_role.lambda.arn
  filename      = data.archive_file.dummy.output_path

  architectures = ["arm64"]
  handler       = "main.lambda_handler"
  runtime       = "python3.13"
  memory_size   = 128 # MB
  timeout       = 5   # 秒

  logging_config {
    log_format            = "JSON"
    system_log_level      = "WARN"
    application_log_level = "INFO"
  }

  lifecycle {
    # 環境変数の変更を無視する
    # 別リポジトリで管理
    ignore_changes = [
      environment,
    ]
  }
}

resource "aws_lambda_event_source_mapping" "primary" {
  event_source_arn                   = aws_sqs_queue.primary.arn
  function_name                      = aws_lambda_function.primary.arn
  enabled                            = false
  batch_size                         = 1
  maximum_batching_window_in_seconds = 0

  function_response_types = ["ReportBatchItemFailures"]

  scaling_config {
    maximum_concurrency = 500
  }

  lifecycle {
    ignore_changes = [
      # enabledを無視する
      # コードデプロイ後に手動で有効化する
      enabled,
    ]
  }
}

aws_lambda_event_source_mappingのfunction_response_typesでReportBatchItemFailuresを指定しているため、Lambda関数で処理に失敗したメッセージだけがSQSに戻されます。
上記のTerraformではbatch_size=1と設定しているため、この設定は現状では効果がありませんが、将来的にbatch_sizeを変更する可能性を考慮して、このように設定しています。

Data Firehose

FirehoseはLambdaからデータを受け取り、S3に配信します。
Lambdaから直接S3に書き込む場合、S3のAPI呼び出しコストやマイクロファイル(小さなファイル)が多数生成されるといった問題が発生します。
特に、マイクロファイルが大量に存在すると、後段でデータ処理をする際のパフォーマンスが著しく低下する原因となります。
Firehoseを利用するとバッファリング、データの圧縮、パーティショニング、S3への書き込みなどを自動で行ってくれるため、これらの問題を解決できます。

terraformのコードは以下のようになります。

resource "aws_kinesis_firehose_delivery_stream" "primary" {
  name        = "${var.prefix}-primary-stream"
  destination = "extended_s3"

  extended_s3_configuration {
    bucket_arn = aws_s3_bucket.this.arn
    role_arn   = aws_iam_role.firehose.arn

    buffering_size     = 128 # MB
    buffering_interval = 300 # 秒

    file_extension = ".json"

    # プレフィックスの設定
    prefix              = "logs/!{partitionKeyFromQuery:event_name}/!{partitionKeyFromQuery:year}/!{partitionKeyFromQuery:month}/!{partitionKeyFromQuery:day}/!{partitionKeyFromQuery:hour}/"
    error_output_prefix = "errors/!{firehose:error-output-type}/"

    dynamic_partitioning_configuration {
      enabled = true
    }

    processing_configuration {
      enabled = true
      processors {
        type = "MetadataExtraction"
        parameters {
          parameter_name  = "JsonParsingEngine"
          parameter_value = "JQ-1.6"
        }
        parameters {
          parameter_name  = "MetadataExtractionQuery"
          parameter_value = "{year:.time | gmtime | .[0] | tostring,month:.time | gmtime | (.[1] + 1) | (. | if . < 10 then \"0\" + tostring else tostring end),day:.time | gmtime | .[2] | (. | if . < 10 then \"0\" + tostring else tostring end),hour:.time | gmtime | .[3] | (. | if . < 10 then \"0\" + tostring else tostring end),event_name:.event_name}"
        }
      }
    }

    cloudwatch_logging_options {
      enabled         = true
      log_group_name  = aws_cloudwatch_log_group.firehose_primary.name
      log_stream_name = aws_cloudwatch_log_stream.firehose_primary.name
    }

    s3_backup_mode = "Disabled"
  }

  server_side_encryption {
    enabled  = true
    key_type = "AWS_OWNED_CMK"
  }

  depends_on = [
    aws_iam_role_policy_attachment.firehose
  ]
}

動的パーティショニングの設定により、ログ中に含まれるイベント名と時間を元にS3のプレフィックスを動的に生成します。
MetadataExtractionQueryでは、JQを利用してJSON形式のログデータからパーティション分割に必要なキー(イベント名、年、月、日、時)を抽出しています。
これにより、後続のETLなどで効率的にデータを処理できるようになります。

S3

S3はFirehoseによって配信されたログデータの長期保存先として機能します。
バージョニングを設定して意図しない削除に備えるとともに、ACLをプライベートに設定して外部からのアクセスを制限します。

Lambdaの実装

Lambda関数では、SQSから受け取ったメッセージを処理し、Firehoseに送信します。
以下に実装の一部を示します。

def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    batch_item_failures: List[Dict[str, str]] = []
    record: Dict[str, Any]
    for record in event.get("Records", []):
        message_id: Optional[str] = record.get("messageId")
        try:
            message_body_str: Optional[str] = record.get("body")
            log_data: Dict[str, Any] = json.loads(message_body_str)
            # Send to Firehose
            firehose_success: bool = send_to_firehose(log_data)
            # その他の送信

            if not firehose_success:
                batch_item_failures.append({"itemIdentifier": message_id})
        except json.JSONDecodeError as e:
            batch_item_failures.append({"itemIdentifier": message_id})
            continue
        except Exception as e:
                batch_item_failures.append({"itemIdentifier": message_id})
            continue

    if batch_item_failures:
        return {"batchItemFailures": batch_item_failures}
    else:
        return {"batchItemFailures": []}

前述のLambdaのterraformコードでfunction_response_types = ["ReportBatchItemFailures"]としているため、関数の戻り値は{"batchItemFailures": [失敗したメッセージID]}とします。
これにより、複数メッセージを受け取った際に、失敗したメッセージのみをSQSに戻せます。
エラーとなるメッセージがない場合は、{"batchItemFailures": []}を返します。

Lambdaのデプロイ

Lambdaのコードデプロイにはlambrollを利用しています。
コードと環境変数を管理するリポジトリと、Terraformでインフラを管理するリポジトリを分離しています。
これにより、インフラの変更とアプリケーションコードの変更のライフサイクルを分け、それぞれが独立して安全にデプロイできる体制を整えています。

lambroll init --function-name {function-name} --download --profile {aws-profile}で既存のLambdaのコードと設定ファイルをダウンロードできます。
このプロジェクトでは、function.dev.jsonのようにfunction.{env}.jsonという形式で環境ごとの設定ファイルを管理しています。
コードやfunction.jsonの編集後、lambroll deploy --function=function.{env}.json --profile {aws-profile}でデプロイを行います。

まとめ

今回、AWSのマネージドサービスを活用して、スケーラブルなログ収集基盤を構築しました。
API Gatewayを利用することで、アプリケーションのアップデートを行うことなく、ログ収集基盤を変更できる柔軟性を持たせています。

LaravelでJSON形式のログを出力してfluentbit経由でS3にアップロードしてみた

はじめに

こんにちは、エブリーでサーバーサイドをメインに担当している清水です。

私のチームではPHP, Laravelを使用して小売店向けのSaaS型Webサービスの開発を行っています。インフラはAmazon ECS (Fargate)です。
このシステムではユーザーのアクションを分析するためのイベントログを出力する機能があり、こちらをAmazon S3に出力するためにfluentbitを使用することにしました。
当初の予定では1日程度で終わるはず思っていたのですが、思った以上にうまくいかず1週間ほどかかってしまいました。

本記事では、Laravel → 標準出力 → Fluent Bit → S3 → Athenaという構成で、イベントログを出力するまでのフローをコードレベルで丸ごと紹介しつつ、いくつかハマったポイントを共有します。
本記事を参考にとりあえず動く状態にしていただいて、足りない部分はカスタマイズしていただくような形で利用いただければ幸いです。

環境情報

  • PHP 8.3.22
  • Laravel 11.18.1
  • Fluent Bit 1.9.10

Laravelからイベントログを標準出力する機能を実装する

ある処理が呼び出された時のイベントログ出力処理例

    public function show(int $id)
    {
        Log::channel('event')->info('記事閲覧イベントログ', [
            'event_type' => 'show_article',
            'article_id' => $id,
        ]);
        // ※ログ以外の処理は省略
    }

イベントログ用のフォーマッターを実装

<?php

namespace App\Logging;

use Monolog\Formatter\NormalizerFormatter;
use Monolog\LogRecord;

class EventLogFormatter extends NormalizerFormatter
{
    public function format(LogRecord $record): string
    {
        $formatted = parent::format($record);
        $segments = [
            'level' => $formatted['level_name'],
            'datetime' => $formatted['datetime'],
            'env' => $formatted['channel'],
            'message' => $formatted['message'],
            'extra' => $formatted['extra'],
            'log_type' => 'EVENT',
        ];

        // コンテキストのすべてのキーを出力に含める
        foreach ($formatted['context'] as $key => $value) {
            $segments[$key] = $value;
        }

        return json_encode($segments, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).PHP_EOL;
    }
}

※以下の記事を参考にさせていただきました。
Laravel(monolog)で構造化ログを実装する

config/logging.phpにeventチャネルを定義

        'event' => [
            'driver' => 'monolog',
            'level' => env('LOG_LEVEL', 'debug'),
            'handler' => StreamHandler::class,
            'formatter' => EventLogFormatter::class,
            'with' => [
                // Swooleを使用しているとstdoutの標準出力に余計な文字列がつけられるため、stderrとしました。
                'stream' => 'php://stderr',
            ],
        ],

ローカル開発環境にて標準出力でイベントログがJSON形式で出力されることを確認

{"level":"INFO","datetime":"2025-06-24T12:59:29.427634+09:00","env":"local","message":"記事閲覧イベントログ","extra":[],"log_type":"EVENT","event_type":"show_article","article_id":1}

fluentbitを設定する

Dockerfile

# ベースイメージとしてAWS公式Fluent Bitイメージを指定
FROM public.ecr.aws/aws-observability/aws-for-fluent-bit:stable

# 作業ディレクトリの作成
WORKDIR /fluent-bit/etc

# カスタムfluent-bit.confをイメージ内へコピー
COPY fluent-bit-custom.conf /fluent-bit/etc/fluent-bit-custom.conf

# カスタムパーサーファイルもコピー
COPY json-string.conf /fluent-bit/etc/json-string.conf

fluent-bit-custom.conf

[SERVICE]
    Parsers_File    /fluent-bit/etc/json-string.conf
    Flush           5
    Grace           30

# 空のログをフィルタリング
[FILTER]
    Name            grep
    Match           *-firelens-*
    Exclude         log ^$

# JSONパース用フィルター:カスタムパーサー json_string を使用
[FILTER]
    Name            parser
    Match           *-firelens-*
    Parser          json_string
    Key_Name        log
    Preserve_Key    Off
    Reserve_Data    Off

# event log のタグ付け
[FILTER]
    Name            rewrite_tag
    Match           *-firelens-*
    Rule            log_type ^EVENT$ s3.event.$event_type true

# EVENTログ出力
[OUTPUT]
    Name            s3
    Match           s3.event.*
    bucket          ${AWS_S3_LOG_BUCKET} # 環境変数で出力先S3バケット名を指定する形とする
    region          ap-northeast-1
    use_put_object  Off
    upload_timeout  60s
    compression     gzip
    s3_key_format   /EVENT/$TAG[2]/%Y/%m/%d/%H_%M_%S_$UUID.gz

json-string.conf

[PARSER]
    Name                json_string
    Format              json
    Time_Key            datetime
    Time_Keep           On
    Time_Format         %Y-%m-%dT%H:%M:%S.%L%z

→Dockerイメージをビルドして、latestタグでAmazon ECRにプッシュしておく

ECSタスク定義でfluentbitを使用する設定に変更する

   "containerDefinitions": [
        {
            "name": "{Webサービスの名前}",
            "image": "{イメージ名}",

            (省略)

            "logConfiguration": {
                "logDriver": "awsfirelens"
            }
        },
        {
            "name": "log_router",
            "image": "XXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/{fluentbitイメージ名}:latest",
            
            (省略)
            
            "environment": [
                {
                    "name": "AWS_S3_LOG_BUCKET",
                    "value": "{出力先となるS3バケット}"
                }
            ],

            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-group": "{log_routerのログ出力先}",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "ecs",
                    "awslogs-create-group": "true",
                    "mode": "non-blocking",
                    "max-buffer-size": "25m"
                }
            },

            "firelensConfiguration": {
                "type": "fluentbit",
                "options": {
                    "config-file-type": "file",
                    "config-file-value": "/fluent-bit/etc/fluent-bit-custom.conf"
                }
            }
        }

Athenaで外部テーブルを作成する

CREATE EXTERNAL TABLE IF NOT EXISTS show_article_event_logs (
  level       string,
  datetime    string,
  env         string,
  message     string,
  extra       array<string>,
  log_type    string,
  event_type  string,
  article_id  int
)
PARTITIONED BY (
  year  string,
  month string,
  day   string
)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
LOCATION 's3://{バケット名}/EVENT/show_article/'
TBLPROPERTIES (
  'projection.enabled'       = 'true',
  'projection.year.type'     = 'integer',
  'projection.year.range'    = '2025,2100',
  'projection.year.digits'   = '4',
  'projection.month.type'    = 'integer',
  'projection.month.range'   = '1,12',
  'projection.month.digits'  = '2',
  'projection.day.type'      = 'integer',
  'projection.day.range'     = '1,31',
  'projection.day.digits'    = '2',
  'storage.location.template'=
    's3://{バケット名}/EVENT/show_article/${year}/${month}/${day}/'
);

引っかかった部分

ローカルでテストする方法がわからなかった

最初は一発で上手くいくだろうと楽観視していきなりAWS上にデプロイしてテストしてみましたが、当然上手くいきませんでした。
ローカルでどうやってダミーデータを標準出力させるか?どうやってfluentbitにその標準出力を認識させるのか?といったことを考えていたら手が止まりました。

ローカルのテスト手順はこちらの記事の通りに対応して上手くいきました。

Fluent Bitを導入しました:ローカル実行・確認方法と、導入の過程でハマったこと

application.conf にテストデータ投入用の[INPUT]、出力確認用の[OUTPUT]を追加します。

   Name dummy
   Tag  *-firelens-*
   Dummy {"date":"2022-01-23T03:10:33.317817Z","source":"stdout","log":"time:2022-01-23T03:10:33+00:00\tprotocol:HTTP/1.1\tstatus:200\tsize:1450\treqsize:150\treferer:-\treqtime:0.176\tcache:-\truntime:-\t"}

Dockerを起動してターミナルを開き、以下でFluent Bitを起動します。

/fluent-bit/bin/fluent-bit -c /fluent-bit/etc/application.conf

コンテナ内でAWSのアクセスキーをexportするだけでS3へのアップロード処理も問題なく実行されます

export AWS_ACCESS_KEY_ID="{AWSアクセスキー}"
export AWS_SECRET_ACCESS_KEY="{AWSシークレットアクセスキー}"

AthenaのSELECT実行結果が全カラム空白になってしまう

色々試した結果、ログファイルの拡張子が.gzになっていないと発生することがわかった

# before
    s3_key_format   /EVENT/$TAG[2]/%Y/%m/%d/%H_%M_%S_$UUID
# after
    s3_key_format   /EVENT/$TAG[2]/%Y/%m/%d/%H_%M_%S_$UUID.gz

おわりに

最後までお読みいただきありがとうございました。
今回の作業をするにあたって多くの先駆者様の記事を参考にさせていただきました。
ログ出力する方法には様々な実装方法があり、どのような方法を採用するべきか悩みながら進めました。
この記事がこれから同じことをする人に少しでも参考になれば幸いです。

PHP Conference Japan 2025 にGOLDスポンサーとして今年も協賛します!

PHP Conference Japan 2025 にGOLDスポンサーとして今年も協賛します!
PHP Conference Japan 2025 にGOLDスポンサーとして今年も協賛します!

はじめに

 この度、株式会社エブリーは、2025 年 6 月 28 日(土)に開催される「PHPカンファレンス2025」に、ゴールドスポンサーとして昨年に続き今年も協賛することになりました!

phpcon.php.gr.jp

PHPカンファレンス とは?

 日本PHPユーザ会 PHPカンファレンス実行委員会が主催となって、2000年より年に一度開催されている日本最大のPHPのイベントです。

 WEBサーバにインストールされているシェア8割を超える人気言語のイベントとして、初心者から上級者まで幅広い層のWEB系エンジニアが参加します。

2024年のカンファレンス概況

参加者数 約1,150名
協賛企業数 55社
スポンサーセッション数 7セッション
セッション数 52セッション

2025開催プラン

会場 大田区産業プラザPiO
オンラインツール 後日簡易アーカイブを実施予定
セッション 最大並行5トラック程度※トラックは増減する可能性はあります
25~35セッション程度+LTを予定
タイムテーブルはこちら
コンテンツ ・基調講演
・公募セッション
・スポンサーセッション
・スタンプラリー(プレゼント抽選)
懇親会 実施予定(有料)

イベントの参加申し込みはこちらで行っておりますのでぜひ会場でお会いしましょう

昨年の様子など

イベント当日について

当日は大田区産業プラザPiOの1F大展示ホール会場の各スポンサーブースにて、毎年恒例のスタンプラリーが実施されます。

エブリーのブースでは弊社のXアカウントをフォローしていただいた皆様全員に当たるくじ引き景品もございますので、ぜひご興味のある方はお越しください!

ノベルティ
ノベルティ

最後までお読みいただき、ありがとうございました!

OpenAPI における null 値の表現の仕方

こんにちは、@きょーです!普段はデリッシュキッチン開発部のバックエンド中心で業務をしています。

はじめに

OpenAPI で API 仕様書を書く際、null 値を許容するプロパティの表現方法はバージョンによって異なります。たとえば、ユーザープロフィールのメールアドレスのように「値が存在しない(null)」を許容したいケースはよくありますが、その書き方や推奨される方法は OpenAPI のバージョンごとに変化してきました。

この記事では、OpenAPI 3.0.0 と 3.1.0 それぞれでの null 許容プロパティの書き方や、その背景、なぜ仕様が変わったのか、どちらを使うべきかについて解説します。API 設計やスキーマ管理で迷ったときの参考になれば幸いです。

OpenAPI 3.0.0 での nullable の基本的な使い方

OpenAPI 3.0.0 ではnullable: trueを使用することで、プロパティが null 値を許容することを表現できます。

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
        email:
          type: string
          nullable: true # emailはnull値を許容

swagger で表示すると以下のようになります。

OpenAPI 3.1.0 での nullable の表現方法

OpenAPI 3.1.0 では、nullableキーワードが廃止され、代わりにtype配列にnullを含める形で表現するようになりました。

components:
  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
        email:
          type: [string, "null"] # emailはnull値を許容

swagger で表示すると以下のようになります。

なぜ変更されたのか

OpenAPI 3.1.0 でのnullableの廃止には、重要な背景があります。この変更はOpenAPI Specification の Proposalで詳しく議論され、主に以下のような理由が挙げられています。

nullable キーワードの曖昧さ

  • nullable: true は「型指定されたスキーマにおいて null を許容する」という意図で導入されたが、OpenAPI 3.0 のドキュメントではその意味や他キーワードとの相互作用が十分に明確化されていなかった。
  • nullable: false(デフォルト値)についても、null を明示的に禁止するのか、単に変更しないのかが明確でなかった。

JSON Schema との整合性

これらの曖昧さや、JSON Schema への整合性の無さにより、バリデーターの挙動に一貫性がありませんでした。そのため OpenAPI 3.1.0 ではnullableが廃止され、type にnullを指定する方法に変更されました。

コード生成への影響

自分のチームでは OpenAPI で定義したスキーマを元にコードを自動生成しているプロジェクトがあります。oapi-codegenopenapi-typescriptを用いて Go のモデル定義や TypeScript の型を自動生成しています。

OpenAPI のバージョンの違いによって変わる null 値の表現方法がツールによるコード生成にどう影響を与えるか確認してみようと思います。

TypeScript での生成コード

openapi-typescript を使用して TypeScript のコードを生成しようとした場合、以下のようになります。

// OpenAPI 3.0.0 での生成コード
export interface components {
  schemas: {
    User: {
      id: number;
      name: string;
      email: string | null;
    };
  };
}

// OpenAPI 3.1.0 での生成コード
export interface components {
  schemas: {
    User: {
      id: number;
      name: string;
      email: string | null;
    };
  };
}

TypeScript の場合、生成されるコードに大きな違いはなく、nullalbe として指定したプロパティは自動生成されたコードに反映されているのがわかるかと思います。

Go での生成コード

oapi-codegen を使用して Go のコードを生成しようとした場合、以下のようになります。

// OpenAPI 3.0.0 での生成コード
type User struct {
    Email *string `json:"email"`
    Id    int     `json:"id"`
    Name  string  `json:"name"`
}

// OpenAPI 3.1.0 での生成コード
// WARNING: You are using an OpenAPI 3.1.x specification, which is not yet supported by oapi-codegen (https://github.com/oapi-codegen/oapi-codegen/issues/373) and so some functionality may not be available. Until oapi-codegen supports OpenAPI 3.1, it is recommended to downgrade your spec to 3.0.x

Go の場合、OpenAPI 3.0.0 で生成されたコードは nullable として指定したプロパティが反映されているのがわかります。一方 OpenAPI 3.1.0 ではコードの自動生成がされず 3.0.0 にバージョンを落として実行するように警告が出されます。これは oapi-codegen が依存している kin-openapi で OpenAPI 3.1 系にはまだ対応していないためこのような挙動になっているとのことでした。

ここでは取り扱いませんがこちらで言及されている OpenAPI Overlay を使って OpenAPI 3.1.0 を 3.0.0 に downgrade する手法もあるみたいです。

まとめ

OpenAPI における null 値の表現方法について、仕様の違いとツールの対応状況を確認してきました。

  1. 仕様の違い

    • OpenAPI 3.0.0 では nullable: true による独自拡張
    • OpenAPI 3.1.0 では type: [string, "null"] による JSON Schema 準拠
    • 3.1.0 での変更は、より標準的な方法での null の表現を可能に
  2. 実際のツール対応状況

    • TypeScript (openapi-typescript)
      • 3.0.0, 3.1.0 ともに問題なく動作
    • Go (oapi-codegen)
      • 3.0.0 では正常に動作
      • 3.1.0 は現時点で未対応(kin-openapi の制限)
      • 3.1.0 を使用する場合は OpenAPI Overlay による downgrade が必要

新規プロジェクトで新しく API スキーマを書く場合、JSON Schema との互換性がある 3.1.0 の方式が望ましいものの、実際の採用にあたってはツールの対応状況を十分に確認する必要がありそうです。もし既存プロジェクトで 3.1.0 に移行する場合でも使っているツールの対応状況を把握してから移行するのが良さそうです。

MySQL のテーブル構成を Liam ERD で可視化してみた

目次

はじめに

こんにちは、開発本部開発1部トモニテグループのエンジニアの rymiyamoto です。 プロダクトを開発していると、DB のテーブルは常に変化していきます。 そのため、テーブルの構成を可視化することで、テーブルの関係性を把握することができるので設計や開発の効率化に繋がります。 今回は、MySQL のテーブル構成を Liam ERD で可視化してみたので、その方法を紹介します。

Liam ERD とは

Liam ERD は、データベースから ER 図を生成するツールで、複雑なスキーマを簡単に可視化することができます。

liambx.com

主な特徴

公式サイトで挙げられている、Liam ERD が選ばれる理由は以下の通りです。開発者にとって嬉しいポイントが詰まっています。

  • Beautiful UI & Interactive(美しく、インタラクティブな UI)

    • 洗練されたクリーンなデザインで、どんなに複雑なデータベースでも直感的に理解できる ER 図を生成します。パン、ズーム、フィルタリングといった機能により、ストレスなく目的の情報にたどり着けます。
  • Simple Reverse Engineering(簡単なリバースエンジニアリング)

    • 既存のデータベーススキーマを、明確で読みやすい ER 図にシームレスに変換します。これにより、プロジェクトの全体像把握や、新しいメンバーのキャッチアップが格段に容易になります。
  • Effortless Setup(手間いらずのセットアップ)

    • 複雑な設定は一切不要です。データベースのスキーマ情報を提供するだけで、すぐに使い始めることができます。
  • High Performance(高いパフォーマンス)

    • 小規模なプロジェクトから、100 を超えるテーブルを持つ大規模なプロジェクトまで対応できるように最適化されており、軽快な動作を実現しています。
  • Fully Open-Source(完全なオープンソース)

    • Liam ERD はオープンソースソフトウェアです。誰でも自由に利用できるだけでなく、プロジェクトに貢献し、自身のニーズに合わせてツールを形成していくことが可能です。

サポート状況

2025/06/17 現在、全面的にサポートされているものとしては以下のものがあります。

liambx.com

  • PostgreSQL
  • Ruby on Rails
  • Prisma
  • tbls

MySQL は直接はサポートされていませんが、tbls 経由で可能になっています。 tbls は、データベース スキーマを文書化するためのツールで、DB 構成をドキュメント化することができます。 現在 MySQL / SQLite / BigQuery は tbls 経由でサポートされています。

github.com

x.com

導入

今回はコンテナで動かしている MySQL のテーブル構成を出力したいため、CLI で実行してローカル環境で動かすことを想定しています。

サンプルを用意しているので、そちらを参考にしてください。

github.com

手順

1. Liam ERD のビルドをさせるための Dockerfile を用意する

tbls のインストールは go install で行っています。
※ go と tbls のバージョンは、執筆時点でのものです。

ARG GO_VERSION=1.24.4
FROM golang:${GO_VERSION}-alpine AS liam-builder
RUN apk add --no-cache nodejs npm git bash
ARG TBLS_VERSION=1.85.4
RUN go install github.com/k1LoW/tbls@v${TBLS_VERSION}
WORKDIR /work

2. Docker Compose ファイルを用意する

ER 図を生成する builder コンテナと、生成された ER 図を表示する viewer コンテナを用意します。
profiles を設定することで、docker compose up 実行時にこれらのコンテナが起動しないようにしています。

services:
  db:
    # 省略
  # ER図をビルドするためのコンテナ
  builder:
    container_name: builder
    image: liam-erd-sample-builder:latest
    build:
      context: .
      dockerfile: ./dockers/liam/Dockerfile
    env_file:
      - .env
    volumes:
      - ./erd:/work
    working_dir: /work
    depends_on:
      - db
    profiles:
      - builder

  # 生成されたER図を表示するためのコンテナ
  viewer:
    container_name: viewer
    image: nginx:1.27-alpine
    volumes:
      - ./erd:/usr/share/nginx/html:ro
    ports:
      - "127.0.0.1:8080:80"
    profiles:
      - viewer

3. テーブル構成を出力するためのコマンドを用意する

Makefile に一連の処理をまとめます。
open コマンドは macOS 用です。他の OS をお使いの場合は、コマンド実行後に手動でブラウザを開いてください。

.PHONY: view-erd
view-erd:
  mkdir -p ./erd
  rm -rf ./erd/*
  docker compose run --rm builder sh -c "tbls out -t json -o schema.json \"mysql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:3306/${DB_SCHEMA}\" && npx --yes @liam-hq/cli erd build --format=tbls --input schema.json && mv dist/* ."
  docker compose --profile viewer up -d
  open http://localhost:8080

4. テーブル構成を出力する

$ make view-erd

使い勝手

テーブル選択時に外部キー制約があると関連するテーブルと色付きで表示され視覚的にわかりやすく、動作も快適な印象を受けました。 また、テーブルの詳細はサイドバーで出てくるので確認がしやすく、テーブル名のみや全カラムを表示を選べるので、必要な情報を素早く確認することができます。

他にもリンクを生成する事ができるので、共有する際にも便利です。

まとめ

今回は、MySQL のテーブル構成を Liam ERD で可視化してみました。

Liam ERD は、データベースから ER 図を生成するツールで、複雑なスキーマを簡単に可視化することができます。

ツールとして導入難易度も低く、見た目や動作が良い印象を受けました。 また、最近出たオープンソースであるため、今後の発展性も期待できます。

まだチーム内で導入したばかりなので、今後の活用方法を模索していきたいと思います。

最後に

エブリーでは、ともに働く仲間を募集しています。

テックブログを読んで少しでもエブリーに興味を持っていただけた方は、ぜひ一度カジュアル面談にお越しください!

corp.every.tv

最後までお読みいただき、ありがとうございました!