Azure Container Apps で Dapr と Application Insights を使って分散トレーシング

# 本記事について

Azure Container Apps では、サイドカーコンテナーとして展開される Dapr を使って、サービス間呼び出し、Pub/Sub, 状態管理、シークレット管理などを使用することができます。

https://learn.microsoft.com/ja-jp/azure/container-apps/dapr-overview

この Dapr の機能のひとつとして、Observability (可観測性) があり、トレース情報を取得することができます。本記事では、その機能を使って Container Apps 上のトレース情報を Application Insights に送信し、分散トレーシングをどう行っていけるかを見ていきます。

# 本記事で用いるサンプルアプリケーションについて

本記事では、下記のサンプルアプリケーションに少し変更を加えて利用します。

https://learn.microsoft.com/ja-jp/azure/container-apps/microservices-dapr-service-invoke?pivots=python

下の絵の形で、コンテナーアプリを３つ用意します。checkout アプリはサンプルアプリケーションをほぼそのまま利用し order を発行する役割のコンソールアプリ、order-processor は checkout から order を受け取るアプリです。さらに、その order-processor アプリをベースに receipt アプリを追加し、order-processor から order 情報を受け取り、その情報を Blob Storage にファイルをアップロードします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/b6de766d-71ed-3a2a-9555-269b845f5fe8.png)

## Azure Container Apps の構成

Container Apps で Application Insights を利用して、Dapr からのトレース情報を受け取るには、Container Apps 環境の作成時に Application Insights 利用を有効化する必要があります。これは、Azure Portal での作成では設定できないため、CLI コマンドか、ARM Template, Bicep を利用します。

CLI コマンドは下記になります。詳細は[公式のリファレンス](https://learn.microsoft.com/ja-jp/cli/azure/containerapp/env?view=azure-cli-latest#az-containerapp-env-create)をご参照ください。

```bash: Azure CLI コマンド
az containerapp env create --name <Container Apps 環境名> --resource-group <リソースグループ名> --location <リージョン名> --dapr-instrumentation-key <利用する Application Insights のインストルメンテーションキー>
```
## 各アプリの app.py のコード

各コンテナーアプリの app.py コードを下記のように準備します。Docker ファイルはサンプルのものをそのまま利用します。(receipt アプリは、order-processor アプリのコードをベースにし、少し変更しています。)

**checkout**

```python:app.py (checkout)
import json
import time
import logging
import requests
import os

base_url = os.getenv('BASE_URL', 'http://localhost') + ':' + os.getenv(
                    'DAPR_HTTP_PORT', '3500')
dapr_app_id = "<order-processor のコンテナーアプリの Dapr アプリ ID>"
content_type = "application/json"

while(True):
  for i in range(1, 20):
    order = {'orderId': i}
    headers = {
        'dapr-app-id': dapr_app_id, 
        'content-type': content_type
    }

    # Invoking a service
    result = requests.post(
        url='%s/orders' % (base_url),
        data=json.dumps(order),
        headers=headers
    )

    print(str(result.json()))
    print('Order passed: ' + json.dumps(order), flush=True)

    time.sleep(10)
  time.sleep(20)
```

**order-processor**

```python:app.py (order-processor)

from flask import Flask, request
import json
import os
import requests

app = Flask(__name__)

base_url = os.getenv('BASE_URL', 'http://localhost') + ':' + os.getenv(
                    'DAPR_HTTP_PORT', '3500') 
dapr_app_id = "<receipt のコンテナーアプリの Dapr アプリ ID"
content_type = "application/json"

@app.route('/orders', methods=['POST'])
def getOrder():
    order = request.json
    print('Order received : ' + json.dumps(order), flush=True)
    
    traceparent = request.headers.get('traceparent')
    headers = {
        'dapr-app-id': dapr_app_id, 
        'content-type': content_type,
        'traceparent': traceparent
    }

    # Invoking a service
    result = requests.post(
        url='%s/orders' % (base_url),
        data=json.dumps(order),
        headers=headers
    )

    print(f"Request was sent to receipt: result = {result}", flush=True)

    return json.dumps({'success': True}), 200, {
        'ContentType': 'application/json'}
   
app.run(port=8001, host="0.0.0.0")
```

**receipt**

```python:app.py (receipt)

from flask import Flask, request
import json
import requests
from azure.storage.blob import BlobServiceClient
import time

app = Flask(__name__)

account_url = "<ファイルの送信先のストレージアカウントの URL>"
shared_access_key = "<ストレージアカウントのキー>"
credential = shared_access_key
container_name = "<ストレージアカウントのBlob コンテナー名>"

@app.route('/orders', methods=['POST'])
def getOrder():
    order = request.json
    print('Order received : ' + json.dumps(order), flush=True)

    ut = time.time()
    filename = f"order-{ut}.json"

    blob_service_client = BlobServiceClient(account_url, credential=credential)
    container_client = blob_service_client.get_container_client(container=container_name)

    blob_client = container_client.get_blob_client(filename)
    blob_client.upload_blob(data=json.dumps(order), overwrite=True)

    return json.dumps({'success': True}), 200, {
        'ContentType': 'application/json'}
   
app.run(port=8001, host="0.0.0.0")
```

## 上記コードの Application Insights 利用時のポイント

Dapr を介して HTTP によるサービス間通信を行うアーキテクチャで分散トレーシングを行うのに重要なのが、HTTP ヘッダーの traceparent を介してトレース情報をやりとりすることです。traceparent の値は、W3C の標準に基づいており、`<Version>-<Trace ID (16進数表記)>-<Parent ID (16進数表記)>-<Trace Flags>` となっています。

https://docs.dapr.io/operations/observability/tracing/w3c-tracing-overview/

Container Apps の Dapr では、Application Insights を利用する際に、Container Apps の Dapr が自動的に traceparent の値を付与します。ただ、前のサービスが利用した traceparent の値を明示的に次の HTTP リクエストのヘッダーに与えることで、同一のオペレーションとして、Application Insights が認識できるようになります。

> When a request arrives without a trace ID, Dapr creates a new one. Otherwise, it passes the trace ID along the call chain.

そのため、今回は order-processor アプリは、checkout アプリからの Dapr 経由で渡された traceparent の値をそのまま次の receipt アプリへの HTTP リクエストのヘッダーに追記しています。絵で表現すると下記のようなイメージです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/00170804-38e0-b684-9106-bcbec4adac12.png)


こうしたときに、Application Insights でどうトレース情報を確認できるか、確認していきます。

# アプリの Container Apps での実行

サンプルアプリケーションのフォルダーに含まれている Dockerfile を使って、それぞれコンテナーイメージを作成し、Azure Container Registry にプッシュします。そして、Application Insights 連携を有効化した、ひとつの Container Apps 環境に３つのコンテナーアプリをそれぞれデプロイします。

コンテナーアプリのデプロイ後は、各 コンテナーアプリで Dapr を有効化し Dapr アプリ ID を設定します。この値は各アプリ内で利用されるものになります。

# Application Insights でのトレース情報の確認

Application Insights では、下記のように情報を見ることができます。

## アプリケーションマップ

アプリケーションマップでは、3つのコンテナーアプリがそれぞれ通信を行っていることがビジュアルに確認できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/5f404a9d-2165-5f89-3d79-72255dd0f555.png)

## エンドツーエンドトランザクション

今回、checkout → order-processor と order-processor → receipt をひとつのオペレーションとして捉えたいというのが課題でした。そして、traceparent の値を伝搬させた結果、下記のようにトランザクション内で両通信を確認することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/104c4fa2-238f-3972-1fae-db93decb3600.png)

ログとして確認しても、同じ operation_Id (OpenTelemetry における trace ID) の中に、それぞれの記録が入っていることが分かります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/ff1543d7-31ae-aacf-a5ff-c4a13835fcd6.png)

Application Insights でトレースのデータをどう扱えるかについては、下記 Qiita 記事にまとめていますので併せてご参照ください。

https://qiita.com/YoshiakiOi/items/c4de9b1ffc01ecf292f9#%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AE%E8%A8%AD%E5%AE%9A%E3%81%A8-application-insights-%E4%B8%8A%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E7%A2%BA%E8%AA%8D

# Appendix: Dapr 以外の情報を Application Insights のトレースに含めたい場合の実装について

実際にトレースをする際に、Dapr 以外の情報もトレースに含めたいケースがあるかと思います。本記事の Appendix では、以下の２ケースについて実装の方法を見ていきます。

## ① receipt アプリの Blob Storage へのファイルアップロードの情報をトレースしたい

Application Insights の SDK を Python コードに埋め込むことで、Blob Storage へのアップロードをトレース情報として取得することができます。この際に、Trace ID を Dapr で利用していたものを使うことで同一のトランザクションとして扱えるようになります。全体像は以下のイメージです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/9415bc57-9223-eba9-ab4c-1225145a77e3.png)


実装は下記になります。まず、以下のモジュールを　receipt アプリの app.py でインポートします。

```python:app.py (receipt-blob)
from azure.monitor.opentelemetry import configure_azure_monitor
from opentelemetry import trace
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
```

次に、receipt アプリの `app = Flask(__name__)`　の下あたりに `configure_azure_monitor()` と `tracer = trace.get_tracer(__name__)` を追加し、Application Insights へ接続と trace の起動を行います。

```python:app.py (receipt-blob)
configure_azure_monitor(
    connection_string="<Application Insights のインストルメンテーションキー>",
)
tracer = trace.get_tracer(__name__)
```

Application Insights と tracer については、下記もご覧ください。

https://qiita.com/YoshiakiOi/items/c4de9b1ffc01ecf292f9#5-tracer-%E3%81%A8-%E3%83%91%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%B3%E3%82%B9%E9%9A%9C%E5%AE%B3dependencies-%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB

そして、`def getOrder()` 直下に下記コードを配置し、Dapr の使っていた Trace ID を使ったスパンをひとつ作成します。

```python:app.py (receipt-blob)
    traceparent = request.headers.get('traceparent')
    carrier = {
        'traceparent': traceparent
    }
    ctx = TraceContextTextMapPropagator().extract(carrier=carrier)
    with tracer.start_as_current_span('receipt-blob', context=ctx) as span:
```

Trace ID の取得や、指定した Trace ID による span の作成についての詳細は下記をご参照ください。

https://opentelemetry.io/docs/languages/python/cookbook/#manually-setting-span-context

また、Dockerfile に下記の行を追加し、OpenTelemetry のサービス名の環境変数を設定し、Dapr と SDK が同一の名前を使うようにしておきます。

```Dockerfile
ENV OTEL_SERVICE_NAME="<receiptアプリのアプリ名>"
```

このようにコードを変更し、再度 Container Apps にアプリをデプロイします。そして実行結果を Application Insights のトランザクションで確認すると、下記のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/22365813-c3a7-1d9f-b61a-b40ddfe8a09a.png)

またログで見ると下記になり、こちらでもすべてが同一の operation_Id (Trace ID) に紐づいていることが分かります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/f8e382cc-bbc7-099f-4a69-46a03cd7fe57.png)

副次的ですが嬉しい内容として、アプリケーションマップにも、Blob Storage がノードとして表示されるようにもなります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/02778af6-b647-3e81-7804-0a845ba98fed.png)


## ② checkout アプリ側でトレースを起動したい

一方で、今回のアプリケーションでは、checkout アプリから処理が開始しています。そのため、checkpoint アプリでの order 作成前に trace の span を作成したくなるかもしれません。その場合、checkpoint アプリ側で tracer を起動し、その Trace ID や Span ID を traceparent ヘッダーを介して Dapr に渡すことで、実行することができます。

以下のようにコードを修正します。

**モジュールの追加と tracer の起動**

```python:app.py (checkpoint)
from azure.monitor.opentelemetry import configure_azure_monitor
from opentelemetry import trace

configure_azure_monitor(
    connection_string="<Application Insights のインストルメンテーションキー>",
)
tracer = trace.get_tracer(__name__)
```

**span の作成とヘッダーの変更**

```python:app.py (checkpoint)
dapr_app_id = "<order-processor アプリの Dapr アプリ ID>"
content_type = "application/json"
traceparent_version = "00"
traceparent_trace_flags = "01"

while(True):
  for i in range(1, 20):
    order = {'orderId': i}
    
    span_name = f"order-{i}"
    with tracer.start_as_current_span(name=span_name) as span:
      span_context = span.get_span_context()
      trace_id = int(span_context.trace_id)
      span_id = int(span_context.span_id)
      trace_id_hex = hex(trace_id)
      span_id_hex = hex(span_id)
      trace_id_hex_value = trace_id_hex[2:]
      span_id_hex_value = span_id_hex[2:]
      traceparent = f"{traceparent_version}-{trace_id_hex_value}-{span_id_hex_value}-{traceparent_trace_flags}"
            
      headers = {
        'dapr-app-id': dapr_app_id, 
        'content-type': content_type,
        'traceparent': traceparent
      }

      # --- result = 以下は同じ
```

結果、Application Insights でトランザクション画面で、最初に自分で作成したスパンから捜査が始まっていることが確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/276225/c915346e-83fc-3c2e-c012-e81cb64a2d7a.png)

# 最後に

＊本稿は、個人の見解に基づいた内容であり、所属する会社の公式見解ではありません。また、いかなる保証を与えるものでもありません。正式な情報は、各製品の販売元にご確認ください。