Quantcast
Channel: 小惡魔 – 電腦技術 – 工作筆記 – AppleBOY
Viewing all 325 articles
Browse latest View live

為什麼 signal.Notify 要使用 buffered channel

$
0
0

golang logo

如果不了解什麼是 buffer 或 unbuffer channel 的朋友們,可以參考這篇文章先做初步了解,本文要跟大家介紹為什麼 signal.Notify 要使用 buffered channel 才可以,底下先來看看如何使用 signal.Notify,當我們要做 graceful shutdown 都會使用到這功能,想要正常關閉服務或連線,透過 signal 可以偵測訊號來源,執行後續相關工作 (關閉 DB 連線,檢查 Job 是否結束 … 等)。

package main

import (
    "fmt"
    "os"
    "os/signal"
)

func main() {
    // Set up channel on which to send signal notifications.
    // We must use a buffered channel or risk missing the signal
    // if we're not ready to receive when the signal is sent.
    c := make(chan os.Signal, 1)
    signal.Notify(c, os.Interrupt)

    // Block until a signal is received.
    s := <-c
    fmt.Println("Got signal:", s)
}

上面例子可以很清楚看到說明,假如沒有使用 buffered channel 的話,你有一定的風險會沒抓到 Signal。那為什麼會有這段說明呢?底下用其他例子來看看。

使用 unbuffered channel

將程式碼改成如下:

package main

import (
    "fmt"
    "os"
    "os/signal"
)

func main() {
    c := make(chan os.Signal)
    signal.Notify(c, os.Interrupt)

    // Block until a signal is received.
    s := <-c
    fmt.Println("Got signal:", s)
}

執行上述程式碼,然後按下 ctrl + c,沒意外你會看到 Got signal: interrupt,那接著我們在接受 channle 前面有處理一些很複雜的工作,先用 time.Sleep 來測試

package main

import (
    "fmt"
    "os"
    "os/signal"
)

func main() {
    c := make(chan os.Signal)
    signal.Notify(c, os.Interrupt)

    time.Sleep(5 * time.Second)

    // Block until a signal is received.
    s := <-c
    fmt.Println("Got signal:", s)
}

一樣執行程式,然後按下 ctrl + c,你會發現在這五秒內,怎麼按都不會停止,等到五秒後,整個程式也不會終止,需要再按下一次 ctrl + c,這時候程式才會終止,我們預期的是,在前五秒內,按下任何一次 ctrl + c,理論上五秒後要能正常接受到第一次傳來的訊號,底下來看看原因

形成原因

我們打開 Golang 的 singal.go 檔案,找到 process func,可以看到部分程式碼

    for c, h := range handlers.m {
        if h.want(n) {
            // send but do not block for it
            select {
            case c <- sig:
            default:
            }
        }
    }

可以看到上述程式碼,如果使用 unbuffered channel,那麼在五秒內接收到的任何訊號,都會跑到 default 條件內,所以造成 Channel 不會收到任何值,這也就是為什麼五秒內的任何動作,在五秒後都完全收不到。為了避免這件事情,所以通常我們會將 signal channel 設定為 buffer 1,來避免需要中斷程式時,確保主程式可以收到一個 signal 訊號。


初探 Open Policy Agent 實作 RBAC (Role-based access control) 權限控管

$
0
0

Open Policy Agent

最近公司內部多個專案都需要用到 RBAC (Role-based access control) 權限控管,所以決定來找尋 Go 語言的解決方案及套件,在 Go 語言比較常聽到的就是 Casbin,大家眾所皆知,但是隨著專案變大,系統複雜性更高,希望未來可以打造一套可擴充性的權限機制,故網路上看到一篇 ladon vs casbin 的介紹文章,文章留言有中國開發者對於 Casbin 的一些看法,以及最後他推薦另一套 CNCF 的專案叫 Open Policy Agent 來實作權限控管機制。本篇直接來針對 Open Policy Agent 簡稱 (OPA) 來做介紹,並且用 Go 語言來驗證 RBAC 權限。底下是文章內其他開發者用過 Casbin 的感想

1.使用覺得ladon的質量更好,支持類ACL和RBAC的權限系統,跟亞馬遜AWS的IAM非常契合 2.casbin那些庫的質量真的是無力吐槽,都沒有經常測試的東西就往github發,UI也到處bug,全都是畢業生寫的一樣,試用便知 3.casbin這個項目不讓提問題,提問題就給你關閉,作者很涉別人提問題 4.這些確實是本人的經歷,大家慎重選擇吧

最後的推薦

強烈推薦CNCF今年畢業的策略引擎OPA(維護團隊主要是Google,微軟,Styra等),可以實現ABAC,RBAC,PBAC等各種權限模型,目前我們已經在生產環境中使用。 也是基於OPA實現的。

本篇所使用的範例程式碼請從這邊下載或觀看

線上影片

如果需要搭配購買請直接透過 FB 聯絡我,直接匯款(價格再減 100

什麼是 Open Policy Agent

Open Policy Agent (念 "oh-pa") 是一套開源專案,用來讓開發者制定各種不同的 Policy 機制,並且創造了 OPA’s policy 語言 (Rego) 來協助開發者快速撰寫各種不同的 Policy 政策,並且可以透過 Command (opa) 來驗證及測試。透過 OPA 可以制定像是微服務或 CI/CD Pipeline 等之間溝通的政策,來達到權限的分離。底下用一張官網的圖來介紹

open policy agent infra

簡單來說各個服務之間有不同的權限需要處理,這時透過 OPA 專門做授權管理的服務會是最好的,整個流程就會如下:

  1. 服務定義好 Query 格式 (任意的 JSON 格式)
  2. 撰寫所有授權政策 (Rego)
  3. 準備在授權過程需要用到的資料 (Data JSON)
  4. OPA 執行決定,並回傳服務所需的資料 (任意的 JSON 格式)

撰寫 RBAC 政策及驗證

OPA 官網已經提供完整的範例給各位開發者參考,也有完整的 Rego 文件格式,我們先定義 User 跟 Role 權限關係,接著定義 Role 可以執行哪些操作

package rbac.authz

# user-role assignments
user_roles := {
  "design_group_kpi_editor": ["kpi_editor_design", "viewer_limit_ds"],
  "system_group_kpi_editor": ["kpi_editor_system", "viewer_limit_ds"],
  "manufacture_group_kpi_editor": ["kpi_editor_manufacture", "viewer"],
  "project_leader": ["viewer_limit_ds", "viewer_limit_m"]
}

# role-permissions assignments
role_permissions := {
  "admin": [
    {"action": "view_all",  "object": "design"},
    {"action": "edit",  "object": "design"},
    {"action": "view_all",  "object": "system"},
    {"action": "edit",  "object": "system"},
    {"action": "view_all",  "object": "manufacture"},
    {"action": "edit",  "object": "manufacture"},
  ],
  "quality_head_design":[
    {"action": "view_all",  "object": "design"},
    {"action": "edit",  "object": "design"},
    {"action": "view_all",  "object": "system"},
    {"action": "view_all",  "object": "manufacture"},
  ],
  "quality_head_system":[
    {"action": "view_all",  "object": "design"},
    {"action": "view_all",  "object": "system"},
    {"action": "edit",  "object": "system"},
    {"action": "view_all",  "object": "manufacture"},
  ],
  "quality_head_manufacture":[
    {"action": "view_all",  "object": "design"},
    {"action": "view_all",  "object": "system"},
    {"action": "view_all",  "object": "manufacture"},
    {"action": "edit",  "object": "manufacture"},
  ],

  "kpi_editor_design":[
    {"action": "view_all",  "object": "design"},
    {"action": "edit",  "object": "design"},
  ],
  "kpi_editor_system":[
    {"action": "view_all",  "object": "system"},
    {"action": "edit",  "object": "system"},
  ],
  "kpi_editor_manufacture":[
    {"action": "view_all",  "object": "manufacture"},
    {"action": "edit",  "object": "manufacture"},
  ],

  "viewer":[
    {"action": "view_all",  "object": "design"},
    {"action": "view_all",  "object": "system"},
    {"action": "view_all",  "object": "manufacture"},
  ],

  "viewer_limit_ds":[
    {"action": "view_all",  "object": "design"},
    {"action": "view_all",  "object": "system"},
  ],

  "viewer_limit_m":[
    {"action": "view_l3_project",  "object": "manufacture"},
  ],
}

資料準備完成後,接著就是寫政策

# logic that implements RBAC.
default allow = false
allow {
  # lookup the list of roles for the user
  roles := user_roles[input.user[_]]
  # for each role in that list
  r := roles[_]
  # lookup the permissions list for role r
  permissions := role_permissions[r]
  # for each permission
  p := permissions[_]
  # check if the permission granted to r matches the user's request
  p == {"action": input.action, "object": input.object}
}

大家可以看到其中 input 就是上面第一點 Query 條件,可以是任意的 JSON 格式,接著在 allow 裡面開始處理整個政策流程,第一就是拿到 User 是屬於哪些角色,第二就是找到這些角色相對應得權限,最後就是拿 Query 的條件進行比對,最後可以輸出結果 truefalse。寫完上面 Rego 檔案後,開發者可以下 OPA 執行檔,並且撰寫測試文件,進行驗證,跟 Go 語言一樣,直接檔名加上 _test 即可

test_design_group_kpi_editor {
  allow with input as {"user": ["design_group_kpi_editor"], "action": "view_all", "object": "design"}
  allow with input as {"user": ["design_group_kpi_editor"], "action": "edit", "object": "design"}
  allow with input as {"user": ["design_group_kpi_editor"], "action": "view_all", "object": "system"}
  not allow with input as {"user": ["design_group_kpi_editor"], "action": "edit", "object": "system"}
  not allow with input as {"user": ["design_group_kpi_editor"], "action": "view_all", "object": "manufacture"}
  not allow with input as {"user": ["design_group_kpi_editor"], "action": "edit", "object": "manufacture"}
}

像是這樣的格式,接著用 OPA Command 執行測試

$ opa test -v *.rego
data.rbac.authz.test_design_group_kpi_editor: PASS (8.604833ms)
data.rbac.authz.test_system_group_kpi_editor: PASS (7.260166ms)
data.rbac.authz.test_manufacture_group_kpi_editor: PASS (2.217125ms)
data.rbac.authz.test_project_leader: PASS (1.823833ms)
data.rbac.authz.test_design_group_kpi_editor_and_system_group_kpi_editor: PASS (1.150791ms)
--------------------------------------------------------------------------------
PASS: 5/5

整合 Go 語言驗證及測試

上面是透過 OPA 官方的 Command 驗證 Policy 是否正確,接著我們可以整合 Go 語言進行驗證。通常會架設一台 OPA 服務,用來處理授權機制,那現在直接把 Policy 寫進去 Go 執行檔,減少驗證的 Latency。

package main

import (
    "context"
    "log"

    "github.com/open-policy-agent/opa/rego"
)

var policyFile = "example.rego"
var defaultQuery = "x = data.rbac.authz.allow"

type input struct {
    User   string `json:"user"`
    Action string `json:"action"`
    Object string `json:"object"`
}

func main() {
    s := input{
        User:   "design_group_kpi_editor",
        Action: "view_all",
        Object: "design",
    }

    input := map[string]interface{}{
        "user":   []string{s.User},
        "action": s.Action,
        "object": s.Object,
    }

    policy, err := readPolicy(policyFile)
    if err != nil {
        log.Fatal(err)
    }

    ctx := context.TODO()
    query, err := rego.New(
        rego.Query(defaultQuery),
        rego.Module(policyFile, string(policy)),
    ).PrepareForEval(ctx)

    if err != nil {
        log.Fatalf("initial rego error: %v", err)
    }

    ok, _ := result(ctx, query, input)
    log.Println("", ok)
}

func result(ctx context.Context, query rego.PreparedEvalQuery, input map[string]interface{}) (bool, error) {
    results, err := query.Eval(ctx, rego.EvalInput(input))
    if err != nil {
        log.Fatalf("evaluation error: %v", err)
    } else if len(results) == 0 {
        log.Fatal("undefined result", err)
        // Handle undefined result.
    } else if result, ok := results[0].Bindings["x"].(bool); !ok {
        log.Fatalf("unexpected result type: %v", result)
    }

    return results[0].Bindings["x"].(bool), nil
}

其中 readPolicy 可以直接用 go1.16 推出的 embed 套件,將 rego 檔案直接整合進 go binary。

// +build go1.16

package main

import (
    _ "embed"
)

//go:embed example.rego
var policy []byte

func readPolicy(path string) ([]byte, error) {
    return policy, nil
}

撰寫測試,直接在 Go 語言進行測試及資料讀取,以便驗證更多細項功能

package main

import (
    "context"
    "log"
    "os"
    "testing"

    "github.com/open-policy-agent/opa/rego"
)

var query rego.PreparedEvalQuery

func setup() {
    var err error
    policy, err := readPolicy(policyFile)
    if err != nil {
        log.Fatal(err)
    }

    query, err = rego.New(
        rego.Query(defaultQuery),
        rego.Module(policyFile, string(policy)),
    ).PrepareForEval(context.TODO())

    if err != nil {
        log.Fatalf("initial rego error: %v", err)
    }
}

func TestMain(m *testing.M) {
    setup()
    code := m.Run()
    os.Exit(code)
}

func Test_result(t *testing.T) {
    ctx := context.TODO()
    type args struct {
        ctx   context.Context
        query rego.PreparedEvalQuery
        input map[string]interface{}
    }
    tests := []struct {
        name    string
        args    args
        want    bool
        wantErr bool
    }{
        {
            name: "test_design_group_kpi_editor_edit_design",
            args: args{
                ctx:   ctx,
                query: query,
                input: map[string]interface{}{
                    "user":   []string{"design_group_kpi_editor"},
                    "action": "edit",
                    "object": "design",
                },
            },
            want:    true,
            wantErr: false,
        },
        {
            name: "test_design_group_kpi_editor_edit_system",
            args: args{
                ctx:   ctx,
                query: query,
                input: map[string]interface{}{
                    "user":   []string{"design_group_kpi_editor"},
                    "action": "edit",
                    "object": "system",
                },
            },
            want:    false,
            wantErr: false,
        },
        {
            name: "test_design_group_kpi_editor_and_system_group_kpi_editor_for_edit_design",
            args: args{
                ctx:   ctx,
                query: query,
                input: map[string]interface{}{
                    "user":   []string{"design_group_kpi_editor", "system_group_kpi_editor"},
                    "action": "edit",
                    "object": "design",
                },
            },
            want:    true,
            wantErr: false,
        },
        {
            name: "test_design_group_kpi_editor_and_system_group_kpi_editor_for_edit_system",
            args: args{
                ctx:   ctx,
                query: query,
                input: map[string]interface{}{
                    "user":   []string{"design_group_kpi_editor", "system_group_kpi_editor"},
                    "action": "edit",
                    "object": "system",
                },
            },
            want:    true,
            wantErr: false,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := result(tt.args.ctx, tt.args.query, tt.args.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("result() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if got != tt.want {
                t.Errorf("result() = %v, want %v", got, tt.want)
            }
        })
    }
}

心得

由於網路上教學文件也不多,故自己先寫一篇紀錄基本操作,未來會有更多跟 Go 整合的實際案例, 屆時會再分享給大家。OPA 除了 RBAC 之外,還有更多功能可以在官網上面查詢,個人覺得整合起來應該會相當方便,各種情境幾乎都有考慮到,不單單只有一些特定情境可以使用,至於怎麼擴充到更多情境,就是靠 Rego 撰寫 Policy 語法,並撰寫驗證及測試。本篇所使用的範例程式碼請從這邊下載或觀看

使用 RESTful API 串接 Open Policy Agent

$
0
0

Open Policy Agent

上一篇『初探 Open Policy Agent 實作 RBAC (Role-based access control) 權限控管』介紹了如何透過 Go 語言直接坎入 Open Policy Agent (簡稱 OPA)設定檔,並透過 Go 套件直接查詢使用者權限。由於目前 OPA 只有支援三種模式串接各種不同的 Application,一種是透過 Go 語言直接整合,詳細請看上一篇教學,另一種是透過 RESTful API,也就是本篇的教學,最後一種是透過 WebAssembly 讓其他 application 可以直接讀取。之後有機會再來寫 WebAssembly 教學。而本篇將帶您了解如何透過 RESTful API 方式來完成 RBAC 權限控管,其實我比較期待支援 gRPC 模式,但是看到這篇 issue 提到,OPA 現在已經支援 Plugin 模式,大家想擴充的,可以自行處理。

請求流程

blog_01

可以看到上述圖片看到整個 OPA 系統流程,OPA 就是確保各種 API Request 的權限,首先第一步驟會帶著 Auuthorization Token 去跟單一個服務詢問,接著此服務就會將資料帶到 OPA 進行查詢此 請求是否有權限可以存取,最後 OPA 會回傳結果,接著再由服務端決定要回送哪些訊息。那本篇的重點會在下圖部分

blog_02

準備查詢資料

底下是 OPA 的系統流程圖

blog_03

我們可以很清楚知道,要拿到最後的 Query Result (JSON) 需要有三個 Input 的,分別是

  1. Query Input (JSON)
  2. Data (JSON)
  3. Policy (Rego)

就以 RBAC 為範例,第一個 Query Input 的資料,就需要帶入像是使用者所在的群組 (user),使用者現在要執行的動作 (action),使用者要對什麼資源做事情 (object)。這三個資料轉成 JSON 格式如下:

{
  "input": {
    "user": ["design_group_kpi_editor", "system_group_kpi_editor"],
    "action": "edit",
    "object": "design"
  }
}

第二項就是準備系統內建的 Data 資料給 OPA,上述資料可以看到 user 所在的群組資訊,但是這些群組能做哪些事情,是 OPA 沒辦法知道的,所以需要將這些資料整理成 JSON 格式,並且上傳到 OPA 系統內

{
  "group_roles": {
    "admin": ["admin"],
    "quality_head_design": ["quality_head_design"],
    "quality_head_system": ["quality_head_system"],
    "quality_head_manufacture": ["quality_head_manufacture"],
    "kpi_editor_design": ["kpi_editor_design"],
    "kpi_editor_system": ["kpi_editor_system"],
    "kpi_editor_manufacture": ["kpi_editor_manufacture"],
    "viewer": ["viewer"],
    "viewer_limit_ds": ["viewer_limit_ds"],
    "viewer_limit_m": ["viewer_limit_m"],
    "design_group_kpi_editor": ["kpi_editor_design", "viewer_limit_ds"],
    "system_group_kpi_editor": ["kpi_editor_system", "viewer_limit_ds"],
    "manufacture_group_kpi_editor": ["kpi_editor_manufacture", "viewer"],
    "project_leader": ["viewer_limit_ds", "viewer_limit_m"]
  },
  "role_permissions": {
    "admin": [
      {"action": "view_all", "object": "design"},
      {"action": "edit", "object": "design"},
      {"action": "view_all", "object": "system"},
      {"action": "edit", "object": "system"},
      {"action": "view_all", "object": "manufacture"},
      {"action": "edit", "object": "manufacture"}
    ],
    "quality_head_design": [
      {"action": "view_all", "object": "design"},
      {"action": "edit", "object": "design"},
      {"action": "view_all", "object": "system"},
      {"action": "view_all", "object": "manufacture"}
    ],
    "quality_head_system": [
      {"action": "view_all", "object": "design"},
      {"action": "view_all", "object": "system"},
      {"action": "edit", "object": "system"},
      {"action": "view_all", "object": "manufacture"}
    ],
    "quality_head_manufacture": [
      {"action": "view_all", "object": "design"},
      {"action": "view_all", "object": "system"},
      {"action": "view_all", "object": "manufacture"},
      {"action": "edit", "object": "manufacture"}
    ],
    "kpi_editor_design": [
      {"action": "view_all", "object": "design"},
      {"action": "edit", "object": "design"}
    ],
    "kpi_editor_system": [
      {"action": "view_all", "object": "system"},
      {"action": "edit", "object": "system"}
    ],
    "kpi_editor_manufacture": [
      {"action": "view_all", "object": "manufacture"},
      {"action": "edit", "object": "manufacture"}
    ],
    "viewer": [
      {"action": "view_all", "object": "design"},
      {"action": "view_all", "object": "system"},
      {"action": "view_all", "object": "manufacture"}
    ],
    "viewer_limit_ds": [
      {"action": "view_all", "object": "design"},
      {"action": "view_all", "object": "system"}
    ],
    "viewer_limit_m": [{"action": "view_l3_project", "object": "manufacture"}]
  }
}

上述 Data 可以知道 Group 跟 Role 的對應關係,以及 Role 可以做得相對應事情。最後一項就是撰寫 OPA Policy,要透過 Rego 語言來撰寫,其實沒有很難。

package rbac.authz

import data.rbac.authz.acl
import input

# logic that implements RBAC.
default allow = false

allow {
    # lookup the list of roles for the user
    roles := acl.group_roles[input.user[_]]

    # for each role in that list
    r := roles[_]

    # lookup the permissions list for role r
    permissions := acl.role_permissions[r]

    # for each permission
    p := permissions[_]

    # check if the permission granted to r matches the user's request
    p == {"action": input.action, "object": input.object}
}

上面就是先把 User 對應的 Group Role 找到之後,再將全部的 Role 權限拿出來進行最後的比對產生結果,回傳值就會是 allow 布林值。

RESTful API

上述步驟將檔案整理成底下三個

  1. input.json
  2. data.json
  3. rbac.authz.rego

透過底下指令依序將資料上傳到 OPA Server 內,第一個先上傳 data

curl -X PUT http://localhost:8181/v1/data/rbac/authz/acl \
  --data-binary @data.json

接著上傳 Policy

curl -X PUT http://localhost:8181/v1/policies/rbac.authz \
  --data-binary @rbac.authz.rego

最後驗證 input 資料

curl -X POST http://localhost:8181/v1/data/rbac/authz/allow \
  --data-binary @input.json

bat tool 驗證

$ bat POST http://localhost:8181/v1/data/rbac/authz/allow < input.json
POST /v1/data/rbac/authz/allow HTTP/1.1
Host: localhost:8181
Accept: application/json
Accept-Encoding: gzip, deflate
Content-Type: application/json
User-Agent: bat/0.1.0

{"input":{"action":"edit","object":"design","user":["design_group_kpi_editor","system_group_kpi_editor"]}}

HTTP/1.1 200 OK
Content-Type: application/json
Date: Sat, 01 May 2021 08:43:30 GMT
Content-Length: 15

{
  "result": true
}

或者可以透過簡單的 Go 語言來驗證,用 Go 1.16 新的 embed package 來驗證。

package main

import (
    "bytes"
    _ "embed"
    "fmt"
    "io/ioutil"
    "net/http"
    "time"
)

//go:embed input.json
var input []byte

func main() {
    url := "http://localhost:8181/v1/data/rbac/authz/allow"
    method := "POST"

    payload := bytes.NewReader(input)

    client := &http.Client{
        Timeout: 5 * time.Second,
    }
    req, err := http.NewRequest(method, url, payload)

    if err != nil {
        fmt.Println(err)
        return
    }
    req.Header.Add("Content-Type", "application/json")

    res, err := client.Do(req)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer res.Body.Close()

    body, err := ioutil.ReadAll(res.Body)
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(string(body))
}

結論

本篇透過 RESTful 方式來更新 Data 及 Policy,此方式相對於前篇直接坎入在 Go 語言內,效率上來說會比較差些,因為會牽扯到你把 OPA 部署在哪個環境內,中間肯定會有一些 Latency,不過這就要看團隊怎麼使用 OPA 了,各有優缺點,團隊如果不是在寫 Go 語言的,肯定只能用 RESTful 方式來更新及查詢。程式碼可以在這邊找到

MongoDB 效能調校紀錄

$
0
0

mongodb

最近剛好在實作 Prometheus + Grafana 的時候,對 MongoDB 做了容器 CPU 使用率 (container_cpu_usage_seconds_total) 的監控,Metrics 寫法如下:

sum(
    rate(container_cpu_usage_seconds_total{name!~"(^$|^0_.*)"}[1m]))
by (name)

從上面的 Metrics 可以拉長時間來看,會發現專案的 MongoDB 非常不穩定,起起伏伏,這時候就需要來看看資料庫到底哪邊慢,以及看看哪個語法造成 CPU 飆高?

接著為了看 MongoDB 的 Log 紀錄,把 Grafana 推出的 Loki,也導入專案系統,將容器所有的 Log 都導向 Loki,底下可以看看 docker-compose 將 Log 輸出到 loki

    logging:
      driver: loki
      options:
        loki-url: "http://xxxxxxx/loki/api/v1/push"
        loki-retries: "5"
        loki-batch-size: "400"
        loki-external-labels: "environment=production,project=mongo"

先看看結論,做法其實很簡單,找出相對應 Slow Query,把相關的欄位加上 Index,就可以解決了

啟動資料庫 Profiler

MongoDB 預設 Profiler 是關閉的,遇到效能問題,就需要打開,來收集所有的操作記錄 (CRUD),透過底下指令可以知道目前 MongoDB 的 Profiler 狀態

> db.getProfilingStatus()
{ "was" : 0, "slowms" : 100, "sampleRate" : 1 }

可以看到 was 為 0 代表沒有啟動

Level Description
0 The profiler is off and does not collect any data. This is the default profiler level.
1 The profiler collects data for operations that take longer than the value of slowms.
2 The profiler collects data for all operations.

這邊先將 Level 設定為 2,或者是只需要看 slow query,那就設定為 1

> db.setProfilingLevel(2)
{ "was" : 0, "slowms" : 100, "sampleRate" : 1, "ok" : 1 }

如果使用完畢,請將 Profiler 關閉。

用 Profiler 分析效能

上一個步驟,Profile 打開後,就可以看到 Mongo 收集一堆 Slow Query Log 了

最後驗證結果就很簡單,只要 Log 量減少及 CPU 使用率下降,就代表成功了,底下介紹幾個好用的分析效能語法。第一直接找目前系統 command 類別內執行時間最久的狀況 (millis: -1 反向排序)

db.system.profile.
  find({ op: { $eq: "command" }}).
  sort({ millis: -1 }).
  limit(2).
  pretty();

第二可以找執行時間超過 100 ms 的指令。

db.system.profile.
  find({ millis: { $gt: 100 }}).
  pretty();

最後透過 planSummary 語法可以找出 query command 掃描 (COLSCAN) 整個資料表,代表語法沒有被優化,資料表越大,查詢速度越慢

db.system.profile.
  find({ "planSummary": { $eq: "COLLSCAN" }, "op": { $eq: "query" }}).
  sort({ millis: -1 }).
  pretty();

或者可以透過 db.currentOp 觀察現在正在執行中的 Command,底下語法可以針對 db1 資料庫查詢執行超過 3 秒鐘的指令

db.currentOp(
   {
     "active" : true,
     "secs_running" : { "$gt" : 3 },
     "ns" : /^db1\./
   }
)

了解 Slow Query

從上面的 Profiler 效能分析指令,可以查詢到哪些 SQL 指令造成系統效能不穩定,這些 SQL 可以透過 EXPLAIN 方式找尋到執行效能瓶頸。底下直接透過 explain 方式會產生出 JSON 格式輸出:

db.orders.explain("executionStats").find({maID:"bfce30cab12311eba55d09972",maOrderID:"2222318209",deleted:false})

透過 db.collection.explain 可以知道此 Query 在 Mongodb 內是怎麼有效率的執行,底下來看看 explain 回傳的結果:

{
  "queryPlanner" : {
    "plannerVersion" : 1,
    "namespace" : "fullinn.orders",
    "indexFilterSet" : false,
    "parsedQuery" : {
      "$and" : [
        {
          "deleted" : {
            "$eq" : false
          }
        },
        {
          "maID" : {
            "$eq" : "bfce30cab12311eba55d09972"
          }
        },
        {
          "maOrderID" : {
            "$eq" : "2222318209"
          }
        }
      ]
    },
    "winningPlan" : {
      "stage" : "COLLSCAN",
      "filter" : {
        "$and" : [
          {
            "deleted" : {
              "$eq" : false
            }
          },
          {
            "maID" : {
              "$eq" : "bfce30cab12311eba55d09972"
            }
          },
          {
            "maOrderID" : {
              "$eq" : "2222318209"
            }
          }
        ]
      },
      "direction" : "forward"
    },
    "rejectedPlans" : [ ]
  },
  "executionStats" : {
    "executionSuccess" : true,
    "nReturned" : 0,
    "executionTimeMillis" : 237,
    "totalKeysExamined" : 0,
    "totalDocsExamined" : 192421,
    "executionStages" : {
      "stage" : "COLLSCAN",
      "filter" : {
        "$and" : [
          {
            "deleted" : {
              "$eq" : false
            }
          },
          {
            "maID" : {
              "$eq" : "bfce30cab12311eba55d09972"
            }
          },
          {
            "maOrderID" : {
              "$eq" : "2222318209"
            }
          }
        ]
      },
      "nReturned" : 0,
      "executionTimeMillisEstimate" : 30,
      "works" : 192423,
      "advanced" : 0,
      "needTime" : 192422,
      "needYield" : 0,
      "saveState" : 192,
      "restoreState" : 192,
      "isEOF" : 1,
      "direction" : "forward",
      "docsExamined" : 192421
    }
  },
  "serverInfo" : {
    "host" : "60b424d18015",
    "port" : 27017,
    "version" : "4.4.4",
    "gitVersion" : "8db30a63db1a9d84bdcad0c83369623f708e0397"
  },
  "ok" : 1
}

直接注意到幾個數據,看到 executionTimeMillis 執行時間,totalDocsExamined 是在執行過程會掃過多少資料 (越低越好),由上面可以知道此 Query 執行時間是 237 ms,並且需要掃過 192421 筆資料,另外一個重要指標就是 executionStages 內的 stage

    "executionStages" : {
      "stage" : "COLLSCAN",
      "filter" : {
        "$and" : [
          {
            "deleted" : {
              "$eq" : false
            }
          },
          {
            "maID" : {
              "$eq" : "bfce30cab12311eba55d09972"
            }
          },
          {
            "maOrderID" : {
              "$eq" : "2222318209"
            }
          }
        ]
      },
      "nReturned" : 0,
      "executionTimeMillisEstimate" : 30,
      "works" : 192423,
      "advanced" : 0,
      "needTime" : 192422,
      "needYield" : 0,
      "saveState" : 192,
      "restoreState" : 192,
      "isEOF" : 1,
      "direction" : "forward",
      "docsExamined" : 192421
    }
  },

Stage 狀態分成底下幾種

  • COLLSCAN: for a collection scan
  • IXSCAN: for scanning index keys
  • FETCH: for retrieving documents
  • SHARD_MERGE: for merging results from shards
  • SHARDING_FILTER: for filtering out orphan documents from shards

這次我們遇到的就是第一種 COLLSCAN,資料表全掃,所以造成效能非常低,這時就要檢查看看是否哪邊增加 Index 可以解決效能問題。底下增加一個 index key 看看結果如何?

db.orders.createIndex({maID: 1})

接著再執行一次,可以看到底下結果:

  "executionStats" : {
    "executionSuccess" : true,
    "nReturned" : 0,
    "executionTimeMillis" : 2,
    "totalKeysExamined" : 1,
    "totalDocsExamined" : 1,
    "executionStages" : {
      "stage" : "FETCH",
      "filter" : {
        "$and" : [
          {
            "deleted" : {
              "$eq" : false
            }
          },
          {
            "maOrderID" : {
              "$eq" : "2222318209"
            }
          }
        ]
      },
      "nReturned" : 0,
      "executionTimeMillisEstimate" : 0,
      "works" : 3,
      "advanced" : 0,
      "needTime" : 1,
      "needYield" : 0,
      "saveState" : 0,
      "restoreState" : 0,
      "isEOF" : 1,
      "docsExamined" : 1,
      "alreadyHasObj" : 0,
      "inputStage" : {
        "stage" : "IXSCAN",
        "nReturned" : 1,
        "executionTimeMillisEstimate" : 0,
        "works" : 2,
        "advanced" : 1,
        "needTime" : 0,
        "needYield" : 0,
        "saveState" : 0,
        "restoreState" : 0,
        "isEOF" : 1,
        "keyPattern" : {
          "maID" : 1
        },
        "indexName" : "maID_1",
        "isMultiKey" : false,
        "multiKeyPaths" : {
          "maID" : [ ]
        },
        "isUnique" : false,
        "isSparse" : false,
        "isPartial" : false,
        "indexVersion" : 2,
        "direction" : "forward",
        "indexBounds" : {
          "maID" : [
            "[\"bfce30cab12311eba55d09972\", \"bfce30cab12311eba55d09972\"]"
          ]
        },
        "keysExamined" : 1,
        "seeks" : 1,
        "dupsTested" : 0,
        "dupsDropped" : 0
      }
    }
  },

可以看到 executionTimeMillis 降低到 2,totalDocsExamined 變成 1,用 index 去找就是特別快。inputStage.stage 用的就是 IXSCAN。針對上述找尋方式把相對的 index key 補上,並且優化商業邏輯,就可以達到底下結果

相關參考文件:

如何取得上傳進度條 progress bar 相關數據及實作 Graceful Shutdown

$
0
0

由於專案需求,需要開發一套 CLI 工具,讓 User 可以透過 CLI 上傳大檔案來進行 Model Training,請參考上面的流程圖。首先第一步驟會先跟 API Server 驗證使用者,驗證完畢就開始上傳資料到 AWS S3 或其他 Storage 空間,除了上傳過程需要在 CLI 顯示目前進度,另外也需要將目前上傳的進度 (速度, 進度及剩餘時間) 都上傳到 API Server,最後在 Web UI 介面透過 GraphQL Subscription 讓使用者可以即時看到上傳進度數據。

而 CLI 上傳進度部分,我們選用了一套開源套件 cheggaaa/pb,相信有在寫 Go 語言都並不會陌生。而此套件雖然可以幫助在 Terminal 顯示進度條,但是有些接口是沒有提供的,像是即時速度,上傳進度及剩餘時間。本篇教大家如何實作這些數據,及分享過程會遇到相關問題。

讀取上傳進度顯示

透過 cheggaaa/pb 提供的範例如下:

package main

import (
    "crypto/rand"
    "io"
    "io/ioutil"
    "log"

    "github.com/cheggaaa/pb/v3"
)

func main() {

    var limit int64 = 1024 * 1024 * 10000
    // we will copy 10 Gb from /dev/rand to /dev/null
    reader := io.LimitReader(rand.Reader, limit)
    writer := ioutil.Discard

    // start new bar
    bar := pb.Full.Start64(limit)
    // create proxy reader
    barReader := bar.NewProxyReader(reader)
    // copy from proxy reader
    if _, err := io.Copy(writer, barReader); err != nil {
        log.Fatal(err)
    }
    // finish bar
    bar.Finish()
}

很清楚可以看到透過 io.Copy 方式開始上傳模擬進度。接著需要透過 goroutine 方式讀取目前進度並上傳到 API Server。使用 pb v3 版本只有開放幾個 public 資訊,像是起始進度時間,及目前上傳了多少 bits 資料,透過這兩個資料,可以即時算出剩餘時間,目前速度及進度。

package main

import (
    "crypto/rand"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "time"

    "github.com/cheggaaa/pb/v3"
)

func main() {
    var limit int64 = 1024 * 1024 * 10000
    // we will copy 10 Gb from /dev/rand to /dev/null
    reader := io.LimitReader(rand.Reader, limit)
    writer := ioutil.Discard

    // start new bar
    bar := pb.Full.Start64(limit)
    go func(bar *pb.ProgressBar) {
        d := time.NewTicker(2 * time.Second)
        startTime := bar.StartTime()
        // Using for loop
        for {
            // Select statement
            select {
            // Case to print current time
            case <-d.C:
                if !bar.IsStarted() {
                    continue
                }
                currentTime := time.Now()
                dur := currentTime.Sub(startTime)
                lastSpeed := float64(bar.Current()) / dur.Seconds()
                remain := float64(bar.Total() - bar.Current())
                remainDur := time.Duration(remain/lastSpeed) * time.Second
                fmt.Println("Progress:", float32(bar.Current())/float32(bar.Total())*100)
                fmt.Println("last speed:", lastSpeed/1024/1024)
                fmt.Println("remain duration:", remainDur)

                // TODO: upload progress and remain duration to api server
            }
        }
    }(bar)
    // create proxy reader
    barReader := bar.NewProxyReader(reader)
    // copy from proxy reader
    if _, err := io.Copy(writer, barReader); err != nil {
        log.Fatal(err)
    }
    // finish bar
    bar.Finish()
}

使用 time.NewTicker 固定每兩秒計算目前進度資料,並且上傳到 API Server,從上傳資料及使用的時間,可以算出目前 Speed 大概多少,當然這不是很準,原因是從上傳開始到現在時間計算 (總已上傳資料/目前花費時間)。做完上述這些功能,不難的發現有個問題,這個 goroutine 不會停止,還是會每兩秒去計算進度,這時候需要透過一個 Channel 通知 goroutine 結束。

package main

import (
    "crypto/rand"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "time"

    "github.com/cheggaaa/pb/v3"
)

func main() {
    var limit int64 = 1024 * 1024 * 10000
    // we will copy 10 Gb from /dev/rand to /dev/null
    reader := io.LimitReader(rand.Reader, limit)
    writer := ioutil.Discard

    // start new bar
    bar := pb.Full.Start64(limit)
    finishCh := make(chan struct{})
    go func(bar *pb.ProgressBar) {
        d := time.NewTicker(2 * time.Second)
        startTime := bar.StartTime()
        // Using for loop
        for {
            // Select statement
            select {
            case <-finishCh:
                d.Stop()
                log.Println("finished")
                return
            // Case to print current time
            case <-d.C:
                if !bar.IsStarted() {
                    continue
                }
                currentTime := time.Now()
                dur := currentTime.Sub(startTime)
                lastSpeed := float64(bar.Current()) / dur.Seconds()
                remain := float64(bar.Total() - bar.Current())
                remainDur := time.Duration(remain/lastSpeed) * time.Second
                fmt.Println("Progress:", float32(bar.Current())/float32(bar.Total())*100)
                fmt.Println("last speed:", lastSpeed/1024/1024)
                fmt.Println("remain suration:", remainDur)
            }
        }
    }(bar)
    // create proxy reader
    barReader := bar.NewProxyReader(reader)
    // copy from proxy reader
    if _, err := io.Copy(writer, barReader); err != nil {
        log.Fatal(err)
    }
    // finish bar
    bar.Finish()
    close(finishCh)
}

先宣告一個 finishCh := make(chan struct{}),用來通知 goroutine 跳出迴圈,大家注意看一下,最後是用的是關閉 Channel,如果是用底下方法:

finishCh <- strunct{}{}

這時候看看 switch case 有機率是同時到達,造成無法跳脫迴圈,而直接關閉 channel,可以確保 case <-finishCh 一直拿到空的資料,進而達成跳出迴圈的需求。最後來看看如何整合 Graceful Shutdown。當使用者按下 ctrl + c 需要停止上傳,並將狀態改成 stopped。底下來看看加上 Graceful Shutdown 的方式:

package main

import (
    "context"
    "crypto/rand"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/cheggaaa/pb/v3"
)

func withContextFunc(ctx context.Context, f func()) context.Context {
    ctx, cancel := context.WithCancel(ctx)
    go func() {
        c := make(chan os.Signal, 1)
        signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
        defer signal.Stop(c)

        select {
        case <-ctx.Done():
        case <-c:
            f()
            cancel()
        }
    }()

    return ctx
}

func main() {

    ctx := withContextFunc(
        context.Background(),
        func() {
            // clear machine field
            log.Println("interrupt received, terminating process")
        },
    )

    var limit int64 = 1024 * 1024 * 10000
    // we will copy 10 Gb from /dev/rand to /dev/null
    reader := io.LimitReader(rand.Reader, limit)
    writer := ioutil.Discard

    // start new bar
    bar := pb.Full.Start64(limit)
    finishCh := make(chan struct{})
    go func(ctx context.Context, bar *pb.ProgressBar) {
        d := time.NewTicker(2 * time.Second)
        startTime := bar.StartTime()
        // Using for loop
        for {
            // Select statement
            select {
            case <-ctx.Done():
                d.Stop()
                log.Println("interrupt received")
                return
            case <-finishCh:
                d.Stop()
                log.Println("finished")
                return
            // Case to print current time
            case <-d.C:
                if ctx.Err() != nil {
                    return
                }
                if !bar.IsStarted() {
                    continue
                }
                currentTime := time.Now()
                dur := currentTime.Sub(startTime)
                lastSpeed := float64(bar.Current()) / dur.Seconds()
                remain := float64(bar.Total() - bar.Current())
                remainDur := time.Duration(remain/lastSpeed) * time.Second
                fmt.Println("Progress:", float32(bar.Current())/float32(bar.Total())*100)
                fmt.Println("last speed:", lastSpeed/1024/1024)
                fmt.Println("remain suration:", remainDur)
            }
        }
    }(ctx, bar)
    // create proxy reader
    barReader := bar.NewProxyReader(reader)
    // copy from proxy reader
    if _, err := io.Copy(writer, barReader); err != nil {
        log.Fatal(err)
    }
    // finish bar
    bar.Finish()
    close(finishCh)
}

透過 Go 語言的 context 跟 signal.Notify 可以偵測是否有系統訊號關閉 CLI 程式,這時候就可以做後續相對應的事情,在程式碼就需要多接受 ctx.Done() Channel,由於在 Select 多個 Channel 通道,故也是有可能同時發生,所以需要在另外的 switch case 內判斷 conetxt 的 Err 錯誤訊息,如果不等於 nil 那就是收到訊號,進而 return,必免 goroutine 在背景持續進行。大家執行上述程式後,按下 ctrl + c 可以正常看到底下訊息:

^C
2021/05/21 12:29:25 interrupt received, terminating process
2021/05/21 12:29:25 interrupt received
^C
signal: interrupt

可以看到要在按下一次 ctrl + c 才能結束程式,這邊的原因就是 io.Reader 還是正在上傳,並沒有停止,而系統第一次中斷訊號已經被程式用掉了,這時候解決方式就是要修改底下程式

    barReader := bar.NewProxyReader(reader)
    // copy from proxy reader
    if _, err := io.Copy(writer, barReader); err != nil {
        log.Fatal(err)
    }

io.Copy 需要支援 context 中斷程式,但是我們只能從 reader 下手,,先看看原本 Reader 的 interface:

type Reader interface {
    Read(p []byte) (n int, err error)
}

現在來自己寫一份 func 來支援 context 功能:

type readerFunc func(p []byte) (n int, err error)

func (r readerFunc) Read(p []byte) (n int, err error) { return rf(p) }
func copy(ctx context.Context, dst io.Writer, src io.Reader) error {
    _, err := io.Copy(dst, readerFunc(func(p []byte) (int, error) {
        select {
        case <-ctx.Done():
            return 0, ctx.Err()
        default:
            return src.Read(p)
        }
    }))
    return err
}

由於 io.Reader 會把整個檔案分成多個 chunk 分別上傳,避免 Memory 直接讀取太大的檔案而爆掉,那在每個 chunk 上傳前確保沒有收到 context 中斷的訊息,這樣就可以解決無法停止上傳的行為。整體程式碼如下:

package main

import (
    "context"
    "crypto/rand"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/cheggaaa/pb/v3"
)

type readerFunc func(p []byte) (n int, err error)

func (rf readerFunc) Read(p []byte) (n int, err error) { return rf(p) }

func copy(ctx context.Context, dst io.Writer, src io.Reader) error {
    _, err := io.Copy(dst, readerFunc(func(p []byte) (int, error) {
        select {
        case <-ctx.Done():
            return 0, ctx.Err()
        default:
            return src.Read(p)
        }
    }))
    return err
}

func withContextFunc(ctx context.Context, f func()) context.Context {
    ctx, cancel := context.WithCancel(ctx)
    go func() {
        c := make(chan os.Signal, 1)
        signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
        defer signal.Stop(c)

        select {
        case <-ctx.Done():
        case <-c:
            f()
            cancel()
        }
    }()

    return ctx
}

func main() {

    ctx := withContextFunc(
        context.Background(),
        func() {
            // clear machine field
            log.Println("interrupt received, terminating process")
        },
    )

    var limit int64 = 1024 * 1024 * 10000
    // we will copy 10 Gb from /dev/rand to /dev/null
    reader := io.LimitReader(rand.Reader, limit)
    writer := ioutil.Discard

    // start new bar
    bar := pb.Full.Start64(limit)
    finishCh := make(chan struct{})
    go func(bar *pb.ProgressBar) {
        d := time.NewTicker(2 * time.Second)
        startTime := bar.StartTime()
        // Using for loop
        for {
            // Select statement
            select {
            case <-ctx.Done():
                log.Println("stop to get current process")
                return
            case <-finishCh:
                d.Stop()
                log.Println("finished")
                return
            // Case to print current time
            case <-d.C:
                if !bar.IsStarted() {
                    continue
                }
                currentTime := time.Now()
                dur := currentTime.Sub(startTime)
                lastSpeed := float64(bar.Current()) / dur.Seconds()
                remain := float64(bar.Total() - bar.Current())
                remainDur := time.Duration(remain/lastSpeed) * time.Second
                fmt.Println("Progress:", float32(bar.Current())/float32(bar.Total())*100)
                fmt.Println("last speed:", lastSpeed/1024/1024)
                fmt.Println("remain suration:", remainDur)
            }
        }
    }(bar)
    // create proxy reader
    barReader := bar.NewProxyReader(reader)
    // copy from proxy reader
    if err := copy(ctx, writer, barReader); err != nil {
        log.Println("cancel upload data:", err.Error())
    }
    // finish bar
    bar.Finish()
    close(finishCh)
    time.Sleep(1 * time.Second)
}
Viewing all 325 articles
Browse latest View live