SEあれこれ

プログラミングからSE、PM色々なこと書いていくブログ

Panderaでデータ検証を始める

以前pydanticでのデータ型検証やPolarsでのデータパイプライン応用を扱いましたが、pandasやPolarsのデータフレームそのものに対して「想定した形になっているか」を検証したい場面もあります。今回は、データフレーム専用のバリデーションライブラリ**Pandera**を整理します。

なぜデータフレーム専用の検証ライブラリが必要か

 (1) 以前扱ったpydanticは、1件のレコード(辞書やオブジェクト)に対する型検証には強力ですが、数百万行のデータフレーム全体に対して「amount列は必ず0以上」「customer_id列に欠損値がない」といった検証をかけるのには不向きです。Panderaは、こうした「列全体・データフレーム全体に対する制約」を宣言的に表現できます。

 (2) 以前扱ったGlue ETLやPolarsでのデータパイプラインでは、外部から取り込んだCSVやAPIレスポンスのデータ品質が保証されているとは限りません。パイプラインの入り口でPanderaによる検証を挟んでおくことで、想定外のデータが後続処理に流れ込むのを未然に防げます。

環境構築と基本スキーマ

uv add pandera
import pandas as pd
import pandera as pa
from pandera import Column, Check

schema = pa.DataFrameSchema({
    "order_id": Column(int, Check.greater_than(0)),
    "amount": Column(int, Check.greater_than_or_equal_to(0)),
    "status": Column(str, Check.isin(["pending", "shipped", "delivered"])),
})

df = pd.read_csv("orders.csv")
validated_df = schema.validate(df)

 (1) `DataFrameSchema`に各列の型と制約(`Check`)を定義し、`validate`を呼ぶだけでデータフレーム全体を検証できます。制約に違反する行があれば、以前扱ったpydanticのバリデーションエラーと同様、`SchemaError`が送出され、どの列・どの値が問題だったかが具体的に示されます。

クラスベースでのスキーマ定義

import pandera as pa
from pandera.typing import Series

class OrderSchema(pa.DataFrameModel):
    order_id: Series[int] = pa.Field(gt=0)
    amount: Series[int] = pa.Field(ge=0)
    status: Series[str] = pa.Field(isin=["pending", "shipped", "delivered"])

    class Config:
        strict = True

@pa.check_types
def load_orders(path: str) -> pa.typing.DataFrame[OrderSchema]:
    return pd.read_csv(path)

 (1) この`DataFrameModel`によるクラスベースの書き方は、以前扱ったpydanticの`BaseModel`とほぼ同じ見た目で定義できます。`@pa.check_types`デコレータを関数につけておくと、戻り値のデータフレームが自動的に検証され、以前扱ったデコレータによる共通化の考え方がここでも活きています。

 (2) `strict = True`を指定すると、スキーマに定義されていない列が含まれている場合もエラーになります。想定外の列が紛れ込んでいないかまで含めて検証したい場合に有効です。

列間の関係を検証するカスタムチェック

class OrderSchema(pa.DataFrameModel):
    order_id: Series[int] = pa.Field(gt=0)
    subtotal: Series[int] = pa.Field(ge=0)
    tax: Series[int] = pa.Field(ge=0)
    total: Series[int] = pa.Field(ge=0)

    @pa.dataframe_check
    def total_matches_subtotal_plus_tax(cls, df: pd.DataFrame) -> Series[bool]:
        return df["total"] == df["subtotal"] + df["tax"]

 (1) 単一の列だけでなく、複数の列を組み合わせた業務ルール(この例では「合計金額が小計と税額の和と一致するか」)も`dataframe_check`で表現できます。以前扱ったRDSでの制約(CHECK制約)に近い発想を、DBに保存する前のPython側で先に確認できるイメージです。

Polarsとの連携

import polars as pl
import pandera.polars as pa

class OrderSchema(pa.DataFrameModel):
    order_id: int = pa.Field(gt=0)
    amount: int = pa.Field(ge=0)

df = pl.read_csv("orders.csv")
validated_df = OrderSchema.validate(df)

 (1) 以前扱ったPolarsでのデータパイプラインに対しても、`pandera.polars`モジュールを使えば同様のスキーマ検証が行えます。pandas版とほぼ同じ書き方で移行できるため、データフレームライブラリを切り替えた場合でも検証ロジックを大きく書き直す必要がありません。

ETLパイプラインへの組み込み

def transform_orders(raw_df: pd.DataFrame) -> pd.DataFrame:
    validated_df = OrderSchema.validate(raw_df, lazy=True)
    return validated_df.assign(amount_with_tax=lambda d: d["amount"] * 1.1)

 (1) `lazy=True`を指定すると、最初のエラーで処理を止めるのではなく、すべての検証エラーをまとめて収集してから報告してくれます。以前扱ったGlue ETLのような大規模なバッチ処理では、1件ずつエラーを直しては再実行するより、まとめて問題点を洗い出せる方が効率的です。

テストコードとの組み合わせ

import pytest

def test_load_orders_schema():
    df = load_orders("tests/fixtures/sample_orders.csv")
    OrderSchema.validate(df)

 (1) 以前扱ったpytestのテストコードにスキーマ検証を組み込んでおくと、外部から取り込むサンプルデータの形式が変わった場合に、CIの段階で気づけるようになります。以前扱ったGitHub ActionsのCIパイプラインに組み込めば、データ品質のチェックもテストの一部として自動化できます。

まとめ

Panderaは、データフレーム全体に対して型・値の範囲・列間の関係といった制約を宣言的に検証できるライブラリです。以前扱ったpydanticが1件のレコードの検証に向いていたのに対し、Panderaは大量の行を持つデータフレームの検証に向いており、両者は対象とするデータの粒度が異なります。ETLパイプラインの入り口や、テストコードの一部として組み込んでおくことで、データ品質の劣化に早く気づける体制を作れます。

Kafka入門(Amazon MSK)

以前Kinesisでのストリーミング処理を扱いましたが、今回はもう一つの代表的なストリーミング基盤であるApache Kafkaと、そのマネージドサービスであるAmazon MSK(Managed Streaming for Apache Kafka)を整理します。両者は似た用途で使われますが、設計思想やAWSでの位置づけに違いがあります。

KafkaとKinesisの違い

 (1) 以前扱ったKinesisはAWSのフルマネージドサービスとして、シャード数の増減やスケーリングがAWSのAPIを通じて完結する設計でした。一方Kafkaはオープンソースのミドルウェアで、複数クラウド・オンプレミスを問わず同じ形で動かせる点が大きな違いです。他クラウドやオンプレミスとのハイブリッド構成、あるいはクラウドロックインを避けたい場合にKafkaが選ばれることがあります。

 (2) 用語も対応関係にあります。Kinesisの「シャード」はKafkaでは「パーティション」、Kinesisの「ストリーム」はKafkaでは「トピック」と呼ばれ、概念としてはほぼ同じ役割を果たします。

 (3) Kafkaはコンシューマー側でのオフセット管理や、Kafka Streams・ksqlDBのようなストリーム処理エコシステムが豊富で、複雑なストリーム処理パイプラインを自前で構築したい場合に強みがあります。

Amazon MSKの位置づけ

 (1) MSKは、Kafkaクラスターの構築・運用(ブローカーの管理、パッチ適用、モニタリング連携)をAWSに任せられるマネージドサービスです。以前扱ったECS/Fargateがコンテナ運用の手間を減らしてくれたように、MSKはKafkaクラスター運用の手間を減らしてくれます。

 (2) MSKにはブローカーを自分でプロビジョニングする「MSK Provisioned」と、トラフィックに応じて自動スケーリングする「MSK Serverless」があり、以前扱ったAuroraのServerless版と同様、予測しにくいワークロードにはサーバーレス版が向いています。

Pythonからのプロデューサー実装

uv add kafka-python
from kafka import KafkaProducer
import json

producer = KafkaProducer(
    bootstrap_servers=["broker1.example.com:9092", "broker2.example.com:9092"],
    value_serializer=lambda v: json.dumps(v).encode("utf-8"),
)

producer.send("orders-topic", key=b"order-1001", value={"order_id": 1001, "amount": 5000})
producer.flush()

 (1) `bootstrap_servers`には、クラスター内のブローカーのアドレスを指定します。以前扱ったKinesisの`PartitionKey`と同様、Kafkaでも`key`を指定すると同じキーのメッセージが同じパーティションに送られ、順序が保証されます。

コンシューマーグループでの並列処理

from kafka import KafkaConsumer
import json

consumer = KafkaConsumer(
    "orders-topic",
    bootstrap_servers=["broker1.example.com:9092"],
    group_id="order-processor-group",
    value_deserializer=lambda v: json.loads(v.decode("utf-8")),
    auto_offset_reset="earliest",
)

for message in consumer:
    order = message.value
    print(f"パーティション{message.partition}から受信: {order}")
    process_order(order)

 (1) 同じ`group_id`を持つ複数のコンシューマーインスタンスは、トピックのパーティションを自動的に分担して処理します。以前扱ったLambdaの同時実行数制御と似た発想で、パーティション数を増やせば、その分だけ並行して処理できるコンシューマー数も増やせます。

 (2) `auto_offset_reset="earliest"`は、そのコンシューマーグループが初めて接続したときにトピックの先頭から読み始める設定です。以前扱ったKinesisのデータ保持期間の考え方と同様、Kafkaもメッセージを一定期間(デフォルト7日など)保持し続けるため、後から遡って再処理することができます。

IAM認証でのMSK接続

from kafka import KafkaProducer
from aws_msk_iam_sasl_signer import MSKAuthTokenProvider

def token_provider():
    token, _ = MSKAuthTokenProvider.generate_auth_token("ap-northeast-1")
    return token

producer = KafkaProducer(
    bootstrap_servers=["broker1.msk.amazonaws.com:9098"],
    security_protocol="SASL_SSL",
    sasl_mechanism="OAUTHBEARER",
    sasl_oauth_token_provider=token_provider,
)

 (1) 以前扱ったIAMロール設計の考え方をそのままMSKにも適用でき、IAM認証を使えば、従来のKafkaで必要だったユーザー名・パスワードやTLS証明書の管理をIAMポリシーによるアクセス制御に置き換えられます。誰がどのトピックに読み書きできるかを、AWSのIAMポリシーで一元管理できるのは、MSK特有のメリットです。

MSK ConnectによるS3・RDSへの自動連携

 (1) 以前扱ったKinesis Data Firehoseに近い役割を果たすのがMSK Connectで、Kafka Connect用のコネクタを使って、トピックのデータをS3・RDSなど他のサービスへ自動的に配信できます。コンシューマー用のコードを自作せずに、宛先を設定するだけでデータ連携パイプラインを構築できる点は共通しています。

Kinesis・SQS/SNSとの使い分け

 (1) 以前整理したSQS(1対1のキュー)、SNS(1対多のファンアウト)、EventBridge(パターンベースのルーティング)、Kinesis(AWSネイティブなストリーミング)に加えて、Kafka/MSKは「複数クラウド・オンプレミスとのハイブリッド構成」「Kafka Streamsなど既存のKafkaエコシステムを活用したい」といった場面での選択肢になります。

 (2) 純粋にAWS内で完結するストリーミング処理であればKinesisの方が運用がシンプルになりやすく、他システムとの連携やKafka特有のエコシステムを重視するならMSKを選ぶ、という判断軸が実務的です。

まとめ

Kafka/MSKは、以前扱ったKinesisと同じくストリーミングデータを扱う基盤ですが、オープンソースゆえの可搬性とエコシステムの豊富さが特徴です。パーティション・コンシューマーグループといった概念はKinesisのシャード・コンシューマーと対応しており、片方を理解していればもう片方の理解もスムーズです。マルチクラウドやハイブリッド構成を検討する際の選択肢として、頭の片隅に置いておくとよいと思います。

gRPC入門

以前FastAPIでのREST API開発やGraphQLを扱いましたが、今回はサービス間通信でよく使われる**gRPC**の基礎を整理します。GoogleがオープンソースとGoogleが公開したRPC(Remote Procedure Call)フレームワークで、HTTP/2とProtocol Buffersを土台にした高速な通信が特徴です。

RESTとの違い

 (1) 以前扱ったFastAPIでのREST APIは、JSONというテキストベースのフォーマットでリソースをやり取りする設計でした。gRPCはProtocol Buffers(protobuf)というバイナリ形式でデータをシリアライズするため、JSONに比べてペイロードサイズが小さく、パース処理も高速です。

 (2) また、gRPCはHTTP/1.1ではなくHTTP/2を前提としており、1つのコネクション上で複数のリクエスト・レスポンスを多重化できます。サービス間通信のように、大量の小さなリクエストが頻繁に発生する場面で性能差が出やすくなります。

 (3) 「ブラウザから直接叩く公開APIで、人が読みやすい形式が重要」ならREST、「マイクロサービス同士の内部通信で、速度と型の厳密さが重要」ならgRPCという判断軸が実務的です。

Protocol Buffersでのスキーマ定義

syntax = "proto3";

package sample;

service OrderService {
  rpc GetOrder (GetOrderRequest) returns (OrderResponse);
  rpc ListOrders (ListOrdersRequest) returns (stream OrderResponse);
}

message GetOrderRequest {
  int32 order_id = 1;
}

message OrderResponse {
  int32 order_id = 1;
  int32 amount = 2;
  string status = 3;
}

message ListOrdersRequest {
  int32 customer_id = 1;
}

 (1) `.proto`ファイルでサービスとメッセージの型を定義します。以前扱ったpydanticがPythonのコード上で型を定義していたのに対し、gRPCでは言語に依存しないスキーマファイルとして先に型を定義し、そこからPython・Go・Javaなど各言語のコードを生成する、という順序になります。

Pythonコードの自動生成

uv add grpcio grpcio-tools
python -m grpc_tools.protoc \
  -I. \
  --python_out=. \
  --grpc_python_out=. \
  order.proto

 (1) このコマンドを実行すると、`.proto`ファイルから`order_pb2.py`(メッセージ型の定義)と`order_pb2_grpc.py`(サービスのクライアント・サーバー雛形)が自動生成されます。以前扱ったFastAPIの自動ドキュメント生成と同様、スキーマから実装コードが機械的に導かれる点が、手書きのAPIクライアントとの大きな違いです。

サーバーの実装

import grpc
from concurrent import futures
import order_pb2
import order_pb2_grpc

class OrderServiceServicer(order_pb2_grpc.OrderServiceServicer):
    def GetOrder(self, request, context):
        order = fetch_order_from_db(request.order_id)
        return order_pb2.OrderResponse(
            order_id=order["id"],
            amount=order["amount"],
            status=order["status"],
        )

    def ListOrders(self, request, context):
        orders = fetch_orders_by_customer(request.customer_id)
        for order in orders:
            yield order_pb2.OrderResponse(
                order_id=order["id"],
                amount=order["amount"],
                status=order["status"],
            )

server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
order_pb2_grpc.add_OrderServiceServicer_to_server(OrderServiceServicer(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()

 (1) `ListOrders`のように`yield`で複数のレスポンスを返すメソッドは、`.proto`で`stream`と宣言したことに対応しており、1回のリクエストに対してサーバーが連続的にデータを返す「サーバーストリーミング」を実現します。以前扱ったKinesisのようなストリーミング処理とは異なり、こちらは1つのRPC呼び出しの中で発生する応答の流れという位置づけです。

クライアントの実装

import grpc
import order_pb2
import order_pb2_grpc

with grpc.insecure_channel("localhost:50051") as channel:
    stub = order_pb2_grpc.OrderServiceStub(channel)

    response = stub.GetOrder(order_pb2.GetOrderRequest(order_id=1001))
    print(response.amount, response.status)

    for order in stub.ListOrders(order_pb2.ListOrdersRequest(customer_id=1)):
        print(order.order_id, order.amount)

 (1) クライアント側も自動生成された`Stub`クラスを使うだけで、まるでローカルの関数を呼び出すような感覚でリモートサービスにアクセスできます。以前扱ったboto3がAWSサービスをPythonオブジェクトのように扱えたのと近い体験です。

FastAPIとの併用

 (1) 以前扱ったFastAPIは外部公開用のREST APIとして残しつつ、社内のマイクロサービス間通信だけをgRPCに置き換える、という構成もよく見られます。外部向けには扱いやすいJSON/RESTを、内部向けには高速なgRPCを、という使い分けです。

ECS/Fargateでの運用

 (1) 以前扱ったECS/Fargateの構成でgRPCサービスを運用する場合、ALBはHTTP/2をサポートしているものの、gRPC特有のヘルスチェックやロードバランシングの挙動に注意が必要です。gRPC向けのターゲットグループ設定(プロトコルバージョンをGRPCに指定)を使うことで、正しくリクエストをルーティングできます。

エラーハンドリング

def GetOrder(self, request, context):
    order = fetch_order_from_db(request.order_id)
    if order is None:
        context.set_code(grpc.StatusCode.NOT_FOUND)
        context.set_details("注文が見つかりません")
        return order_pb2.OrderResponse()
    return order_pb2.OrderResponse(order_id=order["id"], amount=order["amount"])

 (1) 以前扱ったFastAPIの`HTTPException`に相当するのが、gRPCでは`context.set_code`によるステータスコードの設定です。`NOT_FOUND`や`INVALID_ARGUMENT`のような標準化されたステータスコードが用意されており、クライアント側でも一貫したエラー処理がしやすくなっています。

まとめ

gRPCは、Protocol BuffersとHTTP/2を土台にした高速なRPCフレームワークで、特にマイクロサービス間の内部通信において強みを発揮します。以前扱ったFastAPIでのRESTやGraphQLと比べ、スキーマファーストな設計と自動生成されるクライアントコードが特徴で、外部公開APIにはREST、サービス間通信にはgRPCという役割分担で組み合わせるのが実務的な選択になります。

gRPC入門

以前FastAPIでのREST API開発やGraphQLを扱いましたが、今回はサービス間通信でよく使われる**gRPC**の基礎を整理します。GoogleがオープンソースとGoogleが公開したRPC(Remote Procedure Call)フレームワークで、HTTP/2とProtocol Buffersを土台にした高速な通信が特徴です。

RESTとの違い

 (1) 以前扱ったFastAPIでのREST APIは、JSONというテキストベースのフォーマットでリソースをやり取りする設計でした。gRPCはProtocol Buffers(protobuf)というバイナリ形式でデータをシリアライズするため、JSONに比べてペイロードサイズが小さく、パース処理も高速です。

 (2) また、gRPCはHTTP/1.1ではなくHTTP/2を前提としており、1つのコネクション上で複数のリクエスト・レスポンスを多重化できます。サービス間通信のように、大量の小さなリクエストが頻繁に発生する場面で性能差が出やすくなります。

 (3) 「ブラウザから直接叩く公開APIで、人が読みやすい形式が重要」ならREST、「マイクロサービス同士の内部通信で、速度と型の厳密さが重要」ならgRPCという判断軸が実務的です。

Protocol Buffersでのスキーマ定義

syntax = "proto3";

package sample;

service OrderService {
  rpc GetOrder (GetOrderRequest) returns (OrderResponse);
  rpc ListOrders (ListOrdersRequest) returns (stream OrderResponse);
}

message GetOrderRequest {
  int32 order_id = 1;
}

message OrderResponse {
  int32 order_id = 1;
  int32 amount = 2;
  string status = 3;
}

message ListOrdersRequest {
  int32 customer_id = 1;
}

 (1) `.proto`ファイルでサービスとメッセージの型を定義します。以前扱ったpydanticがPythonのコード上で型を定義していたのに対し、gRPCでは言語に依存しないスキーマファイルとして先に型を定義し、そこからPython・Go・Javaなど各言語のコードを生成する、という順序になります。

Pythonコードの自動生成

uv add grpcio grpcio-tools
python -m grpc_tools.protoc \
  -I. \
  --python_out=. \
  --grpc_python_out=. \
  order.proto

 (1) このコマンドを実行すると、`.proto`ファイルから`order_pb2.py`(メッセージ型の定義)と`order_pb2_grpc.py`(サービスのクライアント・サーバー雛形)が自動生成されます。以前扱ったFastAPIの自動ドキュメント生成と同様、スキーマから実装コードが機械的に導かれる点が、手書きのAPIクライアントとの大きな違いです。

サーバーの実装

import grpc
from concurrent import futures
import order_pb2
import order_pb2_grpc

class OrderServiceServicer(order_pb2_grpc.OrderServiceServicer):
    def GetOrder(self, request, context):
        order = fetch_order_from_db(request.order_id)
        return order_pb2.OrderResponse(
            order_id=order["id"],
            amount=order["amount"],
            status=order["status"],
        )

    def ListOrders(self, request, context):
        orders = fetch_orders_by_customer(request.customer_id)
        for order in orders:
            yield order_pb2.OrderResponse(
                order_id=order["id"],
                amount=order["amount"],
                status=order["status"],
            )

server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
order_pb2_grpc.add_OrderServiceServicer_to_server(OrderServiceServicer(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()

 (1) `ListOrders`のように`yield`で複数のレスポンスを返すメソッドは、`.proto`で`stream`と宣言したことに対応しており、1回のリクエストに対してサーバーが連続的にデータを返す「サーバーストリーミング」を実現します。以前扱ったKinesisのようなストリーミング処理とは異なり、こちらは1つのRPC呼び出しの中で発生する応答の流れという位置づけです。

クライアントの実装

import grpc
import order_pb2
import order_pb2_grpc

with grpc.insecure_channel("localhost:50051") as channel:
    stub = order_pb2_grpc.OrderServiceStub(channel)

    response = stub.GetOrder(order_pb2.GetOrderRequest(order_id=1001))
    print(response.amount, response.status)

    for order in stub.ListOrders(order_pb2.ListOrdersRequest(customer_id=1)):
        print(order.order_id, order.amount)

 (1) クライアント側も自動生成された`Stub`クラスを使うだけで、まるでローカルの関数を呼び出すような感覚でリモートサービスにアクセスできます。以前扱ったboto3がAWSサービスをPythonオブジェクトのように扱えたのと近い体験です。

FastAPIとの併用

 (1) 以前扱ったFastAPIは外部公開用のREST APIとして残しつつ、社内のマイクロサービス間通信だけをgRPCに置き換える、という構成もよく見られます。外部向けには扱いやすいJSON/RESTを、内部向けには高速なgRPCを、という使い分けです。

ECS/Fargateでの運用

 (1) 以前扱ったECS/Fargateの構成でgRPCサービスを運用する場合、ALBはHTTP/2をサポートしているものの、gRPC特有のヘルスチェックやロードバランシングの挙動に注意が必要です。gRPC向けのターゲットグループ設定(プロトコルバージョンをGRPCに指定)を使うことで、正しくリクエストをルーティングできます。

エラーハンドリング

def GetOrder(self, request, context):
    order = fetch_order_from_db(request.order_id)
    if order is None:
        context.set_code(grpc.StatusCode.NOT_FOUND)
        context.set_details("注文が見つかりません")
        return order_pb2.OrderResponse()
    return order_pb2.OrderResponse(order_id=order["id"], amount=order["amount"])

 (1) 以前扱ったFastAPIの`HTTPException`に相当するのが、gRPCでは`context.set_code`によるステータスコードの設定です。`NOT_FOUND`や`INVALID_ARGUMENT`のような標準化されたステータスコードが用意されており、クライアント側でも一貫したエラー処理がしやすくなっています。

まとめ

gRPCは、Protocol BuffersとHTTP/2を土台にした高速なRPCフレームワークで、特にマイクロサービス間の内部通信において強みを発揮します。以前扱ったFastAPIでのRESTやGraphQLと比べ、スキーマファーストな設計と自動生成されるクライアントコードが特徴で、外部公開APIにはREST、サービス間通信にはgRPCという役割分担で組み合わせるのが実務的な選択になります。

SQLAlchemyでORM入門

以前RDSチューニングやFastAPIでのAPI開発、pydanticでの型検証を扱いましたが、実際のアプリケーションコードでSQLを直接書く場面は意外と少なく、多くの場合ORM(Object-Relational Mapping)を介してDBを操作します。今回はPythonで広く使われている**SQLAlchemy**の基礎を整理します。

ORMを使う理由

 (1) SQLを文字列として直接組み立てると、以前扱ったセキュリティ関連の記事でも触れたSQLインジェクションのリスクや、テーブル構造が変わったときの修正漏れが起きやすくなります。ORMを使うと、Pythonのオブジェクトとしてテーブルを表現し、値のバインドも自動的に安全に行われます。

 (2) 以前扱ったpydanticが「データの形」を型として表現するものだったのに対し、SQLAlchemyは「DBのテーブル構造」をPythonのクラスとして表現するものです。両者を組み合わせることで、APIの入出力とDBのモデルを一貫した型で管理できます。

環境構築とモデル定義

uv add sqlalchemy psycopg2-binary
from sqlalchemy import String, Integer, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

class Base(DeclarativeBase):
    pass

class Customer(Base):
    __tablename__ = "customers"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(100))
    orders: Mapped[list["Order"]] = relationship(back_populates="customer")

class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    amount: Mapped[int] = mapped_column(Integer)
    customer_id: Mapped[int] = mapped_column(ForeignKey("customers.id"))
    customer: Mapped[Customer] = relationship(back_populates="orders")

 (1) `Mapped`と`mapped_column`を組み合わせた記法は最近のSQLAlchemy(2.0系)の書き方で、以前扱ったpydanticと同様、型ヒントをそのままDB上のカラム型と対応付けられます。`relationship`によって、外部キーで結ばれたテーブル同士をPythonのオブジェクトの属性として自然にたどれるようになります。

エンジンとセッションの作成

from sqlalchemy import create_engine
from sqlalchemy.orm import Session

engine = create_engine("postgresql://user:password@localhost:5432/sample_db")
Base.metadata.create_all(engine)

with Session(engine) as session:
    customer = Customer(name="サンプル顧客")
    session.add(customer)
    session.commit()

 (1) `Session`はDBとの1回のやり取りの単位を表し、以前扱ったFastAPIでの依存性注入によるDBセッション管理と組み合わせて、リクエストごとにセッションを生成・破棄するのが一般的なパターンです。

クエリの基本

from sqlalchemy import select

with Session(engine) as session:
    stmt = select(Customer).where(Customer.name == "サンプル顧客")
    customer = session.scalars(stmt).first()

    stmt = select(Order).where(Order.amount > 1000).order_by(Order.amount.desc())
    orders = session.scalars(stmt).all()

 (1) `select`と`where`を組み合わせた書き方はSQLの構文に近く、以前扱ったRDSチューニングでのSQL最適化の知識をそのまま活かせます。生のSQLを文字列で書く代わりにPythonのメソッドチェーンで表現するため、IDEの補完や型チェックの恩恵を受けられる点がORMの強みです。

joinによる関連データの取得

from sqlalchemy.orm import joinedload

with Session(engine) as session:
    stmt = select(Customer).options(joinedload(Customer.orders))
    customers = session.scalars(stmt).unique().all()
    for customer in customers:
        print(customer.name, len(customer.orders))

 (1) `joinedload`を指定しないと、`customer.orders`にアクセスするたびに追加のクエリが発行される「N+1問題」が発生しやすくなります。以前扱ったDynamoDB設計パターンでのアクセスパターン設計と同様、ORMでも「どのデータを一緒に取得するか」を事前に意識しておくことが、パフォーマンス上重要です。

FastAPIとの組み合わせ

from fastapi import Depends, FastAPI

app = FastAPI()

def get_session():
    with Session(engine) as session:
        yield session

@app.get("/customers/{customer_id}")
def get_customer(customer_id: int, session=Depends(get_session)) -> dict:
    customer = session.get(Customer, customer_id)
    return {"id": customer.id, "name": customer.name}

 (1) 以前扱ったFastAPIの`Depends`によるDBセッション管理の実例として、SQLAlchemyのセッションをそのまま注入する形が定番です。エンドポイントの処理が終わると`with`ブロックを抜け、セッションが自動的にクローズされます。

マイグレーション管理(Alembic)

uv add alembic
alembic init migrations
alembic revision --autogenerate -m "add orders table"
alembic upgrade head

 (1) テーブル構造をコードで変更した場合、実際のDBスキーマにも反映する必要があります。AlembicはSQLAlchemyのモデル定義と実際のDBの差分を検出し、マイグレーションスクリプトを自動生成してくれます。以前扱ったTerraformでのIaCが「インフラの差分をコードで管理する」ものだったのに対し、Alembicは「DBスキーマの差分をコードで管理する」ものと捉えると理解しやすいです。

非同期対応(asyncio連携)

from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession

engine = create_async_engine("postgresql+asyncpg://user:password@localhost:5432/sample_db")

async def get_customer(customer_id: int) -> Customer | None:
    async with AsyncSession(engine) as session:
        return await session.get(Customer, customer_id)

 (1) 以前扱ったasyncioやFastAPIの非同期エンドポイントと組み合わせる場合、SQLAlchemyの非同期エンジンとasyncpgのような非同期ドライバを使うことで、DBへの問い合わせ中も他のリクエストの処理を進められます。同期版と非同期版でAPIの書き方はほぼ同じため、必要になったタイミングで移行しやすい設計になっています。

まとめ

SQLAlchemyは、SQLを直接書く代わりにPythonのオブジェクトとしてDB操作を表現できるORMです。以前扱ったpydanticがAPIの入出力の型を保証するものだったのに対し、SQLAlchemyはDBのテーブル構造とクエリを型安全に扱うためのものであり、FastAPIと組み合わせることでAPIからDBまで一貫した型の流れを作れます。Alembicによるマイグレーション管理も含めて導入しておくと、DBスキーマの変更履歴を安全に追跡できるようになります。

Locustで負荷テストを始める

以前FastAPIでAPI開発を扱い、GitHub ActionsでCI/CDパイプラインを構築しましたが、実際に本番投入する前に「どれくらいのアクセスに耐えられるか」を確認しておくことも重要です。今回は、Pythonでシナリオを書ける負荷テストツール**Locust**を整理します。

なぜ負荷テストが必要か

 (1) 以前扱ったLambdaの同時実行数制御やAPI Gatewayのスロットリング設定は、あくまで「上限を決める」設定です。実際にその上限に達したときアプリケーションやDBがどう振る舞うか、レスポンスタイムがどこまで劣化するかは、実際に負荷をかけてみないと分かりません。

 (2) 以前扱ったRedis/ElastiCacheでのキャッシュ導入も、「導入前後でどれだけレスポンスタイムやDB負荷が変わったか」を負荷テストで数値として比較することで、効果を客観的に確認できます。

環境構築と最小構成

uv add locust
from locust import HttpUser, task, between

class SampleUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def get_orders(self):
        self.client.get("/orders")
locust -f locustfile.py --host=https://api.example.com

 (1) `wait_time`は、各仮想ユーザーがタスクを実行するたびに待機する時間の範囲です。実際のユーザーの行動に近い間隔を設定することで、より現実的な負荷を再現できます。起動後はブラウザでWeb UI(デフォルトはポート8089)にアクセスし、仮想ユーザー数とスポーン速度を指定してテストを開始します。

複数のタスクと重み付け

class SampleUser(HttpUser):
    wait_time = between(1, 3)

    @task(3)
    def get_orders(self):
        self.client.get("/orders")

    @task(1)
    def create_order(self):
        self.client.post("/orders", json={"customer_id": 1, "amount": 1000})

 (1) `@task(3)`のように数値を指定すると、その数値の比率でタスクが選択されます。この例では「一覧取得」が「新規作成」の3倍の頻度で実行され、実際のアクセスパターン(参照が多く更新が少ない)に近い負荷を再現できます。

認証を伴うシナリオ

from locust import HttpUser, task, between

class AuthenticatedUser(HttpUser):
    wait_time = between(1, 3)

    def on_start(self):
        response = self.client.post("/auth/login", json={
            "username": "sample_user",
            "password": "sample_password",
        })
        token = response.json()["access_token"]
        self.client.headers.update({"Authorization": f"Bearer {token}"})

    @task
    def get_profile(self):
        self.client.get("/me")

 (1) `on_start`は各仮想ユーザーがテスト開始時に1度だけ実行するメソッドで、以前扱ったCognitoでのユーザー認証のようなログイン処理をここで行い、以降のリクエストに認証トークンを付与する、という現実的なシナリオを組み立てられます。

コマンドラインでのヘッドレス実行

locust -f locustfile.py --host=https://api.example.com \
  --headless \
  --users 100 \
  --spawn-rate 10 \
  --run-time 5m \
  --csv=result

 (1) `--headless`を指定すると、Web UIを介さずコマンドラインだけでテストを実行できます。以前扱ったGitHub ActionsのCIパイプラインに組み込み、デプロイ前に自動的に負荷テストを走らせ、一定の基準を下回った場合はデプロイを止める、という運用も可能です。

分散実行での大規模負荷生成

# マスターノード
locust -f locustfile.py --master

# ワーカーノード(複数台で実行)
locust -f locustfile.py --worker --master-host=master-node-ip

 (1) 1台のマシンから生成できる負荷には限界があるため、大規模なテストでは複数のワーカーノードに負荷生成を分散させます。以前扱ったECS/Fargateの構成を使い、ワーカーノードをコンテナとして必要な台数だけ一時的に起動する、という構成も現実的です。

結果の見方と指標

 (1) Locustは、各エンドポイントごとのリクエスト数・エラー率・レスポンスタイムの中央値やパーセンタイル(95%tile、99%tileなど)をリアルタイムに集計します。平均値だけでなく、95%tileや99%tileのような外れ値寄りの指標を見ることで、「大半のユーザーは快適だが、一部のユーザーが極端に遅い」といった問題にも気づけます。

 (2) 以前扱ったCloudWatch監視設計パターンと組み合わせ、負荷テスト中にRDSのCPU使用率やDynamoDBのスロットリング状況を並行して確認すると、「アプリケーション側が遅いのか、DB側がボトルネックなのか」を切り分けやすくなります。

段階的に負荷を上げるシナリオ

from locust import LoadTestShape

class StepLoadShape(LoadTestShape):
    step_time = 60
    step_users = 50
    max_users = 300

    def tick(self):
        run_time = self.get_run_time()
        current_step = run_time // self.step_time
        users = min((current_step + 1) * self.step_users, self.max_users)
        if run_time > self.step_time * (self.max_users // self.step_users):
            return None
        return (users, self.step_users)

 (1) `LoadTestShape`を使うと、時間経過とともに仮想ユーザー数を段階的に増やしていくシナリオを組めます。「どのアクセス数を超えたあたりからレスポンスタイムが劣化し始めるか」という限界点を見つけたい場合に有効です。

まとめ

Locustは、Pythonのコードでリアルなユーザー行動を再現しながら負荷をかけられるツールです。以前扱ったFastAPIでのAPI構築やCI/CDパイプラインと組み合わせ、リリース前に負荷テストを組み込んでおくことで、本番投入後に想定外のアクセス数で慌てるリスクを減らせます。まずは1つのエンドポイントに対する単純なシナリオから、少しずつ現実的な負荷パターンへと育てていくのがおすすめです。

GitHub ActionsでCI/CDパイプラインを構築する

以前TerraformでのIaC入門やpytest・mypy・banditによる品質担保を扱いましたが、今回はそれらを「コードをプッシュするたびに自動実行する」ための仕組みであるGitHub Actionsを整理します。テスト・型チェック・デプロイを自動化し、手作業のミスを減らすことが目的です。

GitHub Actionsの基本構造

 (1) リポジトリ内の`.github/workflows/`ディレクトリにYAMLファイルを置くことで、特定のイベント(プッシュ・プルリクエストなど)をトリガーにジョブを自動実行できます。ジョブはさらに複数の「ステップ」に分かれ、それぞれが順番に実行されます。

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

テスト・型チェックの自動実行

      - name: Install dependencies
        run: |
          pip install uv
          uv sync
      - name: Run pytest
        run: uv run pytest
      - name: Run mypy
        run: uv run mypy .
      - name: Run bandit
        run: uv run bandit -r .

 (1) 以前扱ったpytest・mypy・banditをそのままステップとして並べるだけで、プルリクエストを作成するたびにこれらのチェックが自動実行されます。手元で実行し忘れる、あるいはローカル環境の違いで結果が変わるといった問題を防げます。

 (2) 以前扱ったuvを使えば、依存関係のインストールが高速に完了するため、CI全体の実行時間の短縮にも直接効いてきます。

マトリクスビルドでの複数バージョン検証

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - run: uv sync && uv run pytest

 (1) `strategy.matrix`を使うと、複数のPythonバージョンに対して同じジョブを並行実行できます。ライブラリとして複数バージョンをサポートしたい場合や、バージョンアップ前の互換性確認をしたい場合に有効です。

AWSへのデプロイ:OIDC認証

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/github-actions-deploy-role
          aws-region: ap-northeast-1

 (1) 以前扱ったIAMロール設計の考え方をここでも活かし、GitHub ActionsからAWSへ長期的なアクセスキーを渡すのではなく、OIDC(OpenID Connect)を使って一時的な認証情報を発行する構成が現在の推奨です。GitHub側に固定のシークレットを保存しなくて済むため、キー漏洩のリスクを減らせます。

Lambdaへの自動デプロイ

      - name: Package Lambda function
        run: |
          uv export --format requirements-txt > requirements.txt
          pip install -r requirements.txt -t package/
          cp -r src/* package/
          cd package && zip -r ../function.zip .
      - name: Deploy to Lambda
        run: |
          aws lambda update-function-code \
            --function-name sample-function \
            --zip-file fileb://function.zip

 (1) 以前扱ったLambdaパフォーマンス最適化で触れたパッケージングの手順を、そのままCIのステップとして自動化しています。手動でzipを作ってコンソールからアップロードしていた作業が、プッシュ1回で完結するようになります。

Terraformの自動適用

      - name: Terraform Plan
        run: terraform plan -out=tfplan
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve tfplan

 (1) 以前扱ったTerraformでのIaC入門の`plan`と`apply`をCIに組み込むことで、プルリクエスト時には差分確認(plan)だけを行い、mainブランチへのマージ後に自動的に`apply`を実行する、という安全なワークフローを構築できます。`if`条件でブランチを絞ることで、意図しない環境への適用を防ぎます。

ECS/Fargateへのデプロイ

      - name: Build and push image
        run: |
          docker build -t sample-app .
          docker tag sample-app:latest $ECR_REGISTRY/sample-app:latest
          docker push $ECR_REGISTRY/sample-app:latest
      - name: Update ECS service
        run: |
          aws ecs update-service \
            --cluster sample-cluster \
            --service sample-service \
            --force-new-deployment

 (1) 以前扱ったECS/Fargateの構成に対しても、イメージのビルド・ECRへのプッシュ・サービスの更新までを一連のジョブとして自動化できます。以前扱ったStep Functions応用のように複雑なオーケストレーションが必要ない、シンプルなローリングデプロイであればこの形で十分実用的です。

通知との連携

      - name: Notify Slack on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text": "CIが失敗しました: ${{ github.repository }} (${{ github.sha }})"}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

 (1) 以前扱ったSlack自動化の仕組みをCIのステップに組み込むことで、テストやデプロイが失敗した際にすぐ気づける体制を作れます。`if: failure()`を指定すると、直前のステップが失敗した場合にのみこのステップが実行されます。

まとめ

GitHub Actionsは、以前扱ったpytest・mypy・bandit・Terraform・Lambda・ECSといった個々のツールや構成を、コードのプッシュを起点に自動でつなぎ合わせるための仕組みです。OIDCによる認証やマトリクスビルドといった機能を活用しながら、テストからデプロイまでを一貫したパイプラインとして構築しておくと、日々の運用がぐっと楽になります。