GitLab の Feature Flag を使って一部の人にだけ機能を公開してみる
Feature Flag とは
パラメータによって動作を変更するための実装手法です。
カナリアリリース, A/B テスト を目的として使用されます。
SaaS や OSS も提供されています。利用することでオンデマンドでの変更を可能とし、対象となるユーザの制限もできます。
注意事項
多用してしまうことにより、コードの品質や可読性に影響を及ぼす可能性があります。
また、一時的な機能の有効化は障害調査の枷となる可能性があります。
実験や検証を目的とし、リリース管理やユーザ毎の挙動に使うことは望ましくありません。
マーティン・ファウラーは機能トグルは最後の手段であるべきとして、機能トグルを利用する前に次のような手段を検討すべきと述べている
- 機能を小さく分割して段階的にリリースしていく方法
- 新機能へのエントリーポイントとなる UI を最後に作る方法
やってみた
1. サンプルアプリの構築
今回は AWS Lambda 上にサンプルアプリを展開します。
その後、Feature Flag を使って特定ユーザのみ機能を提供していきます。
サンプルアプリはこちらの記事を参考に構築しました。
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 点の機能を検証していきます。
- ランダムの名前ではなく, cognito で登録したユーザ名を表示する
- 絵文字の表示をゼロにする
ではまず、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 環境での動作確認も容易となります。
一方でコンテキストがわかりにくいコードの管理が難しく、ログの設定が十分でない場合、思わぬ事故につながりかねません。
また、シビアなタイミングのコントロールは難しいように見受けられました。
参考
- https://docs.gitlab.com/ee/operations/feature_flags.html
- https://dev.classmethod.jp/articles/ojichat-serverless/
- https://github.com/Unleash/unleash
- https://en.wikipedia.org/wiki/Feature_toggle
- https://www.martinfowler.com/articles/feature-toggles.html
- https://betterprogramming.pub/feature-toggle-introduction-68d58f5c709