Traefik Plugins 一文解析


Traefik v2.3 及以上版本允許開發人員使用 Plugins 插件向 Traefik 添加新功能或定義新行為。例如,可以修改請求或標頭、重定向、添加身份驗證等,提供與 Traefik 中間件類似的功能。 不過,和傳統中間件不同,插件是動態加載的,並由嵌入式解釋器執行,無需編譯二進制文件,所有插件都是 100% 跨平台的,這使得它們易於開發和共享(通過 Traefik Pilot)。

Traefik Pilot

Traefik Pilot 是一個 Traefik 的監控和管理平台,可以集中管理在任何環境中運行的所有 Traefik 實例。它通過統一的儀表板提供對 Traefik 實例的觀察性和控制,可提供詳細的網絡指標、服務器監控和安全通知。 Traefik Pilot 還為自定義中間件插件托管了一個公共插件中心(public plugins hub),支持流量整形、流量 QoS、流量速率限制等。

在使用 Plugins 之前,需要在 Traefik Pilot 平台(https://pilot.traefik.io/)上注冊一個賬號,這里我直接使用 Github 授權登陸: image.png

登陸成功后,我們需要注冊一個 Traefik Instance ,點擊 Register New Traefik Instance 按鈕會生成一個 token ,復制 token :

image.png
image.png

勾選 I have restarted my Traefik instance 保存該 Traefik Instance :

image.png 此時,顯示還未綁定我們的 Trarfik 實例。

綁定 Traefik Instance

創建 traefik 配置 traefik-config.yaml ,並填入上面得到的 token :

pilot:
  enabled: true
  token: "898bb869-77ad-4594-b68f-1f87e0aa2e9b"
  dashboard: true
ports:
  traefik:
    expose: true
  web:
    nodePort: 80
  websecure:
    nodePort: 443

使用 helm 安裝 :

helm upgrade --install traefik traefik/traefik -n traefik -f traefik-config.yaml

訪問面板,可以看到,Traefik Instance 已經綁定了我們的 traefik 實例: image.png

可以通過 Metrics 觀察指標:

image.png
image.png

Traefik Plugins 使用

以 plugindemo 插件為例,為請求頭部添加一個 whoami-header: hello worldimage.png 修改 traefik 配置 traefik-config.yaml ,開啟並安裝 plugindemo@v0.2.1 插件:

pilot:
  enabled: true
  token: "898bb869-77ad-4594-b68f-1f87e0aa2e9b"
  dashboard: true
additionalArguments:
  - "--experimental.plugins.plugindemo.modulename=github.com/traefik/plugindemo"
  - "--experimental.plugins.plugindemo.version=v0.2.1"
experimental:
  plugins:
    enabled: true
ports:
  traefik:
    expose: true
  web:
    nodePort: 80
  websecure:
    nodePort: 443

使用 helm 更新重啟 traefik :

helm upgrade --install traefik traefik/traefik -n traefik -f traefik-config.yaml

創建 whoami.yaml

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: whoamiplugin
spec:
  plugin:
    plugindemo:  # plugindemo 插件
      Headers:
        whoami-header: hello world
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: whoamiingressroute # 入口路由名稱
spec:
  entryPoints: # 網絡入口點
    - web
  routes:
  - match: Host(`master`) && PathPrefix(`/whoami/`) # 路由匹配器,匹配 http://master/whoami/
    middlewares: # 使用 plugindemo 插件
    - name: whoamiplugin
    kind: Rule
    services: # 代理服務
    - name: whoami
      port: 80
---
apiVersion: v1
kind: Service
metadata:
  name: whoami
spec:
  ports:
    - protocol: TCP
      name: web
      port: 80
  selector:
    app: whoami
---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: whoami
  labels:
    app: whoami
spec:
  replicas: 2
  selector:
    matchLabels:
      app: whoami
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
        - name: whoami
          image: containous/whoami
          ports:
            - name: web
              containerPort: 80

使用 kubectl 啟動 whoami :

kubectl apply -f whoami.yaml

查看插件使用效果: image.png

請求 header 已增加 whoami-header: hello worldimage.png

Traefik Plugins 源碼分析

源碼以 https://github.com/traefik/traefik/commit/ca2ff214c49a2aa1f8b590d4f2158f0ea734322b 版本為例。

traefik 會在 cmd/traefik/traefik.go 的 172 行 setupServer 函數進行初始化服務,其中構造插件實例也是在此函數內完成:

 // 構造插件實例
 pluginBuilder, err := createPluginBuilder(staticConfiguration)
 if err != nil {
  return nil, err
 }

深入 createPluginBuilder 函數:

func createPluginBuilder(staticConfiguration *static.Configuration) (*plugins.Builder, error) {
    // 初始化插件
 client, plgs, localPlgs, err := initPlugins(staticConfiguration)
 if err != nil {
  return nil, err
 }
 // 返回 *plugins.Builder ,實現了 PluginsBuilder 接口
 return plugins.NewBuilder(client, plgs, localPlgs)
}

initPlugins 過程中,會進行插件配置檢查,下載,解壓等一系列操作:

func initPlugins(staticCfg *static.Configuration) (*plugins.Client, map[string]plugins.Descriptor, map[string]plugins.LocalDescriptor, error) {
 err := checkUniquePluginNames(staticCfg.Experimental)
 if err != nil {
  return nilnilnil, err
 }

 var client *plugins.Client
 plgs := map[string]plugins.Descriptor{}

 // 是否啟用了 Traefik Pilot,並且配置了插件
 if isPilotEnabled(staticCfg) && hasPlugins(staticCfg) {
  opts := plugins.ClientOptions{
   Output: outputDir,
   Token:  staticCfg.Pilot.Token, // Pilot 的 token
  }

  var err error
  // 創建插件客戶端
  client, err = plugins.NewClient(opts)
  if err != nil {
   return nilnilnil, err
  }

  // 初始化所有插件
  err = plugins.SetupRemotePlugins(client, staticCfg.Experimental.Plugins)
  if err != nil {
   return nilnilnil, err
  }

  plgs = staticCfg.Experimental.Plugins
 }

 localPlgs := map[string]plugins.LocalDescriptor{}

 if hasLocalPlugins(staticCfg) {
  err := plugins.SetupLocalPlugins(staticCfg.Experimental.LocalPlugins)
  if err != nil {
   return nilnilnil, err
  }

  localPlgs = staticCfg.Experimental.LocalPlugins
 }

 return client, plgs, localPlgs, nil
}

其中下載插件的邏輯如下:

func SetupRemotePlugins(client *Client, plugins map[string]Descriptor) error {
 // 檢查插件配置
 err := checkRemotePluginsConfiguration(plugins)
 if err != nil {
  return fmt.Errorf("invalid configuration: %w", err)
 }
 // 清理舊插件
 err = client.CleanArchives(plugins)
 if err != nil {
  return fmt.Errorf("failed to clean archives: %w", err)
 }

 ctx := context.Background()

 // 依次下載所有插件
 for pAlias, desc := range plugins {
  log.FromContext(ctx).Debugf("loading of plugin: %s: %s@%s", pAlias, desc.ModuleName, desc.Version)

  // 開始下載插件
  hash, err := client.Download(ctx, desc.ModuleName, desc.Version)
  if err != nil {
   _ = client.ResetAll()
   return fmt.Errorf("failed to download plugin %s: %w", desc.ModuleName, err)
  }

  // hash 校驗
  err = client.Check(ctx, desc.ModuleName, desc.Version, hash)
  if err != nil {
   _ = client.ResetAll()
   return fmt.Errorf("failed to check archive integrity of the plugin %s: %w", desc.ModuleName, err)
  }
 }

 err = client.WriteState(plugins)
 if err != nil {
  _ = client.ResetAll()
  return fmt.Errorf("failed to write plugins state: %w", err)
 }

 // 解壓所有下載成功的插件
 for _, desc := range plugins {
  err = client.Unzip(desc.ModuleName, desc.Version)
  if err != nil {
   _ = client.ResetAll()
   return fmt.Errorf("failed to unzip archive: %w", err)
  }
 }

 return nil
}

分析 client.Download() 的關鍵源碼,可以知道,traefik 的插件下載 url 格式為 https://plugin.pilot.traefik.io/public/download/github.com/traefik/plugindemo/v0.2.1

const pilotURL = "https://plugin.pilot.traefik.io/public/"
...
 // 組合 url , pilotURL/download/插件名/版本號
 endpoint, err := c.baseURL.Parse(path.Join(c.baseURL.Path, "download", pName, pVersion))
 if err != nil {
  return "", fmt.Errorf("failed to parse endpoint URL: %w", err)
 }

 req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
 if err != nil {
  return "", fmt.Errorf("failed to create request: %w", err)
 }

 if hash != "" {
  req.Header.Set(hashHeader, hash)
 }

 if c.token != "" {
  req.Header.Set(tokenHeader, c.token)
 }

 resp, err := c.HTTPClient.Do(req)
...

可以使用 curl 工具驗證:

curl -H 'X-Token:898bb869-77ad-4594-b68f-1f87e0aa2e9b' -O https://plugin.pilot.traefik.io/public/download/github.com/traefik/plugindemo/v0.2.1

traefik 會將插件下載到 /plugins-storage 目錄。

插件下載解壓完成后,會使用 plugins.NewBuilder(client, plgs, localPlgs) 將插件源碼讀取加載到 *plugins.Builder 實例,這里用到一個十分強大的 go 解釋器庫 yaegi ,同樣出自 traefik 之手,地址在 https://github.com/traefik/yaegi ,使用起來也很簡單,只要使用 new()創建解釋器,后續使用 Eval()就可以運行代碼了。

traefik 插件分為 provider 和 middleware 兩種,故 *plugins.Builder 實例也提供了 middlewareDescriptors 和 providerDescriptors 兩種 map 類型來存放插件:

// NewBuilder creates a new Builder.
func NewBuilder(client *Client, plugins map[string]Descriptor, localPlugins map[string]LocalDescriptor) (*Builder, error) {
 pb := &Builder{
        middlewareDescriptors: map[string]pluginContext{}, // 中間件類型插件包名:插件實例
        providerDescriptors:   map[string]pluginContext{}, // 提供者類型插件包名:插件實例
 }

 for pName, desc := range plugins {
  manifest, err := client.ReadManifest(desc.ModuleName)
  if err != nil {
   _ = client.ResetAll()
   return nil, fmt.Errorf("%s: failed to read manifest: %w", desc.ModuleName, err)
  }

  // 創建 go 解釋器
  i := interp.New(interp.Options{GoPath: client.GoPath()})

  err = i.Use(stdlib.Symbols)
  if err != nil {
   return nil, fmt.Errorf("%s: failed to load symbols: %w", desc.ModuleName, err)
  }

  err = i.Use(ppSymbols())
  if err != nil {
   return nil, fmt.Errorf("%s: failed to load provider symbols: %w", desc.ModuleName, err)
  }

  // 導入包
  _, err = i.Eval(fmt.Sprintf(`import "%s"`, manifest.Import))
  if err != nil {
   return nil, fmt.Errorf("%s: failed to import plugin code %q: %w", desc.ModuleName, manifest.Import, err)
  }

  switch manifest.Type {
  case "middleware":
   // 將 middleware 類型插件放置到這里
   pb.middlewareDescriptors[pName] = pluginContext{
    interpreter: i, // 解釋器實例
    GoPath:      client.GoPath(),
    Import:      manifest.Import,
    BasePkg:     manifest.BasePkg,
   }
  case "provider":
   // 將 provider 類型插件放置到這里
   pb.providerDescriptors[pName] = pluginContext{
    interpreter: i, // 解釋器實例
    GoPath:      client.GoPath(),
    Import:      manifest.Import,
    BasePkg:     manifest.BasePkg,
   }
  default:
   return nil, fmt.Errorf("unknow plugin type: %s", manifest.Type)
  }
 }
    ......

    // 返回 *plugins.Builder 實例
 return pb, nil
}

初始化構造插件完成后回到 cmd/traefik/traefik.gosetupServer 函數中,會進行插件的動態加載過程,首先是 Provider 類型的插件:

 // Providers plugins

 // 遍歷 Provider 類型的插件
 for name, conf := range staticConfiguration.Providers.Plugin {
        // 實例化插件
  p, err := pluginBuilder.BuildProvider(name, conf)
  if err != nil {
   return nil, fmt.Errorf("plugin: failed to build provider: %w", err)
  }

  err = providerAggregator.AddProvider(p)
  if err != nil {
   return nil, fmt.Errorf("plugin: failed to add provider: %w", err)
  }
 }

traefik 插件規定必須實現 CreateConfig 和 New 函數,而 pluginBuilder.BuildProvider 就是使用解釋器執行插件的 CreateConfig 函數,然后使用 wrapper.NewWrapper 調用插件的 New 函數:

 // 使用之前保存的解釋器去調用插件的 CreateConfig 函數
 vConfig, err := descriptor.interpreter.Eval(basePkg + `.CreateConfig()`)
 if err != nil {
  return nil, fmt.Errorf("failed to eval CreateConfig: %w", err)
 }
 ......
 _, err = descriptor.interpreter.Eval(`package wrapper

import (
 "context"

 `
 + basePkg + ` "` + descriptor.Import + `"
 "github.com/traefik/traefik/v2/pkg/plugins"
)

func NewWrapper(ctx context.Context, config *`
 + basePkg + `.Config, name string) (plugins.PP, error) {
 p, err := `
 + basePkg + `.New(ctx, config, name)
 var pv plugins.PP = p
 return pv, err
}
`
)
 if err != nil {
  return nil, fmt.Errorf("failed to eval wrapper: %w", err)
 }

 fnNew, err := descriptor.interpreter.Eval("wrapper.NewWrapper")
 if err != nil {
  return nil, fmt.Errorf("failed to eval New: %w", err)
 }
...

Provider 加載完成后,traefik 就會開始監聽路由,若路由配置了中間件插件,traefik 就會同理去加載對應的 middleware 類型插件:

 // Plugin
 if config.Plugin != nil {
  if middleware != nil {
   return nil, badConf
  }

  pluginType, rawPluginConfig, err := findPluginConfig(config.Plugin)
  if err != nil {
   return nil, fmt.Errorf("plugin: %w", err)
  }

        // 執行中間件類型插件
  plug, err := b.pluginBuilder.Build(pluginType, rawPluginConfig, middlewareName)
  if err != nil {
   return nil, fmt.Errorf("plugin: %w", err)
  }

  middleware = func(next http.Handler) (http.Handler, error) {
   return plug(ctx, next)
  }
 }

關鍵地方在 pkg/plugins/middlewares.go 的 40 行,同樣是使用解釋器執行插件的 CreateConfig 和 New 函數:

func newMiddleware(descriptor pluginContext, config map[string]interface{}, middlewareName string) (*Middleware, error) {
 basePkg := descriptor.BasePkg
 if basePkg == "" {
  basePkg = strings.ReplaceAll(path.Base(descriptor.Import), "-""_")
 }

    // 使用之前保存的解釋器去調用插件的 CreateConfig 函數
 vConfig, err := descriptor.interpreter.Eval(basePkg + `.CreateConfig()`)
 if err != nil {
  return nil, fmt.Errorf("failed to eval CreateConfig: %w", err)
 }

 cfg := &mapstructure.DecoderConfig{
  DecodeHook:       mapstructure.StringToSliceHookFunc(","),
  WeaklyTypedInput: true,
  Result:           vConfig.Interface(),
 }

 decoder, err := mapstructure.NewDecoder(cfg)
 if err != nil {
  return nil, fmt.Errorf("failed to create configuration decoder: %w", err)
 }

 err = decoder.Decode(config)
 if err != nil {
  return nil, fmt.Errorf("failed to decode configuration: %w", err)
 }

    // 使用之前保存的解釋器去調用插件的 New 函數
 fnNew, err := descriptor.interpreter.Eval(basePkg + `.New`)
 if err != nil {
  return nil, fmt.Errorf("failed to eval New: %w", err)
 }

 return &Middleware{
  middlewareName: middlewareName,
  fnNew:          fnNew,
  config:         vConfig,
 }, nil
}

插件機制架構圖:

traefik插件架構.jpg
traefik插件架構.jpg

Traefik Plugins 開發

上文分析 traefik 的插件實現源碼已經知道,traefik 的插件是靠 Yaegi 解釋器動態加載實現的,所以開發 traefik 插件變得很簡單,和開發 web 瀏覽器擴展一樣。

traefik 的插件托管在 GitHub ,這里以 https://github.com/togettoyou/traefik-timer-plugin 為例。

GitHub 倉庫需要按照規范,有 readme.md ,需設置一個名稱為 traefik-plugin 的 topic ,根目錄下需要創建一個 .traefik.yml 配置文件:

# 在 Traefik Pilot Web UI 中顯示的插件的名稱
displayName: Timer Plugin

# 插件類型,目前版本只支持 middleware
type: middleware

# 插件導入路徑
import: github.com/togettoyou/traefik-timer-plugin

# 插件簡介
summary: 用於請求響應計時

# 配置數據
testData:
  log: true

接下來就是開發代碼了,traefik 也為插件代碼提供了規范,需要包含以下對象:

  • type Config struct { ... } 結構體,字段任意。
  • func CreateConfig() *Config 函數。
  • func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) 函數。

代碼示例:

package traefik_timer_plugin

import (
 "context"
 "fmt"
 "net/http"
 "time"
)

// Config 自定義配置
type Config struct {
 Log bool `json:"log,omitempty"`
}

// CreateConfig 提供給 traefik 設置配置
func CreateConfig() *Config {
 return &Config{}
}

// New 提供給 traefik 創建 Timer 插件
func New(_ context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
 return &Timer{
  next: next,
  name: name,
  log:  config.Log,
 }, nil
}

type Timer struct {
 next http.Handler
 name string
 log  bool
}

func (t *Timer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
 start := time.Now()
 t.next.ServeHTTP(rw, req)
 cost := time.Since(start)
 if t.log {
  fmt.Println("請求花費時間:", cost)
 }
}

最后的最后,為倉庫打一個版本標簽如 v0.1.0 即可發布插件。

image.png
image.png

插件驗證,修改 traefik 配置 traefik-config.yaml ,安裝 traefik-timer-plugin@v0.1.0 插件:

pilot:
  enabled: true
  token: "898bb869-77ad-4594-b68f-1f87e0aa2e9b"
  dashboard: true
additionalArguments:
  - "--experimental.plugins.traefik_timer_plugin.modulename=github.com/togettoyou/traefik-timer-plugin"
  - "--experimental.plugins.traefik_timer_plugin.version=v0.1.0"
experimental:
  plugins:
    enabled: true
ports:
  traefik:
    expose: true
  web:
    nodePort: 80
  websecure:
    nodePort: 443

使用 helm 更新重啟 traefik :

helm upgrade --install traefik traefik/traefik -n traefik -f traefik-config.yaml

更改 whoami.yaml

apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: timerplugin
spec:
  plugin:
    traefik_timer_plugin:  # 自定義的計時插件
      log: true
---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: whoamiingressroute # 入口路由名稱
spec:
  entryPoints: # 網絡入口點
    - web
  routes:
  - match: Host(`master`) && PathPrefix(`/whoami/`) # 路由匹配器,匹配 http://master/whoami/
    middlewares: # 使用 timerplugin 插件
    - name: timerplugin
    kind: Rule
    services: # 代理服務
    - name: whoami
      port: 80
---
apiVersion: v1
kind: Service
metadata:
  name: whoami
spec:
  ports:
    - protocol: TCP
      name: web
      port: 80
  selector:
    app: whoami
---
kind: Deployment
apiVersion: apps/v1
metadata:
  name: whoami
  labels:
    app: whoami
spec:
  replicas: 2
  selector:
    matchLabels:
      app: whoami
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
        - name: whoami
          image: containous/whoami
          ports:
            - name: web
              containerPort: 80

重啟 whoami :

kubectl apply -f whoami.yaml

訪問路由並查看 traefik 日志:

kubectl logs -f  traefik-5d87cd6b75-6pnrn -n traefik

image.png 當然,這里只是作為示例,traefik 的插件機制開發必然可以為我們提供更強大的功能。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM