☝️

Dify開発のTips集: DSLのバージョン管理/デプロイフロー/モノレポ連携

に公開

概要

最近名前が変わったDify(ディフィ)を使用して、実際にサービス開発を行う際のTipsについてまとめました。
この記事では、以下の内容について知ることができます。

  1. Difyをセルフホスティングしない場合のバージョン管理手法
  2. Dify Consoleへのデプロイフロー
  3. モノレポ環境での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へのデプロイまでを以下のフローで運用しています。

  1. ローカル環境で立ち上げたDifyを修正
  2. 修正したDSLをプルリクエストでレビュー
  3. 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