Dify開発のTips集: DSLのバージョン管理/デプロイフロー/モノレポ連携
概要
最近名前が変わったDify(ディフィ)を使用して、実際にサービス開発を行う際のTipsについてまとめました。
この記事では、以下の内容について知ることができます。
- Difyをセルフホスティングしない場合のバージョン管理手法
- Dify Consoleへのデプロイフロー
- モノレポ環境でのDify運用方法
対象読者
- Difyの基本を理解している方
- Difyへの知見を深めたい方
- プロダクトにDifyを導入している or したい方
① Difyのバージョン管理
Difyをセルフホスティング(自前でサーバーにDifyをインストール)しない場合のバージョン管理について説明します。
DifyアプリはYAML形式でエクスポートできるため、このファイル(DSLと呼ばれます)をGitで管理する方針を採用しています。
DSLの自動エクスポート
毎回手動でエクスポートするのも微妙なので、Goでプログラムを書いて誰でも実行できるような形にしてます。
管理画面から叩くAPIをハックしてるイメージです。
コード(Go)
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"time"
)
const (
difyConsoleAPIBaseURL = "http://localhost/console/api"
loginPath = "login"
appPath = "apps"
dslOutputDir = "dify/dsl"
)
type client struct {
httpClient *http.Client
}
func main() {
var appName, email, password string
flag.StringVar(&appName, "app", "", "dify app name")
flag.StringVar(&email, "email", "", "dify user email")
flag.StringVar(&password, "password", "", "dify user password")
flag.Parse()
if appName == "" {
log.Fatalf("appName is not provided")
}
if email == "" {
log.Fatalf("email is not provided")
}
if password == "" {
log.Fatalf("password is not provided")
}
client := &client{
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
ctx := context.Background()
token, err := client.getToken(ctx, email, password)
if err != nil {
log.Fatalf("failed to get token: %v", err)
}
appID, err := client.getAppIDByAppName(ctx, token, appName)
if err != nil {
log.Fatalf("failed to get app list: %v", err)
}
dsl, err := client.getDSLByAppID(ctx, token, appID)
if err != nil {
log.Fatalf("failed to get dsl: %v", err)
}
dslFileName := fmt.Sprintf("%s.yml", appName)
dslFileOutputPath := filepath.Join(dslOutputDir, dslFileName)
if err := os.WriteFile(dslFileOutputPath, []byte(dsl), os.FileMode(0600)); err != nil {
log.Fatalf("failed to write dsl to file: %v", err)
}
log.Printf("dsl exported successfully to %s", dslFileName)
}
func (c *client) getToken(ctx context.Context, email, password string) (string, error) {
body, err := json.Marshal(
map[string]string{
"email": email,
"password": password,
},
)
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/%s", difyConsoleAPIBaseURL, loginPath)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
res, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var loginResponse struct {
Result string `json:"result"` // 成功したら`success`
Data struct {
AccessToken string `json:"access_token"` // DifyコンソールAPIを利用するために必要な認証トークン
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&loginResponse); err != nil {
return "", err
}
if loginResponse.Result != "success" {
return "", fmt.Errorf("result is not success")
}
if loginResponse.Data.AccessToken == "" {
return "", fmt.Errorf("access token is empty")
}
return loginResponse.Data.AccessToken, nil
}
func (c *client) getAppIDByAppName(ctx context.Context, token, appName string) (string, error) {
url := fmt.Sprintf("%s/%s", difyConsoleAPIBaseURL, appPath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var appListResponse struct {
Data []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&appListResponse); err != nil {
return "", err
}
if len(appListResponse.Data) == 0 {
return "", fmt.Errorf("app list is empty")
}
var appID string
for _, app := range appListResponse.Data {
if app.Name != appName {
continue
}
appID = app.ID
}
if appID == "" {
return "", fmt.Errorf("app is not found")
}
return appID, nil
}
func (c *client) getDSLByAppID(ctx context.Context, token, appID string) (string, error) {
url := fmt.Sprintf("%s/apps/%s/export?include_secret=false", difyConsoleAPIBaseURL, appID) // include_secretをtrueにすると環境変数もexportされてしまうのでfalseに設定してます
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
res, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var dslResponse struct {
Data string `json:"data"` // DifyのDSL(YAML形式)が文字列として格納される
}
if err := json.NewDecoder(res.Body).Decode(&dslResponse); err != nil {
return "", err
}
if dslResponse.Data == "" {
return "", fmt.Errorf("dsl is empty")
}
return dslResponse.Data, nil
}
② Dify Consoleへのデプロイフロー
基本的には、エクスポートしてGit管理しているDSLをインポートする形でアプリを更新します。
現在は手動での作業となるため若干煩わしさはありますが、Dify Consoleを利用している以上はやむを得ない部分です。(理想的には、修正したDSLをmainブランチにマージしたタイミングで自動リリースしたいです。。。)
実際のインポート手順
対象のアプリを開いて、DSLファイルをインポートし、公開すればデプロイ完了です。
環境ごとのデプロイ管理
アプリ名は同じだが、バックエンドから呼び出す先を環境ごとに使い分けたい場合があります。このような時はタグ機能を活用します。
アプリごとにタグを設定できるため、タグを環境名として設定し、それぞれ手動でインポートすることで、同一アプリ名でも異なる環境を管理できます。
実際のデプロイフロー
弊社では、Dify Consoleへのデプロイまでを以下のフローで運用しています。
- ローカル環境で立ち上げたDifyを修正
- 修正したDSLをプルリクエストでレビュー
- mainブランチにマージ後、手動でDify Consoleへリリース
③ モノレポでのDify運用
弊社のプロダクトはモノレポで開発しており、その中にDifyも統合しています。モノレポ内でディレクトリを移動するだけで、そのままDifyを立ち上げられる構成にしています。
基本的なディレクトリ構成は以下のようになっています。
root/
frontend/
backend/
apps/
project_a/
dify/ ← Difyを使用しているアプリケーション配下に配置
Difyの最新バージョンへの更新
Dify Consoleを使用する際に重要なのは、Dify Console上で使用されているDifyのバージョンに、ローカル開発環境のバージョンを合わせることです。
モノレポ管理では、ソースコードをそのまま持ってくる必要があります(git pull
でも可能ですが、モノレポ自体のGit環境と競合する可能性があります)。これ自体は結構面倒なので、自動化するためにMakefileでスクリプトを作成しました。
やっていることは単純で、
- 一時ディレクトリを作成し、そこに最新のDifyをgit cloneする
- 必要なコード部分のみを抜き出して、既存のDifyディレクトリを更新する
- 最後に一時ディレクトリを削除する
のようになってます。
これでGit競合もしないし、すぐDifyの更新を終わらせることができます。
dify-upgrade: dify-stop ## Upgrade Dify from remote repository
@set -eu; \
echo ">> Cloning docker/ from https://github.com/langgenius/dify.git"; \
rm -rf ./dify/docker; \
rm -rf ./dify/dify_tmp; \
git clone --depth=1 https://github.com/langgenius/dify.git ./dify/dify_tmp; \
mv ./dify/dify_tmp/docker ./dify/docker; \
rm -rf ./dify/dify_tmp; \
echo ">> Done: ./dify/docker updated."
.PHONY: dify-upgrade
.gitignoreの設定
モノレポでは他のメンバーにも影響があるため、個人で使用するデータ関連のファイルは.gitignore
で除外しています。
具体的には、サーバーの設定ファイルやvolumeなどのデータ関連ファイルを無視する設定にしています。
docker/nginx/conf.d/default.conf
docker/volumes/app
docker/volumes/db
docker/volumes/redis
docker/volumes/weaviate
docker/volumes/plugin_daemon
まとめ
Difyは非常に多機能で面白いツールですが、まだ開発ノウハウがそれほど蓄積されておらず、手探りで進める部分が多いのが現状です。
筆者自身も、Difyの画像処理でつまずいた際にソースコードまで調査して解決した経験があります。
このような苦労を経験される方も多いと思い、本記事が少しでもお役に立てれば幸いです。
Difyコミュニティをみんなで盛り上げていきましょう!💪
Discussion