DacraneでAzure App ServiceにAPIサーバを立ててみる

# はじめに

開発中のクラウドインフラとアプリのプロビジョニングツールであるDacraneを利用して、APIサーバをAzure App Serviceにデプロイする例をご紹介したいと思います。

https://github.com/SIOS-Technology-Inc/dacrane

# Dacraneとは

サイオステクノロジーで開発しているクラウドインフラとアプリまでをデプロイするプロビジョニングツールです。現在の最新バージョンは0.0.7の開発初期段階のOSSです。IaCツール（特にTerraform）の影響を受けており、宣言的なコードでデプロイメント扱うことができます。
特徴としては、

1. YAMLによる宣言的なデプロイ定義
1. デプロイプロセスをコンテナ上で動かすことによる可搬性の高さ
1. インフラからアプリまで一式がデプロイできる

という点があげられます。
今回はDacraneで簡単なAPIをデプロイする方法をご紹介したいと思います。

# 環境

* macOS Ventura
* Go 1.21
* Docker Desktop 4.25.0
* GNU Make 3.81

# 手順

## Dacraneをインストールする

Dacraneのインストール手順を説明します。基本的な手順はリポジトリをクローンしてビルドを実行します。
現在のバージョンは`brew`や`yum`、`apt`などのパッケージ管理ツールでは配布していないので、ご自身の環境でビルドいただく必要があります。

まずはDacraneのリポジトリ（Dacrane 0.0.7）をクローンします。

```bash
$ git clone https://github.com/SIOS-Technology-Inc/dacrane.git -b 0.0.7
```

```bash
$ cd dacrane
```

次のコマンドで、`/user/local/bin`に`dacrane`コマンドをインストールします。

```bash
$ make install
```

次のコマンドでDacraneが利用するプラグインをインストールします。プラグインはコンテナです。現在はローカルでプラグインをビルドしていますが、今後はDockerHubなどからビルド済みイメージをプルして使えるようにすることを想定しています。

```bash
$ make install-plugin
```

次のように表示されればインストール完了です。

```bash
$ dacrane --help
Usage:
  dacrane [command]

Available Commands:
  apply       create or update resource
  completion  Generate the autocompletion script for the specified shell
  destroy     destroy resource and artifact
  help        Help about any command
  init        initialize dacrane project
  ls          show instance list

Flags:
  -h, --help   help for dacrane

Use "dacrane [command] --help" for more information about a command.
```

## 作業ディレクトリを準備する

作業ディレクトリを作成します。

```bash
$ mkdir work
$ cd work/
```

次にサンプルコードディレクトリからアプリケーションのコードをコピーします。

```bash
$ cp ../example/app-services/index.js ./index.js
$ cp ../example/app-services/package-lock.json ./package-lock.json
$ cp ../example/app-services/package.json ./package.json
```

ディレクトリには次のようなファイルが格納されています。

```bash
$ ls

index.js		package-lock.json	package.json
```

index.jsはExpress.js製の簡単なAPIサーバのコードです。

```js:index.js
const express = require('express')
const app = express()

// respond with "hello world" when a GET request is made to the homepage
app.get('/', (req, res) => {
  res.send('hello world')
})

const port = process.env.PORT || 3000
app.listen(port, () => {
    console.log(`Start server on ${port}!`);
})
```

このコードをデプロイしていきます。

## ローカルのDcokerにデプロイする

まずはいきなりApp Serviceにデプロイするのではなく、ローカルのDocker上にデプロイしてみましょう。
Dacraneでデプロイメントを定義するには`dacrane.yaml`というファイルを作成し、「モジュール(Module)」と呼ばれるデプロイの基本単位を定義します。モジュールはYAMLの1ドキュメントとして定義します。

### コンテナビルド

最初にコンテナビルドするモジュール定義をします。定義は次のようになります。

```yaml:dacrane.yaml
name: api-image # モジュール名
parameter:      # モジュール引数
  type: object
  properties:
    tag: { type: string, default: "latest" }
import: # 外部モジュールインポート
  - https://raw.githubusercontent.com/SIOS-Technology-Inc/dacrane/0.0.7/module/docker-npm.yaml
modules: # 他モジュール、プラグイン呼び出し。
- name: api-local-image
  module: docker-npm # npmでビルドされるdockerイメージを作るモジュールを呼び出す
  argument:
    image: sample-api
    tag: ${{ parameter.tag }}
```

モジュールはプログラミング言語における関数のような概念で、名前(`name`)、引数(`parameter`)、本体(`modules`)を持ちます。このモジュール`api-image`は引数`tag`を受け取り、`sample-api`という名前でDockerイメージをビルドするという定義です。

では、早速これを実行してみましょう。

実行前にプロジェクトの初期化が必要です。`dacrane init`で行います。プロジェクトで１度のみ実行すればOKです。

```bash
$ dacrane init
```

初期化を実行すると`.dacrane`フォルダが作成されます。この中に作成したインフラやアプリの状態が記録されます。

```bash
$ ls -a

.			.dacrane		index.js		package.json
..			dacrane.yaml		package-lock.json
```

次を実行するとコンテナイメージのビルドが走ります。このコマンドの`api-image`は実行したいモジュール名です。`api-latest`はインスタンス名といって、このモジュールから作られるビルド成果物の実体に名前をつけています。`-a`はモジュールに与える引数です。デフォルトが定められていれば省略することが可能です。
注：現在は引数はYAMLで渡すことになっていますが、YAMLをコマンドラインで渡すのは、辛いのでこの仕様は変更する可能性高いです。

```bash
$ dacrane apply api-image api-latest -a '{ tag: latest }'

[api-latest (docker-npm)] Evaluating...
[api-latest (docker-npm)] Evaluated.
[api-latest.api-local-image (local/resource/file)] Evaluating...
...
#10 writing image sha256:163888daf501aa2201fc0bc050d95dab7d6e2f60aa53aa137741d5bf51f0335f done
#10 naming to docker.io/library/sample-api:latest done
#10 DONE 0.2s
[api-latest.api-local-image.image (docker/resource/local-image)] Created.
```

これでイメージのビルドが完了しました。


`dacrane ls`で作ったインスタンスの一覧を表示することができます。`[instance_name] ([module_name])`を表しています。

```bash
$ dacrane ls

api-latest (api-image)
```

次にコンテナを起動の定義を実施します。`api-image`の下に`---`でドキュメントを区切って定義します。（記載の仕方がわからない場合は最後に完全版のコードを載せています。）
定義は次のとおりです。

### コンテナ起動

```yaml:dacrane.yaml
name: api-image # モジュール名
// ...(省略)...
---
name: local-docker
parameter:
  type: object
  properties:
    image: { type: string, default: sample-api }
    tag: { type: string, default: latest }
modules:
- module: docker/resource/container
  name: api
  argument:
    name: api
    image: ${{ parameter.image }}
    tag: ${{ parameter.tag }}
    port: 3000:3000
    env:
      - name: PORT
        value: "3000"
```

同様にコンテナを起動します。

```bash
$ dacrane apply local-docker api

[api (docker/resource/container)] Evaluating...
[api (docker/resource/container)] Evaluated.
[api.api (docker/resource/container)] Creating...
> docker run -d --name api -p 3000:3000 -e "PORT=3000" sample-api:latest

5a1648503aebb4f8edcad2dd1df9e2a990b069da25a19209ff6e853fa7be27ce
[api.api (docker/resource/container)] Created.
```

これでコンテナがデプロイできました。
APIに疎通確認しています。

```bash
$ curl http://localhost:3000

hello world
```

無事デプロイされました。

## App Serviceにデプロイする

### サービスプリンシパルの作成

次にAuzreのApp Serviceにデプロイしてみます。Azureのアカウントが必要になるのでお持ちでない方は事前にご準備ください。

アカウントができましたら、dacrane用のサービスプリンシパルを用意します。サービスプリンシパルの作成のコマンドは次のとおりです。（Azure CLIを使わない場合は、Azureポータル上でも作成できます。その際に`Contributor`ロールをつけて、クライアントシークレットを発行してください。）

```bash
$ az ad sp create-for-rbac --name "dacrane-${your_name}" --role="Contributor" --scopes="/subscriptions/${your_subscription_id}"

{
  "appId": "00000000-0000-0000-0000-000000000000",
  "displayName": "dacrane-your-name",
  "password": "12345678-0000-0000-0000-000000000000",
  "tenant": "10000000-0000-0000-0000-000000000000"
}
```

サービスプリンシパルが準備できたら、その情報を環境変数に格納してください。次のように`.env`ファイルに環境変数を定義して、`source .env`で読み込むと便利です。

```bash:.env
export ARM_CLIENT_ID="00000000-0000-0000-0000-000000000000"
export ARM_CLIENT_SECRET="12345678-0000-0000-0000-000000000000"
export ARM_TENANT_ID="10000000-0000-0000-0000-000000000000"
export ARM_SUBSCRIPTION_ID="20000000-0000-0000-0000-000000000000"
```

```bash
$ source .env
```

これでDacraneでAzureのリソースを操作する準備が整いました。

### 基本リソースの作成

App Serviceを作成する前にResource GroupとAzure Container Registryを構築します。
定義は次のとおりです。

```yaml:dacrane.yaml
# (省略)他のリソース
---
name: base
parameter:
  type: object
  required: ["prefix"]
  properties:
    prefix: { type: string }
modules:
- name: rg
  # terraformのazurerm_resource_groupリソースを呼んでいる。
  module: terraform/resource/azurerm_resource_group 
  argument:
    provider: &config
      features: {}
      client_id: ${{ env.ARM_CLIENT_ID }}
      client_secret: ${{ env.ARM_CLIENT_SECRET }}
      tenant_id: ${{ env.ARM_TENANT_ID }}
      subscription_id: ${{ env.ARM_SUBSCRIPTION_ID }}
    resource:
      name: ${{ parameter.prefix }}-sample-rg
      location: "Japan East"
- name: acr
  # terraformのazurerm_container_registryリソースを呼んでいる。
  module: terraform/resource/azurerm_container_registry 
  argument:
    provider: *config
    resource:
      name: ${{ parameter.prefix }}sampleacr
      resource_group_name: ${{ modules.rg.name }}
      location: "Japan East"
      sku: Basic
      admin_enabled: true
```

この定義では`terraform`プラグインを呼び出し、リソースを定義しています。Terraformのプロセスはプラグインのコンテナ上で動くので個別にコマンドをインストールする必要はありません。

`yourname`に任意の名称を入れて構築します。

```bash
$ dacrane apply base base -a '{ prefix: yourname }'

[base (terraform/resource/azurerm_resource_group)] Evaluating...
[base (terraform/resource/azurerm_resource_group)] Evaluated.
[base.rg (terraform/resource/azurerm_resource_group)] Creating...
Terraform apply complete
[base.rg (terraform/resource/azurerm_resource_group)] Created.
[base (terraform/resource/azurerm_container_registry)] Evaluating...
[base (terraform/resource/azurerm_container_registry)] Evaluated.
[base.acr (terraform/resource/azurerm_container_registry)] Creating...
Terraform apply complete
[base.acr (terraform/resource/azurerm_container_registry)] Created.
```

`base`が完了しました。

```bash
$ dacrane ls

base (base)
api (local-docker)
api-latest (api-image)
```

### ACRにコンテナをプッシュする

次にACRにコンテナイメージをプッシュします。モジュールの定義は次のようになります。

```yaml:dacrane.yaml
# (省略)他のリソース
---
name: api-remote-image
parameter:      
  type: object
  properties:
    tag: { type: string, default: "latest" }
    image: { type: string, default: "sample-api" }
    acr: { type: object }
modules: 
- name: api-remote-image
  module: docker/resource/remote-image
  argument:
    image: ${{ parameter.image }}
    tag: ${{ parameter.tag }}
    remote: # プッシュ先のコンテナリポジトリ
      url: ${{ parameter.acr.login_server }}
      user: ${{ parameter.acr.admin_username }}
      password: ${{ parameter.acr.admin_password }}
```

これを実行するとACRにコンテナイメージがプッシュされます。このとき、先ほど作成したACRのインスタンスを引数として渡す必要があります。

```bash
$ dacrane apply api-remote-image remote-latest \
  -a '{ acr: "${{ instances.base.modules.acr }}" }'

[remote-latest (docker/resource/remote-image)] Evaluating...
[remote-latest (docker/resource/remote-image)] Evaluated.
[remote-latest.api-remote-image (docker/resource/remote-image)] Creating...
...
latest: digest: sha256:d0e684f4b2a5a11031e6a93c331d24d7812e19c8526961fef17981e9720cf5ea size: 2837
[remote-latest.api-remote-image (docker/resource/remote-image)] Created.
```

### App Serviceのデプロイ

では最後にこれをApp Service上にデプロイしましょう。定義は次のようになります。

```yaml:dacrane.yaml
# (省略)他のリソース
---
name: app-service
argument:
parameter:
  type: object
  required: ["base"]
  properties:
    base: { type: object }
    tag: { type: string, default: "latest" }
    image: { type: string, default: "sample-api" }
modules:
- name: asp
  module: terraform/resource/azurerm_app_service_plan
  argument:
    provider:
      features: {}
      client_id: ${{ env.ARM_CLIENT_ID }}
      client_secret: ${{ env.ARM_CLIENT_SECRET }}
      tenant_id: ${{ env.ARM_TENANT_ID }}
      subscription_id: ${{ env.ARM_SUBSCRIPTION_ID }}
    resource:
      name: ${{ parameter.base.parameter.prefix }}-sample-asp
      resource_group_name: ${{ parameter.base.modules.rg.name }}
      location: "Japan East"
      kind: "Linux"
      reserved: true
      sku:
        tier: Basic
        size: B1
- name: as
  module: terraform/resource/azurerm_app_service
  argument:
    provider:
      features: {}
      client_id: ${{ env.ARM_CLIENT_ID }}
      client_secret: ${{ env.ARM_CLIENT_SECRET }}
      tenant_id: ${{ env.ARM_TENANT_ID }}
      subscription_id: ${{ env.ARM_SUBSCRIPTION_ID }}
    resource:
      name: ${{ parameter.base.parameter.prefix }}-sample-as
      resource_group_name: ${{ parameter.base.modules.rg.name }}
      location: "Japan East"
      app_service_plan_id: ${{ modules.asp.id }}
      site_config:
        linux_fx_version: DOCKER|${{ parameter.base.modules.acr.login_server }}/${{ parameter.image }}:${{ parameter.tag }}
      app_settings:
        DOCKER_REGISTRY_SERVER_URL: ${{ parameter.base.modules.acr.login_server }}
        DOCKER_REGISTRY_SERVER_USERNAME: ${{ parameter.base.modules.acr.admin_username }}
        DOCKER_REGISTRY_SERVER_PASSWORD: ${{ parameter.base.modules.acr.admin_password }}
        WEBSITES_PORT: "3000"
```

いよいよApp ServiceにAPIをデプロイします。

```bash
$ dacrane apply app-service as \
  -a '{ base: "${{ instances.base }}" }'

[as (terraform/resource/azurerm_app_service_plan)] Evaluating...
[as (terraform/resource/azurerm_app_service_plan)] Evaluated.
[as.asp (terraform/resource/azurerm_app_service_plan)] Creating...
Terraform apply complete
[as.asp (terraform/resource/azurerm_app_service_plan)] Created.
[as (terraform/resource/azurerm_app_service)] Evaluating...
[as (terraform/resource/azurerm_app_service)] Evaluated.
[as.as (terraform/resource/azurerm_app_service)] Creating...
Terraform apply complete
[as.as (terraform/resource/azurerm_app_service)] Created.
```

APIがデプロイされました。

```
$ dacrane ls

base (base)
remote-latest (api-remote-image)
api-latest (api-image)
as (app-service)
api (local-docker)
```

実際にAPIの疎通確認をしてみます。

```bash
$ curl https://tikeda-sample-as.azurewebsites.net

hello world
```

これで全てのデプロイが完了です！

## 環境削除

最後にDacraneで作った環境を破棄します。破棄もインスタンスごとに行います。リソース間の依存関係に注意して順番に削除してください。

```bash
$ dacrane destroy as
$ dacrane destroy remote-latest
$ dacrane destroy api
$ dacrane destroy base
$ dacrane destroy api-latest
```

## Dacraneのアンインストール

Dacraneもアンインストールされたい方は、次のコマンドでアンインストールできます。

```bash
$ make uninstall-plugin
$ make uninstall
```

## 参考

### 完全なコード

今回作成したデプロイメントコードの完全版です。

```yaml:dacrane.yaml
name: api-image # モジュール名
parameter:      # モジュール引数
  type: object
  properties:
    tag: { type: string, default: "latest" }
import: # 外部モジュールインポート
  - https://raw.githubusercontent.com/SIOS-Technology-Inc/dacrane/0.0.7/module/docker-npm.yaml
modules: # 他モジュール、プラグイン呼び出し。
- name: api-local-image
  module: docker-npm # npmでビルドされるdockerイメージを作るモジュールを呼び出す
  argument:
    image: sample-api
    tag: ${{ parameter.tag }}
---
name: local-docker
parameter:
  type: object
  properties:
    image: { type: string, default: sample-api }
    tag: { type: string, default: latest }
modules:
- module: docker/resource/container
  name: api
  argument:
    name: api
    image: ${{ parameter.image }}
    tag: ${{ parameter.tag }}
    port: 3000:3000
    env:
      - name: PORT
        value: "3000"
---
name: base
parameter:
  type: object
  required: ["prefix"]
  properties:
    prefix: { type: string }
modules:
- name: rg
  module: terraform/resource/azurerm_resource_group
  argument:
    provider: &config
      features: {}
      client_id: ${{ env.ARM_CLIENT_ID }}
      client_secret: ${{ env.ARM_CLIENT_SECRET }}
      tenant_id: ${{ env.ARM_TENANT_ID }}
      subscription_id: ${{ env.ARM_SUBSCRIPTION_ID }}
    resource:
      name: ${{ parameter.prefix }}-sample-rg
      location: "Japan East"
- name: acr
  module: terraform/resource/azurerm_container_registry
  argument:
    provider: *config
    resource:
      name: ${{ parameter.prefix }}sampleacr
      resource_group_name: ${{ modules.rg.name }}
      location: "Japan East"
      sku: Basic
      admin_enabled: true
---
name: api-remote-image
parameter:
  type: object
  properties:
    tag: { type: string, default: "latest" }
    image: { type: string, default: "sample-api" }
    acr: { type: object }
modules:
- name: api-remote-image
  module: docker/resource/remote-image
  argument:
    image: ${{ parameter.image }}
    tag: ${{ parameter.tag }}
    remote:
      url: ${{ parameter.acr.login_server }}
      user: ${{ parameter.acr.admin_username }}
      password: ${{ parameter.acr.admin_password }}
---
name: app-service
argument:
parameter:
  type: object
  required: ["base"]
  properties:
    base: { type: object }
    tag: { type: string, default: "latest" }
    image: { type: string, default: "sample-api" }
modules:
- name: asp
  module: terraform/resource/azurerm_app_service_plan
  argument:
    provider:
      features: {}
      client_id: ${{ env.ARM_CLIENT_ID }}
      client_secret: ${{ env.ARM_CLIENT_SECRET }}
      tenant_id: ${{ env.ARM_TENANT_ID }}
      subscription_id: ${{ env.ARM_SUBSCRIPTION_ID }}
    resource:
      name: ${{ parameter.base.parameter.prefix }}-sample-asp
      resource_group_name: ${{ parameter.base.modules.rg.name }}
      location: "Japan East"
      kind: "Linux"
      reserved: true
      sku:
        tier: Basic
        size: B1
- name: as
  module: terraform/resource/azurerm_app_service
  argument:
    provider:
      features: {}
      client_id: ${{ env.ARM_CLIENT_ID }}
      client_secret: ${{ env.ARM_CLIENT_SECRET }}
      tenant_id: ${{ env.ARM_TENANT_ID }}
      subscription_id: ${{ env.ARM_SUBSCRIPTION_ID }}
    resource:
      name: ${{ parameter.base.parameter.prefix }}-sample-as
      resource_group_name: ${{ parameter.base.modules.rg.name }}
      location: "Japan East"
      app_service_plan_id: ${{ modules.asp.id }}
      site_config:
        linux_fx_version: DOCKER|${{ parameter.base.modules.acr.login_server }}/${{ parameter.image }}:${{ parameter.tag }}
      app_settings:
        DOCKER_REGISTRY_SERVER_URL: ${{ parameter.base.modules.acr.login_server }}
        DOCKER_REGISTRY_SERVER_USERNAME: ${{ parameter.base.modules.acr.admin_username }}
        DOCKER_REGISTRY_SERVER_PASSWORD: ${{ parameter.base.modules.acr.admin_password }}
```

### Dacraneサンプルコード

↓で今回の元ネタのサンプルコードを公開しています。こちらもよろしければご覧ください。
https://github.com/SIOS-Technology-Inc/dacrane/tree/0.0.7/example/app-services

# まとめ

最後までお読みいただきありがとうございます！
ご覧いただいたとおりインフラからアプリまで全てをDacraneが全て管理できていることがお分かりいただけたかと思います。

## GitHub StarやIssueで開発を応援ください！

まだまだ発展途上のOSSですが、少しでもいいなと思っていただいた方はGitHub Starで応援ください！また、意見や改善を思いついた方はIssueやPRをいただけると嬉しいです！

https://github.com/SIOS-Technology-Inc/dacrane