Quantcast
Channel: Carpe Diem

go-redisのtimeoutで気をつけること

$
0
0

概要

Go言語でRedisを使う際に選択肢に挙がるのがgo-redisです。

今回はgo-redisでTimeoutを設定する際に注意すべきことをまとめました。

手前味噌ですがBlast Radius of Failureを最小にするためにTimeoutを短くすることを1つのテクニックとしても紹介しています。

W杯全64試合無料生中継で「落ちない」を実現。「小さく壊れる」ために行った負荷・障害・セキュリティ対策とは?【ABEMA DEVELOPER CONFERENCE 2023#3】 | レバテックラボ(レバテックLAB)

しかし短すぎるとそれはそれで問題が発生することになるので、その理由を説明します。

環境

  • redis/go-redis v9.0.5

go-redisのTimeoutの種類

go-redisでは

  1. ReadTimeout
  2. WriteTimeout
  3. ContextTimeout

の3つを主に扱うことができます。

クライアントを生成する際に指定でき、contextについてはContextTimeoutEnabledをtrueにすることで有効化できます。

cli := redis.NewClient(&redis.Options{
        Addr:                  "localhost:6379",
        ReadTimeout:           3 * time.Second,
        WriteTimeout:          3 * time.Second,
        ContextTimeoutEnabled: true,
})

ReadTimeout, WriteTimeoutのデフォルト値は3秒です。

気をつけること

1. 読み取り処理でもWriteTimeoutを使う

例えばGETは読み取り処理なのでReadTimeoutのみ使うと思えますが、そうではありません。

次のようなコードを用意すると

func main() {
        cli := redis.NewClient(&redis.Options{
                Addr:         "localhost:6379",
                ReadTimeout:  1 * time.Second,
                WriteTimeout: 1 * time.Nanosecond,
        })
        out, err := cli.Get(context.TODO(), "key").Result()
        if err != nil {
                fmt.Println(err)
        }
        cancel()
        fmt.Println(out)
}

このようにwrite timeoutになります。

$ go run main.go
write tcp [::1]:51732->[::1]:6379: i/o timeout

原因

Getコマンドを深ぼってみます。

func (c cmdable) Get(ctx context.Context, key string) *StringCmd {
    cmd := NewStringCmd(ctx, "get", key)
    _ = c(ctx, cmd)
    return cmd
}

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/commands.go#L977C9-L981

cmdableはClientにembedされています。

type Client struct {
    *baseClient
    cmdable
    hooksMixin
}

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/redis.go#L600C1-L604C2

これは初期化時にc.Processで設定されます。

func (c *Client) init() {
    c.cmdable = c.Process
    c.initHooks(hooks{
        dial:       c.baseClient.dial,
        process:    c.baseClient.process,
        pipeline:   c.baseClient.processPipeline,
        txPipeline: c.baseClient.processTxPipeline,
    })
}

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/redis.go#L621-L629

c.ProcessはClientにembedされているhooksMixinのメソッドであるc.processHookを使います。

func (c *Client) Process(ctx context.Context, cmd Cmder) error {
    err := c.processHook(ctx, cmd)
    cmd.SetErr(err)
    return err
}

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/redis.go#L649-L653

c.processHookはprocessを使います。

func (hs *hooksMixin) processHook(ctx context.Context, cmd Cmder) error {
    return hs.current.process(ctx, cmd)
}

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/redis.go#L171C1-L173

processは初期化時にbaseClient.processを渡しています。

   c.initHooks(hooks{
        dial:       c.baseClient.dial,
        process:    c.baseClient.process,
        pipeline:   c.baseClient.processPipeline,
        txPipeline: c.baseClient.processTxPipeline,
    })

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/redis.go#L623-L628

baseClient.processはc._processを使います。

func (c *baseClient) process(ctx context.Context, cmd Cmder) error {
    var lastErr errorfor attempt := 0; attempt <= c.opt.MaxRetries; attempt++ {
        attempt := attempt

        retry, err := c._process(ctx, cmd, attempt)
        if err == nil || !retry {
            return err
        }

        lastErr = err
    }
    return lastErr
}

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/redis.go#L370-L383

最後c._processは内部でcn.WithWritercn.WithReaderの両方を呼んでいます。

このためwriteのtimeoutも使われます。

func (c *baseClient) _process(ctx context.Context, cmd Cmder, attempt int) (bool, error) {
    if attempt > 0 {
        if err := internal.Sleep(ctx, c.retryBackoff(attempt)); err != nil {
            returnfalse, err
        }
    }

    retryTimeout := uint32(0)
    if err := c.withConn(ctx, func(ctx context.Context, cn *pool.Conn) error {
        if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error {
            return writeCmd(wr, cmd)
        }); err != nil {
            atomic.StoreUint32(&retryTimeout, 1)
            return err
        }

        if err := cn.WithReader(c.context(ctx), c.cmdTimeout(cmd), cmd.readReply); err != nil {
            if cmd.readTimeout() == nil {
                atomic.StoreUint32(&retryTimeout, 1)
            } else {
                atomic.StoreUint32(&retryTimeout, 0)
            }
            return err
        }

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/redis.go#L385-L408

ただしWriteTimeoutを極端に小さくしない限りは読み取り処理においては影響はないです。

2. 書き込み処理でもReadTimeoutを使う

注意なのは書き込み側のReadTimeoutです。

次のようなコードを用意します。

func main() {
        cli := redis.NewClient(&redis.Options{
                Addr:         "localhost:6379",
                ReadTimeout:  1 * time.Millisecond,
                WriteTimeout: 1 * time.Second,
        })
        out, err := cli.Set(context.TODO(), "key", "val", 0).Result()
        if err != nil {
                fmt.Println(err)
        }
        cancel()
        fmt.Println(out)
}

このようにread timeoutが出ました。

$ go run main.go
read tcp [::1]:51748->[::1]:6379: i/o timeout

原因

先程のコードが原因なわけですが、コードを見るにレスポンスを受け取る際にもこのRaedTimeoutを使っていると考えられます。

if err := cn.WithReader(c.context(ctx), c.cmdTimeout(cmd), cmd.readReply); err != nil {
            if cmd.readTimeout() == nil {
                atomic.StoreUint32(&retryTimeout, 1)
            } else {
                atomic.StoreUint32(&retryTimeout, 0)
            }
            return err
        }

これはローカルtoローカル通信なのでまだ良いですが、クラウドなどの通信では小さいReadTimeoutを設定していると書き込み処理においてこのエラーが発生しやすくなります。

MSET, Pipelineへの影響

例えばローカルtoローカルの通信環境において、GETが ReadTimeout: 5 * time.Millisecond で問題ないのに対し、

MSET

10件程度でread timeout

PipelineでのSET

1200件程度でread timeout

といった結果になりました。

このことからMSETやPipelineの処理は、件数によってGETよりもReadTimeoutの影響が強く出る事が分かります。

テストコード

テストコードにおいてもPing処理でread/write両方のtimeoutでエラーを発生させているので、どちらも使われているのは仕様通りと言えます。

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/redis_test.go#L363-L442

3. Timeoutはコネクションをクローズさせる

以下の記事には、多くのDBではcontext cancelの場合はコネクションをクローズするとあります。

When context is cancelled, go-redis and most other database clients (including database/sql) must do the following:

  1. Close the connection, because it can't be safely reused.
  2. Open a new connection.
  3. Perform TLS handshake using the new connection.
  4. Optionally, pass some authentication checks, for example, using Redis AUTH command.

ref: Go Context timeouts can be harmful

そしてコネクションの再生成が必要となりオーバーヘッドが発生することで、よりTimeoutが発生しやすくなるという負のループに陥る可能性があります。

実際に検証してみました。

Timeoutしない場合

func main() {
        cli := redis.NewClient(&redis.Options{
                Addr:                  "localhost:6379",
                ReadTimeout:           3 * time.Second,
                WriteTimeout:          3 * time.Second,
        })
        for i := 0; i < 3; i++ {
                // KEYS *
                _, err := cli.Keys(context.TODO(), "*").Result()
                time.Sleep(1 * time.Second)
        }
        time.Sleep(3 * time.Second)
}

このように最後の処理が完了してからコネクションがクローズされます。
またコネクションはプールで再利用されます。

ContextTimeoutする場合

しかしcontext timeoutすると、

func main() {
        cli := redis.NewClient(&redis.Options{
                Addr:                  "localhost:6379",
                ReadTimeout:           3 * time.Second,
                WriteTimeout:          3 * time.Second,
                ContextTimeoutEnabled: true,
        })
        for i := 0; i < 3; i++ {
                ctx, _ := context.WithTimeout(context.TODO(), 5*time.Millisecond)
                // KEYS *
                _, err := cli.Keys(ctx, "*").Result()
                time.Sleep(1 * time.Second)
        }
        time.Sleep(3 * time.Second)
}

再利用するには適切な状態ではないと判断されコネクションは即座にクローズされます。

実コード

該当の処理はここにあたります。

func isBadConn(err error, allowTimeout bool, addr string) bool {
    switch err {
    casenil:
        returnfalsecase context.Canceled, context.DeadlineExceeded:
        returntrue
    }

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/error.go#L79-L85

func (c *baseClient) releaseConn(ctx context.Context, cn *pool.Conn, err error) {
    if c.opt.Limiter != nil {
        c.opt.Limiter.ReportResult(err)
    }

    if isBadConn(err, false, c.opt.Addr) {
        c.connPool.Remove(ctx, cn, err)
    } else {
        c.connPool.Put(ctx, cn)
    }
}

https://github.com/redis/go-redis/blob/c0ab7815ea66b036d0cb02e2902a7820551d948a/redis.go#L336C1-L346C2

ReadTimeoutする場合

じゃあReadTimeoutなどは大丈夫なのかというと、

func main() {
        cli := redis.NewClient(&redis.Options{
                Addr:                  "localhost:6379",
                ReadTimeout:           10 * time.Millisecond,
                WriteTimeout:          3 * time.Second,
        })
        for i := 0; i < 3; i++ {
                // KEYS *
                _, err := cli.Keys(context.TODO(), "*").Result()
                time.Sleep(1 * time.Second)
        }
        time.Sleep(3 * time.Second)
}

こちらもコネクションがクローズされました。

ただしcontext timeoutが全処理の合計としてのtimeoutとして扱われがちのに対し、ReadTimeoutやWriteTimeoutはコマンドは都度の通信で設定されるのでハンドリングしやすいと言えます。

対応

これまでの内容から以下のことを念頭に置く必要があります。

  1. 読み取り処理、書き込み処理それぞれにおいてReadTimeout, WriteTimeoutが影響する
  2. 特にReadTimeoutはレスポンスの取得に使われるので注意
  3. Timeoutが発生するとコネクションがクローズされ再利用されなくなるため、よりレイテンシが悪化しTimeoutが起きやすくなるので負のループに陥る
  4. Context TimeoutよりはReadTimeout, WriteTimeoutの方がオススメ

まとめ

go-redisを使う上でのTimeoutの扱い方の注意を説明しました。

参考


http.ResponseWriterに書き込んだstatus codeを取得したい

$
0
0

背景

  • 5xx系エラーをbugsnagのようなエラー検知サービスに送信したい
  • middleware層で網羅的に対応したい

といった際に、

  • http.ResponseWriterに書き込まれたstatus codeは直接アクセスできない

という問題があります。

今回はこの問題を解決する方法を紹介します。

環境

  • Go 1.21.0

方法

Custom ResponseWriterを作り、それをラップさせることで対応できます。

実装

具体的な実装方法です。

error middleware

type Reporter interface {
        Report(ctx context.Context, err error)
}

// ErrorHandler reports errors to the reporter.func ErrorHandler(reporter Reporter) func(http.Handler) http.Handler {
        returnfunc(next http.Handler) http.Handler {
                return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                        sw := &statusResponseWriter{ResponseWriter: w}
                        next.ServeHTTP(sw, r)

                        if sw.status >= http.StatusInternalServerError {
                                err := errorFromContext(r.Context())
                                if err == nil {
                                        return
                                }
                                // notify
                                reporter.Report(r.Context(), err)
                        }
                })
        }
}

type statusResponseWriter struct {
        http.ResponseWriter
        status int
}

func (w *statusResponseWriter) WriteHeader(status int) {
        w.status = status
        w.ResponseWriter.WriteHeader(status)
}

ポイント

ポイントは以下です。

  • ResponseWriterをCustom ResponseWriterでラップする
  • Custom ResponseWriterはhttp.ResponseWriterを埋め込んで基本実装されている状態にする
  • WriteHeader()実行時にstatus codeを保持する
  • 保持したstatus codeを使ってReportするかどうか判定する

またReporterをinterfaceで用意しておくと、今回の例の様にbugsnagだけでなく単なるloggerなど切り返しやすくなります。

main

ミドルウェアを使う部分の実装です。

func main() {
        mux := http.NewServeMux()

        mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
                w.WriteHeader(http.StatusOK)
        })

        mux.HandleFunc("/500", func(w http.ResponseWriter, r *http.Request) {
                err := errors.New("some error")
                WithError(r, err)
                w.WriteHeader(http.StatusInternalServerError)
        })

        http.ListenAndServe(":8080", ErrorHandler(&reporter{})(mux))
}

type reporter struct{}

func (r *reporter) Report(ctx context.Context, err error) {
        log.Println(err)
}

エラー詳細

ここはoptionalですが、w.Write()前にエラーの詳細をrequest contextの渡しておくことでエラー検知ツールにエラー詳細を渡すことができます。

var errorContextKey struct{}

func WithError(r *http.Request, err error) {
        if err == nil {
                return
        }
        r2 := r.WithContext(withError(r.Context(), err))
        *r = *r2
}

// withError sets an error to the context.func withError(ctx context.Context, err error) context.Context {
        if err == nil {
                return ctx
        }
        return context.WithValue(ctx, errorContextKey, err)
}

// errorFromContext returns an error from the context.func errorFromContext(ctx context.Context) error {
        if err, ok := ctx.Value(errorContextKey).(error); ok {
                return err
        }
        returnnil
}

ミドルウェアは全リクエストに対して網羅的に対応できる一方で、アクセスできる部分が限定されるので愚直ではありますがrequest context辺りが無難かと思われます。

動作検証

200の場合

$ curl localhost:8080/

Reporterの分岐に入らずログは出ません。

$ go run .

500の場合

$ curl localhost:8080/500

Reporterの分岐に入りログが出るようになります。

$ go run .
2023/07/28 12:08:07 some error

その他

サンプルコード

今回のサンプルコードはこちらです。

github.com

http.ResponseWriterの注意点

http.ResponseWriterのメソッドは実行順序があり、

  1. w.Header() or Read Request.Body
  2. WriteHeader(statusCode)
  3. Write([]byte)

の順に使わないといけません。
なのでこれを知っていないと以下のような問題にぶつかります。

Changing the header map after a call to WriteHeader (or Write) has no effect

w.WriteHeaderを呼んでからw.Header()でヘッダーMapをいじっても変更できません。

If WriteHeader is not called explicitly, the first call to Write will trigger an implicit WriteHeader(http.StatusOK).

w.WriteHeader()を呼ぶ前にWrite()を呼ぶと、データを書き込む前に自動的にWriteHeader(http.StatusOK)を呼ばれます。

そしてWriteHeaderは一度呼ぶと変更はできず内部的に以下のエラーが表示されます。

http: superfluous response.WriteHeader call from main.(*statusResponseWriter).WriteHeader

Depending on the HTTP protocol version and the client, calling Write or WriteHeader may prevent future reads on the Request.Body.

w.Write or w.WriteHeaderを呼んだらRequest.Bodyは読めなくなります。

http package - net/http - Go Packages

まとめ

Custom ResponseWriterを使うことでmiddlewareの柔軟性を上げることができる例を紹介しました。

KubernetesでPodを複数のZoneに分散させる

$
0
0

概要

Podの冗長化をする上でマルチゾーン構成にしたい場合

Pod Topology Spread Constraints | Kubernetes

上記のPod Topology Spread Constraintsを使うと実現できます。

環境

Pod Topology Spread Constraints

パラメータ

主に使うパラメータは以下です。

パラメータ説明デフォルト値
topologyKey スケジュールの制約条件に使う Node Label -
maxSkew zone間のPod数の差の上限
0より大きい数値でないといけない
-
whenUnsatisfiable DoNotSchedule: maxSkewを満たさない場合、そのトポロジーにスケジュールしない
ScheduleAnyway: maxSkewを満たさない場合、skewを減らすようにスケジュールする
DoNotSchedule
labelSelector スケジュール対象の Pod Label -

これら以外のパラメータはドキュメントを参照して下さい。

ref: Pod Topology Spread Constraints | Kubernetes

skewとは

skew(歪度)の計算式は以下の通りです。

skew = 現トポロジーにマッチするPod数 - 全トポロジーでの最小Pod数

以下の図を元に考えると、

ref: https://kubernetes.io/blog/2020/05/introducing-podtopologyspread/

maxSkew=1として

zone1に配置しようとした時

  1. 現状zone1にPodが2つある。
  2. 1つスケジュールすると3つになる。
  3. 各zoneで最小のPodはzone2の0。
  4. 計算式3−0=3でmaxSkewに違反(3>1)する

zone2に配置しようとした時

  1. 現状zone2にPodはない
  2. 1つスケジュールすると1つになる。
  3. 各zoneで最小のPodは同じくzone2の1。
  4. 計算式1−1=0でmaxSkewを満たす(0≦1)

となります。

既に均一だったら?

既にzone1とzone2が均一(例えば2 Podずつ)の場合は、

  1. zone1にスケジュールすると3
  2. 各zoneで最小のPodは同じくzone2の2。
  3. 計算式3−2=1でmaxSkewを満たす(1≦1)

となります。
なのでmaxSkewは0より大きな数値でないといけません。

デフォルト設定

Kubernetes v1.24からはクラスタレベルで次の制約がデフォルト設定されています。

defaultConstraints:- maxSkew:3topologyKey:"kubernetes.io/hostname"whenUnsatisfiable: ScheduleAnyway
  - maxSkew:5topologyKey:"topology.kubernetes.io/zone"whenUnsatisfiable: ScheduleAnyway

ref: https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/#internal-default-constraints

設定方法

具体的な設定方法です。

Deploymentに次のようにtopologySpreadConstraintsを設定します。

apiVersion: apps/v1
kind: Deployment
metadata:name: my-app
spec:replicas:6selector:matchLabels:app: my-app
  template:metadata:labels:app: my-app
    spec:containers:- name: nginx
        image: nginx:1.25
        ports:- containerPort:80topologySpreadConstraints:- maxSkew:1topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:matchLabels:app: my-app

またラベルが増えるのでこんがらがる人は以下の記事を参考にして下さい。

christina04.hatenablog.com

注意点

調査時や導入して気になった点を挙げます。

デプロイ時の旧Podも計算対象に入る

DeploymentのstrategyによってはAZ分散がうまく行かないパターンがあります。

例えば

  • replica: 3 (zone-a: 1, zone-b: 1, zone-c: 1)
  • Deploymentのstrategy.rollingUpdate.maxSurge: 50%

のような時に、

  1. zone-a, zone-bに1Podずつ、計2Pod追加される
    • zone-a: 2, zone-b: 2, zone-c: 1
  2. zone-bの旧PodがTerminate
    • zone-a: 2, zone-b: 1, zone-c: 1
  3. 新しいPodがzone-bに追加される(maxSkewを違反しないため)
    • zone-a: 2, zone-b: 2, zone-c: 1
  4. zone-aの旧PodがTerminate
    • zone-a: 1, zone-b: 2, zone-c: 1
  5. zone-cの旧PodがTerminate
    • zone-a: 1, zone-b: 2, zone-c: 0

といったことが起きることがあります。

ゾーン障害から復旧してもリバランスはされない

Pod Topology Spread ConstraintsはあくまでPodスケジュール時の制約であるため、

  • replica: 6 (zone-a: 2, zone-b: 2, zone-c: 2)

でバランス良く分散されていた状態で、zone-cにゾーン障害が発生した場合

  • replica: 6 (zone-a: 3, zone-b: 3, zone-c: 0)

となります。

その後zone-cが復旧したとしても、現状のPodをEvictしてzone-cにリバランスしてはくれません。
(もちろんPodの削除やDeploymentが発生すればzone-cにスケジュールされます)

DoNotScheduleだとストックアウト時に配置できなくなる

github.com

にあるように、ストックアウトによって一部のゾーンのノードが増やせない状態に陥った場合に、maxSkew=1だとPodが配置できなくなる状況に陥ります。

  • zone-cでノードのストックアウトが起きる
  • zone-cのノードのリソースがなくなりPodがこれ以上置けない
  • topologySpreadConstraints
    • maxSkew: 1
    • whenUnsatisfiable : DoNotSchedule

という状況の場合、replica: 6だとすると

  1. zone-aにPodをスケジュール→Running
  2. zone-bにPodをスケジュール→Running
  3. zone-cにPodをスケジュールするが配置できるノードが無い→Pending
  4. zone-aにPodをスケジュールしようとするがzone-cとのPod差が1ある状態でDoNotSchedule制約がある→Pending
  5. zone-bにPodをスケジュールしようとするがzone-cとのPod差が1ある状態でDoNotSchedule制約がある→Pending

ということになります。

なので次に紹介するDeschedulerと、whenUnsatisfiable: ScheduleAnywayの組み合わせがベターと言えそうです。

Deschedulerとの組み合わせ

前述の

  • デプロイ時に偏る可能性
  • ゾーン障害復旧後の偏りが直らない

といった問題を解決する方法としてDeschedulerがあります。

github.com

これは条件に違反したPodをEvictさせる仕組みです。
こちらは先程と逆で、PodのEvictのみに責務を持っています。

  • Deployment
  • CronJob
  • Job

の3通りの使い方があり、以下でサンプルコードが提供されています。

https://github.com/kubernetes-sigs/descheduler/tree/master/kubernetes

--policy-config-fileには次のようなポリシーを用意します。ConfigMapなどで用意するのが良いでしょう。

apiVersion:"descheduler/v1alpha2"kind:"DeschedulerPolicy"profiles:- name: ProfileName
    pluginConfig:- name:"RemovePodsViolatingTopologySpreadConstraint"args:constraints:- DoNotSchedule
          - ScheduleAnyway
    plugins:balance:enabled:- "RemovePodsViolatingTopologySpreadConstraint"

ref: https://github.com/kubernetes-sigs/descheduler/blob/master/examples/topology-spread-constraint.yaml

注意点

DeschedulerによってEvict対象のPodが多いことでサービスのキャパシティが足りなくなってしまったり、ダウンタイムが発生する可能性があります。

そうならないためにもPodDisruptionBudgetを設定することで最低限起動するPod数を保証できます。

kubernetes.io

PodDisruptionBudgetはDeschedulerやDeployment strategyよりも優先的に扱われます。

設定例

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:name: my-app
spec:minAvailable:2selector:matchLabels:app: my-app

まとめ

KubernetesでPodをMulti AZにスケジュールする方法、設定する上での注意点を紹介しました。

参考

DeepLのChrome拡張機能を使ってるとGitHubのページ内検索で表示崩れが起きる

$
0
0

背景

最近GitHubでページ内検索を使ってると、たまに次のような表示崩れが発生します。
スクロールしても前のコードがずっとそこに残っています。

このときのコンソールログとしては決まって次のエラーで、

content.js:1 Uncaught DOMException: Failed to execute 'getRangeAt' on 'Selection': 0 is not a valid index. at du (chrome-extension://cofdbpoegempjloogbagkncekinflcnj/build/content.js:1:214647)

cofdbpoegempjloogbagkncekinflcnjchrome拡張を検索すると

DeepLのChrome拡張ということだったので拡張をOFFにしたところ、表示崩れが起きることがなくなりました。

まとめ

DeepLのChrome拡張が原因でページ内検索がおかしくなりました。

DeepL自体は頻繁に使うので早く修正されてほしいです。

同じ事象の人の助けになればと共有です。

ログで機密情報をマスキングする方法

$
0
0

背景

DBのconfigのように一部機密情報が含まれるものを環境変数(k8s Secret等)で注入することは多いです。

そしてその環境変数がちゃんと設定されているか起動時にログを吐きたいということもよくあります。

一方で

type Config struct {
        Addr     string
        Port     int
        Password string
}

この様にパスワードが含まれるstructをログに吐くと、せっかくSecret等で管理した機密情報が漏れてしまいます。

そういったケースで一部のフィールドだけマスキングする方法を紹介します。

環境

  • Go 1.21.1
  • zap v1.25.0
  • golang.org/x/exp/slog v0.0.0-20230905200255

方法

zapとslogそれぞれのやり方を紹介します。

zapの場合

zapではzapcore.ObjectMarshalerを実装することで、ログを吐く際に中身をカスタマイズすることが可能です。

以下の例ではパスワードフィールドのみマスキングするようにしています。

func (c Config) MarshalLogObject(enc zapcore.ObjectEncoder) error {
        enc.AddString("addr", c.Addr)
        enc.AddInt("port", c.Port)
        enc.AddString("password", "****") // パスワードをマスクreturnnil
}

var (
        conf    Config
        logger  *zap.Logger
)

func init() {
        envconfig.Process("myapp", &conf)
        logger, _ = zap.NewDevelopment()
}

func main() {
        defer logger.Sync()
        logger.Info("zap", zap.Object("config", conf))
}

動作結果

生成されるログは次のようになります。

2023-09-12T06:16:00.583+0900 INFO log-masking/main.go:50 zap {"config": {"addr": "localhost", "port": 8080, "password": "****"}}

期待通り機密情報のみマスキングすることができます。

この設計の良いところは

  • 既存の実装を大きく変更することなく導入出来る(ログを吐いている場所自体は修正する必要がない)
  • 一度導入(interfaceを実装)すれば良く、実装漏れしにくい

点でしょう。

slogの場合

Go 1.21から導入された公式の構造化ログであるslogも同様なことが可能です。

slog.LogValuerを実装することで実現できます。

func (c Config) LogValue() slog.Value {
        return slog.GroupValue(
                slog.String("addr", c.Addr),
                slog.Int("port", c.Port),
                slog.String("password", "****"), // パスワードをマスク
        )
}

var (
        conf    Config
        slogger *slog.Logger
)

func init() {
        envconfig.Process("myapp", &conf)
        slogger = slog.New(slog.NewJSONHandler(os.Stdout, nil))
}

func main() {
        slogger.Info("slog", "config", conf)
}

動作結果

生成されるログは次のようになります。

{"time":"2023-09-12T06:16:00.584025+09:00","level":"INFO","msg":"slog","config":{"addr":"localhost","port":8080,"password":"****"}}

zapと同様に期待通り機密情報のみマスキングすることができます。

その他

どんな値が入っているか見当がつくようにしたい

機密情報をマスキングしたい一方で、ちゃんと期待した値が入っているか確認したい気持ちもあります。

そんな時は次のようにハッシュ化して一部だけ表示するのが良いでしょう。

const (
        hashPrefixLength = 8
)

// hash returns sha256 hash value of input string.// You can confirm hash value on terminal as follows:// $ echo -n "xxx" | shasum -a 256func hash(in string) string {
        hash := sha256.Sum256([]byte(in))
        return hex.EncodeToString(hash[:])[:hashPrefixLength] + "****"
}

ログの部分を次のように修正すると、

func (c Config) MarshalLogObject(enc zapcore.ObjectEncoder) error {
        enc.AddString("addr", c.Addr)
        enc.AddInt("port", c.Port)
        enc.AddString("password", hash(c.Password))
        returnnil
}

生成されるログはこのようになります。

{"config": {"addr": "localhost",
    "port": 8080,
    "password": "89e01536****"
  }}

mypasswordという値を期待している場合、ローカルでハッシュ化すると

$ echo -n "mypassword"| shasum -a256
89e01536ac207279409d4de1e5253e01f4a1769e696db0d6062ca9b8f56767c8  -

となり、期待通りの値が入っていると分かります。

ハッシュ値そのままはダメ?

ちなみにハッシュ値全てを入れるのはレインボーテーブル攻撃が可能になるため推奨できません。

次のようなレインボーテーブルの確認サイトで検証すると、パスワードによっては容易に割り出せてしまいます。

ref: CrackStation - Online Password Hash Cracking - MD5, SHA1, Linux, Rainbow Tables, etc.

struct tagで管理する方法はある?

interfaceを実装せずstructのtagで管理できたら良いと思って調べてみると、次のようなライブラリもあります。

github.com

ただこの場合はログを吐く前に各オブジェクトをラップする必要があり、既存の実装を修正するのは非常に大変&今後も漏れが出てくるので導入しても管理が大変になるでしょう。

サンプルコード

今回のサンプルコードはこちらです。

github.com

まとめ

環境変数など外部から注入した値がちゃんと設定されているかログで確認したい、けど機密情報は漏洩させたくないという場合に、ログの一部をマスキングする方法を紹介しました。

IstioやEnvoyで発生するネットワーク系エラー

$
0
0

背景

マイクロサービス環境でIstio(Envoy sidecar)を使っていると、いくつかのエラーに遭遇します。

それぞれどういった状況で発生しているエラーなのかを区別できないと、適切な対応にならないため各種エラーをまとめます。

環境

  • Envoy 1.22.0
  • Go 1.21

構成

次のようにclient appやserver appにsidecarが挟まるようにします。

エラー

dial tcp 172.20.0.4:8001: connect: connection refused

connection error: desc = "transport: Error while dialing: dial tcp 172.20.0.4:8001: connect: connection refused

が発生するパターンです。

周辺ログも含めると次のようになります。

timeout-client-1          | 2023/08/29 04:34:53 INFO: [core] Creating new client transport to "{\n  \"Addr\": \"sidecar-client:8001\",\n  \"ServerName\": \"sidecar-client:8001\",\n  \"Attributes\": null,\n  \"BalancerAttributes\": null,\n  \"Type\": 0,\n  \"Metadata\": null\n}": connection error: desc = "transport: Error while dialing: dial tcp 172.20.0.4:8001: connect: connection refused"
timeout-client-1          | 2023/08/29 04:34:53 WARNING: [core] [Channel #1 SubChannel #2] grpc: addrConn.createTransport failed to connect to {
timeout-client-1          |   "Addr": "sidecar-client:8001",
timeout-client-1          |   "ServerName": "sidecar-client:8001",
timeout-client-1          |   "Attributes": null,
timeout-client-1          |   "BalancerAttributes": null,
timeout-client-1          |   "Type": 0,
timeout-client-1          |   "Metadata": null
timeout-client-1          | }. Err: connection error: desc = "transport: Error while dialing: dial tcp 172.20.0.4:8001: connect: connection refused"
timeout-client-1          | 2023/08/29 04:34:53 INFO: [core] [Channel #1 SubChannel #2] Subchannel Connectivity change to TRANSIENT_FAILURE, last error: connection error: desc = "transport: Error while dialing: dial tcp 172.20.0.4:8001: connect: connection refused"
timeout-client-1          | 2023/08/29 04:34:53 INFO: [core] pickfirstBalancer: UpdateSubConnState: 0xc000010018, {TRANSIENT_FAILURE connection error: desc = "transport: Error while dialing: dial tcp 172.20.0.4:8001: connect: connection refused"}
timeout-client-1          | 2023/08/29 04:34:53 INFO: [core] [Channel #1] Channel Connectivity change to TRANSIENT_FAILURE
timeout-client-1          | 2023/08/29 04:34:53 rpc error: code = Unavailable desc = connection error: desc = "transport: Error while dialing: dial tcp 172.20.0.4:8001: connect: connection refused"
timeout-sidecar-client-1  | [2023-08-29 04:34:53.369][1][warning][main] [source/server/server.cc:761] there is no configured limit to the number of allowed active connections. Set a limit via the runtime key overload.global_downstream_max_connections
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] [Channel #1 SubChannel #2] Subchannel Connectivity change to IDLE, last error: connection error: desc = "transport: Error while dialing: dial tcp 172.20.0.4:8001: connect: connection refused"
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] pickfirstBalancer: UpdateSubConnState: 0xc000010018, {IDLE connection error: desc = "transport: Error while dialing: dial tcp 172.20.0.4:8001: connect: connection refused"}
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] [Channel #1] Channel Connectivity change to IDLE
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] [Channel #1 SubChannel #2] Subchannel Connectivity change to CONNECTING
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] [Channel #1 SubChannel #2] Subchannel picks a new address "sidecar-client:8001" to connect
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] pickfirstBalancer: UpdateSubConnState: 0xc000010018, {CONNECTING <nil>}
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] [Channel #1] Channel Connectivity change to CONNECTING
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] [Channel #1 SubChannel #2] Subchannel Connectivity change to READY
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] pickfirstBalancer: UpdateSubConnState: 0xc000010018, {READY <nil>}
timeout-client-1          | 2023/08/29 04:34:54 INFO: [core] [Channel #1] Channel Connectivity change to READY

原因

このエラーは、接続先のホストが存在しネットワーク上で到達可能であるが、指定されたポートでの接続を受け入れていない場合に発生します。
より具体的に言うと、コンテナ自体は立ち上がっている一方、サーバのプロセスではまだListenできていないような場合です。

に発生します。今回だとclient Appが先に起動し、client-sidecarの方が起動が遅い時に再現できます。

再現方法

  • clinet app → client-sidecarの順に起動させる
  • client-sidecarのlistenerのportを変更する
  • client appの宛先ポートを変更する

といったことをすると再現可能です。

対応方法

以下のような対応があります。

例えば前者であれば、client appの方に

depends_on:sidecar-client:condition: service_started

のような処理を入れることでエラーが出なくなります。

dial tcp lookup timeout

似たようなケースで

rpc error: code = Unavailable desc = connection error: desc = "transport: Error while dialing: dial tcp: lookup my-service: i/o timeout"

というエラーが発生するケースです。

原因

DNSルックアップがタイムアウトした場合に発生します。例えば

  • DNSサーバーがダウンしている
  • ネットワーク遅延

といったことが原因で起きます。

再現方法

ローカル環境ではうまく再現できませんでした。

対応方法

以下のような対応があります。

upstream request timeout

次のようなエラーが発生するパターンです。

timeout-client-1          | 2023/08/29 04:32:00 rpc error: code = Unavailable desc = upstream request timeout
timeout-client-1          | 2023/08/29 04:32:01 rpc error: code = Unavailable desc = upstream request timeout
timeout-client-1          | 2023/08/29 04:32:02 rpc error: code = Unavailable desc = upstream request timeout
timeout-server-1          | 2023/08/29 04:32:02 name:"alice"  age:10  man:true
timeout-client-1          | 2023/08/29 04:32:03 rpc error: code = Unavailable desc = upstream request timeout
timeout-server-1          | 2023/08/29 04:32:03 name:"alice"  age:10  man:true
timeout-client-1          | 2023/08/29 04:32:04 rpc error: code = Unavailable desc = upstream request timeout
timeout-client-1          | 2023/08/29 04:32:05 rpc error: code = Unavailable desc = upstream request timeout
timeout-client-1          | 2023/08/29 04:32:06 rpc error: code = Unavailable desc = upstream request timeout

原因

Envoyのlistener route.timeoutが、レスポンスにかかる時間よりも短いと発生します。

sidecar to sidecar

sidecar to server app

再現方法

1つはserver-sidecarでパケットをDropさせる方法です。

docker execしてiptablesをインストール

$ docker-compose exec sidecar-server /bin/bash
# apt-get update
# apt-get install iptables
# iptables -A INPUT -p tcp --dport 9001 -j DROP

もう1つはsidecarのroute.timeoutを短くする方法です。Envoy, Istioそれぞれでやり方を紹介すると以下です。

Envoy

Envoyはlistenerのroute.timeoutで設定できます。

listeners:- name: server
      address:socket_address:{address: 0.0.0.0, port_value:8001}filter_chains:- filters:- name: envoy.filters.network.http_connection_manager
              typed_config:"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: egress_http
                codec_type: AUTO
                access_log:- name: envoy.access_loggers.file
                    typed_config:"@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
                      path: /dev/stdout
                route_config:name: local_route
                  virtual_hosts:- name: local_service
                      domains:["*"]routes:- match:{prefix:"/"}route:cluster: sidecar-server
                            timeout: 1s # ここ

デフォルトは15sです。

This timeout defaults to 15 seconds, however, it is not compatible with streaming responses (responses that never end), and will need to be disabled. Stream idle timeouts should be used in the case of streaming APIs as described elsewhere on this page.

How do I configure timeouts? — envoy 1.28.0-dev-ef420f documentation

Istio

IstioはVirtualServiceで設定できます。

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:name: reviews
spec:hosts:- reviews
  http:- route:- destination:host: reviews
        subset: v2
    timeout: 0.5s # ここ

ref: Istio / Request Timeouts

対応方法

基本的には以下の対応になります。

  • route.timeoutより短くなるようにレイテンシを改善する
  • route.timeoutを延ばす

後者は先程の再現方法の逆で時間を延ばすことになります。

upstream connect error or disconnect/reset before headers. reset reason: connection failure

次のようなエラーが発生するパターンです。

timeout-client-1          | 2023/08/30 00:21:07 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure
timeout-client-1          | 2023/08/30 00:21:08 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure
timeout-client-1          | 2023/08/30 00:21:09 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure

原因

sidecar-server

sidecar-serverが落ちるとupstream request timeoutの後で発生します。

timeout-sidecar-server-1 exited with code 0
timeout-sidecar-server-1 exited with code 0
timeout-client-1          | 2023/08/30 00:19:48 rpc error: code = Unavailable desc = upstream request timeout
timeout-client-1          | 2023/08/30 00:19:50 rpc error: code = Unavailable desc = upstream request timeout
timeout-client-1          | 2023/08/30 00:19:52 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure
timeout-client-1          | 2023/08/30 00:19:54 rpc error: code = Unavailable desc = upstream request timeout
timeout-client-1          | 2023/08/30 00:19:56 rpc error: code = Unavailable desc = upstream request timeout
timeout-client-1          | 2023/08/30 00:19:58 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure
timeout-client-1          | 2023/08/30 00:20:00 rpc error: code = Unavailable desc = upstream request timeout

connection failureの頻度は cluster.connect_timeout でした。

- name: sidecar-server
      http2_protocol_options:{}connect_timeout: 3s
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      load_assignment:cluster_name: sidecar-server
        endpoints:- lb_endpoints:- endpoint:address:socket_address:address: sidecar-server
                      port_value:9001

server app

server appが落ちてる場合はrequest timeoutも発生せずこのエラーが発生しました。

timeout-client-1          | 2023/08/30 00:21:07 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure
timeout-client-1          | 2023/08/30 00:21:08 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure
timeout-client-1          | 2023/08/30 00:21:09 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure

再現方法

  • server appを落とす
  • sidecar-serverを落とす

とすると再現できます。

対応方法

  • client-sidecar → server-sidecarの間であれば(server-sidecarが落ちている場合)route.timeoutを延ばす
  • server appが落ちている場合はhealth checkでリクエストがルーティングされないようにする

upstream connect error or disconnect/reset before headers. reset reason: connection failure, transport failure reason: delayed connect error: 111

次のようなエラーが発生するパターンです。

timeout-client-1          | 2023/08/30 00:16:15 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure, transport failure reason: delayed connect error: 111
timeout-client-1          | 2023/08/30 00:16:16 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure, transport failure reason: delayed connect error: 111
timeout-client-1          | 2023/08/30 00:16:18 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure, transport failure reason: delayed connect error: 111
timeout-client-1          | 2023/08/30 00:16:19 rpc error: code = Unavailable desc = upstream connect error or disconnect/reset before headers. reset reason: connection failure, transport failure reason: delayed connect error: 111

原因

111は通常connection refusedを意味します。
実際には ECONNREFUSEDとして知られるエラーです。

指定されたIPアドレスとポートの組み合わせでListenしているプロセスがない場合に発生します。

宛先のシステムにリスニングしているサービスが存在しない場合、宛先システムはSYNパケットの応答としてRSTパケットを即座に送信します。このRSTパケットの受信が ECONNREFUSEDエラーの原因となります。
一方、宛先のシステムがSYNパケットを受信したが、何らかの理由で応答しない場合(例: パケットがドロップされた場合)、クライアントはタイムアウトまで待機します。

sidecarがまだlistenしていないケース

server appがまだlistenしていないケース

再現方法

  • server-sidecarのlisten port番号を変える
  • server appのlisten port番号を変える
  • serverに起動処理を加える(listen前にtime.Sleepを入れる)

といったことで再現が可能です。

対応方法

起動時と終了時両方起きうるので、

終了時

  • graceful shutdownでサーバ停止後にリクエストが流れないようにする

起動時

  • health checkでリクエストがルーティングされる前に起動完了させる

となります。

その他

今回のサンプルコードはこちらです。

github.com

まとめ

IstioやEnvoyで発生するネットワーク系エラーの原因、再現方法、対応方法をまとめました。

Node.jsでGraceful Shutdown

$
0
0

概要

christina04.hatenablog.com

のNode.js版です。

環境

  • Node.js v18.18.0
  • TypeScript v5.2.2
  • Express v4.18.2

課題

次のようなアプリケーションコードがあった際に

importtype{ Express, Request, Response }from"express";import express from"express";const app: Express = express();const port =process.env.port ||8000;

app.get("/",(req: Request, res: Response)=>{
  setTimeout(()=>{
    res.end("Hello world");},10000);});const server = require("http").createServer(app);

server.listen(port,()=>{console.log("Express server listening on port " + server.address().port);});

サーバ側はデプロイなどで停止することがあります。

Ctrl-CSIGINTを投げると、途中で処理が中断され、

$ curl localhost:8000
curl: (52) Empty reply from server

クライアント側はエラーになります。

対応方法

Graceful Shutdown

次のようなフローでGraceful shutdownを導入します。

  1. 終了シグナルを受け取る
  2. 新規リクエストの受け付けを停止する
  3. 処理中のリクエストを最後まで処理する
  4. (Option)その他のリソース(DB接続など)を閉じる

1. 終了シグナルを受け取る

まずはSIGINTシグナルを受け取るように修正します。

process.on('SIGINT')でできます。

importtype{ Express, Request, Response }from"express";import express from"express";const app: Express = express();const port =process.env.port ||8000;

app.get("/",(req: Request, res: Response)=>{
  setTimeout(()=>{
    res.end("Hello world");},10000);});const server = require("http").createServer(app);

server.listen(port,()=>{console.log("Express server listening on port " + server.address().port);});process.on("SIGINT",()=>{console.info("SIGINT signal received.");});

2, 3. 新規リクエストの受付を停め、処理中のリクエストを最後まで実行

終了シグナルを受け取ったら新規リクエストの受付を停め、処理中のリクエストを最後まで実行するようにします。

server.close()でできます。

process.on("SIGINT",()=>{console.info("SIGINT signal received.");

  server.close((err: any)=>{console.log("Http server closed.");if(err){console.error(err);process.exit(1);}});});

この時点で先程のcurl: (52) Empty reply from serverも出なくなります。

サーバ

$ npx ts-node src/index.ts
Express server listening on port 8000
^CSIGINT signal received.
Http server closed.

クライアント

$ curl localhost:8000
Hello world

4. その他のリソースを閉じる

必要であればDBなどの接続もきちんと後片付けをして、TCPハーフオープンなコネクションが生まれたりしないようにします。

process.on("SIGINT",()=>{console.info("SIGINT signal received.");

  server.close((err: any)=>{console.log("Http server closed.");if(err){console.error(err);process.exit(1);}

    mongoose.connection.close(false).then(()=>{console.log("MongoDB connection closed.");process.exit(0);});});});

その他

今回のサンプルコードはこちら

github.com

まとめ

Node.jsでのGraceful Shutdownの導入方法を紹介しました。

参考

BigQueryのパーティション分割テーブル、日付別テーブル

$
0
0

概要

BigQueryにおける分割テーブルは

の大きく2種類あり、さらにパーティション分割テーブルは

  • 取り込み時間による分割
  • 時間単位カラムによる分割
  • 整数範囲による分割

の3種類あります。

今回はそれらの違い、作成方法、terraformの設定方法を解説します。

パーティション分割テーブル

取り込み時間による分割

データを取り込む時間に基づいて、パーティションに自動的に行を割り当てます。
パーティションの境界は UTC時間に基づきます。
取り込み時間パーティション分割テーブルには、_PARTITIONTIMEという名前の疑似列が用意されます。

ref: https://cloud.google.com/bigquery/docs/partitioned-tables?hl=ja#ingestion_time

作り方

パーティション設定で「取り込み時間により分割」を選択します。

時間単位列による分割

データの特定のカラム(型がDATETIMESTAMPDATETIMEのどれか)を元にパーティション分割します。
実際のイベント時間と取り込み時間にラグがある場合はこちらが良いでしょう。

作り方

フィールドから選択します。 スキーマには、パーティショニング列に DATETIMESTAMPDATETIME 列を含める必要があります。

整数範囲による分割

年齢など特定のINTEGERカラムを元にパーティション分割します。

時系列的な分割はできないけれど、パーティション分割したい場合に有用です。

作り方

フィールドから選択します。 スキーマにパーティショニング列に対する INTEGER 列が含まれている必要があります。

整数範囲による分割では開始、終了、間隔の値を指定します。

この範囲外の値は、特定の __UNPARTITIONED__ パーティションに入ります。

日付別テーブル

パーティション分割テーブルが提供される前のテーブル分割方法として用意されたものです。今だと使うことはほぼありません。

パーティション分割テーブルの1テーブルあたりのパーティション上限は4000なので、もしそれを超えるケースがあれば採用するのが良いでしょう。

作り方

テーブル名に_yyyymmddサフィックスを付けるだけです。

テーブルとしては独立して存在していますが、

このようにBigQuery UIでまとめてくれます。

Terraformの設定方法

次にTerraformでの設定の違いを説明します。

https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/bigquery_table#nested_time_partitioning

で設定します。

取り込み時間による分割

要素設定
time_partitioning設定する
time_partitioning.type設定する
time_partitioning.field設定しない
range_partitioning設定しない
resource "google_bigquery_table" "ingestion" {
  dataset_id = google_bigquery_dataset.main.dataset_id
  table_id   ="ingestion"
  schema     = file("./schema.json")

  time_partitioning {
    type                     ="DAY"
    require_partition_filter = true
  }}

時間単位列による分割

要素設定
time_partitioning設定する
time_partitioning.type設定する
time_partitioning.fieldスキーマにおけるTIMESTAMP系のフィールドを指定する
range_partitioning 設定しない
resource "google_bigquery_table" "time_based_column" {
  dataset_id = google_bigquery_dataset.main.dataset_id
  table_id   ="time-based-column"
  schema     = file("./schema.json")

  time_partitioning {
    type                     ="DAY"
    field                    ="time"
    require_partition_filter = true
  }}

整数範囲による分割

要素設定
time_partitioning設定しない
time_partitioning.type設定しない
time_partitioning.field設定しない
range_partitioning 設定する
range_partitioning.field スキーマにおけるINTEGERのフィールドを指定する
range_partitioning.range 設定する
resource "google_bigquery_table" "range_based_column" {
  dataset_id = google_bigquery_dataset.main.dataset_id
  table_id   ="range-based-column"
  schema     = file("./range_schema.json")

  range_partitioning {
    field ="age"
    range {
      start    =0
      end      =100
      interval =1}}}

参考


IngressとGCPロードバランサーの命名規則

$
0
0

背景

KubernetesIngressで作成されたGCPロードバランサー周りのコンポーネント

  • k8s2-um-xxx
  • k8s2-rm-xxx
  • k8s2-tp-xxx
  • k8s-be-xxx

など色々あり、どれが何を表しているのか分かりづらかったのでまとめます。

命名規則コンポーネント

命名規則コンポーネントの関係を一覧化すると以下です。

命名規則コンポーネントannotation
k8s2-um-xxx-{namespace}-{name}-yyy URL Map ingress.kubernetes.io/url-map
k8s2-rm-xxx-{namespace}-{name}-yyy Redirect URL Map ingress.kubernetes.io/redirect-url-map
k8s2-fr-xxx-{namespace}-{name}-yyy Forwarding Rule ingress.kubernetes.io/forwarding-rule
k8s2-fs-xxx-{namespace}-{name}-yyy HTTPS Forwarding Rule ingress.kubernetes.io/https-forwarding-rule
k8s2-tp-xxx-{namespace}-{name}-yyy Target HTTP Proxy ingress.kubernetes.io/target-proxy
k8s2-ts-xxx-{namespace}-{name}-yyy Target HTTPS Proxy ingress.kubernetes.io/https-target-proxy
k8s2-cr-xxx-yyy-zzz SSL Certificate ingress.kubernetes.io/ssl-cert
k8s-be-xxx--yyy Backend Service (Default) ingress.kubernetes.io/backends
k8s1-xxx-{namespace}-{name}-{port}-yyy Backend Service ingress.kubernetes.io/backends

コンポーネント

アーキテクチャ

前述のコンポーネントは次のアーキテクチャロードバランサーを実現しています。

ref: https://cloud.google.com/load-balancing/docs/https?hl=ja#component

役割

コンポーネントの役割は次のようになっています。

コンポーネント説明
Forwarding Rule 外部 IP アドレスを持ち、特定のポート/プロトコルをListenする。
受け取ったトラフィックをTarget Proxyへ転送する
HTTPS Forwarding Rule ↑のHTTPS
Target HTTP Proxy クライアントからの HTTP接続を終端する。
下記のURL Mapを用いてルーティングを決定し、特定のBackend ServiceやBucketに転送する
Target HTTPS Proxy ↑のHTTPS
SSL Certificate TLS/SSL証明書の管理
URL Map HTTP 属性(リクエストパス、Cookie、ヘッダーなど)を使ってどのBackend Service/Bucketに渡すかのマッピング
Redirect URL Map HTTP→HTTPSのようなリダイレクトを設定した場合のURL Map。
Backend Serviceは持たない
Backend Service Ingressで設定したバックエンド
Backend Service (Default) Ingressで設定されなかったpathに対するデフォルトのバックエンド。
404を返したりする

カテゴリ

GCPのWebコンソールでは次のように

  • ロードバランサ
  • バックエンド
  • フロントエンド

と分かれています。

コンポーネントとカテゴリの関係は以下になっています。

命名規則コンポーネントカテゴリ
k8s2-fr-xxx-{namespace}-{name}-yyy Forwarding Rule フロントエンド
k8s2-fs-xxx-{namespace}-{name}-yyy HTTPS Forwarding Rule フロントエンド
k8s2-tp-xxx-{namespace}-{name}-yyy Target HTTP Proxy 含まれない
k8s2-ts-xxx-{namespace}-{name}-yyy Target HTTPS Proxy 含まれない
k8s2-cr-xxx-yyy-zzzSSL Certificate 含まれない
k8s2-um-xxx-{namespace}-{name}-yyy URL Map ロードバランサ
k8s2-rm-xxx-{namespace}-{name}-yyy Redirect URL Map ロードバランサ
k8s1-xxx-{namespace}-{name}-{port}-yyy Backend Service バックエンド
k8s-be-xxx--yyy Backend Service (Default) バックエンド

なのでCtrl-Fによるブラウザ検索ではk8s2-fr-xxxロードバランサタブで検索しても出てこない、といったことが起きます。
ただしWebコンソールのフィルタ機能を使えば紐づくロードバランサ(URL Map)を表示してくれます。

まとめ

Ingressから生成されるコンポーネント命名GCPロードバランサーの関係をはっきりさせたことで、Webコンソール上から見てもコンポーネントの役割や関係が理解できるようになりました。

参考

DockerでMySQLのクエリログを見れるようにする

$
0
0

背景

GraphQLでN+1になってないかを確認したいときに、スロークエリだけでなく全てのクエリログを見たくなったのでその設定方法を説明します。

  • Dockerコンテナの中に直接入って見る方法
  • Dockerログに吐き出す方法

の2通りで説明します。

環境

設定方法

まずは直接コンテナに入って見る方法です。

MySQLの設定

ローカルもしくはDockerコンテナ内でmysqlにログインして次のクエリを投げると設定を確認できます。

mysql> show variables like'general_log%';
+------------------+---------------------------------+
| Variable_name    | Value                           |
+------------------+---------------------------------+
| general_log      | OFF                             |
| general_log_file | /var/lib/mysql/cfadc6a5faf3.log |
+------------------+---------------------------------+

general_logONになっていないとクエリログは出ないのでONにします。

mysql> set global general_log = on;

mysql> show variables like'general_log%';
+------------------+---------------------------------+
| Variable_name    | Value                           |
+------------------+---------------------------------+
| general_log      | ON                              |
| general_log_file | /var/lib/mysql/cfadc6a5faf3.log |
+------------------+---------------------------------+

以上で設定はOKです。

確認

後はコンテナに入り、general_log_fileをtailすればクエリログを見ることができます。

$ tail -f /var/lib/mysql/cfadc6a5faf3.log2023-11-30T01:21:20.370746Z        38Connect   root@192.168.65.1on mydb using TCP/IP
2023-11-30T01:21:20.371718Z        38 Query     SELECT @@socket, @@max_allowed_packet, @@wait_timeout
2023-11-30T01:21:20.374743Z        38 Prepare   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE1=1ORDERBY `mydb`.`User`.`id` ASC LIMIT ? OFFSET ?
2023-11-30T01:21:20.375434Z        38Execute   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE1=1ORDERBY `mydb`.`User`.`id` ASC LIMIT 100 OFFSET 02023-11-30T01:21:20.376700Z        38 Prepare   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (?)
2023-11-30T01:21:20.377051Z        38Execute   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (1)
2023-11-30T01:21:34.027736Z        14 Query     select * fromUser

ログファイルの場所を変更したい場合

デフォルトだとランダムな文字列のファイル名になっているので、固定したファイルに吐き出したい場合は次のように設定します。

mysql>set global general_log_file='/tmp/mysql.log';

mysql> show variables like 'general_log%';
+------------------+----------------+
| Variable_name    | Value          |
+------------------+----------------+
| general_log      | ON             |
| general_log_file | /tmp/mysql.log |
+------------------+----------------+

Dockerログに直接吐き出す方法

次はdockerの標準出力ログに吐き出す方法です。

  • docker run
  • docker-compose

の2通りで解説します。

docker run

docker runにおけるCOMMANDを次のようにいじります。

$ docker run -p 3306:3306 \-eMYSQL_ROOT_PASSWORD="mypass"\
  mysql \
  bash -c'touch /tmp/mysql.log && tail -f /tmp/mysql.log & /usr/local/bin/docker-entrypoint.sh mysqld --datadir=/var/lib/mysql --user=root --general-log=true --general-log-file=/tmp/mysql.log'

general-log-file/dev/stdoutが指定できたらシンプルにできて理想ですが、現在はエラーが出ます(以前はできたようです)。

docker-compose

docker-composeの場合は次のようなyamlになります。

version:"3.9"services:mysql:image: arm64v8/mysql:8
    restart: always
    command:>
      bash -c '      touch /tmp/mysql.log &&      tail -f /tmp/mysql.log &      /usr/local/bin/docker-entrypoint.sh mysqld      --character-set-server=utf8mb4      --collation-server=utf8mb4_unicode_ci      --general-log=true      --general-log-file=/tmp/mysql.log'ports:- "3306:3306"environment:MYSQL_ROOT_PASSWORD: mypass
      MYSQL_DATABASE: mydb

その他

localhostでアクセスするとコケる

host名をlocalhostでアクセスしようとすると、

$ mysql --host=localhost-u root -p

次のようにエラーになりました。

ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock' (2)

mysqllocalhostを利用した接続に際してはUNIXドメインソケットを使おうと試みるため、127.0.0.1としてTCPコネクションにする必要があるようです。

$ mysql --host=127.0.0.1-u root -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 39
Server version: 8.2.0 MySQL Community Server - GPL

Copyright (c)2000, 2023, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h'for help. Type '\c' to clear the current input statement.

mysql>

参考

GraphQLのメリット

$
0
0

背景

GraphQLでよく挙がるメリットとして以下があります。

  • RESTful APIと違って都度UIに依存したAPI設計をする必要がない
    • マルチデバイス対応サービスにおいて大きなメリット
  • オーバーフェッチを避けることができる
    • Switchなどデバイス制約が多いクライアントにとっても適したAPIとなる
  • アンダーフェッチによるAPIコールの増大を防げる

一方であまり触れられない大きなメリットとして

  • ビジネスロジックを集約できる
  • クライアントからのクエリによってバックエンド処理を減らせる

という点があります。今回はこれについて説明します。

環境

  • TypeScript 5.2.2
  • Node.js 20.8.10
  • GraphQL 16.8.1
  • Apollo-Server 4.9.5
  • Prisma 5.5.2

グラフ構造で考えた場合のメリット

スキーマ定義をグラフのモデルと考えると、そこにおけるオブジェクト(データ)の探索・処理がグラフ理論に基づいていて効率的になります。 RESTful APIの場合、そのデータの探索・処理が実装する人に依存したコードとなるため、探索効率が適切かどうかはレビューするまで分かりません
一方でGraphQLではGraphQLのやり方で書けばデータ探索・処理が自動的に適切なものとなります。
(自動的であるがゆえN+1問題が起きてしまうようなデメリットももちろん有ります)

ではどうすればGraphQLらしい書き方になるか、というと

  • ゾルバをQueryノードだけでなく、データモデルのノードにも割り当てること

になります。

具体的な実装

それでは具体的な実装について例を挙げてみます。

ビジネスロジックの集約

オブジェクト指向の考えに近く、ロジックをノードに実装しようという話です。

例えば次のようなスキーマがあり、

typeUser{id: Intname: Stringemail: Stringage: Int}typeQuery{users: [User]userById(id: Int): UseruserByEmail(email: String): User}

実装は次のようになっています。

const resolvers ={
  Query: {
    users: async()=>{returnawait prisma.user.findMany();},
    userById: async(_: any, args: any)=>{returnawait prisma.user.findUnique({
        where: { id: args.id },});},
    userByEmail: async(_: any, args: any)=>{returnawait prisma.user.findUnique({
        where: { email: args.email },});},},};

その後仕様変更が入り、UserisAdultみたいなフラグを追加するとします。

typeUser{id: Intname: Stringemail: Stringage: IntisAdult: Boolean}typeQuery{users: [User]userById(id: Int): UseruserByEmail(email: String): User}

GraphQLライクではないコードの場合

GraphQLのメリットを活かさないコードの場合は次のようになります。

const isAdult =(age: number)=>{return age >=18};const resolvers ={
  Query: {
    users: async()=>{const user =await prisma.user.findMany();return user.map((u)=>{return{
          ...u,
          isAdult: isAdult(u.age),};});},
    userById: async(_: any, args: any)=>{const user =await prisma.user.findUnique({
        where: { id: args.id },});if(!user){thrownewError("User not found");}return{
        ...user,
        isAdult: isAdult(user.age),};},
    userByEmail: async(_: any, args: any)=>{const user =await prisma.user.findUnique({
        where: { email: args.email },});if(!user){thrownewError("User not found");}return{
        ...user,
        isAdult: isAdult(user.age),};},},};

このように全ての影響する関数を洗い出し、修正が必要となります。
またisAdultフィールドが不要な場合であっても処理が行われるため、コンピューティングリソースが消費されます。

GraphQLの場合

GraphQLのメリットを活かしたコードの場合は次のようになります。
ポイントはresolversUser.isAdultノードの処理を追加したことです。

const resolvers ={
  User: {
    isAdult: (parent: any)=>{returnparent.age >=18;},},
  Query: {
    users: async()=>{returnawait prisma.user.findMany();},
    userById: async(_: any, args: any)=>{returnawait prisma.user.findUnique({
        where: { id: args.id },});},
    userByEmail: async(_: any, args: any)=>{returnawait prisma.user.findUnique({
        where: { email: args.email },});},},};

既存のコードはいじらず、かつisAdultフィールドが不要なときは実行されないのでコンピューティングリソース消費しません。

DBアクセスの削減

DBアクセスにも同じことが言えます。

Schema

typePost{title: Stringcontent: String}typeUser{id: Intname: Stringemail: Stringposts: [Post]}typeQuery{users: [User]}

このようなスキーマ

query ExampleQuery {
  users {
    id
    name
  }
}

のようにPostsを含まずUserだけ取得するケースの場合に、ちゃんと書かないとPostsまでクエリが流れてしまいます。

GraphQLライクではないコードの場合

GraphQLのメリットを活かさないコードの場合は次のようになります。

const resolvers ={
  Query: {
    users: async()=>{const users =await prisma.user.findMany();const userIds = users.map((user)=> user.id);const posts =await prisma.post.findMany({
        where: { authorId: {in: userIds }},});const usersData = users.map((user)=>{const userPosts = posts.filter((post)=> post.authorId == user.id);return{
          id: user.id,
          name: user.name,
          email: user.email,
          posts: userPosts.map((post)=>{return{ title: post.title, content: post.content };}),};});return usersData;},},};
  1. Userデータの取得
  2. UserIdの抽出
  3. UserIdからPostデータの取得
  4. データの結合

というロジックです。
よくありそうな実装ですが、次のような課題があります。

  1. クエリでPostデータが不要でもDBアクセスしてしまう
  2. ロジックを自分で管理しなくてはいけない
    1. Postフィールドが増えたときに追加修正が必要

クエリ結果

Prepare   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE1=1Execute   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE1=1
Prepare   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`title`, `mydb`.`Post`.`content`, `mydb`.`Post`.`published`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (?,?,?)
Execute   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`title`, `mydb`.`Post`.`content`, `mydb`.`Post`.`published`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (1,2,3)

Prismaのincludeを使う場合

Prismaだと次のように書くことでシンプルにできますが、この書き方でもクエリの発行は防げていません。

const resolvers ={
  Query: {
    users: async()=>{returnawait prisma.user.findMany({
        include: {
          posts: true,},});},},};

クエリ結果

Prepare   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE1=1Execute   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE1=1
Prepare   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`title`, `mydb`.`Post`.`content`, `mydb`.`Post`.`published`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (?,?,?)
Execute   SELECT `mydb`.`Post`.`id`, `mydb`.`Post`.`title`, `mydb`.`Post`.`content`, `mydb`.`Post`.`published`, `mydb`.`Post`.`authorId` FROM `mydb`.`Post` WHERE `mydb`.`Post`.`authorId` IN (1,2,3)

GraphQLの場合

GraphQLのメリットを活かしたコードの場合は次のようになります。

type BatchPost =(userIds: readonlynumber[])=>Promise<Post[][]>;// DataLoader function to batch and cache post requestsconst batchPosts: BatchPost =async(userIds)=>{const posts =await prisma.post.findMany({
    where: {
      authorId: {in: [...userIds]},},});const postsByUserId = userIds.map((userId: number)=>
    posts.filter((post)=> post.authorId === userId));return postsByUserId;};// Create a DataLoader instance for postsconst postsLoader =new DataLoader<number, Post[]>(batchPosts);const resolvers ={
  User: {
    posts: async(parent: any)=>{returnawait postsLoader.load(parent.id);},},
  Query: {
    users: async()=>{returnawait prisma.user.findMany({});},},};

Dataloaderも入れているので若干読みにくいですが、本質はresolversUser.postsノードを入れているところです。
これによってGraphQLクエリが含まれない場合に、実装は意識せずともGraphQLが自動的にresolverの実行を制御しDBクエリを流さなくなります。

クエリ結果

Prepare   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE1=1Execute   SELECT `mydb`.`User`.`id`, `mydb`.`User`.`email`, `mydb`.`User`.`name` FROM `mydb`.`User` WHERE1=1

その他

サンプルコード

今回のサンプルコードはこちら

github.com

クエリログを出すには

こちらで解説しています。

christina04.hatenablog.com

まとめ

オブジェクト指向言語であったとしても書き方によってはオブジェクト指向でないことがあるように、GraphQLを使っていたとしてもそのメリットを享受できる書き方になっているかをちゃんと理解しておく必要がある、という話でした。

参考

二重サブミットを防ぐ方法

$
0
0

背景

支払い処理などで問題になりがちな二重サブミット問題(Double Posting Problem)ですが、主に以下のようなケースで発生します。

  • ボタンのダブルクリック
    • ユーザが間違えて2回ボタンを触ってしまう(ときには遅さにイライラして何度もクリック)
    • リクエスタイムアウトで処理に失敗したように見えて、もう一度押してしまう
  • 完了ページでリロードしてしまう
    • Pull-to-Refreshしてしまう
    • 以前開いていたページをフォアグラウンド復帰時にChromeが勝手にリロードしてしまう
  • 戻るボタンで戻って再度ボタンを押す
  • サービス設計におけるリトライ処理が不適切に行われた

今回はその防止方法について説明します。

対応方法

大まかな方針としてはユニークなトークンを発行してそれをチェックする方法です。

サーバサイドで行う場合

サーバサイドでトークンを発行するパターンです。

シーケンス図

シーケンス図はこちらです。

まず購入要求処理にてサーバ側でトークンを払い出します。
トークンにはもちろんユニーク性が求められます。詳細が知りたい方は以下をご参考ください。

ユースケースに応じたユニークなIDの生成 - Carpe Diem

また場合によってはここでレートリミットであったり在庫管理チェック処理などを挟むことで、決済確定時の処理を少なくできます。

次に確定処理でトークンをクライアントから投げてもらい、それがDBに存在するかどうかで実行済みなのかを判断します。
DBに保存されていれば過去に実行済なので、

  • エラーを返す
  • 何も処理を実行せずに成功を返す

のどちらかが良いでしょう。

CheckとStoreの間の整合性に気をつける

Check(read)してStore(write)する場合、その間に別の並行処理によって状態が変わっている(先にwriteされてしまう)ケースがあります。

なのでシーケンス図では分けていますが、実装ではinsert処理によるエラー(存在しないなら成功、存在するなら失敗)でハンドリングするのが良いでしょう。

もちろんSELECT * FOR UPDATEでreadに対する悲観ロックをかけることもできますが、それによるギャップロックの影響など考慮しないといけないことも増えるのでシンプルにinsertによる処理が良いでしょう。

クライアントサイドで行う場合(Idempotency-Key Header)

トークン(Idempotency-Key)の払い出しをクライアントサイドで行う場合はRFCのドラフトがあります。

https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/

Stripe、PayPalなどの決済SaaSがこの問題を解決するための手法を提案しており、それが標準化されてきている状態です。

シーケンス図

シーケンス図はこちらです。サーバサイドと大きくは変わりません。

異なる点としてはPayload(Request Body)が同じかどうかをチェックするためにfingerprintを生成してトークンと一緒に確認する点です。
もし2回目以降のリクエストでfingerprintが異なる場合は422 Unprocessable Contentを返します。

また同一リクエストが来た際のレスポンスの挙動も次のように定義されてます。
※ドラフトなので変更される可能性があります

最初のリクエストが完了後リトライされた場合

このケースでは2回目以降のリクエストは処理自体は何もせず同じレスポンスを返すようになります。

同時リクエストだった場合

最初のリクエストが完了する前に来た場合はConflictエラーを返します。

この場合1つ目のリクエストが処理中かどうかを判定するためにIdempotency-KeyとFingerprintのレコードに状態(処理中、完了)を持たせる必要があるでしょう。

まとめ

二重サブミットを防ぐ方法をシーケンス図を交えながら解説しました。

サーバサイドで行うかクライアントサイドで行うかについては、基本的にはRFCが整って来ているクライアントサイドのIdempotency-Keyが良いでしょう。
ただし決済処理をできるだけシンプルに保ちたいようなケースでは購入要求処理に事前処理を詰め込む形でサーバサイドの実装にするのが良さそうです。

参考

開発者ポータル Backstage とは

$
0
0

背景

開発チームが抱えるよくある課題として

  • システムが変化する一方でドキュメントは更新されず腐る
    • メンバーの流入出によって口伝でかろうじて継承された知見も失われる
  • 検索性が良くないと過去のドキュメントが気づかれず、同じような内容のドキュメントが新規量産される
    • 後から参加したメンバーはどちらが正のドキュメントか分からず混乱する

といったことが良くあります。
解決方法としては以下のように、GitHub&ルールベースで管理するといった例があります。

future-architect.github.io

また組織・システムが大きくなってくると認知負荷を低減するためにドメインで区切るような形でチームの分割が始まりますが、

  • 異なるチームによってシステムが管理され、システムの依存関係を全て知っている人がいなくなる
    • CxOレイヤが大規模イベント前に現状を把握したいときに都度時間がかかってしまう
  • チームごとにドキュメントの品質・内容・管理方法が異なり、確認のための余計なコミュニケーションコストがかかる

となり、先程のドキュメント管理の課題と相まって生産性を低下させます。

このような状況で、総合的なソリューションとして生み出されたのがSpotifyのBackstageです。

backstage.io

Spotifyでは数百もの独立したマイクロサービスが存在し、それぞれが異なるチームによって管理されていました。
この複雑さを管理するため、Spotify開発プロセスを標準化し、チーム間の共有と協力を促進する内部ツールとしてBackstageを開発しました。

今回はそのBackstageについて説明します。

Backstageとは

Backstageは開発者ポータルで、コア機能としては以下の役割を持ちます。

機能説明
Software Catalogシステムコンポーネントの依存関係を可視化し、API IFやLinkなど情報を集約する
TechDocsコンポーネントのドキュメントを集約する
Software Template新規コンポーネントを作る際のテンプレート

それぞれを簡単に解説します。

Software Catalog

デモサイトを触ってみるのが一番わかり易いです。

https://demo.backstage.io/catalog

Software Catalogではシステムコンポーネントの情報を全て集約し可視化します。

このように情報が集約&標準化(フォーマット等が統一)されるので、

  • 初見のメンバーでも理解しやすい
  • ドキュメントの検索性に依存しない
  • 書く側も迷わずに書くことができる

といったメリットがあります。

TechDocs

TechDocsはコンポーネントのドキュメントを管理するものです。

TechDocsはGitHubのmdファイルを読み込んでポータル上で表示します。

ref: TechDocs Architecture | Backstage Software Catalog and Developer Platform

なので

  • GitHubで管理できる
    • レビュープロセスを踏める
    • Issueと紐づけやすい
    • 開発者が慣れていてハードルが低い

といったメリットがあります。

またPlantUMLやMermaidといった記法もプラグインでサポートしているので、シーケンス図や状態遷移図などもコードベースで管理できます。

Software Template

例えば新規サービスを作る際は、

  • main関数の安全な起動・終了処理
  • パッケージマネージャ
  • ヘルスチェック処理
  • モニタリングツールとの連携
  • (モノリポでないなら)リポジトリの用意
  • CI/CDの設定
  • .editorconfig等

といった要素が既存のサービスと一緒なことが多いため、テンプレートがあった方が迅速に用意できます。

既存のものからコピペする場合は

  • どこまでコピペすべきか分かりにくい
  • 古い書き方のサービスを参考にしてしまうとそれが蔓延する

といった問題があるため、やはりテンプレートとして提供するのがベターです。

BackstageのTemplate機能はGitHubと連携してるので、そういったコード生成を自動的にコミットしたり新規リポジトリとして用意できます。

こちら3分程の動画ですがどんなことができるのかが分かります。

www.youtube.com

Plugins

Backstageの良いところは拡張性で、多くのPluginsが公式・コミュニティから提供されています。

Backstage Software Catalog and Developer Platform

Kubernetes

例えばKubernetes Pluginを入れれば、開発者ポータル上でサクッとPodの状態やマニフェストファイルの確認などができるので「あれ、デプロイできてない?」「環境変数適用されてない?」みたいな確認にも使えて便利です。

https://backstage.io/docs/features/kubernetes/

Bugsnag

Bugsnagなどのエラー検知ツールとも紐付けられるので、このサービスでどんなエラーが起きていたのかをすぐに把握できます。

https://roadie.io/backstage/plugins/bugsnag/

サービスが成長していくとこういったSaaSダッシュボードが増えていくため

  • それぞれ別で見ないといけないので地味に時間を使う
  • 途中から入ったメンバーはどのダッシュボードがあるのか、どれを見ればいいのか分からない

といったことが良くありますが、このように開発者ポータルにまとめることで解決できます。

まとめ

開発者ポータルBackstageがどんな課題背景から生まれ、それをどう解決しているのかメリットについて説明しました。

VSZ, RSS(anonymous, file)の理解を深める

$
0
0

背景

KubernetesでPodがOOM Killされた際には以下のようなログが発生します。

Memory cgroup out of memory: Kill process 9130 (XXXX) score 1592 or sacrifice child
Killed process 9130 (XXXX) total-vm:423008kB, anon-rss:122484kB, file-rss:33792kB, shmem-rss:0kB

その中でtotal-vmanon-rssfile-rssshmem-rssといった単語が出てくるのでそれぞれの違いを説明していきます。

仮想メモリ

仮想メモリはメインメモリ(RAM)の抽象概念で、プロセスとカーネルにほぼ無限(64bitOSだと約16,000PB)のアドレス空間を提供します。

これにより各プロセスとカーネルは競合を気にすることなく専用のアドレス空間を利用できます。

上図のように仮想メモリはメインメモリ(物理メモリ、RAM)やストレージデバイス(ディスク)へマッピングされます。カーネルはできる限りアクティブなデータをメインメモリの方に残そうとします。

メモリの状態遷移

仮想メモリはメインメモリを無駄に使わないよう、プロセスが仮想メモリを確保してもすぐにメインメモリにマッピングはしません。

以下がその状態遷移図です。

malloc仮想メモリを確保(アロケート)しただけではRSS(物理メモリへマッピングしたサイズ)は増えず、writeして初めて増えるという流れが以下の記事で分かりやすく説明されています。

nopipi.hatenablog.com

先程の状態遷移と合わせるとこんな感じです。

AnonymousとFile-backed

Anonymous

ファイルシステム位置やパス名を持たないプライベートデータ(ヒープ、スタック)のメモリです。

Anonymous pageのページアウトは必ず物理スワップバイスへの書き込みが必要となるため、パフォーマンスが悪化します。

File-backed

mmap()を使ったファイルのメモリへのマッピングで発生した分です。

ディスクからメモリに読み込んだファイルなど場合に発生し、その内容をディスクに書き戻せば解放することができます。

/proc/meminfo

/proc/meminfoにAnonymous, File-backedの使用量が表示されます。

$ cat /proc/meminfo
MemTotal:         985392 kB
MemFree:          511608 kB
MemAvailable:     754608 kB
Buffers:           17592 kB
Cached:           282312 kB
SwapCached:            0 kB
Active:            98564 kB
Inactive:         252068 kB
Active(anon):       1048 kB
Inactive(anon):    58184 kB
Active(file):      97516 kB
Inactive(file):   193884 kB
Unevictable:       26320 kB
Mlocked:           26320 kB
SwapTotal:             0 kB
SwapFree:              0 kB
Dirty:              2076 kB
Writeback:             0 kB
AnonPages:         77084 kB
Mapped:            53900 kB
Shmem:              1116 kB
KReclaimable:      27104 kB
Slab:              57436 kB
SReclaimable:      27104 kB
SUnreclaim:        30332 kB
KernelStack:        1740 kB
PageTables:         1728 kB
NFS_Unstable:          0 kB
Bounce:                0 kB
WritebackTmp:          0 kB
CommitLimit:      492696 kB
Committed_AS:     287028 kB
VmallocTotal:   133143592960 kB
VmallocUsed:        9132 kB
VmallocChunk:          0 kB
Percpu:              444 kB
HardwareCorrupted:     0 kB
AnonHugePages:         0 kB
ShmemHugePages:        0 kB
ShmemPmdMapped:        0 kB
FileHugePages:         0 kB
FilePmdMapped:         0 kB
CmaTotal:          32768 kB
CmaFree:           32384 kB
HugePages_Total:       0
HugePages_Free:        0
HugePages_Rsvd:        0
HugePages_Surp:        0
Hugepagesize:       2048 kB
Hugetlb:               0 kB

システムやカーネルのバージョンによって振る舞いが異なることがあるため、ズレが生じることもありますが大まかに以下のような式が成り立ちます。

File-backed

Buffers + Cached = Active(file) + Inactive(file) + Shmem

Anonymous

Active(anon) + Inactive(anon) = Shmem + AnonPages

なのでこれらを図示すると以下のようになります。

メモリの使用状況に対する用語

前提知識が付いた上でVSS, USS, PSS, RSSといった用語を説明します。

用語説明
VSS(VSZ)
(virtual set size)
プロセスが使用する仮想メモリの総量
プロセスのコード、データ、スタック、ヒープ、および共有ライブラリやマッピングされたファイルなどのメモリが含まれる
USS
(unique set size)
プロセスによって専用に使用されている物理メモリの量
そのプロセスにのみ属するプライベートページの合計
プロセスを終了させると解放されるメモリ量
PSS
(proportional set size)
USS + 共有メモリを使用している全プロセス間で均等に分割した量
全プロセスを考慮した上でのメモリ使用割合を公平に評価する際に利用する
RSS
(resident set size)
プロセスが物理メモリ上に実際に保持しているメモリの量
プロセスのプライベートページと共有メモリの両方が含まれる

図で表すと以下です。

DockerはRSSを見てる

以下のブログで詳細が解説されていますが、DockerはRSSベースでOOM Killします。

golangとDockerとOOM — KaoriYa

total-vm、anon-rss、file-rss、shmem-rssとは

最初の背景に戻ってtotal-vmanon-rssfile-rssshmem-rssについて説明すると、以下のようなまとめになります。

用語説明
total-vmプロセスのVSS(VSZ)
つまりプロセスが使用する仮想メモリの総量
anon-rssAnonymousページが使用している物理メモリサイズ
file-rssFile-backedページが使用している物理メモリサイズ
shmem-rss共有メモリが使用している物理メモリサイズ

なのでKubernetesのOOM Killによるログ

Memory cgroup out of memory: Kill process 9130 (XXXX) score 1592 or sacrifice child
Killed process 9130 (XXXX) total-vm:423008kB, anon-rss:122484kB, file-rss:33792kB, shmem-rss:0kB

で見るべき部分は基本的にはanon-rssanon-rssになり、それに合わせてメモリの増強であったり実装の改善をする必要があります。

参考

Backstage をローカルで動かす

$
0
0

概要

前回紹介したBackstageをローカルで使うための説明です。

開発者ポータル Backstage とは - Carpe Diem

環境

  • backstage v1.21.1
  • yarn v1.22.19

Get Started

とりあえず起動してみる

アプリケーション作成

以下のコマンドでアプリケーションを作成できます。
今回はmy-appという名前で作るとします。

$ npx @backstage/create-app@latest
? Enter a name for the app [required] my-app

もし次のエラーが出た場合は

Error: @backstage/create-app requires Yarn v1, found '4.0.0-rc.39'. You can migrate the project to Yarn 3 after creation using https://backstage.io/docs/tutorials/yarn-migration

次のようにyarnのバージョンを固定してください。

$ yarn set version 1.22.19

起動

$ cd my-app  
$ yarn dev

yarn devを実行すると、フロントエンドとバックエンドの両方を実行して起動します。
自動的にブラウザが立ち上がりhttp://localhost:3000/を開きます。

ファイル構成

次に開発する上で覚えておくべきファイル構成について説明します。

アプリケーションを作成すると次のようなファイルが出来上がります。

.
├── README.md
├── app-config.local.yaml├── app-config.production.yaml
├── app-config.yaml
├── backstage.json
├── catalog-info.yaml
├── dist-types
│   ├── packages
│   └── tsconfig.tsbuildinfo
├── examples
│   ├── entities.yaml
│   ├── org.yaml
│   └── template
├── lerna.json
├── node_modules
├── package.json
├── packages
│   ├── README.md
│   ├── app
│   └── backend
├── playwright.config.ts
├── plugins
│   └── README.md
├── tsconfig.json
└── yarn.lock

よくいじる場所のそれぞれの役割を簡単に説明します。

ファイル・ディレクト説明
app-config.yamlBackstageアプリケーションの設定を記述
app-config.local.yamlローカル開発においてapp-config.yamlを上書きしたい部分を記述
app-config.production.yamlプロダクション環境においてapp-config.yamlを上書きしたい部分を記述
catalog-info.yamlSoftware Catalogのカタログファイル。カタログには任意のファイルを読み込めるためここに書かなくても良い
packages/appBackstageアプリケーションのフロントエンド実装
packages/backendBackstageアプリケーションのバックエンド実装

app-config

Backstageのconfigの設定は主に2通り方法があります。

  1. app-config.yamlというファイルで設定
  2. APP_CONFIG_xxxという環境変数で直接設定

複数のファイルで定義されていたり、環境変数などでも設定されていた場合は優先度に基づいて適用されます。 またObject型の設定は優先度に基づいてKustomizeのようにマージされて適用されます。

ファイルの場合

$ yarn start--config ../../app-config.yaml --config ../../app-config.staging.yaml

のように--configパラメータで複数指定可能です。

相対パスの指定方法

上記の指定で気づくように、../../app-config.yamlのように上位ディレクトリに遡っています。
これはyarnの場合ワーキングディレクトリ(ディレクトリルート)はworkspaceになる、つまり

  • packages/app/
  • packages/backend/

なので、app-config.yamlの場所は2階層上ということでそう指定します。

Dockerでビルドする場合

yarnでなくnodeで実行する場合は↑を考慮する必要がなくなるのでディレクトリルートの位置から指定します。

CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.production.yaml"]

環境変数の場合

app-config.yamlでの設定値の._にしてprefixのAPP_CONFIG_をつけます。

例えばapp-config.yamlapp.baseUrlの場合、APP_CONFIG_app_baseUrlという環境変数になります。

読み込み順

次の順に優先的に適用されます。

  1. APP_CONFIG_xxxの値
  2. --config xxx.yamlで設定した時、後方が優先度高い
  3. --configがない場合、app-config.local.yaml>app-config.yamlの優先度
    1. なのでローカル開発だとapp-config.local.yamlが優先される

Software Catalog

次のSoftware Catalogについて説明します。

よく使う要素としては以下の5つがあります。

  • API
  • Component
  • Resource
  • User
  • Group

それぞれの関係は次の図の通りです。

ref: System Model | Backstage Software Catalog and Developer Platform

User, GroupはGitHub Orgの情報と自動連携することもできるので、実際にいじるのはAPI、Component、Resource辺りになります。

細かい設定は次のドキュメントを参考にしてください。

Descriptor Format of Catalog Entities | Backstage Software Catalog and Developer Platform

examples

アプリケーション作成直後のapp-config.yamlには以下の設定が入っています。

catalog:import:entityFilename: catalog-info.yaml
    pullRequestBranchName: backstage-integration
  rules:- allow:[Component, System, API, Resource, Location]locations: # Local example data, file locations are relative to the backend process, typically `packages/backend`- type: file
      target: ../../examples/entities.yaml

    # Local example template- type: file
      target: ../../examples/template/template.yaml
      rules:- allow:[Template] # Local example organizational data- type: file
      target: ../../examples/org.yaml
      rules:- allow:[User, Group]

相対パス../../examplesとなっているのは先程のapp-config.yamlと同様で、packages/backendの位置から見たファイルを指定しているためです。

examples/entities.yamlには次のようなコードがあります。

---# https://backstage.io/docs/features/software-catalog/descriptor-format#kind-systemapiVersion: backstage.io/v1alpha1
kind: System
metadata:name: examples
spec:owner: guests
---# https://backstage.io/docs/features/software-catalog/descriptor-format#kind-componentapiVersion: backstage.io/v1alpha1
kind: Component
metadata:name: example-website
spec:type: website
  lifecycle: experimental
  owner: guests
  system: examples
  providesApis:[example-grpc-api]---# https://backstage.io/docs/features/software-catalog/descriptor-format#kind-apiapiVersion: backstage.io/v1alpha1
kind: API
metadata:name: example-grpc-api
spec:type: grpc
  lifecycle: experimental
  owner: guests
  system: examples
  definition: |
    syntax = "proto3";

    service Exampler {
      rpc Example (ExampleMessage) returns (ExampleMessage) {};
    }

    message ExampleMessage {
      string example = 1;
    };

このようにコンポーネントYamlで定義してBackstageはシステムの情報の集約・依存関係の可視化を行います。

ただしapp-config.yamlでカタログファイルの場所を指定するのは柔軟性が低い(ファイルの追加・変更のたびにデプロイが必要になったり)ため、実際の運用では次のように外部ファイルとして読み込むやり方がオススメです。

外部ファイルからの読み込み

実際に運用してみると分かりますが、Backstageアプリケーション自体とこういったカタログファイルのライフサイクルは異なるため別のリポジトリで管理した方が扱いやすいです。

Publicなリポジトリであれば(例として https://github.com/jun06t/backstage-sample/blob/main/catalog-info.yamlを読み込ませます)

Analyzeボタンを押すとvalidationを行って問題なければImportボタンが押せます。

Importボタンを押すとCatalogページに追加されます。

Publicリポジトリなのに401エラーが出る

もし次のような401エラーが出る場合、

{"error":{"name":"InputError","message":"Error: Unable to read url, Error: https://github.com/jun06t/backstage-sample/tree/main/catalog-info.yaml could not be read as https://api.github.com/repos/jun06t/backstage-sample/contents/catalog-info.yaml?ref=main, 401 Unauthorized","stack":"InputError: Error: Unable to read url, Error: https://github.com/jun06t/backstage-sample/tree/main/catalog-info.yaml could not be read

おそらくアプリケーション作成時に次のようにgithub integrationが設定されているせいです。
GITHUB_TOKENを設定するか、次のようにコメントアウトすれば読み込めるようになります。

integrations:github:- host: github.com
      # This is a Personal Access Token or PAT from GitHub. You can find out how to generate this token, and more information # about setting up the GitHub integration here: https://backstage.io/docs/getting-started/configuration#setting-up-a-github-integrationtoken: ${GITHUB_TOKEN}

#integrations:#  github:#    - host: github.com#      # This is a Personal Access Token or PAT from GitHub. You can find out how to generate this token, and more information#      # about setting up the GitHub integration here: https://backstage.io/docs/getting-started/configuration#setting-up-a-github-integration#      token: ${GITHUB_TOKEN}

rules

前述のように外部ファイルから読み込む場合はexamplesのファイルは不要になるので設定から削除します。

catalog:import:entityFilename: catalog-info.yaml
    pullRequestBranchName: backstage-integration
  rules:- allow:[Component, System, API, Resource, Location]locations: # Local example data, file locations are relative to the backend process, typically `packages/backend`- type: file
      target: ../../examples/entities.yaml

    # Local example template- type: file
      target: ../../examples/template/template.yaml
      rules:- allow:[Template] # Local example organizational data- type: file
      target: ../../examples/org.yaml
      rules:- allow:[User, Group]

catalog:rules:- allow:[API, Component, System, Group, User, Resource, Location, Template]

ただしrulesに関しては各Kindが許可されてないとimport時にエラーが発生するので、このように書いておくことをオススメします。

まとめ

Backstageの起動〜ファイルの設定周りの簡単な説明しました。

参考


2023年買ってよかったものリスト

$
0
0

概要

年の瀬なので2023年に買ってよかったものを挙げてきます。

SESAMEタッチ & オープンセンサー

2021年買ってよかったものリスト - Carpe Diem

でも紹介したスマートロックで

に対応したデバイスがリリースされました。

【New】SESAMEタッチjp.candyhouse.co

開閉検知のセンサーも合わせることで自動ロックも対応しました。

【New】オープンセンサーjp.candyhouse.co

CEOのプレゼンも面白いので応援してるベンチャーです。

www.youtube.com

スマートウォッチ

Xiaomi Smart Band 7の体験が良かったので、より多機能なWearOS対応のスマートウォッチを買ってみました。

Galaxy Watch6からはFelicaにも対応しており、前述のSESAMEタッチと組み合わせてドアの開閉ができるようになりQoLが向上しました。

また時計単体でGPS計測や音楽再生もできるのでこれを機にランニングを始めました。

モバイルモニター

リビングで子供を見ながらリモートワークする際にサブディスプレイが欲しかったので購入しました。

ちょうどゼルダの伝説ティアーズ・オブ・ザ・キングダムも購入したのでSwitchにも対応したモデルを探していて↑を選びました。

オープナー

ダンボールを開ける際にカッターを使っていましたが、子供がいると危ないので良いものはないかなと調べていて見つけたものです。

玄関に貼り付けておけるのですぐ開けることもでき積ダンボールすることもなくなりました。

封筒などのオープナーも買いました。ハサミと違って封筒一枚分だけしか切らずすぐに開ける癖が付きました。

リュック

出社頻度が増えたのでリュックを買い替えました。

  • PC
  • 衣類
  • ペットボトル
  • 財布

などのスペースが全て別で管理できるので中身を探る必要がなくなり体験が良くなりました。

タイマー

ミーティングまでのちょっとの時間を管理する時に使えるタイマーです。

スキマ時間に実装や調査をし始めると集中しすぎてミーティングが始まっていたりすることがあるので購入しました。

アプリでももちろんタイマーはありますが、

  • 物理タイマーなのですぐに設定できる
  • 捻るだけで直感的に操作できる

といった点で気に入っています。

プリンター

前のプリンタが故障したのと、子供のお教室で何枚も印刷するようになりインク代が馬鹿にならないことを受け買い直しました。

買ってからかなり印刷していますが未だインクが1/4程度しか減っておらず、文書を多数印刷するユースケースに非常に向いた商品でした。

Backstage でGitHub認証を導入する

$
0
0

概要

Backstageに認証機能を導入します。

Backstageでは様々な認証方法を提供していますが、今回はGitHubを使った認証を実装します。

環境

  • backstage v1.21.1

認証

GitHub Authentication Provider | Backstage Software Catalog and Developer Platform

に沿って導入します。

GitHub Appの作成

GitHub認証を導入するためにGitHub Appを作成します。

  • GitHubが会社のOrgに所属していたらログインできる認可機構を設定したい
  • Software CatalogのUserやGroupにGitHubのMembersを利用したい

といったケースであれば、個人でなくOrganizationで作成してください。

Register new GitHub App & Identifying and authorizing users

次のパラメータで設定します。

パラメータ説明設定値例
GitHub App nameアプリ名。グローバル(GitHub全体)でユニークである必要がありますxxx-backstage
Homepage URLGitHub Appを使うWebサイトのURLhttp://localhost:3000
Callback URLユーザーがインストールを承認した後にリダイレクトするURLhttp://localhost:7007/api/auth/github/handler/frame
Expire user authorization tokens refresh_tokenを提供してアクセストークンの有効期限が切れたときにアクセストークンを更新する チェックする
Request user authorization (OAuth) during installation アプリのインストール中に、インストールするユーザーが自分のIDへのアクセスを許可するよう要求する チェックする

Post installation & Webhook

Webhookは使わないのでOFFにします。

Permissions

認証部分はIdentityさえ分かれば良いので追加で権限は不要です。

後日紹介する認可部分や、それ以外でGitHubAPIを叩く際に必要になってきます。

インストール対象の選択

個人のGitHub AppとOrgのGitHub Appで若干UIが異なりますが、Backstageではどちらも以下のように設定してください。

作成後

app-config.yamlauth.provider設定で必要となるので、Client IDClient secretsはコピーしておいてください。

auth:environment: development
  providers:github:development:clientId: ${AUTH_GITHUB_CLIENT_ID}
        clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}

実装

packages/app/src/App.tsxを修正します。

import{ githubAuthApiRef }from'@backstage/core-plugin-api';import{ SignInPage }from'@backstage/core-components';

をimportし、createAppに次の行を追加します。

 const app = createApp({
   apis,
+  components: {+    SignInPage: props => (+      <SignInPage+        {...props}+        auto+        provider={{+          id: 'github-auth-provider',+          title: 'GitHub',+          message: 'Sign in using GitHub',+          apiRef: githubAuthApiRef,+        }}+      />+    ),+  },
   bindRoutes({ bind }) {

すると最初のページが次のようになります。

動作確認

app-config.yamlもしくはapp-config.local.yaml

auth:environment: development
  providers:github:development:clientId: ${AUTH_GITHUB_CLIENT_ID}
        clientSecret: ${AUTH_GITHUB_CLIENT_SECRET}

を設定し、先程のGitHubClientIDClientSecret環境変数に設定してアプリを再起動してください。

ログインページでSIGN INをするとGitHubの認可画面に遷移します。

Authorizeするとログインできるようになります。

Setting画面ではアバター画像やメールアドレスが表示されていることが確認できます。

その他

実装サンプル

今回の実装Diffはこちら。

github.com

GitHubアカウントを持っていたら誰でもログインできる?

今回設定した認証は、「GitHubのIDを持っているかどうか」であり、それ以外の認可条件はないためGitHubアカウントを持っていたら誰でもログインできます。

GitHubが会社のOrgに所属していたらログインできる認可機構を設定したい」といったユースケースは認可条件の設定を導入する必要があります。後日紹介します。

SIGN IN時にAuthorizeした情報はどこ?

SIGN IN時にAuthorizeした情報は https://github.com/settings/apps/authorizationsにあります。
RevokeするとSIGN IN時に再びGitHubの認可画面が出てきます。

まとめ

BackstageでGitHubを使った認証機能の導入方法を紹介しました。

Backstage でGitHub Orgを用いた認可を導入する

$
0
0

概要

christina04.hatenablog.com

ではGitHubのIDを持っていればログインできる認証機能を追加しました。

今回は「そのGitHubアカウントが特定のGitHub Organizationに所属しているかどうか」を使ってページを閲覧できるかどうか認可する仕組みを導入します。

認証と認可の違い

認証と認可の違いは以下です。

用語定義
認証誰であるかを確認することパスワード、指紋、etc...
認可権限を与えること切符を購入した人には電車に乗る権限を与える
※誰かを特定する必要はない

ただ多くのケースでは認証と認可はセットになっていることが多いです。

環境

  • backstage v1.21.1

導入方法

GitHub Appの変更

Organizationの情報を利用するので、GitHub Integrationの機能(GitHubAPIを使ってBackstageがアクセスできる情報を増やす)を使います。

Permission

GitHub Apps | Backstage Software Catalog and Developer Platform

で説明されている通りに設定してください。

カテゴリ対象リソースどんな操作を許可するか備考
RepositoryContentsRead & writeSoftware Catalog用。Templateでも使うならWrite権限も
Repository Administration Read & write Templateでリポジトリ作成のため
Repository Metadata Read-only -
Repository Pull requests Read & write -
Repository Issues Read & write -
Repository Workflows Read & write TemplateがGitHub workflowsを使うなら必要
Repository Commit statuses Read-only -
Repository Variables Read & write TemplateがGitHub ActionのVariablesを使うなら必要
Repository Secrets Read & write TemplateがGitHub ActionのSecretsを使うなら必要
Repository Environments Read & write TemplateがGitHub Environmentsを使うなら必要
OrganizationMembersRead-only認証やSoftware CatalogのOrg情報読み込み用

Private Keysの生成

https://github.com/organizations/<Org名>/settings/apps/<GitHub App名>

にてPrivate Keyを生成します。このKeyを使ってGitHubのOrg情報やOrg内のリポジトリにアクセスします。

生成すると自動的に.pemファイルをダウンロードします。

Backstageにcredentialの登録

github-app-credentials.yamlのようなファイルを作ります。

appId:<app id> # GitHub AppのIDclientId:<client id> # GitHub AppのClientIDclientSecret:<client secret> # GitHub AppのClientSecretwebhookSecret:<webhook secret> # 任意privateKey: |
  -----BEGIN RSA PRIVATE KEY-----
  ...Key content...
  -----END RSA PRIVATE KEY-----

RSA PRIVATEの部分に先程の.pemの中身を書きます。インデントに気をつけてください。

そしてapp-config.yamlに次の様に登録します。

integrations:github:- host: github.com
      apps:- $include: github-app-credentials.yaml

インストール

特定のOrganizationにアクセスしているかどうかを取得するために、そのOrganizationに作成したGitHub Appをインストールします。

https://github.com/organizations/<Org名>/settings/apps/<GitHub App名>/installationsにアクセスし、installボタンを押します。

Organizationやリポジトリへの認可処理が挟まります。Backstageを新規リポジトリ作成時にも使う場合はAll repositoriesを選択してください。

実装

GitHub Orgのimport

GitHub Organizational Data | Backstage Software Catalog and Developer Platform

に則って実装します。

まずプラグインのインストール。

$ yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-github

packages/backend/src/plugins/catalog.tsを次のように実装します。orgUrlは自分の組織のOrg名にしてください。

import{ GithubOrgEntityProvider }from'@backstage/plugin-catalog-backend-module-github';exportdefaultasyncfunction createPlugin(
  env: PluginEnvironment,): Promise<Router>{const builder =await CatalogBuilder.create(env);// The org URL below needs to match a configured integrations.github entry// specified in your app-config.
  builder.addEntityProvider(
    GithubOrgEntityProvider.fromConfig(env.config,{
      id: 'production',
      orgUrl: 'https://github.com/jun06t-org',
      logger: env.logger,
      schedule: env.scheduler.createScheduledTaskRunner({
        frequency: { minutes: 60},
        timeout: { minutes: 15},}),}),);
...

またapp-config.yamlのcatalog部分にprovidersを追加します。

catalog:providers:githubOrg:id:'production'orgs:- jun06t-org

すると起動時に次のようにOrg情報を読み込むようになります。

[1] 2024-01-01T00:12:43.170Z catalog info Read 1 GitHub users and 3 GitHub teams in1.3 seconds. Committing... type=plugin target=https://github.com/jun06t-org class=GithubOrgEntityProvider taskId=GithubOrgEntityProvider:development:refresh taskInstanceId=31fa7cab-2fa9-4077-92b5-3face253ecaa
[1] 2024-01-01T00:12:43.175Z catalog info Committed 1 GitHub users and 3 GitHub teams in0.0 seconds. type=plugin target=https://github.com/jun06t-org class=GithubOrgEntityProvider taskId=GithubOrgEntityProvider:development:refresh taskInstanceId=31fa7cab-2fa9-4077-92b5-3face253ecaa

読み込みが完了すると

  • OrgのMembers情報がBackstageのUser
  • OrgのTeam情報がBackstageのGroup

として登録されます。

認証部分の変更

次は認証部分に認可処理を追加します。

packages/backend/src/plugins/auth.tsを次のように変更します。デフォルトのresolverを削除して、コメントアウトされていた部分をアンコメントするだけで済みます。

exportdefaultasyncfunction createPlugin(
  env: PluginEnvironment,): Promise<Router>{returnawait createRouter({
    logger: env.logger,
    config: env.config,
    database: env.database,
    discovery: env.discovery,
    tokenManager: env.tokenManager,
    providerFactories: {
      ...defaultAuthProviderFactories,
      github: providers.github.create({
        signIn: {
          resolver: providers.github.resolvers.usernameMatchingUserEntityName(),},}),},});}

動作確認

ユーザがOrgにいない(=Entityに存在しない)場合、以下のようにエラーになります。

ユーザがOrgにいる場合(=Entityに存在する)場合、問題なくログインできます。

その他

GitHub Appインストール時のコールバックでエラーが出る

"error":{"name":"InputError","message":"Must specify 'env' query to select environment","stack":"InputError: Must specify 'env' query to select environment

というエラーが出るケースです。

OKTA redirect_uri is broken due to configurable authentication environments · Issue #4464 · backstage/backstage · GitHub

を読むとコールバックURLにenvパラメータがないとそうなるようですが、手動でいじらない限り付かなそうでした。
インストール自体は問題ないのでそのまま閉じるでも問題ないです。

Kubernetesでの機密情報の管理

github-app-credentials.yamlをバージョン管理するのは良くないので、これ自体をSecretで管理してマウントし、

integrations:github:- host: github.com
      apps:- $include: ${GITHUB_CREDENTIALS_PATH}

として$includeすると良いでしょう。

また本番用ビルドを作る際はapp-config.yamlに↑の設定がある一方で、ローカルにgithub-app-credentials.yamlを用意していない(Secretに登録後削除した)と次のエラーが発生します。

$ yarn build:backend --config ../../app-config.yaml
yarn run v1.22.19
$ yarn workspace backend build --config ../../app-config.yaml
$ backstage-cli package build --config ../../app-config.yaml
Building app separately because it is a bundled package

$ backstage-cli package build --config github.com/jun06t/backstage-sample/app/app-config.yaml
app:
app:
app:  Error: Failed to read config file at "github.com/jun06t/backstage-sample/app/app-config.yaml", error at .integrations.github[0].apps[0], failed to include "github.com/jun06t/backstage-sample/app/github-app-credentials.yaml", file does not exist

なのでapp-config.yamlでなくapp-config.production.yamlにintegrationsの設定を書くと良いです。

まとめ

GitHubのOrg情報を使った認可機能を実装しました。

参考

ChatGPTのオプトアウト申請方法(2023/10以降版)

$
0
0

概要

WebコンソールのChatGPTでは入力したデータを学習させない方法として2種類のやり方が提供されています。

  • 設定からChatの履歴を保持しないようにする
  • フォームでオプトアウト申請を行う

前者は簡単にできる一方で、Chatの履歴が使えず不便になります。
後者は申請が英語ですが一度設定すれば良いだけでChat履歴も残ります。

なので後者がおすすめですが、2023/10から申請方法が変わったのでやり方を説明します。

※OpenAIのAPIを利用する場合は学習されません

オプトアウト申請手順

まず↓のサイトにアクセスします。

https://privacy.openai.com/policies

Privacy Request Portal

Make a Privacy Requestをクリック。

Consumerをクリック。

Do not train on my contentをクリック。

ChatGPTのアカウントで登録しているメールアドレスを入力

Email認証をして申請

メールアドレスに届いたメールでLog Inをクリック。

するとログイン処理が挟まって確認画面に遷移します。

Country/State of Residencyで日本を選んでConfirm Requestをクリック。 ※ここに書いてあるように、申請以前のデータに関してはオプトアウト対象外となります

Request Submitted Successfully!が表示されたら申請完了です。

申請完了のメールも届きます。

反映の確認

上記はまだ申請だけで、反映が完了された場合はフォームのステータスが変化します。

https://privacy.openai.com/policies

反映待ち

反映完了

また反映完了メールが届きます。

この状態になったら反映完了です。

参考

tmuxでコピーモードを使った時にDeepLに渡されてしまう

$
0
0

背景

tmuxでコピーモードで範囲選択によるコピー機能をよく使っていますが、そこでコピーしたものがそのままDeepLの翻訳の方へ渡されてしまう問題に悩まされてました。

今は有料版なのでコピーしたものが学習データに使われることはありませんが、偶にDeepLのアプデで?ログアウトされてしまっていることがあります。
そうなると無料版状態となり学習データに使われてしまい、情報漏洩に繋がるので解決したかったのがきっかけです。

環境

  • tmux v3.3a
  • Alacritty 0.13.1
  • macOS 13.6

一般的にコピーモードの値がクリップボードに渡されるように次の設定を.tmux.confに入れています。

# コピーモード完了時にクリップボードにコピー
set-window-option -g mode-keys vi
bind-key -T copy-mode-vi Enter send-keys -X copy-pipe-and-cancel "pbcopy"

事象

このようにtmuxで範囲選択コピーを行うと、

勝手にDeepLにコピーした内容が渡されてしまいます。

原因

tmuxのクリップボード連携方法には以下の2種類があります。

  • OSC 52を使う
  • copy-pipeを使う

今回はOSC 52によってクリップボードに渡されて自動的にDeepLが翻訳してしまっていたようです。

OSC 52とは

OSC 52Operating System Command 52の略で、ANSIエスケープシーケンスの一部です。
ANSIエスケープシーケンスは、テキストの色を変えたり、カーソルを動かしたりするためにターミナルで使われるコードです。
OSC 52はこれらのシーケンスの中でクリップボード操作に特化しています。

具体的にはOSC 52を使用するとターミナル内で以下のような操作を行えます。

OSC 52を使うことの利点はターミナルとGUIの間でテキストデータを簡単にやり取りできることです。
しかしセキュリティ上の懸念からすべてのターミナルエミュレータがこの機能をサポートしているわけではなく、デフォルトで無効になっていることもあります。

なぜOSC 52が機能していたか?

tmuxは以下の条件を満たしているとOSC 52が機能します。

  1. set-clipboardオプションがonまたはexternalであること
    • デフォルトはexternal
  2. TERM環境変数で指定したターミナルの terminfo に Ms capability が表示されていること
    • set -g default-terminal "tmux-256color"などを設定していると満たす
  3. 利用しているターミナルエミュレータ自身がOSC 52に対応していること
    • AlacrittyはOSC 52に対応。

ref: https://github.com/tmux/tmux/wiki/Clipboard#the-set-clipboard-option

1はデフォルト設定ですが、偶然もAlacrittyを使っていたり、その際にTrueColor対応のため上記設定を入れていたためOSC 52が意図せず機能していたようです。

christina04.hatenablog.com

解決方法

.tmux.confに以下の設定を追加してset-clipboardを無効化しOSC 52が働かないようにします。

set -s set-clipboard off

まとめ

tmuxでのクリップボード連携方法でよくある設定だけしか気にしたことがなかったので、OSC 52というものを知らず影響を受けていました。

bind-key -T copy-mode-vi Enter send-keys -X copy-pipe-and-cancel "pbcopy"

同じ問題にぶつかった人の助けになれば幸いです。

参考

pyenvからインストールする際にSSLがないというエラー

$
0
0

背景

pyenvでpython 3.10.13をインストールしようとしたところ、次のようなエラーを受けました。

$ pyenv install 3.10.13
python-build: use openssl@1.1 from homebrew
python-build: use readline from homebrew
Downloading Python-3.10.13.tar.xz...
-> https://www.python.org/ftp/python/3.10.13/Python-3.10.13.tar.xz
Installing Python-3.10.13...
python-build: use readline from homebrew
python-build: use ncurses from homebrew
python-build: use zlib from homebrew
Traceback (most recent call last):
  File "<string>", line 1, in <module>
  File "/Users/jun06t/.pyenv/versions/3.10.13/lib/python3.10/ssl.py", line 99, in <module>
    import _ssl             # if we can't import it, let the error propagate
ModuleNotFoundError: No module named '_ssl'
ERROR: The Python ssl extension was not compiled. Missing the OpenSSL lib?

Please consult to the Wiki page to fix the problem.
https://github.com/pyenv/pyenv/wiki/Common-build-problems


BUILD FAILED (OS X 13.6 using python-build 20180424)

その解消方法です。

環境

解決方法

$ brew install openssl@1.1

して

exportLDFLAGS="-L$(brew --prefix openssl@1.1)/lib $LDFLAGS"exportCPPFLAGS="-I$(brew --prefix openssl@1.1)/include $CPPFLAGS"exportPKG_CONFIG_PATH="$(brew --prefix openssl@1.1)/lib/pkgconfig"

をすると解消できます。

その他

どのバージョンでこのエラーが発生する?

どのバージョンでこのエラーが発生するか、いくつか試してみました

バージョンエラー発生
3.9.18発生する
3.10.12発生する
3.10.13発生する
3.11.0発生する
3.11.7発生しない
3.12.1発生しない

CPython supports OpenSSL 3, the support is official since 3.11.5 . So you don't need 1.1 anymore.

とある様に3.11.5からOpenSSL3を使うようになりましたが、それ以前のバージョンではOpenSSL 1.1を使うためこの問題に遭遇するようです。

参考

Function callingのJSON Schemaをpydanticで生成する

$
0
0

背景

Function callingJSONを定義する際はJSON Schemaを用いますが、JSON Schemaは覚えることが多く不慣れだと非常に扱いにくいです。

pydnaticを使うとクラス定義から簡単にJSON Schemaを生成できるので、PythonJSON Schemaを利用する際はおすすめです。

環境

  • python v3.11.8
  • pydantic v2.6.1
  • openai v1.12.0

要件

例えば次のようなJSONがFunction callingで返ってほしいとします。

{"ingredients": [{"ingredient": "人参",
            "amount": "1本"
        },
        {"ingredient": "豚ひき肉",
            "amount": "100g"
        }],
    "cookwares": ["フライパン", "ボウル"],
    "instructions": ["材料を切ります。", "材料を炒めます。"]}

課題

Function callingでの定義

先程のJSONを作るようなJSON Schemaを用意しようとすると、次のように非常に冗長になります。

{"$schema": "http://json-schema.org/draft-04/schema#",
  "type": "object",
  "properties": {"ingredients": {"type": "array",
      "items": [{"type": "object",
          "properties": {"ingredient": {"type": "string"
            },
            "amount": {"type": "string"
            }},
          "required": ["ingredient",
            "amount"
          ]},
        {"type": "object",
          "properties": {"ingredient": {"type": "string"
            },
            "amount": {"type": "string"
            }},
          "required": ["ingredient",
            "amount"
          ]}]},
    "cookwares": {"type": "array",
      "items": [{"type": "string"
        },
        {"type": "string"
        }]},
    "instructions": {"type": "array",
      "items": [{"type": "string"
        },
        {"type": "string"
        }]}},
  "required": ["ingredients",
    "cookwares",
    "instructions"
  ]}

解決方法

pydanticを使う

そこでpydanticを使って、次のようにクラス表現にします。

classIngredient(BaseModel):
    ingredient: str = Field(description="材料", examples=["豚ひき肉"])
    amount: str = Field(description="分量", examples=["300g"])


classRecipe(BaseModel):
    ingredients: list[Ingredient]
    cookwares: list[str] = Field(description="調理器具", examples=["フライパン"])
    instructions: list[str] = Field(description="手順", examples=[["材料を切ります。", "材料を炒めます。"]])

非常に分かりやすいです。

ここでJSON Schemaをparametersに渡しますが、model_json_schema()関数を呼び出すだけOKです。

RECIPE_FUNCTION = {
    "name": "output_recipe",
    "description": "レシピを出力する",
    "parameters": Recipe.model_json_schema(),
}

そしてChat Completions APIにFunction callingとして渡します。

        response = client.chat.completions.create(
            model="gpt-3.5-turbo",
            temperature=0,
            messages=messages,
            functions=[RECIPE_FUNCTION],
            function_call={"name": RECIPE_FUNCTION["name"]},
        )
        response_message = response.choices[0].message
        function_call_args = response_message.function_call.arguments

        recipe = json.loads(function_call_args)
        print(recipe)

結果

期待通りJSONの形で返ってきました。

{"ingredients":[{"ingredient":"合いびき肉",
         "amount":"300g"
      },
      {"ingredient":"玉ねぎ",
         "amount":"1個"
      },
      {"ingredient":"パン粉",
         "amount":"大さじ2"
      },
      {"ingredient":"牛乳",
         "amount":"大さじ2"
      },
      {"ingredient":"",
         "amount":"1個"
      },
      {"ingredient":"ケチャップ",
         "amount":"大さじ2"
      },
      {"ingredient":"ウスターソース",
         "amount":"大さじ1"
      },
      {"ingredient":"",
         "amount":"適量"
      },
      {"ingredient":"こしょう",
         "amount":"適量"
      }],
   "cookwares":["ボウル",
      "フライパン"
   ],
   "instructions":["1. 玉ねぎをみじん切りにします。",
      "2. ボウルに合いびき肉、玉ねぎ、パン粉、牛乳、卵、ケチャップ、ウスターソース、塩、こしょうを入れてよく混ぜます。",
      "3. ハンバーグの形に成形します。",
      "4. フライパンにオリーブオイルを熱し、ハンバーグを焼きます。",
      "5. 両面に焼き色がついたら蓋をして中まで火を通します。",
      "6. ハンバーグが焼けたら完成です。"
   ],
   "in_english":"Hamburger Steak"
}

UI

Streamlitを使ってUIを用意すれば簡単にそれらしいレシピ画面が作れます。

まとめ

Function callingのJSON Schemaをpydanticで生成することで、運用しやすい形でJSON Schemaを管理できます。

参考


GKE Ingressのヘルスチェック生成ルール

$
0
0

概要

GKE Ingressを使うとGCE LBのヘルスチェックが自動的に作成されますが、一定のルールがあります。

これらを理解していないと期待しないヘルスチェックが作成され、疎通ができなかったりするのでまとめておきます。

生成ルール

GKE Ingressは次の流れでヘルスチェックを作成します。

  1. BackendConfigがあればそれを使う
  2. PodにreadinessProbeが設定されている、かつ条件を満たす場合それを使う
  3. 1, 2を満たさない場合、デフォルト値を使う

それぞれについて詳細な設定方法を説明します。

設定方法

BackendConfig

次のようなBackendConfigがあり、Serviceのannotationで使っている場合それがLBのヘルスチェックに使われます。

apiVersion: cloud.google.com/v1
kind: BackendConfig
metadata:name: my-app-bc
spec:connectionDraining:drainingTimeoutSec:30timeoutSec:15healthCheck:checkIntervalSec:5timeoutSec:1healthyThreshold:1unhealthyThreshold:2type: HTTP
    port:8000requestPath: /

Service

apiVersion: v1
kind: Service
metadata:name: my-app-svc
  annotations:cloud.google.com/neg:'{"ingress": true}'cloud.google.com/backend-config:'{"ports": {"8000":"my-app-bc"}}'

Webコンソールでは次のようになっています。

PodのreadinessProbe

PodのreadinessProbeを使いたい場合の注意点としては、ドキュメントに書いてあるポートの指定に気をつける必要があります。

ref: https://cloud.google.com/kubernetes-engine/docs/concepts/ingress?hl=ja#def_inf_hc

簡単にまとめると、NEGを使っている場合は以下の3つの条件を守る必要があります。

  • PodのcontainerPortとreadinessProbeのポートが一致する
  • ServiceのtargetPortとPodのcontainerPortが一致する
  • Ingressのポート番号とServiceのportが一致する

実装ロジックはこちらです。

https://github.com/kubernetes/ingress-gce/blob/4339f9102032862c7b0bbfe2ed393fdb0bb0546a/pkg/controller/translator/translator.go#L487-L488

具体的なDeployment, Service, Ingressの設定は次のようになります。

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:name: my-app
spec:template:metadata:labels:name: my-app
    spec:containers:- name: my-app
          ports:- containerPort:8000name: http
              protocol: TCP
          readinessProbe:failureThreshold:3httpGet:path: /health
              port: http
              scheme: HTTP
            initialDelaySeconds:15periodSeconds:10successThreshold:1timeoutSeconds:5

Service

apiVersion: v1
kind: Service
metadata:name: my-app
  labels:name: my-app
spec:selector:name: my-app
  type: ClusterIP
  ports:- port:8000name: http
    protocol: TCP

Serviceでは複数のポートがありますが(port, targetPort)、targetPort未指定の場合はportと同じ値が設定されます。
※type: NodePortならnodePortもある

Ingress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:name: my-app
  annotations:spec:rules:- http:paths:- path: /*
            pathType: ImplementationSpecific
            backend:service:name: my-app
                port:number:8000

Webコンソールでは次のように

Kubernetes L7 health check generated with readiness probe settings.

となります。

デフォルト値

デフォルト値はこちらのドキュメントの通りです。

https://cloud.google.com/kubernetes-engine/docs/concepts/ingress?hl=ja#def_inf_hc

Webコンソールでは次のようになっています。

まとめ

GKE Ingressにおけるヘルスチェックの生成ルールについてまとめました。

参考

VS CodeのPython環境をDev Containersで構築する

$
0
0

概要

PythonはGoと違ってライブラリのdeprecatedや破壊的変更が多いため、環境やライブラリバージョンの固定が非常に重要です。

  • Dev Containers
  • poetry
  • 仮想環境(venvなど、今回はpoetryのvirtualenv)

を使うことで、それらを固定してチーム内で安定した開発環境を構築できます。

環境

  • macOS 13.6
  • Dev Containers 0.346.0
  • python 3.11.8
  • poetry 1.7.1

役割

Dev Containersのアーキテクチャはこのようになっています。

ref: https://code.visualstudio.com/docs/devcontainers/containers

ソースコードをマウントし、実行環境だけコンテナ化することで開発環境を統一することができます。

役割をそれぞれ説明すると以下のようになります。

コンポーネント役割
Dev Containerspythonのバージョンの固定、実行環境(OSなど)の統一
poetryパッケージ管理(バージョンロックなど)。仮想環境の用意も可能
仮想環境パッケージの導入状態をプロジェクト毎に独立させる

仮想環境については「Dev Containersですでに仮想化されているので不要では?」とも思いますが、以下のメリットがあるので使った方が良いでしょう。

  • コンテナ内で複数のPythonプロジェクトを開発している場合、仮想環境を使ってプロジェクトごとに異なる依存関係を管理できる
  • Dev Containersを使用せずにローカル環境で作業する場合でも、同じ設定を容易に再現できる

構築方法

まずは拡張機能を↓からインストールしておきます。

marketplace.visualstudio.com

Dev Containersの導入

コマンドパレット(macOSならCmd+Shift+P)でdev containersと入力し、構成ファイルの追加を選択します。

次にワークスペースを選択。

次にPythonを選択。

Pythonのバージョンを選択します。
LLaMAなどは最新バージョンだと↓のように怒られるので、今回は3.11を選択します。

llama-index requires Python>=3.8.1,<3.12, so it will not be satisfied for Python>=3.12,<4.0.0

最後に追加パッケージとしてPoetryを選択します。注意としてチェックボックスをクリックしないと有効化されません。

devcontainer.json

すると.devcontainer/devcontainer.jsonというファイルが生成されます。
先程の手順で進めると↓のようになります。

// For format details, see https://aka.ms/devcontainer.json. For config options, see the// README at: https://github.com/devcontainers/templates/tree/main/src/python{"name": "Python 3",
    // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile"image": "mcr.microsoft.com/devcontainers/python:1-3.11-bookworm",
    "features": {"ghcr.io/devcontainers-contrib/features/poetry:2": {}}// Features to add to the dev container. More info: https://containers.dev/features.// "features": {},// Use 'forwardPorts' to make a list of ports inside the container available locally.// "forwardPorts": [],// Use 'postCreateCommand' to run commands after the container is created.// "postCreateCommand": "pip3 install --user -r requirements.txt",// Configure tool-specific properties.// "customizations": {},// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.// "remoteUser": "root"
}

次のように修正してPoetryのバージョンを固定します。

"features": {"ghcr.io/devcontainers-contrib/features/poetry:2": {"version": "1.7.1"
        }}

設定が完了したらコマンドパレットからコンテナをリビルドします。

以降は設定したディレクトリ(.devcontainer/devcontainer.jsonがあるディレクトリ)から開くと次のようにVS Codeがよろしく訊いてくれます。

動作確認

上部メニューからターミナルを作成します。

unameなどのコマンドを打つと設定した環境になっていることを確認できます。

Poetryの設定

先程のターミナルで

$ poetry init

を実行します。それ以降の設定は任意なので全てyesやEnterでもOKです。

するとpyproject.tomlが生成されます。

[tool.poetry]
name = "python-init"
version = "0.1.0"
description = ""
authors = ["Junpei Tsuji <xxx@gmail.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.11"


[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

readme = "README.md"

とあるように、README.mdがないと怒られるので用意しておきます。

$ touch README.md

仮想環境の設定

次のように打ち、ライブラリなどが今のディレクトリ直下の仮想環境で管理されるようにします。

$ poetry config virtualenvs.in-project true--local

次のようなpoetry.tomlが生成されます。

[virtualenvs]
in-project = true

以降は

$ poetry run xxx

とすれば、この仮想環境で実行してくれるようになります。

※最初にpoetry runを実行した際には.venvフォルダが生成されます。

ライブラリの追加

$ poetry add streamlit

などパッケージを追加すると、.venvに追加されていきます。

このように仮想環境によってパッケージの導入状態をプロジェクト毎に独立させることができます。

まとめ

VS CodePython環境をDev Containersで構築する手順を説明しました。

LlamaIndexでPDFに対してベクトル検索を行う

$
0
0

概要

LlamaIndexを使うと非常に簡単にRAG(Retrieval-Augmented Generation)を使った検索システムを作ることができます。

今回はLLMにない情報(PDF)をベクトル化して検索できる方法を紹介します。

環境

  • python 3.11.8
  • streamlit 1.31.1
  • llama-index 0.10.14

実装

開発環境

Python自体もそうですが、LangchainやLlamaIndexはバージョン更新のたびに破壊的な変更が多くバージョンを固定しないと期待通りに動かないことが多いです。

先日の記事のように最初に開発環境を整えることを推奨します。少なくともvenvやvirtualenvのような仮想環境は必ず使いましょう。

christina04.hatenablog.com

準備

まず必要なパッケージを追加します。

$ poetry add langchain-openai streamlit llama-index llama-index-llms-langchain

コード

今回のコードの完成形はこちらです。

import streamlit as st
import tempfile
from pathlib import Path
from langchain.chat_models import ChatOpenAI
from llama_index.core import Settings, VectorStoreIndex, SimpleDirectoryReader

index = st.session_state.get("index")


defon_change_file():
    if"index"in st.session_state:
        st.session_state.pop("index")


st.title("ベクトル検索")

# PDFをアップロードする
pdf_file = st.file_uploader("PDFをアップロードしてください", type="pdf", on_change=on_change_file)

if pdf_file:
    with st.spinner(text="準備中..."):
        # ファイルを一時保存するwith tempfile.NamedTemporaryFile(delete=False) as tmp:
            tmp.write(pdf_file.getbuffer())
            reader = SimpleDirectoryReader(input_files=[tmp.name])
            documents = reader.load_data()
            Settings.llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)
            index = VectorStoreIndex.from_documents(documents=documents)

if index isnotNone:
    user_message = st.text_input(label="質問を入力してください")

    if user_message:
        with st.spinner(text="検索中..."):
            query_engine = index.as_query_engine()
            results = query_engine.query(user_message)
            st.write(results.response)

非常に短いですが、これでRAGを実現できています。LlamaIndexというフレームワークの恩恵を感じますね。

ポイント

PDFアップロード

# PDFをアップロードする
pdf_file = st.file_uploader("PDFをアップロードしてください", type="pdf", on_change=on_change_file)

ファイルをベクトル化

SimpleDirectoryReaderでloadします。
今回はPDFですが、SimpleDirectoryReaderは様々なファイル(.csv, .docx, .md, .pdf, .mp3, .jpeg, .png, etc...)に対応しています。

with tempfile.NamedTemporaryFile(delete=False) as tmp:
    tmp.write(pdf_file.getbuffer())
    reader = SimpleDirectoryReader(input_files=[tmp.name])
    documents = reader.load_data()
    Settings.llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0)
    index = VectorStoreIndex.from_documents(documents=documents)

次にVectorStoreIndexにドキュメントを入れてindexを作ります。
VectorStoreIndexはドキュメントをノードに分割します。そして、LLMが照会できるように、各ノードのテキストのEmbedding(テキストのセマンティクスを数値化=ベクトル化)を作成します。

indexに対してクエリ

LlamaIndexの用語ではindexDocumentオブジェクトで構成されるデータ構造で、LLMによるクエリを可能にするものです。
ユーザ入力を使ってクエリを投げます。
クエリはそれ自体がEmbeddingに変換され、次にVectorStoreIndexによって数学的演算が実行され、どれだけ類似しているかによってランク付けされます。

query_engine = index.as_query_engine()
results = query_engine.query(user_message)
st.write(results.response)

動作確認

PDFの用意

適当にWikipediaから記事を参照し、PDF化します。
gpt-3.5-turboは2021年までのデータでトレーニングされているので、最近作られたものなどが良いでしょう。

今回は2024年1月にサービスリリースされた、パルワールドの記事を使ってみます。

ツール>PDF形式でダウンロードで保存できます。

検証

$ poetry run streamlit run home.py

でサーバが立ち上がります。

http://localhost:8501/

にアクセスします。

アップロード画面になっているので、先程のPDFを入れます。

ベクトル化しています。

質問

「パルワールドの発売日は?」のようなgpt-3.5-turboにない情報で質問します。

ちゃんと答えが返ってきています。

Wikiの情報の通りです。

その他

サンプルコード

今回のサンプルコードはこちら

github.com

ベクトル検索は類似度によるので正しい情報が返るとは限らない

ベクトル化して類似する箇所の内容から答えを生成するので、必ずしも正しい答え・期待する答えを返すわけではありません。

まとめ

LlamaIndexを使うと非常に短いコードでRAGを実現することができました。

参考

LLMのプロンプト

$
0
0

概要

LLMにおけるプロンプトの構成要素を理解して扱うことで

  • 期待通りの回答を得られる(精度が高くなる)
  • なぜTemplateはこの書き方をするのかが分かる
  • なぜagent_scratchpadのような変数がいるのかが分かる

といったようになります。

プロンプト

構成要素

プロンプトの構成要素は主に以下の4つです。

  • 命令(instruction)
  • 入力(input)
  • 文脈(context)
  • 出力形式の指定(output)

単純に「命令」だけだと幅広い解釈をしてしまい、期待しない回答が返ってくるので上記を考慮してプロンプトを作成するのが良いです。

例1

以下は構成要素全てを使った例です。もちろん場合によっては前提条件や出力形式を

前提条件を踏まえて、次の料理のレシピを考えてください。  # 命令前提条件: """ # 文脈分量: 一人分食べる人:10歳の子供"""料理名: """ # 入力麻婆豆腐"""出力形式は次のようなJSON形式にしてください。  # 出力形式{"材料": ["材料1", "材料2"],
  "手順": ["手順1", "手順2"]}

例2

対話形式に、といった指定も可能です。

前提条件を踏まえて、読書感想文を書くサポートをしてください。   # 命令前提条件: """ # 文脈あなた: 中学校の先生。フランクでポジティブな話し方読書感想文を書く人:読書が苦手な14才の男の子その他ルール:・対話をしながらアイデアをまとめてあげる・質問は一度にひとつずつ・回答を受けて深ぼりするように質問をする・抽象的な回答が続いたら、あなたから具体例を出してあげる"""書籍: """ # 入力タイトル:あの日の交換日記著者:辻堂 ゆめ概要:交換日記をモチーフにした短編小説集"""

LangChainのPrompts

LangChainのPromptsにはTemplateというものがあり、それをベースにプロンプトを作成することができます。

Template

PromptTemplate

PromptTemplateはシンプルに変数を直書きして作るテンプレートです。

summarize_template = """以下の文章を結論だけ一言に要約してください{input}"""

summarize_prompt = PromptTemplate(
    input_variables=["input"],
    template=summarize_template,
)

文脈なども追加したい場合は{context}といった変数を追記してあげます。

ChatPromptTemplate

ChatPromptTemplateは対話形式(チャット)に特化したプロンプトです。

roleとして

  • System
  • Human(user)
  • AI(assistant)

の3つがあります。

system_prompt = """あなたはITエンジニアです。"""

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system_prompt),
        ("human", "{input}"),
    ]
)

roleは上記のように書いてもいいですし、モジュールを使っても書けます。

from langchain_core.messages import AIMessage, HumanMessage, SystemMessage

chat_template = ChatPromptTemplate.from_messages(
    [
        SystemMessage(
            content=(
                "You are a helpful assistant that re-writes the user's text to ""sound more upbeat."
            )
        ),
        HumanMessagePromptTemplate.from_template("{text}"),
    ]
)

Promptsを活用した例

Memoryのhistory

Memory機能は会話履歴に基づいた回答ができるというものですが、これは過去の会話をプロンプトにcontextとして持たせることで実現しています。

system_prompt = """あなたはAIエージェントです。"""

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system_prompt),
        MessagesPlaceholder(variable_name="history"),
        ("user", "{input}"),
    ]
)

会話履歴を渡しているので、しりとりが可能です。

ログでプロンプトを表示して見るとこのようになっています。

[llm/start] [1:chain:ConversationChain > 2:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: \nあなたはAIエージェントです。\n\nHuman: しりとりしましょう。\nりんご"
  ]
}
...
[llm/start] [1:chain:ConversationChain > 2:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: \nあなたはAIエージェントです。\n\nHuman: しりとりしましょう。\nりんご\nAI: ごま\nHuman: まくら"
  ]
}
...
[llm/start] [1:chain:ConversationChain > 2:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: \nあなたはAIエージェントです。\n\nHuman: しりとりしましょう。\nりんご\nAI: ごま\nHuman: まくら\nAI: らっぱ\nHuman: パラシュート"
  ]
}

agent_scratchpad

LangChainのAgentsはagent_scratchpadという仮想メモ帳をプロンプトに用意することで、Agentsの情報を一時的に保持して回答しています。
Memoryと違って1回の問い合わせの中で使われ、終わったらリセットされます。

system_prompt = """あなたは天気予報士です。"""

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system_prompt),
        ("user", "{input}"),
        MessagesPlaceholder(variable_name="agent_scratchpad"),
    ]
)

回答はこのようにシンプルですが、

実際のプロンプトではAgents(今回だと外部検索)の結果を渡して、回答の情報として扱っています。

[llm/start] [1:chain:AgentExecutor > 11:chain:RunnableSequence > 16:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "System: \nあなたはAIエージェントです。\n\nHuman: 今日の東京の天気は?\nAI: {'arguments': '{\"__arg1\":\"東京の今日の天気\"}', 'name': 'duckduckgo-search'}\nFunction: 21日は雨が降り、気温は8度から10度で寒くなります。各地の降水確率や気温を地図で確認できます。10日間先までの天気予報もあります。 【予報精度No.1】東京の天気予報を5分毎・1時間毎・今日明日・週間(10日間)で掲載中!今知りたい現地の天気は、ウェザーニュースアプリから届く空の写真で確認。世界最大級の気象情報会社ウェザーニューズの観測ネットワークと独自の予測モデル、AI分析で一番当たる予報をお届けします。 文京区の1時間ごとの天気、気温、降水量などに加え、台風情報、警報注意報を掲載。3日先までわかるからお出かけ計画に役立ちます。気象予報 ... 気圧. 気温. >. 毎時更新【ウェザーニュース】東京の1時間毎・今日明日・週間 (10日間)の天気予報、いまの空模様。. 世界最大の民間気象情報会社ウェザーニューズの日本を網羅する観測ネットワークと独自の予測モデル、AI分析で一番当たる予報をお届け。. 天気マップアニメーション. 毎時更新【ウェザーニュース】東京都新宿区の1時間毎・今日明日・週間 (10日間)の天気予報、いまの空模様。. 世界最大の民間気象情報会社ウェザーニューズの日本を網羅する観測ネットワークと独自の予測モデル、AI分析で一番 ..."
  ]
}

デザインパターン

Zero-shotプロンプティング

Zero-shotプロンプティングは命令を与えるだけで、要約などがこれにあたります。

次の文章を100字程度で要約してください。

LLMは2018年頃に登場し、さまざまなタスク(仕事)で優れた性能を発揮している。これにより、自然言語処理の研究の焦点は、特定のタスクに特化した教師ありモデルを訓練するという以前のパラダイムから転換した[2]。大規模言語モデルの応用は目覚ましい成果を上げているが、大規模言語モデルの開発はまだ始まったばかりであり、多くの研究者が大規模言語モデルの改良に貢献している[3]。

大規模言語モデルという用語の正式な定義はないが、大規模コーパスで事前訓練された、数百万から数十億以上のパラメータを持つディープラーニングモデルを指すことが多い。LLMは、特定のタスク(感情分析、固有表現抽出、数学的推論など)のために訓練されたものとは異なり、幅広いタスクに優れた汎用モデルである[2][4]。LLMがタスクを実行する能力や対応可能な範囲は、ある意味では設計における画期的な進歩には依存せず、LLMに費やされた資源(データ、パラメータサイズ、計算力)の量の関数であるように見える[5]。多数のパラメータを持ったニューラル言語モデルは、文の次の単語を予測するという単純なタスクで十分に訓練することで、人間の言葉の構文や意味の多くを捉えられることがわかった。さらに、大規模な言語モデルは、世の中に関するかなりの一般知識を示し、訓練中に大量の事実を「記憶」することができる[2]。

質の高い証拠とされる2023年のメタ分析によれば、大規模言語モデルの創造性に目を輝かせる研究者はもちろん世界中に存在し、小規模言語モデルにはできないタスクで大規模言語モデルが創造的であると主張する学者もいるが、これは測定基準の選択によるものであり、創造性によるものではないことが示唆されている。異なる測定基準を選択した場合、大規模言語モデルの創造性の優位性は見られない可能性が示唆されている[6]。

ref: 大規模言語モデル - Wikipedia

2018年から登場した大規模言語モデル(LLM)は、多種多様なタスクにおいて卓越した性能を示し、自然言語処理研究の方向性を変えた。これらのモデルは数百万から数十億のパラメータを有し、幅広い応用に適用可能な汎用性を持つ。LLMの能力は主に使用されるデータ量、パラメータサイズ、計算能力に依存するが、その創造性は測定基準によって異なる可能性があり、まだ研究が進行中である。

Few-shotプロンプティング

Few-shotプロンプティングはいくつかの例をcontextとして与えた上で命令を渡します。

種類を答えてください。

Q: 犬
A: 哺乳類
Q: マグロ
A: 魚類
Q: カエル
A: 
両生類

プロンプト内のいくつかの例によって言語モデルにタスクを学ばせる、In-Context Learningという手法です。

Zero-shot Chain of Thoughtプロンプティング

Zero-shotでいきなり回答させると精度が低いことがありますが、

ステップバイステップで考えてみましょう。

や、

途中式を記述してください。

といった一言を追加して、そこに至る過程を先に生成させると精度が上がることがある、というテクニックです。

まとめ

プロンプトエンジニアリングについてはさまざまな情報が飛び交っており怪しげなものもありますが、このように構成要素から理解しておけばLangChainのMemoryやAgentsの機能であったり、デザインパターンがどうして有効なのかを理解できると思います。

参考

CEL(Common Expression Language)

$
0
0

概要

CEL(Common Expression Language)とは、Googleによって開発された軽量で高速な式評価言語です。
そのシンプルさと効率性から、セキュリティポリシー、リソースフィルタリング、データ検証などに使われます。

例えばプロダクションでは以下のような利用例があります。

今回はCELの説明と簡単な使い方を紹介します。

環境

  • go v1.22.1
  • cel-go v0.20.1

CELとは

CELは以下の特徴を持ちます。

  • チューリング完全言語
  • 軽量
  • 静的型付けを採用しており、コンパイル時にエラーを検出できる
  • 多数の組み込み関数をサポート
  • ユーザー定義関数やカスタム型を使って拡張できる

具体的には次のようなことができます。

// リソース名がグループ名で始まっているかどうかをチェック
resource.name.startsWith("/groups/" + auth.claims.group)
// リクエストが許可された時間内にあるかどうかをチェック
request.time - resource.age < duration("24h")
// リスト内のすべてのリソース名が指定されたフィルタに一致するかどうかをチェック
auth.claims.email_verified && resources.all(r, r.startsWith(auth.claims.email))

適したユースケース

CELに向いているユースケースとして以下があります。

  • スキーマを定義できない柔軟なConfig(評価式、ポリシー)が必要である
    • セキュリティポリシー
    • アクセス制御
    • コンテンツのフィルタ
      • 特定のジャンルや公開期間の制限を宣言的かつ柔軟に設定したい
  • 非エンジニアでも設定を記述しやすく、かつ事前に設定の正当性がvalidateしたい
  • パフォーマンスがクリティカルなパスである
  • ポリシーの変更は少ないが実行は多い

キーコンセプト

CELのキーコンセプトとしてParse, Check, Evaluateという3つのフェーズがあります。

その中で大きくParse/Check、Evaluateで実行タイミングが分かれます。

Control Plane

ref: https://codelabs.developers.google.com/codelabs/cel-go#1

  • Parse
    • ASTに変換する
  • Check
    • 式に含まれている変数や関数が宣言されているかチェックする

ポイントとしてParseやCheckは低レイテンシが求められる際はランタイム実行すべきではありません。
なので管理ツール等で事前に登録してASTとして保存しておくのがベストプラクティスです。

評価

  • Evaluate
    • 保存されたASTを使って入力値を評価

このように分ける運用方法の実装例はこちらで紹介しています。

christina04.hatenablog.com

実装

次にGoを使った具体的な実装について説明します。

用語

まず出てくる用語について説明します。

用語 役割
environment 環境。独自の変数やカスタム関数などを登録する
compile envにexpressionを渡してparseしてastを生成する
expression
ast 抽象構文木。compileして生成されるもの。
program env内でのastの評価可能なインスタンスを生成する
evaluation inputを渡してprogramから評価

Q. なぜprogramというフェーズがある?

Compile後のASTはprotobufなどにシリアライズして保存ができます(Parse&CheckとEvaluateの分離)。
実際に利用するときはその後でデシリアライズして評価するためのインスタンスを用意する必要があり、そのインスタンスがProgramとなります。

メソッド

cel-goの主なメソッドは以下です。

メソッド 役割
Compile  環境毎に登録する評価式の Parse と Check を行う
Eval  Compileされた評価式(program)と、入力値を使って評価する
Report  評価結果を詳細に表示する

具体的な実装(シンプル)

まずはシンプルな実装方法を説明します。

package main

import (
    "fmt""log""github.com/google/cel-go/cel"
)

func main() {
    // CEL環境の設定
    env, err := cel.NewEnv(
        // 'num'という名前の整数型の変数を宣言
        cel.Variable("num", cel.IntType),
    )
    if err != nil {
        log.Fatalf("Failed to create CEL environment: %v", err)
    }

    // 入力値が偶数かどうかをチェックするCEL式
    expr := `num % 2 == 0`// 式のコンパイル
    ast, issues := env.Compile(expr)
    if issues != nil&& issues.Err() != nil {
        log.Fatalf("Compile error: %v", issues.Err())
    }

    // プログラムの生成
    prg, err := env.Program(ast)
    if err != nil {
        log.Fatalf("Program creation error: %v", err)
    }

    // 評価する入力値
    inputs := map[string]interface{}{
        "num": 9, // この値を変更して異なる入力で試すことができます
    }

    // 評価
    result, _, err := prg.Eval(inputs)
    if err != nil {
        log.Fatalf("Evaluation error: %v", err)
    }

    fmt.Printf("Is %v an even number? %v\n", inputs["num"], result.Value().(bool))
}

コメントに書いてあるように次のフローで実行します。

  1. enviromentで変数等を宣言
  2. 評価式をCompileしてASTを生成
  3. ASTからProgramを生成
  4. 評価

今回は入力値が偶数かどうか、という式を入れてみました。

具体的な実装(評価式の分離)

例えば評価式の部分を次のように外から注入できるようにしておきます。

// ローカルのテキストファイルを読み込み
    b, err := os.ReadFile("./expr.txt")
    if err != nil {
        log.Fatalf("Failed to read file: %v", err)
    }

    // 外部から評価式を取得
    expr := string(b)

    // 式のコンパイル
    ast, issues := env.Compile(expr)
    if issues != nil&& issues.Err() != nil {
        log.Fatalf("Compile error: %v", issues.Err())
    }

そうすることでコードを変更せずとも、評価式を柔軟に変更することができるようになります。

評価式1

num % 2 == 0 && num > 10

入力値:20
結果:true

評価式2

num % 3 == 0 || num < 5

入力値:9
結果:false

その他

サンプルコード

サンプルコードをこちらに用意してあります。

github.com

パフォーマンスを上げたい時

cel.OptOptimizeを使う

cel.OptOptimizeを使います。

prog, err := env.Program(ast, cel.EvalOptions(cel.OptOptimize))

以下ベンチマーク結果です。評価式が複雑になるほど、速度差が顕著に出ていました。

$ go test -bench . -benchmem
goos: darwin
goarch: arm64
pkg: github.com/jun06t/cel-sample/optimize
BenchmarkNewProgramOptimizeTrue-10       1818870               657.3 ns/op           144 B/op          9 allocs/op
BenchmarkNewProgramOptimizeFalse-10       836688              1329 ns/op             896 B/op         33 allocs/op

ref: cel-sample/optimize at main · jun06t/cel-sample · GitHub

ただしCELは軽量ではあるものの、CELを通さず生のコードで実装した方が圧倒的に速いです。
なのでユースケースに述べた柔軟性を必要とするケースでの利用が前提となります。

$ go test -bench . -benchmem
goos: darwin
goarch: arm64
pkg: github.com/jun06t/cel-sample/optimize
BenchmarkNewProgramOptimizeTrue-10       1818870               657.3 ns/op           144 B/op          9 allocs/op
BenchmarkRawCode-10                     589481318                2.036 ns/op           0 B/op          0 allocs/op

マクロをOFFにする

CELには組み込み関数の拡張としてマクロが存在します。

ref: https://codelabs.developers.google.com/codelabs/cel-go#10

カスタム関数を定義するよりも簡単に使えますが、パフォーマンスとしてはカスタム関数の方が高く、またマクロを使わない場合はcel.ClearMacros()を設定してOFFにすることが推奨されています。

    env, _ := cel.NewEnv(
      cel.ClearMacros(),
      cel.Variable("num", cel.IntType),
    )

デバッグしたい

cel.OptExhaustiveEvalを使います。 するとEvaluate()の戻り値であるEvalDetailsに詳細が渡りデバッグしやすくなります。

------ result ------
value: false (types.Bool)

------ eval states ------
1: 9 (types.Int)
2: 1 (types.Int)
3: 2 (types.Int)
4: false (types.Bool)
5: 0 (types.Int)

チュートリアル

チュートリアルが提供されており、これを一度やってみるとイメージがつきやすくなります。

CEL-Go Codelab: Fast, safe, embedded expressions

まとめ

式評価言語であるCELについて簡単な説明をしました。

ユースケースに挙げたような要件が出たときはぜひ使ってみてください。

参考

CELでASTを外部に保存する

$
0
0

概要

CELのキーコンセプトでは

  • Control PlaneでCEL式をParse & Checkし、生成されたASTを保存
  • Data Planeで保存したASTを読み取り、インプット値を評価する

と説明されていました。

主に管理ツール等で前者のControl Planeを実装し、オペレーターに自由に評価式を入力してもらいます。
Control PlaneではCheckも行われるので評価式がおかしければその時点でvalidationしてくれます。
そしてData Planeでは起動時にASTを読み込んで入力値を評価できるようにします。

今回は具体的に外部に保存する手順を説明します。

環境

  • go v1.22.1
  • cel-go v0.20.1

方法

Control Plane、Data Planeそれぞれで説明します。

Control Plane

ref: https://codelabs.developers.google.com/codelabs/cel-go#1

cel.AstToCheckedExprを使ってASTをprotocol buffersに変換します。
そしてprotobufをシリアライズして外部に保存します。

package main

import (
    "log""os""github.com/google/cel-go/cel""google.golang.org/protobuf/proto"
)

func main() {
    env, err := cel.NewEnv(
        cel.Variable("name", cel.StringType),
    )
    if err != nil {
        log.Fatalf("Failed to create CEL environment: %v", err)
    }

    ast, iss := env.Compile(`"Hello, " + name + "!"`)
    if iss.Err() != nil {
        log.Fatalf("Failed to compile expression: %v", iss.Err())
    }
    expr, err := cel.AstToCheckedExpr(ast)
    if err != nil {
        log.Fatalf("Failed to convert an Ast to an protobuf: %v", err)
    }

    // Serialize the AST to Protocol Buffers binary format
    astBytes, err := proto.Marshal(expr)
    if err != nil {
        log.Fatalf("Failed to serialize AST: %v", err)
    }

    // Save the serialized AST to a fileif err := os.WriteFile("ast.pb", astBytes, 0644); err != nil {
        log.Fatalf("Failed to write AST to file: %v", err)
    }
}

今回は簡単のためローカルファイルとして保存しますが、実際の運用ではGCSやデータベースに保存するのが良いでしょう。

動作確認

$ go run main.go
$ ls
ast.pb  go.mod  go.sum  main.go

ast.pbが生成されました。

Data Plane

ローカルにあるast.pbを読み込みデシリアライズし、cel.CheckedExprToAstで*cel.Astにします。

package main

import (
    "log""os""github.com/google/cel-go/cel"
    exprpb "google.golang.org/genproto/googleapis/api/expr/v1alpha1""google.golang.org/protobuf/proto"
)

func main() {
    env, err := cel.NewEnv(
        cel.Variable("name", cel.StringType),
    )
    if err != nil {
        log.Fatalf("Failed to create CEL environment: %v", err)
    }

    // Read the serialized AST from the file
    astBytes, err := os.ReadFile("./gen/ast.pb")
    if err != nil {
        log.Fatalf("Failed to read AST from file: %v", err)
    }

    // Deserialize the AST from Protocol Buffers binary formatvar astPb exprpb.CheckedExpr
    if err := proto.Unmarshal(astBytes, &astPb); err != nil {
        log.Fatalf("Failed to deserialize AST: %v", err)
    }

    // Recover the AST structure
    ast := cel.CheckedExprToAst(&astPb)

    // Create a Program from the AST
    prg, err := env.Program(ast, cel.EvalOptions(cel.OptTrackState, cel.OptExhaustiveEval))
    if err != nil {
        log.Fatalf("Failed to create program: %v", err)
    }

    // Evaluate the Program with a given variable
    out, _, err := prg.Eval(map[string]interface{}{
        "name": "World",
    })
    if err != nil {
        log.Fatalf("Evaluation failed: %v", err)
    }

    log.Printf("Result: %v\n", out)
}

動作確認

$ go run main.go
2024/03/31 15:04:48 Result: Hello, World!

期待通り評価式に入力値が適用されたアウトプットが表示されました。

その他

サンプルコード

今回のサンプルコードはこちらです。

github.com

まとめ

CELのキーコンセプトに合わせた運用方法を紹介しました。

参考

CELで独自のオブジェクトをprotobufを使って変数定義する

$
0
0

概要

CELでは評価式で扱う変数をEnvironment内で定義しますが、既存のデータモデルを使いたい場合は

  • 同じ定義を都度作らないといけない
  • 変更があった際の追従漏れが発生する

といった手間が発生してしまいます。

しかしそのデータモデルがprotobufで定義されていれば再利用することが可能です。

今回はその方法を紹介します。

環境

  • go v1.22.1
  • cel-go v0.20.1

実装

外部protoの例

cel.Typesで型を読み込み、cel.ObjectTypeでその型を指定してcel.Variableで自分の変数として定義します。

import (
    ...

    "github.com/google/cel-go/cel"
    rpcpb "google.golang.org/genproto/googleapis/rpc/context/attribute_context""google.golang.org/protobuf/types/known/structpb"
    tpb "google.golang.org/protobuf/types/known/timestamppb"
)

func main() {
    env, _ := cel.NewEnv(
        cel.Types(&rpcpb.AttributeContext_Request{}),
        cel.Variable("request",
            cel.ObjectType("google.rpc.context.AttributeContext.Request"),
        ),
    )
    ...

ポイント

ポイントは以下です。

すると次のように評価式においてprotobufの定義に基づくオブジェクトとして扱うことが可能です。

   ast, iss := env.Compile(
        `request.auth.claims.group == 'admin'            || request.auth.principal == 'user:me@acme.co'`,
    )

独自protoの例

例えば次のようなprotoを自前で定義して、

syntax = "proto3";

package helloworld;

option go_package = "github.com/jun06t/cel-sample/external-proto/proto;helloworld";

message HelloRequest {
  string name = 1;
  int32 age = 2;
  bool man = 3;
}

message HelloReply { stringmessage = 1; }

これを先ほどと同じようにEnvにセットし、評価式もmessageのフィールドを使うように与えます。

import (
        ...
        pb "github.com/jun06t/cel-sample/external-proto/proto"
        ...
)

main()
        env, _ := cel.NewEnv(
                cel.Types(&pb.HelloRequest{}),
                cel.Variable("request",
                        cel.ObjectType("helloworld.HelloRequest"),
                ),
        )

        ast, iss := env.Compile(
                `request.name == 'Alice'&& request.age > 20 && request.man == false`,
        )
        ...

入力値を与えて評価すると、

        input := map[string]any{
                "request": &pb.HelloRequest{
                        Name: "Alice",
                        Age:  21,
                        Man:  false,
                },
        }
        out, _, err := prog.Eval(input)
        if err != nil {
                log.Fatalf("Evaluation error: %v", err)
        }

        fmt.Println("Is permitted user?", out)

期待通りの結果になります。

$ go run main.go
Is permitted user? true

その他

サンプルコード

今回のサンプルコードはこちらです。

github.com

まとめ

既存のデータモデルを扱う場合にproto定義を利用することで二重管理の負債を避けることができます。

参考


CELでカスタム関数を使う

$
0
0

概要

CELは標準的な演算子や関数に加え、独自のカスタム関数を定義して機能を拡張することが可能です。

今回はカスタム関数を使ってみる際に必要な前提知識を踏まえながらサンプルコードを紹介します。

環境

  • go v1.22.1
  • cel-go v0.20.1

カスタム関数の作り方

Goでの実装になりますが、手順としては以下です。

  1. environmentに関数を登録
  2. bindする関数を定義(interfaceを実装する)
  3. 評価式で関数を使う

事前知識

事前に知っておくと理解が早くなるのであらかじめ説明します。

OverloadとMemberOverloadの違い

Overload

次のように通常の関数のように定義する際はOverloadを使います。

concatStr(arg1, arg2)

MemberOverload

次のようにメンバ(target)に関数を生やす際はMemberOverloadを使います。

target.join(arg1)

overload IDの命名規則

environmentで関数を登録する際に、先程のoverloadにおいてid名を登録する必要があるのですが、

func Overload(overloadID string, args []*Type, resultType *Type, opts ...OverloadOpt) FunctionOpt
func MemberOverload(overloadID string, args []*Type, resultType *Type, opts ...OverloadOpt) FunctionOpt

その命名規則は以下となっています。

Overload

Overloadはfunc_argType_argTypeという命名規則です。

例としてconcatStr("alice", 10)のような関数を定義する場合は

concatStr_string_int

命名します。

MemberOverload

MemberOverloadはtargetType_func_argType_argTypeという命名規則です。

例としてi.greet(you)のような関数を定義する場合(i, youが文字列とします)、

string_greet_string

命名します。

UnaryBinding, BinaryBinding, FunctionBindingの違い

先ほど

  1. bindする関数を定義(interfaceを実装する)

と説明した際に、bindする関数のinterfaceが引数の数によって異なるのでその違いを説明します。

UnaryBinding

引数1つ取ります。戻り値は1つです。

func UnaryBinding(binding functions.UnaryOp) OverloadOpt
type UnaryOp func(value ref.Val) ref.Val

ref: https://pkg.go.dev/github.com/google/cel-go/cel#UnaryBinding

BinaryBinding

引数2つとります。戻り値は1つです。

func BinaryBinding(binding functions.BinaryOp) OverloadOpt
type BinaryOp func(lhs ref.Val, rhs ref.Val) ref.Val

ref: https://pkg.go.dev/github.com/google/cel-go/cel#BinaryBinding

FunctionBinding

引数は可変長です。戻り値は1つです。

func FunctionBinding(binding functions.FunctionOp) OverloadOpt
type FunctionOp func(values ...ref.Val) ref.Val

ref: https://pkg.go.dev/github.com/google/cel-go/cel#FunctionBinding

まとめるとこうなります。

binding引数戻り値
UnaryBinding11
BinaryBinding21
FunctionBinding可変長1

戻り値はどれも1つですが、ListTypeやMapTypeを返すことで擬似的に複数返すことも可能です。

具体的な実装

それでは具体的な実装例を紹介します。

今回は2つの文字列を結合するconcatStrという関数を独自定義します。

グローバルな関数を作るケース

func global() {
    env, err := cel.NewEnv(
        cel.Function("concatStr",
            // グローバルな関数なのでOverloadを使う// 命名規則はfunc_argType_argType
            cel.Overload("concatStr_string_string",
                // 引数の型を指定
                []*cel.Type{cel.StringType, cel.StringType},
                // 戻り値の型を指定
                cel.StringType,
                // 引数が2つの関数を用意するのでBinaryBinding
                cel.BinaryBinding(concatFunc),
            ),
        ),
    )
    if err != nil {
        log.Fatalf("failed to create env: %s\n", err)
    }

    // 評価式でカスタム関数を呼び出す。今回は変数なし
    expr := `concatStr('Hello', 'World')`
    ast, iss := env.Compile(expr)
    if iss != nil&& iss.Err() != nil {
        log.Fatalf("failed to compile expression: %v", iss.Err())
    }

    p, err := env.Program(ast)
    if err != nil {
        log.Fatalf("failed to create program: %s\n", err)
    }

    // 変数なしなので空のmapを渡す
    out, _, err := p.Eval(map[string]interface{}{})
    if err != nil {
        log.Fatalf("evaluation error: %s\n", err)
    }

    fmt.Println("Result:", out)
}

BindするconcatFuncはBinaryOpを実装する形で定義します。

func concatFunc(arg1, arg2 ref.Val) ref.Val {
    v1 := arg1.(types.String)
    v2 := arg2.(types.String)
    return types.String(v1 + v2)
}

評価式にベタ書きなので、それが結合されて返ってきます。

Result: HelloWorld

グローバルな関数に引数をもたせる

func globalWithVariableArg(a, b string) {
    env, err := cel.NewEnv(
        // 引数を変数として定義
        cel.Variable("arg1", cel.StringType),
        cel.Variable("arg2", cel.StringType),
        cel.Function("concatStr",
            // グローバルな関数なのでOverloadを使う
            cel.Overload("concatStr_string_string",
                // 引数の型を指定
                []*cel.Type{cel.StringType, cel.StringType},
                // 戻り値の型を指定
                cel.StringType,
                // 引数が2つの関数を用意するのでBinaryBinding
                cel.BinaryBinding(concatFunc),
            ),
        ),
    )
    if err != nil {
        log.Fatalf("failed to create env: %s\n", err)
    }

    // 評価式でカスタム関数を呼び出す。今回は変数あり
    expr := `concatStr(arg1, arg2)`
    ast, iss := env.Compile(expr)
    if iss != nil&& iss.Err() != nil {
        log.Fatalf("failed to compile expression: %v", iss.Err())
    }

    p, err := env.Program(ast)
    if err != nil {
        log.Fatalf("failed to create program: %s\n", err)
    }

    // 入力値として変数を渡す
    out, _, err := p.Eval(map[string]interface{}{
        "arg1": a,
        "arg2": b,
    })
    if err != nil {
        log.Fatalf("evaluation error: %s\n", err)
    }

    fmt.Println("Result:", out)
}

引数を渡して呼び出すと

globalWithVariableArg("I'm ", "Alice")

次のように結合されます。

Result: I'm Alice

メンバーに関数を生やす

レシーバーにメソッドをつけるような形式でカスタム関数を定義することもできます。

func memberWithVariableArg(a, b string) {
    env, err := cel.NewEnv(
        // メンバ、引数を変数として定義
        cel.Variable("target", cel.StringType),
        cel.Variable("arg1", cel.StringType),
        cel.Function("concatStr",
            // メンバ関数の場合はMemberOverloadを使う// 命名規則はtargetType_func_argType_argType
            cel.MemberOverload("string_concatStr_string",
                // 引数の型を指定。第一引数がメンバの型になる
                []*cel.Type{cel.StringType, cel.StringType},
                // 戻り値の型を指定
                cel.StringType,
                // 引数が2つの関数を用意するのでBinaryBinding
                cel.BinaryBinding(concatFunc),
            ),
        ),
    )
    if err != nil {
        log.Fatalf("failed to create env: %s\n", err)
    }

    // メンバ関数の場合は、メンバに関数を生やして記述する
    expr := `target.concatStr(arg1)`
    ast, iss := env.Compile(expr)
    if iss != nil&& iss.Err() != nil {
        log.Fatalf("failed to compile expression: %v", iss.Err())
    }

    p, err := env.Program(ast)
    if err != nil {
        log.Fatalf("failed to create program: %s\n", err)
    }

    out, _, err := p.Eval(map[string]interface{}{
        "target": a,
        "arg1":   b,
    })
    if err != nil {
        log.Fatalf("evaluation error: %s\n", err)
    }

    fmt.Println("Result:", out)
}

引数を渡して呼び出すと

memberWithVariableArg("I'm ", "Bob")

次のように結合されます。

Result: I'm Bob

その他

サンプルコード

今回のサンプルコードはこちら

github.com

関数内でのエラーハンドリング

Bindする関数内でのエラーハンドリングについては、関数が

の実装をしますがどれも戻り値はref.Valのみです。

なので

  • types.NewErr
  • types.ValOrErr
  • types.MaybeNoSuchOverload

のいずれかを使ってエラーを返します。
以下例です。

func stringOrError(str string, err error) ref.Val {
    if err != nil {
        return types.NewErr(err.Error())
    }
    return types.String(str)
}

cel.TypeParamTypeはどんな時に使う?

codelabではcel.TypeParamTypeを使ったサンプルが書かれています。

// Useful components of the type-signature for 'contains'.
  typeParamA := cel.TypeParamType("A")
  typeParamB := cel.TypeParamType("B")
  mapAB := cel.MapType(typeParamA, typeParamB)

  // Env declaration.
  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    // Declare the request.
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),// Declare the custom contains function and its implementation.
    cel.Function("contains",
      cel.MemberOverload(
        "map_contains_key_value",
        []*cel.Type{mapAB, typeParamA, typeParamB},
        cel.BoolType,
        cel.FunctionBinding(mapContainsKeyValue)),
    ),
  )

ref: https://codelabs.developers.google.com/codelabs/cel-go#7

これはジェネリクス的な使い方で、MapTypeやListTypeをジェネリクス的に汎化したい時に使えます。

もしMapの中身(key-value)がstringに限定されるのであればcel.TypeParamTypeは使わずcel.StringTypeで問題なく動きます。

まとめ

CELでカスタム関数を使う際に自分が気になった部分中心にサンプルを交えて説明しました。

参考



Latest Images