【簡単】Amazon S3を利用して、Difyでスケーラブルなストレージを実現する

# はじめに
お疲れ様です。矢儀 @yuki_ink です。

**この記事では、Difyのストレージ領域としてAmazon S3を利用する方法についてご紹介します。**

S3を利用したいと思った背景の話になりますが、先日EC2でDifyを構築しました。

https://qiita.com/yuki_ink/items/8ac4e5d9e1378a164ba6

EC2のストレージ領域はEBS。
RAG目的でサイズが大きなドキュメントをアップロードすると、ストレージの空き容量がゴリゴリ削られていきます。
```
EBSは拡張も面倒だし、お値段もそれなりだし、、
あ～～～ S3使いたい～～～！！！
```
ということで、以下の対応を行いました。


# 前提
以下の記事で構築した環境で作業を行います。
※Difyの初期構築の手順は以下の記事を参照してください。

https://qiita.com/yuki_ink/items/8ac4e5d9e1378a164ba6

:::note
EC2（RHEL9）での操作を想定しますが、ローカルPC / Windows など異なる環境でも、対応手順の大きな流れは変わりません。
DifyでS3を使いたいという方には、本記事は何かの参考になると思います。
:::

# やったこと

1. S3バケットの作成
1. IAMユーザ（アクセスキー）の作成
1. Difyが稼働するサーバでの環境変数の設定


## 1. S3バケットの作成
Dify用にS3バケットを用意します。
今回はすべてデフォルト値で作成しました。
自環境のセキュリティ要件などに従って設定してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731275/251129f3-7860-3fac-920d-2722bc2cee05.png)

:::note
EC2-S3間の通信をVPCエンドポイント経由にしたい！という方は、VPCエンドポイントを作っておきましょう。
※Gatewayタイプの場合、ルートテーブルの設定もお忘れなく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731275/1f659bea-30d3-5649-70f9-031cbdf951f5.png)
:::


## 2. IAMユーザ（アクセスキー）の作成
DifyからS3バケットにアクセスするためのIAMユーザを作成し、アクセスキーとシークレットアクセスキーを取得します。
アクセスキーの作成方法は以下の記事を参考にしてください。

https://dev.classmethod.jp/articles/iam_user_create_access_keys_2023/

IAMポリシーについては、今回は `AmazonS3FullAccess` をアタッチしました。
自環境のセキュリティ要件などに従って設定してください。
また、S3を作成する際にKMSの暗号化を有効化した場合、KMSキーの利用に必要な権限もあわせて付与します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731275/10835434-ec1f-d100-fc52-a8b1be023fd5.png)


:::note
当初、IAMユーザ（アクセスキー）ではなくIAMロールで制御したいと思っていたんですが、現状のDifyの標準機能ではIAMロールは対応しておらず、アクセスキーの利用が必要となるようです。

```
# IAMロールでやろうとしたときに出たエラー
api-1 | botocore.exceptions.ClientError: An error occurred (AuthorizationHeaderMalformed) when calling the PutObject operation: The authorization header is malformed; a non-empty Access Key (AKID) mustbe provided in the credential.
```
:::


## 3. Difyが稼働するサーバでの環境変数の設定
Difyが稼働するサーバに接続し、以下の環境変数を設定します。

| 環境変数名 | 値 |
|-|-|
|STORAGE_TYPE	|s3 |
|STORAGE_LOCAL_PATH	|storage　|
|S3_ENDPOINT	|https\://s3.(リージョン名).amazonaws.com<br>(例)https://s3.us-west-2.amazonaws.com　|
|S3_BUCKET_NAME	|※`1. S3バケットの作成` で作成したバケットの名前<br>(例)dify-s3-bucket|
|S3_ACCESS_KEY	|※`2. IAMユーザ（アクセスキー）の作成` で取得したアクセスキーの値|
|S3_SECRET_KEY  |※`2. IAMユーザ（アクセスキー）の作成` で取得したシークレットアクセスキーの値|
|S3_REGION  |※S3バケットを作成したリージョン<br>(例)us-west-2|


:::note
各環境変数についての詳細は公式ドキュメント「[環境変数の説明](https://docs.dify.ai/ja-jp/getting-started/install-self-hosted/environments)」をご確認ください。
:::

Linux環境では、以下のコマンドで設定できます。
ついでに、`/etc/profile` にも書いておきましょう。
```
export STORAGE_TYPE='s3'
export STORAGE_LOCAL_PATH='storage'
export S3_ENDPOINT='https://s3.us-west-2.amazonaws.com'
export S3_BUCKET_NAME='dify-s3-bucket'
export S3_ACCESS_KEY='xxxxxxxxxxxxx'
export S3_SECRET_KEY='yyyyyyyyyyyyyyyyyyyyyyyyyyyy'
export S3_REGION='us-west-2'
```

ここまで来たら、サービスを再起動します。
```
cd dify/docker

#-d オプションをつけて、バックグラウンドで起動
docker compose up -d
```

:::note
`dify/docker/docker-compose.yaml` の中身を見てみると、以下のような記述があり、ホストの環境変数を利用しているのが分かります。
```
  S3_ENDPOINT: ${S3_ENDPOINT:-}
  S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
  S3_ACCESS_KEY: ${S3_ACCESS_KEY:-}
  S3_SECRET_KEY: ${S3_SECRET_KEY:-}
  S3_REGION: ${S3_REGION:-us-east-1}
```
:::


# 動作確認
Difyにログインし、`ナレッジの作成` からドキュメントをアップロードします。
今回はQiita CLIの `README` を利用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731275/a3d9a29c-a884-0fbc-1c49-6ec0e309bb8e.png)

問題なくナレッジを作成することができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731275/69519dc8-53c9-2456-e150-206ffcd09756.png)

簡単なRAGワークフローでテストしてみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731275/661b4caf-b4fc-14bb-894f-7e70699aab54.png)
大丈夫そう！！

S3バケットを確認すると、`upload_files/` というフォルダが作成されており、配下にそれらしきファイルが格納されていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731275/0f2ffc82-6f12-8066-968f-4ab0fd6c590b.png)


# 終わりに
以上、DifyとS3を連携させ、スケーラブルかつ安価なストレージを利用できるようになりました。
これでDifyに大きめのドキュメントをアップロードするときの罪悪感が軽くなりますw

Difyに関しては、AWS公式からもブログが出るほどAWSとの相性もよく、yamlファイルをいじらなくてもある程度のことはできそうです。
今後もDifyとAWSサービスの連携について、色々検証できたらと思います。

https://aws.amazon.com/jp/builders-flash/202409/dify-bedrock-automate-security-operation/

最後までお目通しいただき、ありがとうございました。


# Tips (2024/12/3追記)
ある日突然、こんな事象が発生しました。
- Difyを開き、作成済みのチャットボットでプロンプトを入力すると、「Internal Server Error」というエラーメッセージが表示される。
※以前は使えていたものでもNG
- ワークフロー選択画面で何か編集しようとしても、同様に「Internal Server Error」というエラーメッセージが表示される。
- Difyの設定画面でモデル一覧が読み込めない。

`docker logs docker-api-1` コマンドでログを確認すると、こんなエラーが発生していました。

```
2024-12-03 08:38:18,900.900 ERROR [Dummy-12] [app.py:838] - Exception on /console/api/workspaces/current/model-providers [GET]
Traceback (most recent call last):
  File "/app/api/extensions/storage/s3_storage.py", line 48, in load_once
   data = client.get_object(Bucket=self.bucket_name, Key=filename)["Body"].read()
  File "/app/api/.venv/lib/python3.10/site-packages/botocore/client.py", line 565, in _api_call
   return self._make_api_call(operation_name, kwargs)
  File "/app/api/.venv/lib/python3.10/site-packages/botocore/client.py", line 1017, in _make_api_call
   raise error_class(parsed_response, operation_name)
botocore.errorfactory.NoSuchKey: An error occurred (NoSuchKey) when calling the GetObject operation: The specified key does not exist.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/app/api/libs/rsa.py", line 55, in get_decrypt_decoding
   private_key = storage.load(filepath)
  File "/app/api/extensions/ext_storage.py", line 43, in load
   return self.load_once(filename)
  File "/app/api/extensions/ext_storage.py", line 46, in load_once
   return self.storage_runner.load_once(filename)
  File "/app/api/extensions/storage/s3_storage.py", line 51, in load_once
   raise FileNotFoundError("File not found")
FileNotFoundError: File not found

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/app/api/.venv/lib/python3.10/site-packages/flask/app.py", line 880, in full_dispatch_request
   rv = self.dispatch_request()
  File "/app/api/.venv/lib/python3.10/site-packages/flask/app.py", line 865, in dispatch_request
   return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)  # type: ignore[no-any-return]
  File "/app/api/.venv/lib/python3.10/site-packages/flask_restful/__init__.py", line 489, in wrapper
   resp = resource(*args, **kwargs)
  File "/app/api/.venv/lib/python3.10/site-packages/flask/views.py", line 110, in view
   return current_app.ensure_sync(self.dispatch_request)(**kwargs)  # type: ignore[no-any-return]
  File "/app/api/.venv/lib/python3.10/site-packages/flask_restful/__init__.py", line 604, in dispatch_request
   resp = meth(*args, **kwargs)
  File "/app/api/controllers/console/setup.py", line 65, in decorated
   return view(*args, **kwargs)
  File "/app/api/libs/login.py", line 93, in decorated_view
   return current_app.ensure_sync(func)(*args, **kwargs)
  File "/app/api/controllers/console/wraps.py", line 22, in decorated
   return view(*args, **kwargs)
  File "/app/api/controllers/console/workspace/model_providers.py", line 38, in get
   provider_list = model_provider_service.get_provider_list(tenant_id=tenant_id, model_type=args.get("model_type"))
  File "/app/api/services/model_provider_service.py", line 46, in get_provider_list
   provider_configurations = self.provider_manager.get_configurations(tenant_id)
  File "/app/api/core/provider_manager.py", line 134, in get_configurations
   custom_configuration = self._to_custom_configuration(
  File "/app/api/core/provider_manager.py", line 640, in _to_custom_configuration
   self.decoding_rsa_key, self.decoding_cipher_rsa = encrypter.get_decrypt_decoding(tenant_id)
  File "/app/api/core/helper/encrypter.py", line 34, in get_decrypt_decoding
   return rsa.get_decrypt_decoding(tenant_id)
  File "/app/api/libs/rsa.py", line 57, in get_decrypt_decoding
   raise PrivkeyNotFoundError("Private key not found, tenant_id: {tenant_id}".format(tenant_id=tenant_id))
libs.rsa.PrivkeyNotFoundError: Private key not found, tenant_id: 5bc686ba-491a-433c-bf79-7be863327732
```

[2. ローカル展開ログで「ファイルが見つかりません」というエラーを修正する方法は？](https://docs.dify.ai/ja-jp/learn-more/faq/install-faq#id-2-rkarurogudefairugatsukarimasentoiuerwosuruha) を参考にして、 `docker exec -it docker-api-1 flask reset-encrypt-key-pair` コマンドを実行。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731275/08cec7a4-a159-c097-c9fc-f31b1a46fb1e.png)

一応復旧しました、、
心当たりのある作業がないのですが、いったい何だったのか、、


もしかしたらこっちだったかも（未検証）

https://docs.dify.ai/ja-jp/learn-more/faq/install-faq#id-23-fairuworkarusutorjikarakuraudosutorjinisuru

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/731275/531acee1-8b8b-b589-885f-1e023423669d.png)