GitLab の Feature Flag を使って一部の人にだけ機能を公開してみる

2022.07.05

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

Feature Flag とは

パラメータによって動作を変更するための実装手法です。

カナリアリリース, A/B テスト を目的として使用されます。

SaaS や OSS も提供されています。利用することでオンデマンドでの変更を可能とし、対象となるユーザの制限もできます。

注意事項

多用してしまうことにより、コードの品質や可読性に影響を及ぼす可能性があります。

また、一時的な機能の有効化は障害調査の枷となる可能性があります。

実験や検証を目的とし、リリース管理やユーザ毎の挙動に使うことは望ましくありません。

マーティン・ファウラーは機能トグルは最後の手段であるべきとして、機能トグルを利用する前に次のような手段を検討すべきと述べている

  • 機能を小さく分割して段階的にリリースしていく方法
  • 新機能へのエントリーポイントとなる UI を最後に作る方法

やってみた

1. サンプルアプリの構築

今回は AWS Lambda 上にサンプルアプリを展開します。

その後、Feature Flag を使って特定ユーザのみ機能を提供していきます。

サンプルアプリはこちらの記事を参考に構築しました。

ojichat をサーバーレス API 化した

AWSTemplateFormatVersion: "2010-09-09"

Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Timeout: 3

Resources:
  UserPool:
    Type: AWS::Cognito::UserPool

  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref UserPool
      ExplicitAuthFlows:
        - ALLOW_REFRESH_TOKEN_AUTH
        - ALLOW_ADMIN_USER_PASSWORD_AUTH

  Api:
    Type: AWS::Serverless::Api
    Properties:
      StageName: Development
      Auth:
        DefaultAuthorizer: CognitoAuthorizer
        Authorizers:
          CognitoAuthorizer:
            UserPoolArn: !GetAtt UserPool.Arn

  Function:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: .
      Handler: main
      Runtime: go1.x
      Events:
        Register:
          Type: Api
          Properties:
            Path: /
            Method: GET
            RestApiId: !Ref Api
      Environment:
        Variables:
          APP_ENV: Development

Outputs:
  DevelopmentEndpoint:
    Value: !Sub https://${Api}.execute-api.${AWS::Region}.amazonaws.com/Development
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "os"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/greymd/ojichat/generator"
    "github.com/rs/zerolog"
    "github.com/rs/zerolog/log"
)

func main() {
    zerolog.TimeFieldFormat = zerolog.TimeFormatUnixMicro
    switch os.Getenv("APP_LOG_LEVEL") {
    case "TRACE":
        zerolog.SetGlobalLevel(zerolog.TraceLevel)
    case "DEBUG":
        zerolog.SetGlobalLevel(zerolog.DebugLevel)
    case "INFO":
        zerolog.SetGlobalLevel(zerolog.InfoLevel)
    case "WARN":
        zerolog.SetGlobalLevel(zerolog.WarnLevel)
    case "ERROR":
        zerolog.SetGlobalLevel(zerolog.ErrorLevel)
    }

    lambda.Start(Handler)
}

func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (res *events.APIGatewayProxyResponse, resErr error) {
    ctxLogger := log.Logger.With().Str("requestId", req.RequestContext.RequestID).Logger()
    ctx = ctxLogger.WithContext(ctx)

    cauth, resErr := convToCognitoAuthorizer(req.RequestContext.Authorizer)
    if resErr != nil {
        return nil, fmt.Errorf("failed to convert cognitoAuthorizer: %w", resErr)
    }

    ctxLogger = ctxLogger.With().
        Str("claims::sub", cauth.Claims.Sub).
        Str("user", cauth.Claims.CognitoUsername).
        Logger()
    ctx = ctxLogger.WithContext(ctx)

    cfg := generator.Config{EmojiNum: 4}

    ojiText, err := generator.Start(cfg)
    if err != nil {
        return nil, fmt.Errorf("failed to generate a ojichat text: %w", resErr)
    }

    b, resErr := json.Marshal(struct {
        Text string `json:"text"`
    }{Text: ojiText})

    return &events.APIGatewayProxyResponse{
        StatusCode: http.StatusOK,
        Body:       string(b),
    }, nil
}

func convToCognitoAuthorizer(v map[string]interface{}) (cognitoAuthorizer, error) {
    var c cognitoAuthorizer

    b, err := json.Marshal(v)
    if err != nil {
        return c, fmt.Errorf("failed to marshal: %w", err)
    }

    if err := json.Unmarshal(b, &c); err != nil {
        return c, fmt.Errorf("failed to unmarshal: %w", err)
    }

    return c, nil
}

type cognitoAuthorizer struct {
    Claims struct {
        Sub             string `json:"sub"`
        Aud             string `json:"aud"`
        AuthTime        string `json:"auth_time"`
        CognitoUsername string `json:"cognito:username"`
        EventID         string `json:"event_id"`
        Exp             string `json:"exp"`
        Iat             string `json:"iat"`
        Iss             string `json:"iss"`
        Jti             string `json:"jti"`
        OriginJti       string `json:"origin_jti"`
        TokenUse        string `json:"token_use"`
    } `json:"claims"`
}

参考記事の違いは AWS::Cognito::UserPool を追加している点です。

次に 2 人のユーザ cmkojimat0, cmkojimat1 を作成します。

aws cognito-idp admin-create-user --region us-east-2 --user-pool-id us-east-2_VF5Cjci2p --username "$username"
{
    "User": {
        "Username": "cmkojimat0",
        "Attributes": [
            {
                "Name": "sub",
                "Value": "d9b292ae-6479-4ec0-8b10-6720b04c932b"
            }
        ],
        "UserCreateDate": "2022-07-05T17:10:29.442000+09:00",
        "UserLastModifiedDate": "2022-07-05T17:10:29.442000+09:00",
        "Enabled": true,
        "UserStatus": "FORCE_CHANGE_PASSWORD"
    }
}
aws cognito-idp admin-set-user-password --region us-east-2 --user-pool-id us-east-2_VF5Cjci2p --username "$username" --password "$password" --permanent
aws cognito-idp admin-create-user --region us-east-2 --user-pool-id us-east-2_VF5Cjci2p --username "$username"
{
    "User": {
        "Username": "cmkojimat1",
        "Attributes": [
            {
                "Name": "sub",
                "Value": "6f63cfb0-6abf-4a42-a894-32e784c207e2"
            }
        ],
        "UserCreateDate": "2022-07-05T17:12:37.159000+09:00",
        "UserLastModifiedDate": "2022-07-05T17:12:37.159000+09:00",
        "Enabled": true,
        "UserStatus": "FORCE_CHANGE_PASSWORD"
    }
}
aws cognito-idp admin-set-user-password --region us-east-2 --user-pool-id us-east-2_VF5Cjci2p --username "$username" --password "$password" --permanent

では一度実行してみます。

username=cmkojimat0
export AUTHORIZATION_HEADER=$( \
  aws cognito-idp admin-initiate-auth --region us-east-2 --user-pool-id us-east-2_VF5Cjci2p --client-id 7352uc9ardrbbd7qpbn35brt69 \
    --auth-flow ADMIN_USER_PASSWORD_AUTH --auth-parameters "USERNAME=$username,PASSWORD=$password" \
    --query AuthenticationResult.IdToken \
    --output text \
)
curl -H "Authorization: $AUTHORIZATION_HEADER" "https://a4fm7eigpa.execute-api.us-east-2.amazonaws.com/Development"
{"text":"カホちゃん、そっちも雪なのかな⁉?今日も頑張ってネ?✋(^_^)(笑)?"}

元気の出るコメントをいただきました。

2. FeatureFlag の追加

では次に本題の Feature Flag を追加していきます。

今回は 2 点の機能を検証していきます。

  1. ランダムの名前ではなく, cognito で登録したユーザ名を表示する
  2. 絵文字の表示をゼロにする

ではまず、GitLab に Feature Flag を登録します。

画面左の デプロイ > 機能フラグ から設定できます。

設定 から接続情報を取得できます。

機能フラグ から作成します。

ランダムの名前ではなく, cognito で登録したユーザ名を表示する 機能を target_name_from_authorizer とします。

対象は全てのユーザとしました。

絵文字の表示をゼロにする 機能を emoji_num_none とします。

こちらには先ほど作った cmkojimat1 の sub を指定しておきます。

登録結果はこのようになりました。

次にコードを修正します。

GitLab では Unleash を使用して Feature Flag を提供しています。

そのため、まず unleash を設定します。

unleash.WithListener によって各イベントで呼ばれる Listener を登録できるため、Debug 用の Logger も作成します。

diff --git a/main.go b/main.go
index 6ba5e08..098b628 100644
--- a/main.go
+++ b/main.go
@@ -7,6 +7,8 @@ import (
    "net/http"
    "os"

+   unleash "github.com/Unleash/unleash-client-go/v3"
+   unleash_context "github.com/Unleash/unleash-client-go/v3/context"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/greymd/ojichat/generator"
@@ -29,6 +31,19 @@ func main() {
        zerolog.SetGlobalLevel(zerolog.ErrorLevel)
    }

+   if err := unleash.Initialize(
+       unleash.WithAppName(os.Getenv("APP_ENV")),
+       unleash.WithUrl(os.Getenv("UNLEASH_API_ENDPOINT")),
+       unleash.WithInstanceId(os.Getenv("UNLEASH_INSTANCE_ID")),
+       unleash.WithListener(&unleashZeroLogger{
+           Logger: log.Logger.With().Str("service", "unleash").Logger(),
+       }),
+   ); err != nil {
+       log.Fatal().Err(err).Msg("failed to initialze unleash")
+   }
+
+   defer unleash.Close()
+
    lambda.Start(Handler)
 }
@@ -94,3 +129,31 @@ type cognitoAuthorizer struct {
        TokenUse        string `json:"token_use"`
    } `json:"claims"`
 }
+
+type unleashZeroLogger struct {
+   Logger zerolog.Logger
+}
+
+func (l unleashZeroLogger) OnError(err error) {
+   l.Logger.Error().Err(err).Msg("error")
+}
+
+func (l unleashZeroLogger) OnWarning(err error) {
+   l.Logger.Warn().Err(err).Msg("warning")
+}
+
+func (l unleashZeroLogger) OnReady() {
+   l.Logger.Debug().Msg("ready")
+}
+
+func (l unleashZeroLogger) OnCount(name string, enabled bool) {
+   l.Logger.Debug().Str("name", name).Bool("enabled", enabled).Msg("counted")
+}
+
+func (l unleashZeroLogger) OnSent(payload unleash.MetricsData) {
+   l.Logger.Debug().Interface("payload", payload).Msg("sented")
+}
+
+func (l unleashZeroLogger) OnRegistered(payload unleash.ClientData) {
+   l.Logger.Debug().Interface("payload", payload).Msg("registered")
+}

次に 機能を toggle したい箇所にコードを埋め込みます。

diff --git a/main.go b/main.go
index 6ba5e08..098b628 100644
--- a/main.go
+++ b/main.go
@@ -49,6 +64,17 @@ func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (res *event

    cfg := generator.Config{EmojiNum: 4}

+   uctx := convToUneashContext(cauth, req.RequestContext)
+   if unleash.IsEnabled("target_name_from_authorizer", unleash.WithContext(uctx)) {
+       ctxLogger.Debug().Str("feature", "target_name_from_authorizer").Msg("feature is enabled")
+       cfg.TargetName = cauth.Claims.CognitoUsername
+   }
+
+   if unleash.IsEnabled("emoji_num_none", unleash.WithContext(uctx)) {
+       ctxLogger.Debug().Str("feature", "emoji_num_none").Msg("feature is enabled")
+       cfg.EmojiNum = 0
+   }
+
    ojiText, err := generator.Start(cfg)
    if err != nil {
        return nil, fmt.Errorf("failed to generate a ojichat text: %w", resErr)
@@ -64,6 +90,15 @@ func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (res *event
    }, nil
 }

+func convToUneashContext(c cognitoAuthorizer, ctx events.APIGatewayProxyRequestContext) unleash_context.Context {
+   return unleash_context.Context{
+       UserId:        c.Claims.Sub,
+       SessionId:     ctx.RequestID,
+       RemoteAddress: ctx.Identity.SourceIP,
+       Environment:   ctx.Stage,
+   }
+}
+
 func convToCognitoAuthorizer(v map[string]interface{}) (cognitoAuthorizer, error) {
    var c cognitoAuthorizer

最後に Lambda に対して環境変数から接続情報を渡します。

diff --git a/template.yaml b/template.yaml
index f223fc2..bf15d06 100644
--- a/template.yaml
+++ b/template.yaml
@@ -1,5 +1,12 @@
 AWSTemplateFormatVersion: "2010-09-09"

+Parameters:
+  UnleashAPIEndpoint:
+    Type: String
+
+  UnleashInstanceId:
+    Type: String
+
 Transform: AWS::Serverless-2016-10-31

 Globals:
@@ -44,6 +51,8 @@ Resources:
       Environment:
         Variables:
           APP_ENV: Development
+          UNLEASH_API_ENDPOINT: !Ref UnleashAPIEndpoint
+          UNLEASH_INSTANCE_ID: !Ref UnleashInstanceId

 Outputs:
   DevelopmentEndpoint:

では実行してみます。

username=cmkojimat0
curl -H "Authorization: $AUTHORIZATION_HEADER" "https://a4fm7eigpa.execute-api.us-east-2.amazonaws.com/Development"
{"text":"cmkojimat0チャン、そっちも雪なのかな❓バー?好きカナ?⁉️?⁉突然だけど、cmkojimat0チャンはバー?好きカナ❗❓?⁉️⁉❓火曜日ご飯行こうよ?(^o^)??"}
username=cmkojimat1
curl -H "Authorization: $AUTHORIZATION_HEADER" "https://a4fm7eigpa.execute-api.us-east-2.amazonaws.com/Development"
{"text":"cmkojimat1ちゃん、髪の毛、切ったのかな。似合いすぎダヨ。可愛すぎてボクお仕事に集中できなくなっちゃいそうだよ。どうしてくれるんダ。"}

cognito で登録した名前が表示されていることが確認できます。

また、cmkojimat1 にのみ有効にした 絵文字の表示をゼロにする 機能も動作していることが確認できました。

名前が表示されていることが気になるとの意見をいただいたため、機能をオフにします。

では実行していきます。

username=cmkojimat0
curl -H "Authorization: $AUTHORIZATION_HEADER" "https://a4fm7eigpa.execute-api.us-east-2.amazonaws.com/Development"
{"text":"cmkojimat0チャン、お疲れ様〜?(^o^)?(笑)ちょっと電話できるかナ?⁉️?今日はよく休んでネ??"}

あれ、名前がまだ表示されています。

cmkojimat1 でも実行してみます。

username=cmkojimat1
curl -H "Authorization: $AUTHORIZATION_HEADER" "https://a4fm7eigpa.execute-api.us-east-2.amazonaws.com/Development"
{"text":"アキノちゃん、ヤッホー。何してるのかい。今日は宮城30度超えるんだって。暑いね〜。こんな日は小生と裸のお付き合い。しヨ。ナンチャッテ。"}

こちらは反映されていました。

もう一度実行します。

username=cmkojimat0
curl -H "Authorization: $AUTHORIZATION_HEADER" "https://a4fm7eigpa.execute-api.us-east-2.amazonaws.com/Development"
{"text":"あれ^^;琉里ちゃん、朝と夜間違えたのかな?⁉️( ̄ー ̄?)?⁉小生はまだ起きてますよ〜???バー?好きカナ❓✋❓❗❓このホテル?、おにぎり?がオイシイんだって??小生と一緒に行こうよ?❗ナンチャッテ?(^o^)?♥ "}

反映されていることが確認できました。

まとめ

GitLab の FeatureFlag を使うことで機能を簡単に オン/オフ することを確認できました。

また、一部のユーザにのみ適用する、環境毎に適用を変える、といったことも行えるため、Production 環境での動作確認も容易となります。

一方でコンテキストがわかりにくいコードの管理が難しく、ログの設定が十分でない場合、思わぬ事故につながりかねません。

また、シビアなタイミングのコントロールは難しいように見受けられました。

参考