databricksのdbxを試してみる（インストール〜サンプルプロジェクトのデプロイ）

## Notebook環境だけではMLOpsの実現は難しい
Databricksにはsparkによる計算基盤に加えnotebook環境、データ基盤、mlflowによる実験管理、モデルリポジトリが用意されており、機械学習モデル構築においては自己完結できる環境と言えます。
作成したNotebookもDatabricks上でスケジュールジョブ登録できるので、開発したnotebookをそのままproductionに回すことも可能ですが、MLOps的には以下の点で推奨されません。
- 処理がモジュール化されておらず
    - 保守性が低い
    - リファクタリングした際の単体テストが難しい
    - コード再利用が難しい

従ってMLOpsの運用にあたっては、データサイエンティストが開発したnotebookをproduction用にモジュラーなコードに変換し、MLパイプラインとしてデプロイする必要があります。
DatabricksではコードをDatabricksにデプロイするツールとして[**dbx**](https://dbx.readthedocs.io/en/latest/)が用意されています。


今回はこのdbxで用意されているサンプルプロジェクトの実行を試してみます。
なおdbx自体はまだメジャーバージョンが0で、メジャーアップデートになった際に、大幅な仕様変更が生じる可能性があります。今回の記事ではdbxバージョン0.87を想定しています。
## インストール
```bash
$ pip install dbx==0.8.7
```
dbxをインストールすると、databricks-cliもインストールされます。

## Databricks-CLIの設定

ターミナル上で`databricks configure --token`を実行します。ここでdatabricks hostとtokenを入力します。
```bash
$ databricks configure --token
Databricks Host (should begin with https://):
```
1. databricks host
databricks hostはdatabricksのワークスペースにログインした際のURLになります。
通常であれば`?o=`の直前のスラッシュまでがワークスペースURLになります。下記の例でのワークスペースURLは`https://xxx.net/`になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158676/7b1473e5-d5ac-b1a5-9e11-3d648acfbbc0.png)
URLをコピーしたら、ターミナル上の`Databricks Host:`にペーストしてエンターを押しましょう。
2. token
続いてはtokenの入力が求められます。
```
Token:
```
   + tokenを取得するには、まず「User Settings」にアクセスします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158676/90c97f9b-4146-7c2c-6f21-f86dde45e99f.png)
   + 「Access Token」の画面で「Generate new token」をクリック
   + 利用目的が分かるようにコメントを入力
   + 「Generate」をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158676/ca4a55cf-75c9-2708-f942-01bba29d91f3.png)
   + tokenが生成されるのでコピーする
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158676/d24e012f-95cf-2168-faf0-c00b7e10127d.png)
ターミナルに戻り、tokenをペーストしエンターを押します。特に結果出力などはありませんが、設定は完了しており、設定ファイル.databrickscfgが作成されています。
Macであれば`~/.databrickscfg`にあります。
Windowsであれば`%USERPROFILE%/.databrickscfg`です。
なおプロファイル名は`[DEFAULT]`になってますが、複数のプロファイルを作成したい場合は
```bash
databricks configure --token --profile <profile-name>
```
とすると`[DEFAULT]`以外のプロファイル名で接続設定を作成することができます。
## サンプルプロジェクトの作成
いよいよサンプルプロジェクトの作成です。ターミナル上で
```bash
$ dbx init
```
を実行します。ここからはいくつか設定事項を質問されます。
```bash
project_name [cicd-sample-project]: 
version [0.0.1]: 
Select cloud:
1 - AWS
2 - Azure
3 - Google Cloud
Choose from 1, 2, 3 [1]: 
Select cicd_tool:
1 - GitHub Actions
2 - Azure DevOps
3 - GitLab
4 - None
Choose from 1, 2, 3, 4 [1]: 
project_slug [cicd_sample_project]: 
workspace_dir [/Shared/dbx/cicd_sample_project]: 
artifact_location [dbfs:/Shared/dbx/projects/cicd_sample_project]: 
profile [DEFAULT]: 
```
- project_name
    - プロジェクトパッケージ名
- version
    - プロジェクトバージョン
- cloud
    - databricksが稼働しているクラウド環境。AWS, Azure Google Cloudから選択。デフォルトはAWS。
- cicd_tool
    - GitHub Actions, Azure DevOps, GitLab, Noneから選択。
- project_slug
    - URLやディレクトリに使用されるプロジェクト名。
- workspace_dir
    - databricks上のワークスペースディレクトリ
- artifact_location
    - アーティファクトの保存先
- profile
    - `databricks configure`で設定したプロファイル。基本は`DEFAULT`で大丈夫ですが、`databricks configure -- profile <profile-name>`で、任意のプロファイル名を指定している場合はそのプロファイル名を指定します。

今回、私のdatabricks環境はAzureなのでクラウド環境はAzureを選択し、それ以外はデフォルトのままにしておきます。
作成されたサンプルプロジェクトのフォルダ構造は以下のようになります。
```
cicd-sample-project
├── README.md
├── cicd_sample_project
│   ├── __init__.py
│   ├── common.py
│   └── tasks
│       ├── __init__.py
│       ├── sample_etl_task.py
│       └── sample_ml_task.py
├── conf
│   ├── deployment.yml
│   └── tasks
│       ├── sample_etl_config.yml
│       └── sample_ml_config.yml
├── notebooks
│   └── sample_notebook.py
├── pyproject.toml
├── setup.py
└── tests
    ├── entrypoint.py
    ├── integration
    │   └── e2e_test.py
    └── unit
        ├── conftest.py
        └── sample_test.py
```
細かい中身については次回以降に触れますが、実行プログラムはtasksディレクトリ内の**sample_etl_task.py**と**sample_ml_task.py**でこれらのプログラムで参照するパラメータは**conf/tasks**内にあるyamlファイルで定義しています。

## 少し中身の探検
dbxは開発したプログラムをdatabricks上のworkflowsとしてデプロイが可能です。workflowsへのデプロイは**conf/deployment.yml**で定義します。なおこのyamlファイルのフォーマットはdatabricksの[**Jobs API**](https://docs.databricks.com/dev-tools/api/latest/jobs.html)に準拠しているようです。

ここでは**sample_etl_task.py**を単体で実行する場合の定義を見てみます。
```yaml:deployment.yml
environments:
  default:
    workflows:
      - name: "cicd-sample-project-sample-etl"
        tasks:
          - task_key: "main"
            <<: *basic-static-cluster
            python_wheel_task:
              package_name: "cicd_sample_project"
              entry_point: "etl" # take a look at the setup.py entry_points section for details on how to define an entrypoint
              parameters: ["--conf-file", "file:fuse://conf/tasks/sample_etl_config.yml"]
```
**sample_etl_task.py**で参照するパラメータyamlファイルのパスはparametersで指定されています。
この定義ではプログラムをwheelパッケージとして参照します。`entry_point`キーで指定している値`"etl"`は**setup.py**の`entry_point`で定義されている必要があります。
```python:setup.py
setup(
    name="cicd_sample_project",
    packages=find_packages(exclude=["tests", "tests.*"]),
    setup_requires=["setuptools","wheel"],
    install_requires=PACKAGE_REQUIREMENTS,
    extras_require={"local": LOCAL_REQUIREMENTS, "test": TEST_REQUIREMENTS},
    entry_points = {
        "console_scripts": [
            "etl = cicd_sample_project.tasks.sample_etl_task:entrypoint",
            "ml = cicd_sample_project.tasks.sample_ml_task:entrypoint",
    ]},
    version=__version__,
    description="",
    author="",
)
```
**setup.py**では`etl`の`entry point`は**sample_etl_task.py**の`entrypoint`関数を指定しており、deployment.ymlで実行`entry_point`に`etl`を指定することで、**sample_etl_task.py**のentrypoint関数が実行されます。
```python:sample_etl_task.py
class SampleETLTask(Task):
    def _write_data(self):
        db = self.conf["output"].get("database", "default")
        table = self.conf["output"]["table"]
        self.logger.info(f"Writing housing dataset to {db}.{table}")
        _data: pd.DataFrame = fetch_california_housing(as_frame=True).frame
        df = self.spark.createDataFrame(_data)
        df.write.format("delta").mode("overwrite").saveAsTable(f"{db}.{table}")
        self.logger.info("Dataset successfully written")

    def launch(self):
        self.logger.info("Launching sample ETL task")
        self._write_data()
        self.logger.info("Sample ETL task finished!")

# if you're using python_wheel_task, you'll need the entrypoint function to be used in setup.py
def entrypoint():  # pragma: no cover
    task = SampleETLTask()
    task.launch()
```
流れをまとめると以下のとおり。
- **sample_etl_task.py**内でメイン実行関数`entrypoint`を定義
- `entrypoint`関数をsetup.py内でetlという名前で`entry_points`に登録
- **deployment.yml**は**setup.py**の`entry_points`を参照。`etl`を指定することで**sample_etl_tasklpy**の`entrypoint`関数をworkflowsのジョブに指定。

## デプロイ
いよいよdatabricksにデプロイしてみましょう。デプロイコマンドは以下のとおり。
```bash
dbx deploy --workflow <workflow-name>
```
`<workflow-name>`は**deployment.yml**で定義されている`name`キーを指定します。今回は上の例で挙げている`cicd-sample-project-sample-etl`をデプロイしてみます。
```bash
dbx deploy --workflow cicd-sample-project-sample-etl
```
以下のような出力が出たらデプロイは成功しています。
```bash
[dbx][2023-01-10 23:22:35.542] ✨ Deployment for environment default finished successfully
```

ではdatabricksワークスペースでworklowsのジョブが作成されたか確認してみましょう。
databricksワークスペースの画面左側にあるメニューから「workflows」をクリックしましょう。
「Jobs」タブに`dbx deploy`で指定した<workflow-name>のジョブが作成されていれば成功です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158676/82b72de6-1895-e467-6953-75e197d94722.png)
ジョブの実行はこのワークスペースのUI上からも実行できますが、ローカルPCのターミナルからdbx launchコマンドでも実行可能です。
```bash
dbx launch <workflow-name>
```
先ほどデプロイした｀cicd-sample-project-sample-etl`を実行してみましょう。
```bash
dbx launch cicd-sample-project-sample-etl
```
以下のような出力がされればジョブ実行リクエストが正常にサブミットされています。
```bash
[dbx][2023-01-10 23:37:26.779] Workflow successfully launched in the non-tracking mode 🚀. Please check Databricks UI for job status 👀
```
再びdatabricksのワークスペースでworkflows画面でジョブを見ると、ジョブが実行されていることが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158676/9eb3c886-8d1a-7b98-5bee-71492ac62a61.png)

## 参考
- [Databricks Labsのdbx](https://qiita.com/taka_yayoi/items/994c701552e58d260562)
- [公式ドキュメント](https://dbx.readthedocs.io/en/latest/)
- [databricks CLI](https://docs.databricks.com/dev-tools/cli/index.html)