App Service Linux で Dapr サイドカーを使う

# はじめに

本記事は [Microsoft Azure Tech Advent Calendar 2024](https://qiita.com/advent-calendar/2024/microsoft-azure-tech) 12/09 の記事です。

本記事では先日 [GA](https://azure.github.io/AppService/2024/11/08/Global-Availability-Sidecars.html) された [Azure App Service で Linux アプリのサイドカー コンテナー](https://learn.microsoft.com/ja-jp/azure/app-service/tutorial-sidecar?tabs=portal)機能を用いて [Dapr](https://docs.dapr.io/) を利用してみます。

サイドカー機能については、下記記事も参考にしていただけますと幸いです。

https://qiita.com/georgeOsdDev@github/items/8df014c0fea153659074

上記記事では、サイドカーで ManagedId が利用できることや、環境変数 Application Setting の継承、App Service Storage が利用できることを確認しています。

# Daprサイドカーをどうやって起動するか

Dapr のホスティングオプションとしては
以下の 3 つがあります。
- Run Dapr in self-hosted mode
- Deploy and run Dapr in Kubernetes mode
- Run Dapr in a serverless offering

https://docs.dapr.io/operations/hosting/

App Service 上で動作させるには、1つ目の self-hosted モードとして動作させる必要があります。なお、serverless offering として [Azure Container Apps](https://learn.microsoft.com/ja-jp/azure/container-apps/dapr-overview) が  Dapr の動作をサポートしています。(そっちを使おう)

App Service Linux の仕組みとしてはコンテナの起動方法にユーザーが作用することはできません。ゆえに、`dapr run` コマンドでメインとなるコンテナと共に dapr サイドカーを起動させるといったこともできません。
そのため、[Run Dapr in self-hosted mode without Docker](https://docs.dapr.io/operations/hosting/self-hosted/self-hosted-no-docker/) にあるように、`daprd` を実行するコンテナをサイドカーとして自力で用意する必要があります。

## dapr コンテナの構成

[こちら](https://docs.dapr.io/operations/hosting/self-hosted/self-hosted-with-docker/#run-both-app-and-dapr-in-a-single-docker-container) を参考に、`daprd` が動作するコンテナを用意します。


```Dockerfile
FROM alpine:latest
RUN apk add --no-cache wget bash
RUN wget -q https://raw.githubusercontent.com/dapr/cli/master/install/install.sh -O - | /bin/bash


# Install daprd
RUN dapr init --slim

COPY components/ /root/.dapr/components/
COPY sshd_config /etc/ssh/
COPY entrypoint.sh ./

# Start and enable SSH
RUN apk add openssh \
    && echo "root:Docker!" | chpasswd \
    && chmod +x ./entrypoint.sh \
    && cd /etc/ssh/ \
    && ssh-keygen -A

# 3500: Dapr HTTP port, 50001: Dapr gRPC port, 2223: SSH port, 9999: Dapr dashboard port
EXPOSE 3500 50001 2223

ENTRYPOINT [ "./entrypoint.sh" ]
```

`entrypoint.sh`では、`--app-id ${APPID} --app-port ${APPPORT} ` でメインとなるアプリケーションコンテナを指定して `daprd` を開始します。

```sh:entrypoint.sh
#!/bin/sh
set -e

cat >/etc/motd <<EOL
_________________

Dapr container
_________________

EOL
cat /etc/motd

# Get environment variables to show up in SSH session
# This will replace any \ (backslash), " (double quote), $ (dollar sign) and ` (back quote) symbol by its escaped character to not allow any bash substitution.
(printenv | sed -n "s/^\([^=]\+\)=\(.*\)$/export \1=\2/p" | sed 's/\\/\\\\/g' | sed 's/"/\\\"/g' | sed 's/\$/\\\$/g' | sed 's/`/\\`/g' | sed '/=/s//="/' | sed 's/$/"/' >> /etc/profile)

/usr/sbin/sshd

# APPID should come from environment variable, if it not set, use default value as "main"
APPID=${APPID:-main}
APPPORT=${APPPORT:-9000}
DAPR_LOGLEVEL=${DAPR_LOGLEVEL:-debug}

# nohup dapr dashboard -p 9999 &
/root/.dapr/bin/daprd --resources-path /root/.dapr/components/ --log-level ${DAPR_LOGLEVEL} --enable-api-logging --log-as-json --app-id ${APPID} --app-port ${APPPORT} #--enable-metrics --enable-app-health-check --app-health-check-path /.internal/healthz
```

今回コンテナ内では `daprd` の起動のみを行っています。
通常　`dapr run` で開始した際に合わせて起動される `placement` などは用意していません。

> Dapr initialization includes:
>
> Running a Redis container instance to be used as a local state store and message broker.
 Running a Zipkin container instance for observability.
 Creating a default components folder with component definitions for the above.
 Running a Dapr placement service container instance for local actor support.
 Running a Dapr scheduler service container instance for job scheduling.

 https://docs.dapr.io/getting-started/install-dapr-selfhost/

つまり、Actor などこれらに依存する component は利用できないことが見込まれます。（未検証ですが個別に全部用意してあげれば動くとは思います。。。）

ssh接続用のセットアップについては下記を参照してください。

https://qiita.com/georgeOsdDev@github/items/8c937e26d07fd9d23bb2

### daprで有効にするcomponentsの設定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/deec4b51-df00-af27-1886-66e0f88521a3.png)


今回は Binding コンポーネントを 2 種類用意してみました。

1つ目は [cron](https://docs.dapr.io/reference/components-reference/supported-bindings/cron/) インプットバインディングとし、10秒ごとに `/.internal/batch` エンドポイントを呼び出します。
```yaml:binding-cron.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: batch
spec:
  type: bindings.cron
  version: v1
  metadata:
  - name: schedule
    value: "@every 10s" # valid cron schedule
  - name: direction
    value: "input"
  - name: route
    value: "/.internal/batch"
```
もう一方は [Blob Storage](https://docs.dapr.io/reference/components-reference/supported-bindings/blobstorage/) アウトプットバインディングとし、マネージドIDを利用して(accountKeyを指定せずに)構成します。

```yaml:binding-blob.yaml
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: blob
spec:
  type: bindings.azure.blobstorage
  version: v1
  metadata:
  - name: accountName
    value: toshidasandboxstorage
  - name: containerName
    value: container1
```

Azure Functions のタイマートリガーで Blob アウトプットバインディングを利用するみたいなものですね。

今回はイメージビルド時に `COPY components/ /root/.dapr/components/` として決めてしまっていますが、`$home` 配下に配置したファイルなどを利用するのもいいかもしれません。
App Service の 環境変数(Application Settings) を利用したい場合は、entryPoint.sh 内で `sed` 等を利用する必要がありそうです。


### アプリケーション側コード

Dockerfile自体は特にDaprを使うためになにかをする必要はありません。
1点注意点としては、メインコンテナとサイドカーコンテナがどういった順番で起動されるかがはっきりしません。
そのため、アプリケーションコード側で DaprClientを作成したタイミングでサイドカーコンテナが起動していないとエラーとなることがありました。
そこで、entrypoint.sh 内で サイドカーコンテナの起動が確認できたのちに、アプリケーションプロセスを開始するといったことをやっています。

```sh:entrypoint.sh
#!/bin/sh
set -e

cat >/etc/motd <<EOL
_________________

Main container
_________________

EOL
cat /etc/motd

CONTAINER_INFO_FROM_CGROUP=$(cat /proc/self/cgroup | head -1| rev | cut -d "/" -f 1 | rev)
logevent() {
  echo $(date "+%Y-%m-%dT%H:%M:%S.%3N%z") "[EntryPoint.backend]  CONTAINER_INFO_FROM_CGROUP:${CONTAINER_INFO_FROM_CGROUP}, Message: $1"
}

handler15() {
  logevent "'SIGTERM received"
}
trap handler15 15 # SIGTERM

logevent "log environment vars with printenv"
printenv
# Get environment variables to show up in SSH session
# This will replace any \ (backslash), " (double quote), $ (dollar sign) and ` (back quote) symbol by its escaped character to not allow any bash substitution.
(printenv | sed -n "s/^\([^=]\+\)=\(.*\)$/export \1=\2/p" | sed 's/\\/\\\\/g' | sed 's/"/\\\"/g' | sed 's/\$/\\\$/g' | sed 's/`/\\`/g' | sed '/=/s//="/' | sed 's/$/"/' >> /etc/profile)

logevent "Starting sshd" "START"
/usr/sbin/sshd

# Wait dapr sidecar is up, if not wait for 5 seconds
logevent "Waiting for dapr container to be up..."
while ! nc -z localhost 3500; do
  logevent "Waiting for dapr container to be up... sleep 5 sec"
  sleep 5
done
logevent "dapr container is up"

logevent "Starting node process"
node /app/dist/index.js
```

Cronバインディングから呼び出されるエンドポイント

```index.ts
let batchLastCalledTime: string = "00:00:00";
app.post('/.internal/batch', async (c) => {
  batchLastCalledTime = new Date().toLocaleTimeString();

  if (!client) {
    client = new DaprClient({daprHost, daprPort: daprGrpcPort, communicationProtocol});
  }
  const bindingName = "blob";
  const bindingOperation = "create";
  const data = batchLastCalledTime;
  const metadata = {
    "blobName": `batch_${Date.now()}.txt`,
  };
  await client.binding.send(bindingName, bindingOperation, data, metadata);

  return c.text('Hello Internal batch');
});

app.get('/batchstatus', (c) => {
  return c.text('batchLastCalledTime:' + batchLastCalledTime);
});
```

今回の例では実施していませんが、 `/.internal` としている理由は、このエンドポイントはパブリックにはアクセスできないことを想定しているためです。
[こちらの記事](https://qiita.com/georgeOsdDev@github/items/8df014c0fea153659074)で試したように、Envoy 等をフロントプロキシとして用意することで、制御できる想定です。


## 結果

無事 Blob コンテナ側にバッチで動作したファイルが作成されていることが確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4914/af469a3a-17d6-5ba6-b744-d18957d91f2b.png)

## サンプルコード

https://github.com/georgeOsdDev/appservicesidecar-tips/tree/main/dapr_example

# 所感
- 抽象化レイヤーとして用いるのはありかなと思いました。すべて DaprClientで実装できるのはうれしい。個別のサービスのSDK等をアプリケーション側に依存関係として持つ必要がなくなります。
- 一方で記事を作成するにあたり、サイドカーの効用を調べたり、いろいろ試しましたが、それ Container Apps でいいのでは、Azure Functions でできるのでは？といったことがままありました。
無理にやらなくても、マネージドサービス側で任せられるところは任せていいのではないかなと思います。
例えば、Secrets や Configuration コンポーネントについては、App Service ではすでに App Settings が KeyVaults 参照、App Configuration 参照が利用できるため、あえてサイドカーの dapr でやる必要性はありません。
サービスバインディングについては、Azure Functions で実装できるものは Azure Functions で実装してもいいかなと思いました。
マイクロサービス間の service invocation については、わざわざ App Service 内で複数のマイクロサービスを立てる需要もなさそう。。


# 参考

https://learn.microsoft.com/ja-jp/azure/architecture/patterns/sidecar

https://learn.microsoft.com/ja-jp/azure/architecture/patterns/ambassador