AWSで構築する生成AIサービスとCloudWatch Application Signalsの監視② 詳細監視編

:::note info  
[３行まとめ]  
・監視対象は生成AI活用したチャットボットコンテナアプリで、フロントエンドがチャットボットWebアプリ、バックエンドでBedrock APIを実行するサイドカーコンテナ構成  
・Amazon CloudWatch Application SignalsのサービスマップでBedrockノード可視化、SLO監視をやりたかった  
・自動計装だとセカンダリーコンテナ側の可視化は難しそう？という結論 
:::

# はじめに
だいぶ間が空いてしまいましたが、[前回](https://qiita.com/melktzk/items/c319b71a5ea1e9e591d2)に引き続きAWSを活用して生成AIサービスを提供する環境におけるAmazon CloudWatch Application Signalsの監視について、以前検証した内容を紹介していきます。AWSコンソール画面など最新のものではない可能性があることご承知おきください。

# 前回結果と今回やったこと
前回はAWSを活用して生成AIサービス環境（[multi-tenant-chatbot-using-rag-with-amazon-bedrock](https://github.com/aws-samples/multi-tenant-chatbot-using-rag-with-amazon-bedrock)）の構築手順をメインにまとめつつ、
CloudWatch Application Signalsを有効化してどのような監視が可能か確認しました。

その結果、本環境ではAmazon CloudWatch Application Signalsを有効化しただけの場合、サービスマップは表示されるものの、期待していたBedrockとの通信部分がうまく可視化できていない状態になることが分かりました。

本来は以下のようにBedrockのノードが可視化されてほしいのですが、

> ![000-bedrocknode.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940015/1a2e5e70-3768-47a6-800b-aa25dddd7125.png)
> 画像出典元：https://aws.amazon.com/jp/blogs/mt/improve-amazon-bedrock-observability-with-amazon-cloudwatch-appsignals/

実際に出てきたマップは以下のようになっていました。

![001-map.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940015/031e244c-d7c3-4bcc-8d8e-5983b602ee3b.png)

そのままでも十分に運用監視は可能とは思いますが、バックエンド側の実装部とマネージドサービスのBedrock部分とで切り分けた状態で可視化できればさらに詳細な監視が可能になります。

したがって、今回はアプリケーション側にも手を入れて、トレースを表示させてみたりしながら上記について原因を確認していきました。

結論としては、Amazon CloudWatch Application Signalsの自動計装を利用する場合、サイドカーコンテナとして実装されているアプリケーションのセカンダリーコンテナ側はそもそも可視化されない、というものでした。ただ、確認しきれていない点もあるかと思いますので誤り等ありましたらコメントいただけると幸いです。

以下、確認した内容を記載していきます。

# 前提
## 監視対象の構成
監視対象の生成AIシステム全体については[前回](https://qiita.com/melktzk/items/c319b71a5ea1e9e591d2#%E6%A7%8B%E6%88%90)記事を参照いただくとして、アプリケーション・コンテナ構成まわりについて少しここで触れていきます。

今回構築しているアプリケーションは、大きく分けてアプリ用のPodとIstio Ingress GatewaysのPodの2つに分類されます。可視化を詳細化したいアプリ側のPodは、以下のように複数のコンテナが含まれたサイドカーコンテナ構成となっています。

![002-otel-init.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940015/3733a55c-7cd5-46c1-91e1-ee881973744f.png)

したがって、chatbotコンテナ（メインコンテナ）とragapiコンテナ（セカンダリーコンテナ）の関連コードを変更して監視状態がどのように変化していくか確かめていきます。

## Application Signalsの自動計装
前回の記事で詳細は省略していましたが、Amazon CloudWatch Application Signalsを有効化すると、選択したサービス内のコンテナに自動的にOpenTelemetry (OTEL)の設定がされることでサービスマップの可視化が可能になります。

![003-otel.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940015/6bedf9c2-05b2-427b-990f-c6026a667f2f.png)

上記は有効時にJavaやPythonの言語を複数指定した場合のchatbotコンテナの環境変数とマウント設定の例です。`OTEL`で始まる設定はすべて自動で導入してくれます。また、前述のコンテナ構成のとおり、必要なライブラリ等を含んだInitコンテナやその他にもトレース情報を転送するCloudWatch Agentコンテナなども自動で構築・管理してくれます。

# メインコンテナ側の可視化
まずはchatbotとしてフロント側の実装がされているメインコンテナ側の監視設定を変更します。先ほど記載したとおりAmazon CloudWatch Application SignalsはOpenTelemetryが利用されており、[完全に互換性がある](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch-Application-Signals-compatibility.html)とのことなので、詳細な監視を実現するためにさらにOpenTelemetryの可視化設定を実施していきます。

変更は以下の流れで実施します。

* アプリケーションコードの修正
* Dockerfileの修正
* イメージビルド・リポジトリ更新
* デプロイ
* 動作確認

## アプリケーションコードの修正
chatbotのアプリケーションコードは、`/multi-tenant-chatbot-using-rag-with-amazon-bedrock/app/`配下に定義されています。
[AWSのWorkshop](https://catalog.workshops.aws/observability/en-US/use-cases/python-instrumentation/tracing)など参考に以下のように修正しました。

<details><summary>/multi-tenant-chatbot-using-rag-with-amazon-bedrock/app/webapp.py</summary>

```diff_python
//---省略---//
+ from opentelemetry import trace
+ from opentelemetry.sdk.trace import TracerProvider
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+ from opentelemetry.sdk.resources import Resource
+ from opentelemetry.semconv.resource import ResourceAttributes
+ from opentelemetry.instrumentation.requests import RequestsInstrumentor
+ from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
+ from opentelemetry.sdk.extension.aws.trace import AwsXRayIdGenerator

+ resource = Resource(attributes={
+     ResourceAttributes.SERVICE_NAME: "streamlit-chatbot"
+ })

+ provider = TracerProvider(resource=resource, id_generator=AwsXRayIdGenerator())
+ processor = BatchSpanProcessor(OTLPSpanExporter())
+ provider.add_span_processor(processor)
+ trace.set_tracer_provider(provider)

+ # RequestsとBotocoreの自動計測
+ RequestsInstrumentor().instrument()
+ BotocoreInstrumentor().instrument()

+ tracer = trace.get_tracer(__name__)

//---省略---//

if user_input:
+     with tracer.start_as_current_span("process_user_input"):
        try:
+             with tracer.start_as_current_span("get_session"):
                sessions = Sessions(dyn_resource)
                sessions_exists = sessions.exists(table_name)
                if sessions_exists:
                    session = sessions.get_session(tenant_id)
                    if session:
                        if ((current_time - session['last_interaction']) < IDLE_TIME):
                            sessions.update_session_last_interaction(tenant_id, current_time)
                            updated_session = sessions.get_session(tenant_id)
                            print(updated_session['session_id'])
                        else:
                            sessions.update_session(tenant_id, current_time)
                            updated_session = sessions.get_session(tenant_id)
                    else:
                        sessions.add_session(tenant_id)
                        session = sessions.get_session(tenant_id)
        except Exception as e:
+             with tracer.start_as_current_span("handle_error"):
                print(f"Something went wrong: {e}")

        # headers for request and response encoding, same for both endpoints
        headers: Dict = {"accept": "application/json",
                         "Content-Type": "application/json"
                        }
        output: str = None
+         with tracer.start_as_current_span("api_request"):
            if mode == MODE_RAG:
                user_session_id = tenant_id + ":" + session["session_id"]
                data = {"q": user_input, "user_session_id": user_session_id, "verbose": True}
                resp = req.post(api_rag_ep, headers=headers, json=data)
                if resp.status_code != HTTP_OK:
                    output = resp.text
                else:
                    resp = resp.json()
                    sources = list(set([d['metadata']['source'] for d in resp['docs']]))
                    output = f"{resp['answer']} \n \n Sources: {sources}"
            else:
                print("error")
                output = f"unhandled mode value={mode}"
+         with tracer.start_as_current_span("update_state"):
            st.session_state.past.append(user_input)  
            st.session_state.generated.append(output) 

# download the chat history
download_str: List = []
+ with tracer.start_as_current_span("download_history"):
    with st.expander("Conversation", expanded=True):
        for i in range(len(st.session_state['generated'])-1, -1, -1):
            st.info(st.session_state["past"][i],icon="❓") 
            st.success(st.session_state["generated"][i], icon="👩‍💻")
            download_str.append(st.session_state["past"][i])
            download_str.append(st.session_state["generated"][i])
        
        download_str = '\n'.join(download_str)
        if download_str:
            st.download_button('Download', download_str)
```
</details>

## Dockerfileの修正
アプリケーションコードの変更に合わせてDockerfileも修正します。追加した各ライブラリをインストールするように以下の様に追記・修正します。

<details><summary>/multi-tenant-chatbot-using-rag-with-amazon-bedrock/image-build/Dockerfile-app</summary>

```diff_docker
FROM public.ecr.aws/docker/library/python:3.11.4-slim AS installer-image
WORKDIR /app
RUN DEBIAN_FRONTEND=noninteractive apt-get -qq update -y 2>/dev/null >/dev/null && \
    DEBIAN_FRONTEND=noninteractive apt-get -qq install -y \
    build-essential \
    curl \
    software-properties-common 2>/dev/null >/dev/null \
    && rm -rf /var/lib/apt/lists/*
ADD app/* ./
+ RUN pip install --user --upgrade -q -q pip && pip install --user -q -q -r requirements.txt && \
+     python -m pip install --user -q -q opentelemetry-api && \
+     python -m pip install --user -q -q opentelemetry-sdk && \
+     python -m pip install --user -q -q opentelemetry-distro && \
+     python -m pip install --user -q -q opentelemetry-exporter-otlp-proto-grpc && \
+     python -m pip install --user -q -q opentelemetry-sdk-extension-aws && \
+     python -m pip install --user -q -q opentelemetry-propagator-aws-xray && \
+     python -m pip install --user -q -q opentelemetry-semantic-conventions && \
+     python -m pip install --user -q -q opentelemetry-instrumentation-requests && \
+     python -m pip install --user -q -q opentelemetry-instrumentation-botocore

//---省略---//

```
</details>

## イメージビルド・リポジトリ更新
今回のアプリケーション環境はスクリプトで自動構築されており（[前回記事参照](https://qiita.com/melktzk/items/c319b71a5ea1e9e591d2#5-setup%E3%82%B9%E3%82%AF%E3%83%AA%E3%83%97%E3%83%88%E3%81%AE%E5%AE%9F%E8%A1%8C)）、イメージのビルドとECRリポジトリへのPushは`image-build/build-chatbot-image.sh`で実行されています。以下の様にイメージのバージョン指定できるようにスクリプトを修正します。

<details><summary>/multi-tenant-chatbot-using-rag-with-amazon-bedrock/image-build/build-chatbot-image.sh</summary>

```diff_shell
#!/usr/bin/env bash
. ~/.bash_profile

+ IMAGE_VER="1.0.1"

aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS \
  --password-stdin ${REPO_URI_CHATBOT}

ECR_IMAGE=$(
    aws ecr list-images \
         --repository-name ${ECR_REPO_CHATBOT} \
         --query 'imageIds[0].imageDigest' \
         --output text
    )

aws ecr batch-delete-image \
     --repository-name ${ECR_REPO_CHATBOT} \
     --image-ids imageDigest=${ECR_IMAGE}

- docker build -f image-build/Dockerfile-app -t ${REPO_URI_CHATBOT}:latest .
- docker push ${REPO_URI_CHATBOT}:latest
+ docker build -f image-build/Dockerfile-app -t ${REPO_URI_CHATBOT}:${IMAGE_VER} .
+ docker push ${REPO_URI_CHATBOT}:${IMAGE_VER}

```
</details>

修正したスクリプトでイメージのビルドとECRへのPushを実行します。

<details><summary>実行ログ例</summary>

```shell-session
~/environment/multi-tenant-chatbot-using-rag-with-amazon-bedrock (main) $ 
~/environment/multi-tenant-chatbot-using-rag-with-amazon-bedrock (main) $ sh image-build/build-chatbot-image.sh 
WARNING! Your password will be stored unencrypted in /home/ec2-user/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded
[+] Building 74.0s (14/14) FINISHED                                                                                                      docker:default
 => [internal] load build definition from Dockerfile-app                                                                                           0.0s
 => => transferring dockerfile: 2.01kB                                                                                                             0.0s
 => [internal] load metadata for public.ecr.aws/docker/library/python:3.11.4-slim                                                                  0.9s
 => [internal] load .dockerignore                                                                                                                  0.0s
 => => transferring context: 2B                                                                                                                    0.0s
 => [internal] load build context                                                                                                                  0.0s
 => => transferring context: 132B                                                                                                                  0.0s
 => [installer-image 1/5] FROM public.ecr.aws/docker/library/python:3.11.4-slim@sha256:17d62d681d9ecef20aae6c6605e9cf83b0ba3dc247013e2f43e1b5a045  0.0s
 => CACHED [installer-image 2/5] WORKDIR /app                                                                                                      0.0s
 => CACHED [installer-image 3/5] RUN DEBIAN_FRONTEND=noninteractive apt-get -qq update -y 2>/dev/null >/dev/null &&     DEBIAN_FRONTEND=nonintera  0.0s
 => CACHED [installer-image 4/5] ADD app/* ./                                                                                                      0.0s
 => [installer-image 5/5] RUN pip install --user --upgrade -q -q pip && pip install --user -q -q -r requirements.txt &&     python -m pip instal  55.0s
 => CACHED [stage-1 2/5] RUN DEBIAN_FRONTEND=noninteractive apt-get -qq update -y 2>/dev/null >/dev/null &&     DEBIAN_FRONTEND=noninteractive ap  0.0s
 => CACHED [stage-1 3/5] WORKDIR /home/streamlit/app                                                                                               0.0s
 => [stage-1 4/5] COPY --chown=streamlit:streamlit --from=installer-image /root/.local /home/streamlit/.local/                                     5.7s
 => [stage-1 5/5] COPY --chown=streamlit:streamlit app/*.py /home/streamlit/app/                                                                   0.1s
 => exporting to image                                                                                                                             8.8s
 => => exporting layers                                                                                                                            8.8s
 => => writing image sha256:06f13486f2c44646213b5aa64c08ae69152e7705ab94201999be6e29c4bdc82a                                                       0.0s
 => => naming to 000000000000.dkr.ecr.ap-northeast-1.amazonaws.com/multitenant-chatapp-XXXXXXXXX-chatbot:1.0.1                                      0.0s
The push refers to repository [000000000000.dkr.ecr.ap-northeast-1.amazonaws.com/multitenant-chatapp-XXXXXXXXX-chatbot]
9cd25ea253ab: Pushed 
d6ee0160c22b: Pushed 
d53056c1cb0a: Layer already exists 
90adf92c730f: Layer already exists 
84aede4a88e1: Layer already exists 
3e5ac6427acb: Layer already exists 
c01ec0304ec8: Layer already exists 
5b60283f3630: Layer already exists 
511780f88f80: Layer already exists 
1.0.1: digest: sha256:c54d99d89b1b7b31694e301bf559b0c8f445aa1dc9df53395dba39c729fe87fe size: 2210
~/environment/multi-tenant-chatbot-using-rag-with-amazon-bedrock (main) $ 

```
</details>

## デプロイ
デプロイはマニフェスト`chatbot-manifest/chatbot.yaml`とスクリプト`deploy-tenant-services.sh`で実行されています。修正したイメージのバージョンに合わせて、マニフェストファイルを以下の様に修正します。

<details><summary>/multi-tenant-chatbot-using-rag-with-amazon-bedrock/chatbot-manifest/chatbot.yaml</summary>

```diff_yaml
//---省略---//
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: chatbot
  labels:
    app: chatbot
spec:
  replicas: 1
  selector:
    matchLabels:
      app: chatbot
  template:
    metadata:
      labels:
        workload-tier: frontend
        app: chatbot
    spec:
      serviceAccountName: ${SA_NAME}
      containers:
-         - image: ${REPO_URI_CHATBOT}:latest
+         - image: ${REPO_URI_CHATBOT}:1.0.1
          imagePullPolicy: Always
          name: chatbot
//---省略---//
```
</details>


スクリプト`deploy-tenant-services.sh`は再デプロイに対応していないので以下の様に別ファイルとして作成していきます。ここで、利用している環境変数`RANDOM_STRING`は前回記事の初期構築時に設定された値が必要なことに注意してください。Cognitoに作成されているユーザプール名（xxx-chatbot-example-com-<RANDOM_STRING>）からも確認は可能です。

<details><summary>/multi-tenant-chatbot-using-rag-with-amazon-bedrock/fix-tenant-services.sh</summary>

```sh
#!/usr/bin/bash
. ~/.bash_profile

TENANTS="tenanta tenantb"

for t in $TENANTS
do
    export TENANT="${t}"
    export SA_NAME="${t}-sa"
    export NAMESPACE="${t}-ns"
    export DOMAIN="${t}-chatbot-example-com-${RANDOM_STRING}"

    USERPOOL_ID=$(
      aws cognito-idp describe-user-pool-domain \
      --domain ${DOMAIN} \
      --query 'DomainDescription.UserPoolId' \
      --output text | xargs
      )
    export ISSUER_URI=https://cognito-idp.${AWS_REGION}.amazonaws.com/${USERPOOL_ID}
    export SESSIONS_TABLE=Sessions_${t}_${RANDOM_STRING}
    export CHATHISTORY_TABLE=ChatHistory_${t}_${RANDOM_STRING}

    echo "Deploying ${t} services ..."

    echo "-> Deploying chatbot service"
    envsubst < chatbot-manifest/chatbot.yaml | kubectl -n ${NAMESPACE} apply -f -

done
```
</details>

作成したスクリプトを実行してデプロイします。

<details><summary>実行ログ例</summary>

```shell-session
~/environment/multi-tenant-chatbot-using-rag-with-amazon-bedrock (main) $ ./fix-tenant-services.sh                                          
Deploying tenanta services ...
-> Deploying chatbot service
serviceaccount/tenanta-sa unchanged
deployment.apps/chatbot configured
service/chatbot unchanged
Deploying tenantb services ...
-> Deploying chatbot service
serviceaccount/tenantb-sa unchanged
deployment.apps/chatbot configured
service/chatbot unchanged

```
</details>

## 動作確認
メインコンテナを修正してデプロイ後、クライアント環境から何度かアクセスさせてからCloudWatchのX-Rayトレース画面を確認すると、しっかりトレース情報がとれていることが確認できました。

![004-trace.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940015/c1206ae1-7c82-4e39-b2e0-23f598fb45a0.png)

`get_session`や`api_request`などアプリケーションコード修正時に設定した名称で記録されていることが分かります。

また、上記トレースをサービスマップとして可視化されることが確認できました。

![005-tracemap.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940015/5066de66-ebac-42f5-a337-22377ded9637.png)

ただし、Application Signalsのサービスマップを確認すると、以下のようにアプリケーションコード修正前と情報量が増えたりはしていません。(以下の図はレイテンシをSLO設定して違反させた際の画面です)

![006-servicemap.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940015/29a1d8af-b6bf-4db6-9d6b-6654bcca36fc.png)

今回の生成AIアプリケーションはBedrockを利用しているので、サービスマップとしてBedrockノードが出てきてほしいのですが、メインコンテナ側からみるとすべて`127.0.0.1:8000`へのAPI実行として隠されてしまっているような状態です。

# セカンダリーコンテナ側の可視化
サービスマップにBedrockノードを表示させるべく、セカンダリーコンテナ側もメインコンテナ側と同様にアプリケーションコード修正とイメージビルド、デプロイを実施してみました。

……が、そもそも今回のようにApplication Signalsの自動計装の仕組みを利用して監視を有効化する場合、セカンダリーコンテナ側のOpenTelemetryによる可視化はできなさそう、ということが分かりました。

[本記事冒頭](#application-signalsの自動計装)でも説明しましたが、Application Signalsの自動計装では、監視対象コンテナに対して必要な環境変数やマウント設定等を実施してくれます。しかし、[その設定単位はワークロードまたは名前空間単位であり](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch-Application-Signals-Enable-EKS-Console.html)、サイドカーコンテナ構成の場合はメインコンテナ側のみに設定され、セカンダリーコンテナ側は以下のように設定されないようでした。

![007-ragapi.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940015/ac1123d5-16e4-482a-afd9-0c360be052b7.png)

ならばマニフェストを修正してメインコンテナと同様に環境変数やマウントの設定をしてあげればよいのでは、と思い`chatbot-manifest/chatbot.yaml`のセカンダリーコンテナ側を修正してみました。

![008-mani-ragapi.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3940015/f55705c8-d0dd-43e6-a9b1-b64199be1f06.png)

……が、上記のようにマウント設定や各環境変数設定をしてデプロイ実行しようとすると、

```shell-session
The Deployment "chatbot" is invalid: spec.template.spec.containers[1].volumeMounts[0].name: Not found: "opentelemetry-auto-instrumentation-python"
```
と、マニフェストで定義されていないInitコンテナのボリュームなのでエラーとなってしまいます。

Initコンテナとして`opentelemetry-auto-instrumentation-python`と同等のものをこちらで用意してあげるなどすれば動作はするようになる、とは思うのですが、その場合Application Signalsの利点が薄れてしまうため今回はここまでとしました。

# おわりに
Amazon CloudWatch Application Signalsを利用すれば、SLO監視も含めて生成AIサービス（Bedrock）の詳細な監視が簡単に実現できそう、と軽く考えてやってみた検証でしたが意外な落とし穴にはまった結果となりました。

もちろん、[AWS公式](https://aws.amazon.com/jp/blogs/mt/improve-amazon-bedrock-observability-with-amazon-cloudwatch-appsignals/)で紹介されているとおり、Application SignalsでBedrockのノードを可視化すること自体は可能です。ただし、今回のようなサイドカーコンテナ構成の場合はその限りではない、という点について何かしらの参考になれば幸いです。

また、もともとは上記を実現したうえでトレースに入出力トークン数もログとして記録するようにして、性能と合わせて監視を可能にするという生成AI特有のところまでやりたかったので、今後、追加で検証できた際には紹介していきたいと思います。