UIKitで巨大かつ複雑なStickyヘッダーを実装する

こんにちは、レシピ事業部でiOSエンジニアをしている山田(@0x746572616e79)です。つい先日リリースしたプロフィール画面のリニューアルで、SNSアプリでよく見かけるプロフィールコンポーネントとStickyなタブを組み合わせたUIを実装し、いくつか学びがあったので実装方法と合わせてご紹介します。

Stickyヘッダーの設計、実装方針

プロフィール画面は元々、プロフィールコンポーネントの下にレシピ一覧、その下に「つくれぽ」のカルーセルを表示するシンプルな縦スクロール構成でした。

リニューアル後は、プロフィールコンポーネントの下に2つのタブ(レシピ一覧・つくれぽ一覧)を配置し、それぞれが独立したスクロール領域を持つ構成に変更します。各タブの内容は既存の検索結果画面と同じUIコンポーネントを流用できたため、UIPageViewControllerで各一覧画面のViewControllerをホスティング(内包・管理)する設計を採用しました。

class ProfileViewController: UIViewController {
    private lazy var pageViewController: UIPageViewController = {
        let viewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
        viewController.dataSource = self
        viewController.delegate = self
        return viewController
    }()
    
    private let recipeListViewController: RecipeListViewController // レシピ一覧画面
    private let cooksnapListViewController: CooksnapListViewController // つくれぽ一覧画面
    
    // プロフィールヘッダー(ユーザー情報やタブを含む)
    private let headerProfileView = ProfileView()
    
    // ヘッダーの位置を制御する制約
    private lazy var headerTopAnchorConstraint = headerProfileView.topAnchor.constraint(
        equalTo: view.safeAreaLayoutGuide.topAnchor
    )

    override func viewDidLoad() {
        super.viewDidLoad()

        setupLayout()

        pageViewController.setViewControllers([recipeListViewController], direction: .forward, animated: false)
    }
    
    private func setupLayout() {
        // PageViewControllerを全画面に配置
        addChild(pageViewController)
        view.addSubview(pageViewController.view)
        pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            pageViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
            pageViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            pageViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            pageViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        pageViewController.didMove(toParent: self)
        
        // ヘッダーをPageViewControllerの上に重ねて配置
        view.addSubview(headerProfileView)
        NSLayoutConstraint.activate([
            headerTopAnchorConstraint, // この制約のconstantを変更してヘッダーを上下に移動
            headerProfileView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            headerProfileView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            headerProfileView.heightAnchor.constraint(equalToConstant: 200)
        ])
    }
}

この構成に至るまでに、以下の技術的、体験的な課題がありました。

  • 複数のスクロールビューとヘッダーの連動
  • ヘッダーの位置に応じた各タブのオフセット調整
  • ヘッダー領域でスクロールジェスチャーが効かない

順番に見ていきましょう

課題1. 複数のスクロールビューとヘッダーの連動

固定ヘッダーをタブのスクロールに合わせて追従させるだけなら、実装はそれほど複雑ではありません。UIPageViewControllerの上にヘッダーコンポーネントを配置し、各タブのViewControllerにヘッダーと同じ高さのadditionalSafeAreaInsets.topを設定するだけで基本的な動作は実現できます。

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    let headerHeight = headerProfileView.frame.height
    if recipeListViewController.additionalSafeAreaInsets.top != headerHeight ||
        cooksnapListViewController.additionalSafeAreaInsets.top != headerHeight {
        recipeListViewController.additionalSafeAreaInsets.top = headerHeight
        cooksnapListViewController.additionalSafeAreaInsets.top = headerHeight
    }
}

あとはヘッダーをスクロール量に合わせて上下に移動させることで、スクロール時にヘッダーが自然に隠れるような動作が実現できます。 しかし今回の実装では、各タブ内にも上部に固定表示したいコンポーネント(検索バー)が存在していました。これが実装を複雑にする要因となりました。

スクロールしても検索バーがその場に止まってしまう

単純にadditionalSafeAreaInsetsを設定するだけでは、スクロールに合わせてタブ内の検索バーの位置を動的に調整することができません。プロフィールヘッダーが隠れた分だけ検索バーを上に移動させる必要があり、これには各タブのViewControllerと親のProfileViewController間でのスクロール状態の連携が不可欠でした。

そこで、additionalSafeAreaInsets.topは基本的なレイアウト調整として設定しつつ、スクロール位置を同期するための専用の仕組みを用意しました。

まず、各タブのViewControllerが共通のインターフェースでスクロール情報を提供できるよう、以下のプロトコルを定義します。

protocol ScrollMovementDelegate: AnyObject {
    func scrollMovementDidScroll(_ scrollView: UIScrollView, viewController: UIViewController)
}

protocol ScrollMovementProvidingViewController: UIViewController {
    var scrollMovementDelegate: ScrollMovementDelegate? { get set }
    func adjustContentTopInset(_ topInset: CGFloat)
}

各タブのViewControllerはこのプロトコルに準拠し、スクロール時に親のProfileViewControllerに変更を通知します。

// レシピ一覧のViewControllerで実装
extension RecipeListViewController: UITableViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        scrollMovementDelegate?.scrollMovementDidScroll(scrollView, viewController: self)
    }
}

親ViewControllerでは、受け取ったスクロール情報を元にヘッダーのトップアンカー制約のconstantを調整してヘッダーを上下に移動させつつ、各タブのadjustContentTopInsetメソッドを呼び出してタブ内の固定コンポーネント(検索バー)の位置を連動させます。これにより、プロフィールヘッダーの移動量に合わせて、各タブ内の検索バーも同じ距離だけ上に移動し、一体感のあるスクロール体験を実現できました。

// プロフィールViewControllerでスクロール情報を受け取り、ヘッダー位置を調整
extension ProfileViewController: ScrollMovementDelegate {
    func scrollMovementDidScroll(_ scrollView: UIScrollView, viewController: UIViewController) {
        guard let tab = currentTab(from: viewController), selectedTab == tab else { return }
        let offsetY = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
        headerTopAnchorConstraint.constant = max(-offsetY, -headerProfileView.frame.height)
        updateTabContentsTopInset()
    }
    
    private func updateTabContentsTopInset() {
        let inset = headerTopAnchorConstraint.constant
        recipeListViewController.adjustContentTopInset(inset)
        cooksnapListViewController.adjustContentTopInset(inset)
    }
}

extension RecipeListViewController: ScrollMovementProvidingViewController {
    func adjustContentTopInset(_ topInset: CGFloat) {
        searchBarTopAnchorConstraint.constant = topInset // ヘッダーが移動した分、検索バーを移動させる
        // スクロールインジケーターの位置も合わせて調整
        tableView.verticalScrollIndicatorInsets.top = topInset + tableView.contentInset.top
    }
}

課題2. ヘッダーの位置に応じたオフセットの調整

この仕組みだけでは新たな問題が発生します。ヘッダーが上下に移動した状態で別のタブに切り替えると、そのタブのスクロール位置が意図しない状態になってしまうのです。

別のタブ切り替え時に意図しない余白が開いてしまう

例えば、以下のようなケースです:

  1. タブAでスクロールしてヘッダーが半分隠れる(headerTopAnchorConstraint.constant = -100)
  2. タブBに切り替え
  3. タブBのコンテンツとヘッダーの間に意図しない余白が開く、または重なって表示される

この問題に対して、複雑な制御でタブごとのヘッダー位置を記憶し、切り替え時に適切な位置に調整することも考えられました。しかし、メンテナンス性と実装の複雑さを考慮して、よりシンプルなアプローチを採用しました。 ヘッダーが移動した状態でタブ切り替えが発生した場合、非表示タブのスクロール位置をヘッダーのトップに合わせる形でリセットする処理を挟みます。

// 各タブのViewControllerで実装
func adjustContentTopInset(_ topInset: CGFloat, isVisible: Bool) {
    let previousTopInset = searchBarTopAnchorConstraint.constant

    searchBarTopAnchorConstraint.constant = topInset
    tableView.verticalScrollIndicatorInsets.top = topInset + tableView.contentInset.top
    
    // 非表示タブ(現在表示されていないタブ)でヘッダー位置が変わった場合は、
    // 次回表示時にスクロール位置を調整するためのフラグを設定
    if !isVisible && topInset != previousTopInset {
        pendingTopInsetAdjustment = topInset
    }
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    // pendingTopInsetAdjustmentがある場合、スクロール位置をリセット
    if let pendingTopInsetAdjustment {
        let minimumTopOffset = -pendingTopInsetAdjustment - tableView.adjustedContentInset.top
        tableView.contentOffset.y = minimumTopOffset
        self.pendingTopInsetAdjustment = nil
    }
}

このpendingTopInsetAdjustmentは、次回そのタブが表示される際にviewDidLayoutSubviewsで処理され、自然な位置にスクロール位置がリセットされます。 片方のタブがトップにスクロールするともう片方のタブもトップに戻ってしまうデメリットはありますが、ユーザーにとって違和感のないタブ切り替え体験を実現することができました。

実は、当初は別のアプローチも検討していました。スクロール位置に応じてadditionalSafeAreaInsetsを動的に変更する方法です。

この方法では、ヘッダーが隠れるにつれてadditionalSafeAreaInsets.topを小さくし、完全に隠れた時点で0にすることで、各タブのコンテンツ領域を自然に拡張させることができます。SafeAreaを変形させる方法のため課題1の問題も自動的に解決します。

// 没になったアプローチの例
func scrollMovementDidScroll(_ scrollView: UIScrollView, viewController: UIViewController) {
    let offsetY = scrollView.contentOffset.y + scrollView.adjustedContentInset.top
    let headerHeight = headerProfileView.frame.height
    let newSafeAreaTop = max(0, headerHeight - offsetY)
    
    // これが問題を引き起こした
    recipeListViewController.additionalSafeAreaInsets.top = newSafeAreaTop
    cooksnapListViewController.additionalSafeAreaInsets.top = newSafeAreaTop
}

しかし、この実装では上方向のスクロール時にスクロールの慣性が不自然に早く止まってしまい、ユーザーが期待するスムーズなスクロール体験を提供できませんでした。具体的には、通常であれば慣性でしばらく続くはずのスクロールが、突然停止してしまう現象が発生しました。 この問題はおそらく、additionalSafeAreaInsetsの変更がスクロールビューのadjustedContentInsetに影響を与え、スクロール中にこれらの値が動的に変わることでUIScrollViewの内部的な慣性計算が狂ってしまうことが原因だと考えています。 ユーザー体験を最優先に考え、技術的には実現可能でも、スクロールの自然さを犠牲にしてまで採用する価値はないと考え、この手法は没にしました。 結果として、制約のconstantを調整する現在のアプローチに落ち着き、スムーズなスクロール体験を保ちながら縦方向にスクロールできるタブUIを実現することができました。

課題3. ヘッダー領域でスクロールジェスチャーが効かない

ここまでで見た目と実装上では意図した通りの配置になりましたが、親のViewControllerで直接表示しているヘッダーコンポーネントとUIPageViewController内の子ViewControllerで表示しているUIScrollViewは全く別の階層構造にあるためジェスチャーの共有は行われず、ヘッダーコンポーネントを縦にスワイプしてもジェスチャーは伝搬しません。

プロフィール画面の階層構造

クックパッドでは画像のようにプロフィールコンポーネントとタブコントロールに加えて、プロフィールの入力を促すカルーセルをヘッダー部分で表示します。もしヘッダー部分をスワイプできないとなると画面下部の極端に小さいエリアをスワイプしなければならず、ユーザー体験は最悪なものになります。どのように対応するべきでしょうか?

カルーセルを含むプロフィール画面

この階層構造の違いを解決するため、UIScrollViewのpanGestureRecognizerを活用します。

Getterとして公開されているため新たにUIPanGestureRecognizerを作成してUIScrollViewに適用することはできませんが、このpanGestureRecognizerを別のViewに付け替えることはできます。今回だと親のViewControllerにあるrootViewに対してUIScrollViewのpanGestureRecognizeraddGestureRecognizerで登録してあげると、UIPageViewControllerのスワイプ操作やヘッダーコンポーネントにあるボタンのタップ操作などはそのままにヘッダーコンポーネントを縦にスワイプ操作してもコンポーネント裏側にあるUIScrollViewへジェスチャーを伝搬させることができます。

ただし、2つのタブのpanGestureRecognizerを同時に紐づけてしまうと片方のジェスチャーのみ有効化されてしまうため、UIPageViewControllerDelegateを用いて現在表示中のタブを管理しつつ、タブが切り替わったタイミングでpanGestureRecognizerを付け替える必要があります。

この実装を可能にするために、まずpanGestureRecognizerをProfileViewControllerへブリッジするために、ScrollMovementProvidingViewControllerプロトコルへpanGestureRecognizerプロパティを追加します。

protocol ScrollMovementProvidingViewController: UIViewController {
    var scrollMovementDelegate: ScrollMovementDelegate? { get set }
    var panGestureRecognizer: UIPanGestureRecognizer { get } // New
    func adjustContentTopInset(_ topInset: CGFloat, isVisible: Bool)
}

class RecipeListViewController: ScrollMovementProvidingViewController {
    ...
    var panGestureRecognizer: UIPanGestureRecognizer {
        tableView.panGestureRecognizer // UICollectionView or UITableViewのpanGestureRecognizerを公開
    }
}

各ViewControllerから公開されたpanGestureRecognizerをタブが切り替わるタイミングでProfileViewControllerのviewに付け替えます。

// ProfileViewControllerでの実装
private func updatePanGestureRecognizer(for tab: Tab) {
    switch tab {
    case .recipes:
        view.removeGestureRecognizer(cooksnapListViewController.panGestureRecognizer)
        view.addGestureRecognizer(recipeListViewController.panGestureRecognizer)
    case .cooksnaps:
        view.removeGestureRecognizer(recipeListViewController.panGestureRecognizer)
        view.addGestureRecognizer(cooksnapListViewController.panGestureRecognizer)
    }
}

// タブ切り替え時に呼び出し
private func updateSelectedTab(_ tab: Tab) {
    selectedTab = tab
    updatePanGestureRecognizer(for: tab)
}

1番上のviewに対してジェスチャーを紐づけることにより、ヘッダーコンポーネントやSegmentControl、UIPageViewControllerのスワイプ、タップジェスチャーを優先しつつ、UIScrollView外の縦スワイプを各UIScrollViewに伝搬させることができるようになりました。

まとめ

以下3つの技術的、体験的課題を解決し、ユーザー体験を損なうことなく折りたたみ可能なStickyヘッダーパターンを実現できました。

  1. 複数スクロールビューとヘッダーの連動: ScrollMovementDelegateプロトコルによるスクロール状態の同期
  2. タブ切り替え時のオフセット調整: pendingTopInsetAdjustmentによる自然なスクロール位置リセット
  3. ヘッダー領域でのジェスチャー伝搬: panGestureRecognizerの動的な付け替えによる解決

完成系

技術的に可能でもユーザー体験を犠牲にするアプローチは採用せず、常にスムーズな操作感を最優先に実装することができました。割とありがちなUIですが実装例を見ることがあまりないので、一つの例として公開しました。

AI にデータ分析 SQL を書かせる仕組みを作りました - Bdash Server MCP の紹介

こんにちは。クックパッドでエンジニアをしている @morishin です。4年前に Bdash Server というデータ分析 SQL を共有・再利用するアプリケーションを作って紹介しましたが、今回はそれをさらに発展させて AI にデータ分析 SQL を書かせる仕組みを作ったのでその紹介をします。

続きを読む

Claude Code を Bedrock 越しに使う

レシピ事業部バックエンド基盤グループの石川です。2025 年 6 月の頭にやった仕事について走り書きのようなメモを残しておきたくなったので、この記事を書いています。

Anthropic 社が開発している Claude Code は、ターミナルの上で動作する LLM agent です: https://docs.anthropic.com/en/docs/claude-code/overview

Claude Code は Anthropic の API 経由で使う方法の他に、AWS や Google Cloud を経由して Claude のモデルを利用する道を用意してくれています: https://docs.anthropic.com/en/docs/claude-code/third-party-integrations

AWS を多用しているクックパッドにおいては、Amazon Bedrock の Claude モデル経由で Claude Code を利用できると管理上いくつかの点で利便性があると考えました。そこでこの記事では、利用に至るまでのセットアップやコスト分析の方法、そして実際に利用してみての制限について、簡単にまとめます。

前提

Claude Code のドキュメントを読むと、Bedrock 越しに利用する際は Claude Code 専用の AWS アカウントを新設して使うようにお勧めされています。コスト管理や権限管理を簡単にするためです。

We recommend creating a dedicated AWS account for Claude Code to simplify cost tracking and access control.

https://docs.anthropic.com/en/docs/claude-code/amazon-bedrock (2025-06-16 閲覧)

一方でクックパッド社内では歴史的経緯により、それなりに多くのサービスがとあるひとつの AWS アカウントに集まる形で AWS を利用しています。このためこの記事では AWS アカウントを分けることなくひとつのアカウントのまま使う前提で話を進めます。

他のアカウントと分けずに Bedrock を利用する場合、コスト分析の観点では Claude Code のために利用した Bedrock の料金とそれ以外の料金とを分けて集計できるようにしておきたいです。このために使えるものとして、Bedrock の application inference profiles というものがあります: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html。これを使うと Bedrock の InvokeModel API などを呼び出した場合にかかった料金について、cost allocation tags を使った分類ができるようになります。より詳しくは、何らかの基盤モデル (foundation model) を直接呼び出すのではなく、基盤モデルを束ねる概念である inference profile を呼び出すことにし、この inference profile にタグ付けしておくことによって、Bedrock の利用料金をタグごとに分類できるようにするという訳です。このような取り組みは、インフラコストの妥当性を説明する手助けになります (https://techlife.cookpad.com/entry/how-to-describe-infra-cost)。

Application Inference Profiles の作成

Claude Code では Claude 系列のいくつかの種類のモデルを使い分けることになります。ひとつのモデルに対してひとつの application inference profile を定義する必要があり、たとえば Terraform で書くと以下のようになります。なお Bedrock はリージョンごとに使える基盤モデルが異なり、ここでは us-east-1 を想定しています。

locals {
  # System-defined inference profiles for Anthropic Claude models.
  model_sources = toset([
    "us.anthropic.claude-3-5-haiku-20241022-v1:0",
    "us.anthropic.claude-3-5-sonnet-20240620-v1:0",
    "us.anthropic.claude-3-5-sonnet-20241022-v2:0",
    "us.anthropic.claude-3-7-sonnet-20250219-v1:0",
    "us.anthropic.claude-3-haiku-20240307-v1:0",
    "us.anthropic.claude-3-opus-20240229-v1:0",
    "us.anthropic.claude-3-sonnet-20240229-v1:0",
    "us.anthropic.claude-opus-4-20250514-v1:0",
    "us.anthropic.claude-sonnet-4-20250514-v1:0",
  ])
}

data "aws_bedrock_inference_profile" "model_source" {
  for_each = local.model_sources

  inference_profile_id = each.value
}

resource "aws_bedrock_inference_profile" "claude_code" {
  for_each = local.model_sources

  name        = "claude-code_${replace(replace(each.value, ".", "_"), ":", "_")}"
  description = "Inference profile used by Claude Code based on ${each.value}"

  model_source {
    copy_from = data.aws_bedrock_inference_profile.model_source[each.value].inference_profile_arn
  }

  tags = {
    Project = "claude-code"
  }
}

このようにして作った application inference profiles は、Claude Code 用の環境変数である ANTHROPIC_MODEL や ANTHROPIC_SMALL_FAST_MODEL に ARN をセットしておくことによって Claude Code から利用できます。詳しくはドキュメントをご覧ください: https://docs.anthropic.com/en/docs/claude-code/amazon-bedrock。自分が忘れがちだったこととして AWS_REGION もセットする必要があります。

権限管理

Claude Code は開発者それぞれの手元の環境からモデルの呼び出しを行うことになるため、各開発者に対して AWS IAM で bedrock:InvokeModel などについて権限を許可することになります。

上記のように inference profiles 経由でモデルを呼び出したい場合、 inference profiles をリソースとする bedrock:InvokeModel を許可するだけでは権限が足りず、inference profiles の背後にいる基盤モデルについても bedrock:InvokeModel を許可する必要があります。

ただしその両方について素朴に許可すると基盤モデルに対する直接の API 呼び出しも許可されてしまうため、基盤モデルに対する呼び出しについては inference profiles 越しの利用に限って許可するように書くことができます。具体的には condition key として bedrock:InferenceProfileArn を利用します。ドキュメントはこちらです: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-prereq.html

たとえば InvokeModel 周りについては以下のような policy が考えられます。基盤モデルの方にだけ Condition があるのがポイントです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "bedrock:InvokeModel",
                "bedrock:InvokeModelWithResponseStream"
            ],
            "Resource": [
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-5-haiku-20241022-v1:0",
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-5-sonnet-20240620-v1:0",
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0",
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-7-sonnet-20250219-v1:0",
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-haiku-20240307-v1:0",
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-opus-20240229-v1:0",
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-sonnet-20240229-v1:0",
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-opus-4-20250514-v1:0",
                "arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-20250514-v1:0"
            ]
            "Condition": {
                "ArnLike": {
                    "bedrock:InferenceProfileArn": "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/*"
                }
            },
        },
        {
            "Effect": "Allow",
            "Action": [
                "bedrock:InvokeModel",
                "bedrock:InvokeModelWithResponseStream"
            ],
            "Resource": [
                "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/aaaaaaaaaaaa",
                "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/bbbbbbbbbbbb",
                "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/cccccccccccc",
                "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/dddddddddddd",
                "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/eeeeeeeeeeee",
                "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/ffffffffffff",
                "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/gggggggggggg",
                "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/hhhhhhhhhhhh",
                "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/iiiiiiiiiiii"
            ]
        }
    ]
}

開発者ごとのコスト分析

Application inference profiles を使うと Claude Code を使うにあたってかかっているコストがざっくり見えるようになります。一方で上記のやり方だと開発者一人ひとりについてどのくらい使っているのかを分析するところまではできません。Anthropic の API を使うのと比べてオトクなのか……みたいなことを考え出すと開発者あたりのお値段も出したくなってくるので、こちらも分析できるようにしてみます。

このためには Bedrock の model invocation logging が使えます: https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html。それぞれの InvokeModel について、どのモデルが呼び出され input と output でどのくらい token が消費されたかの情報がログとして蓄積されます。出力先として Amazon S3 を選べるので、適切に AWS Glue のテーブル定義を作っておけば Amazon Athena で集計できるという寸法です。Athena は便利。

詳細は省きますがたとえば Glue を使い以下のような定義で partition projection 付きのテーブルを作っておけば、Athena で開発者ごとの情報を集計できるようになります。ただしそれぞれの開発者ごとに別々の identity が割り振られている想定です。

CREATE EXTERNAL TABLE `example.bedrock_model_invocation_logs_use1`(
  `schematype` string COMMENT 'from deserializer', 
  `schemaversion` string COMMENT 'from deserializer', 
  `timestamp` string COMMENT 'from deserializer', 
  `accountid` string COMMENT 'from deserializer', 
  `identity` struct<arn:string> COMMENT 'from deserializer', 
  `region` string COMMENT 'from deserializer', 
  `requestid` string COMMENT 'from deserializer', 
  `operation` string COMMENT 'from deserializer', 
  `modelid` string COMMENT 'from deserializer', 
  `input` struct<inputcontenttype:string,inputtokencount:int,cachereadinputtokencount:int,cachewriteinputtokencount:int> COMMENT 'from deserializer', 
  `output` struct<outputcontenttype:string,outputtokencount:int> COMMENT 'from deserializer', 
  `errorcode` string COMMENT 'from deserializer', 
  `inferenceregion` string COMMENT 'from deserializer')
PARTITIONED BY ( 
  `day` string COMMENT '')
ROW FORMAT SERDE 
  'org.apache.hive.hcatalog.data.JsonSerDe' 
STORED AS INPUTFORMAT 
  'org.apache.hadoop.mapred.TextInputFormat' 
OUTPUTFORMAT 
  ''
LOCATION
  's3://example-bucket-use1/'
TBLPROPERTIES (
  'projection.day.format'='yyyy/MM/dd', 
  'projection.day.interval'='1', 
  'projection.day.interval.unit'='DAYS', 
  'projection.day.range'='2025/01/01,NOW', 
  'projection.day.type'='date', 
  'projection.enabled'='true', 
  'storage.location.template'='s3://example-bucket-use1/AWSLogs/012345678901/BedrockModelInvocationLogs/us-east-1/${day}')

なお model invocation logging で入力や出力の内容(テキストや画像など)も含めてログに出力している場合は、これだとクエリ時にエラーが出る場合があります。上記で対象としているログオブジェクト以外にもオブジェクトが生成されるためです。この場合は少し雑ですが serde として org.openx.data.jsonserde.JsonSerDe を使って ignore.malformed.json を使うことにすれば、多少の null を許しつつエラー回避が可能です(追記:クエリ時に $PATH でフィルターすると null も防げます)。あるいはきちんとは試していませんが Glue Crawler で良い感じにできるかもしれません。我々の現在の運用では入出力の具体的な内容についてのログは不要なため、すべて無効にしています。

現実的な制限

以上のようなセットアップを経て、管理上欲しくなってくる点は抑えつつ、開発者に Bedrock 経由で Claude Code を利用してもらうことができました。一方で実際に 2〜3 週間使ってみると微妙な点も出てきています。

まず小さな不満として、各開発者に application inference profile を使ってもらう方式だと、開発者としては今どのモデルを選んで使っているのか分かりづらくなる点があります。us.anthropic.claude-opus-4-20250514-v1:0 のような文字列が abcdefghijkl のような ID の文字列になってしまうためです。いちおう、選んでいるモデルごとに shell alias なり何なりを設定して使い分けるなどの工夫ができはします。

次にそれよりも大きな不満として、Bedrock 経由の利用だとどうしても InvokeModel がエラーになってしまいがちという点があります。返ってくるエラーは 429 Too many tokens や 503 Model is getting throttled で、特に現状は Claude Opus 4 で顕著です。これは Bedrock の基盤モデルごとに存在する quota に引っかかっている訳ではなく、on-demand な呼び出しをする際はクラウド側の都合でスロットリングを受ける可能性があるということです。Claude Code が InvokeModel をリトライしている内に呼び出しが成功したりはするのですが、どうしても待ちの時間が生まれてしまうので今後の改善に期待したいところです。現状はエラーが頻発するようであれば別のモデルへ切り替えることにしています。

以上、Claude Code を Bedrock 越しに使うにあたって自分が設定したことの振り返りでした。これらの情報は 2025 年 6 月上旬時点のものであり、LLM 関係の話はいまだ変化が激しいため、近い将来に状況が変わるかもしれないことはご承知おきください。Anthropic API 経由での利用も含め色々な形を試行錯誤しているところです。

またクックパッドでは現在絶賛採用活動中です。毎日の料理を楽しみにしたい皆様からのご応募を熱烈歓迎しております。まずはいま開いている枠を眺めてみてくださいませ。

cookpad.careers