シ〜らかんす

プログラミングとか、カメラとか。

Go gin & Web Adapterで動作するLambdaからDatadogのトレースを送信する

概要

AWS Web Adapterというものがあり、ウェブAPIのコードをそのままにサーバーレス化することができる。

aws.amazon.com

これいいじゃんということで、GoのGinで動くウェブAPIをサーバーレス化したのだが、以前のECS Fargate環境では正常に動作していたDatadogのトレース送信が動かなくなった。

ハマった末にやり方が分かったので、Lambda Web Adapter & Gin の構成でウェブAPIを動かす際にDatadogのトレースを正常に送る方法をお伝えする。

Web Adapterを使っていない場合はどうやってトレースを送るのか?

まずは、Web Adapterを使っていない通常のLambdaの場合だとどのようにDatadogトレースを送るのか紹介する。

Dockerfileに以下のコードを書き、datadog extensionをコンテナイメージに含める。

COPY --from=public.ecr.aws/datadog/lambda-extension:latest /opt/. /opt/

datadog-lambda-goのライブラリをインストールし、

go get github.com/DataDog/datadog-lambda-go

ddlambda.WrapFunction を仕込む。

func main() {
  // Wrap your lambda handler
  lambda.Start(ddlambda.WrapFunction(myHandler, nil))
}

func myHandler(ctx context.Context, event MyEvent) (string, error) {
  // lambdaの処理をここに書く
}

このやり方は公式ドキュメントにも記載があるし、特に問題なく動かせると思う。(※ 2023年4月現在、英語でないとcontainer imageの場合のやり方が出ないので注意)

docs.datadoghq.com

Web Adapterを使っている場合はどうなるか?

通常、コンテナイメージのLambdaを使う場合、上記のように lambda.Start がエントリポイントとなる。 しかし今回は、Web Adapterを使っているので、アプリケーションのコードはginを使った純粋なweb APIのコードになり、 lambda.Start をどこにも書いておらず、ddlambda.WrapFunctionを仕込むことができない。

ではどうやるのかというと、Datadogのトレーサーをスタートするときに、以下のようにオプションを入れると良い。

tracer.Start(
        tracer.WithEnv("環境名(stagingやproduction)を入れる"),
        tracer.WithService("Datadog上で扱われるサービス名を入れる"),
        tracer.WithLambdaMode(false), // trueにするとstdoutにtraceが出力され、datadog agentに送信されなくなる
        tracer.WithGlobalTag("_dd.origin", "lambda"),
    )
    defer tracer.Stop()

ECSからLambdaにginのAPIを移した際、当初はtracer.WithLambdaMode(false)tracer.WithGlobalTag("_dd.origin", "lambda") を入れておらず、トレースがDatadogに送信されてこなかった。

コードのコメントにも書いた通り、tracer.WithLambdaMode がtrueだとトレースがstdoutに出力され、datadog agentに送信されなくなる。

実際にはLambdaで動かしているのに、WithLambdaModeをfalseにするのは奇妙だが、Datadogのライブラリの実装を追っていくと確かにそうしている。

github.com

まとめ

Web Adapterを使うことで簡単に既存のAPIをLambdaに載せれるし、ローカルでも簡単に動かしやすくなる。 Datadogとの連携がドキュメントにも書いておらず難しかったが、これで解決できたのでよかった。

Lambda Web AdapterでGoのGinを動かすときに、DBコネクションの貼りすぎを防ぐ

概要

Lambda Web Adapterを使うと、それまで非サーバーレス環境下で動かしていたウェブAPIをLambda上で動かすことが可能になる。 コンテナイメージさえあれば、web APIをサーバーレス化できるし、コードの共通化も容易になって便利だ。

ただし、RDSと接続するのならば、コネクションが増えすぎないように工夫する必要がある。 対処方法として、RDS Proxyを使う方法はよく知られていて、導入はマストかと思う。

aws.amazon.com

その上で、RDS Proxyを使ったとしても、コネクション数が圧迫されることがある。 アプリケーション内部で明示的にデータベースとの接続を切断しないと、接続がアイドル状態となってしまい、アイドルコネクションのタイムアウトを迎えるまでコネクション数を消費してしまうのだ。

今回は、Lambda Web Adapter & Go & Ginを使って動かす際に、アプリケーション内部で明示的にコネクションをクローズし、コネクション数を圧迫しないようにする工夫を紹介する。

解決策1: Graceful Shutdown時にコネクションをクローズする

Lambdaはシャットダウン時にSIGTERMのシグナルを受け取るので、これをトリガーにDBとのコネクションをクローズする。

Lambdaのライフサイクル

https://docs.aws.amazon.com/lambda/latest/dg/runtimes-extensions-api.html より

サンプルコードは以下の通り。

r := gin.Default()
r.GET("/healthcheck", healthcheck)

srv := &http.Server{
    Addr:    ":8000",
    Handler: r,
}
go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        panic(err)
    }
}()

// Wait for interrupt signal to gracefully shutdown the server with a timeout of 5 seconds.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit

// DBとのコネクションを切断する
DbConnector.Close()

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
    logging.Error(err, "Server Shutdown:")
}
// catching ctx.Done(). timeout of 5 seconds.
<-ctx.Done()

この方法の問題として、Lambdaがinvokeされなくなってからシャットダウンするまでの間もコネクションが張られ続けてしまう点がある。 実際にAPIに対して同時接続数100件の負荷試験をかけてみると、RDSプロキシのコネクション数も同じ数くらいまで上がってしまった。

↓このようにリクエスト数に合わせてコネクション数が伸びてしまった。

解決策2: エンドポイントの処理が終了したらコネクションをクローズする(これで根本解決)

Ginのmiddlewareとして、DBのコネクションを閉じる処理を追加する。Ginのmiddlewareは、エンドポイントの処理の後に処理を挟み込むことができるので、これを利用する。

サンプルコードは以下の通り。

func SetDbConnClose(dbConn *sql.DB) func(*gin.Context) {
    return func(c *gin.Context) {
        c.Next()
        dbConn.Close()
    }
}


func main() {
    // DBとのコネクションを作成
    cfg := mysql.Config{
        User:   os.Getenv("DBUSER"),
        Passwd: os.Getenv("DBPASS"),
        Net:    "tcp",
        Addr:   "{RDS Proxyのエンドポイント}:3306",
        DBName: "sample",
    }
    dbConn, _ := sql.Open("mysql", cfg.FormatDSN())

    r := gin.Default()
    r.Use(SetDbConnClose())
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message": "pong",
        })
    })
    r.Run()
}

こうすることで、コネクション数が伸びてしまう現象をなくすことができた。

参考

Lambda Web Adapterとは? aws.amazon.com

Lambdaのライフサイクル docs.aws.amazon.com

ALBをトリガーにLambdaを起動する構成をTerraformで作ってみた

概要

HTTP APIをLambdaで動かすときは、AWS Apigateway & Lambdaの構成を取ることが多いが、今回はALBをトリガーとしてLambdaを動かす機会があったので、terraformでの作り方をまとめる。

LambdaをTerraformで作成する

まずは、Lambdaを作る。

resource "aws_lambda_function" "sample" {
  function_name = "sample_function"
  description   = "Awesome API"
  role          = aws_iam_role.metric_api.arn
  package_type  = "Image"
  image_uri     = "{コンテナイメージリポジトリのURI}"
  timeout       = 60
  memory_size   = 128
  vpc_config {
    security_group_ids = [{セキュリティグループのID}]
    subnet_ids         = [{サブネットのID}]
  }
}

ALBをTerraformで作成する

ALB、ターゲットグループ、リスナーグループを作成する。

resource "aws_alb" "alb" {
  name            = "sample-alb"
  internal        = false
  security_groups = [{セキュリティグループのID}]
  subnets         = [{サブネットのID}]

  enable_deletion_protection = false
  idle_timeout               = "60"

  access_logs {
    bucket  = "{アクセスログを置くS3バケット}"
    enabled = true
    prefix  = "alb/"
  }
}

resource "aws_alb_target_group" "tg" {
  name        = "sample_lambda_target_group"
  target_type = "lambda"
}

resource "aws_alb_listener" "https_listener" {
  load_balancer_arn = aws_alb.alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  certificate_arn   = {ACMのARN}

  default_action {
    type             = "forward"
    target_group_arn = aws_alb_target_group.tg.arn
  }
}

ALBがLambdaをトリガーできるように構成する

ALBがLambdaをトリガーできるように設定する。

resource "aws_lambda_permission" "alb" {
  statement_id  = "AllowExecutionFromALB"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.sample.function_name
  principal     = "elasticloadbalancing.amazonaws.com"
  source_arn    = aws_alb_target_group.tg.arn
}

最後に、ALBのターゲットグループとLambdaを紐付ける。

resource "aws_lb_target_group_attachment" "metric_api" {
  target_group_arn = aws_alb_target_group.tg.arn
  target_id        = aws_lambda_function.sample.arn
}

以上です。

Redisへの大量データインサートには、パイプラインを使おう

背景

Redisにダミーデータを投入するpythonスクリプトを使っていた。 データ件数が少ないうちはよかったのだが、多数のデータを入れるようになると、時間がかかりすぎて辛くなってきた。

パイプラインを使い、Redisとの通信ラウンドトリップを減らす

Redisのパイプラインを使うと、複数のRedisへのコマンド実行をまとめて一度に実行することができる。 これにより、ラウンドトリップの回数を減らすことができ、パフォーマンスが向上する。

redis.io

サンプルコード

以下のスクリプトでは、時系列データを24時間分、5秒間隔で作成してRedisに入れている。

r = redis.Redis(host=HOST, password=PASSWORD, port=PORT, db=0)
unix_time_from = unix_time_now - 24 * 3600
for i, data in enumerate(data_list):
        unix_time_current = unix_time_from
        pipe = r.pipeline()
        while unix_time_now >= unix_time_current:
            pipe.zadd(data["id"], {data["value"]: unix_time_current})
            unix_time_current += 5
        pipe.execute()

どれくらい速くなった?

パイプラインを使う前はあまりに遅く、途中で実行を止めたのだが、あのまま続けていたら2.5時間程度かかる見込みだった。 パイプライン使用後は、数分程度まで短縮された。

まとめ

パイプラインを使うことで、複数のコマンドをまとめて実行でき、パフォーマンスが向上する。 Redisのパフォーマンスチューニングの際に参考にしたい。

ECRセキュリティスキャンの結果をslackに通知する

構成図

以下の通り、ECR セキュリティスキャン -> AWS Event Bridge -> SNS -> Chatbot -> slack という流れで通知ができる。

ECRセキュリティスキャンをEventBridgeのイベントとして使う

多くのAWSの各マネージドサービスが、自らEventBridgeにイベントを送信しており、その一覧は以下のページで見ることができる。

イベントブリッジのイベント一覧

イベント送信の信頼性には「Guaranteed」「Best Effort」の2種類がある。 Guaranteedであれば、少なくとも1回は送信が保証されるが、Best Effortの場合は稀にイベントが送信されないことがある。

ECRのセキュリティスキャンは、EventBridgeに送信されるイベントの一つであり、信頼性はBest Effortとなっている。

公式ドキュメント でも、ECRセキュリティスキャンがEventBridgeに送信するイベントの例として使われている。

Github ActionsからAWS SSMのRun Commandを使う

動作環境

やりたいこと

mainブランチへのマージをトリガーに、EC2上で git pull コマンドを実行して最新のコードを反映させたい

というのが今回のやりたいことです。

前提

  • EC2がRunning状態である
  • ec2-userでgitコマンドが使える状態にある
  • githubリポジトリのSecretsの設定にAWSのクレデンシャル情報を登録済みである

どうやるか

今回は、github actionsと、AWS Systems Managerを使用して、EC2インスタンス上でコマンドを実行させてみようと思います。

SSM Agentのセットアップ

まず最初のステップは、EC2にSSM Agentをインストールすることです。

インスタンスの種類によってはデフォルトでインストールされているようですが、今回利用するRed Hat Enterprise Linux 8のインスタンスには入っていないので、入れる必要があります。

公式ドキュメントのこちらのページRHELでのインストール方法が記されていますので、これの通りやります。

RHEL8系のインストール手順を見ると、まずは事前準備としてpython2か3が入っている必要があるようなので、入れます。

$ sudo yum install python3.9

続いて、SSMのインストーラーをダウンロードします。

$ sudo dnf install -y https://s3.ap-northeast-1.amazonaws.com/amazon-ssm-ap-northeast-1/latest/linux_amd64/amazon-ssm-agent.rpm

無事完了すると、SSM Agentのサービスプロセスが起動していることを確認できます。

$ sudo systemctl status amazon-ssm-agent
● amazon-ssm-agent.service - amazon-ssm-agent
   Loaded: loaded (/etc/systemd/system/amazon-ssm-agent.service; enabled; vendor preset: disabled)
   Active: active (running) since Sun 2021-08-15 23:41:54 JST; 42s ago
 Main PID: 20343 (amazon-ssm-agen)
    Tasks: 13 (limit: 4821)
   Memory: 27.3M
   CGroup: /system.slice/amazon-ssm-agent.service
           ├─20343 /usr/bin/amazon-ssm-agent
           └─20397 /usr/bin/ssm-agent-worker

IAM Roleの作成

続いて、EC2に付与するIAMロールの作成を行います。

management consoleからやります。

IAMのコンソールからロールの作成に行き、ユースケースとしてEC2を選択します。

f:id:sas-surfer0jonny:20210816000504p:plain

続いて、ポリシーを付与する画面で、「AmazonEC2RoleforSSM」と検索して、出てきたポリシーを付与します。

f:id:sas-surfer0jonny:20210816000617p:plain

あとは、お好きな名前とdescriptionを書いてRoleを作成して、EC2インスタンスに付与してください。

AWSコンソール上でRun Commandする

github actionsの設定に入る前に、SSM Run Commandが動くかどうかの確認と、github actions上で実行するコマンドを明らかにしておきましょう。

そのために、AWSコンソール上からRun Commandを実行し、git pull コマンドを実行させてみます。

AWS SSMのコンソールの左のナビゲーションの中に、「Run Command」があるので、そこから「コマンドの実行」へ移ります。 f:id:sas-surfer0jonny:20210816091911p:plain

f:id:sas-surfer0jonny:20210816092023p:plain

コマンドドキュメントとして「AWS-RunShellScript」を選びます。

f:id:sas-surfer0jonny:20210816092222p:plain

コマンドのパラメータの入力欄に、実行したいコマンドを書き込みます。

注意点として、SSMのRun Commandはrootユーザーで実行されてしまうので、ec2-userでgitの設定を行なっている場合はうまく動きません。

git pullコマンドを実行するときは、sudo -u ec2-user git pull とすることでec2-userとして実行させましょう。

f:id:sas-surfer0jonny:20210816094505p:plain

次に、ターゲットとして、コマンドを実行したいEC2を選びます。 タグやリソースグループでの指定も行えるようですが、今回はインスタンスを手動で選択します。

f:id:sas-surfer0jonny:20210816092917p:plain

SSMのRun Commandのコンソールでは、親切にもAWS CLIで同じRun Commandを実行する際のコマンドを自動で生成してくれていますので、控えておきましょう。

f:id:sas-surfer0jonny:20210816093327p:plain

実行し、しばらくすると成功か失敗かわかります。

github actions上からRun Commandを実行する

すでにSSM Run Commandを実行するコマンドはわかっているので、あとはGithub Actionsから実行してやるだけです。

.github/workflows/ 配下にyamlを作成し、以下のように書きます。

name: Deploy to Sandbox
on:
  push:
    branches:
      - main

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-1

      - name: Execute SSM Run Command
        id: exec
        run: |
          export RESPONSE=$(aws ssm send-command --document-name "AWS-RunShellScript" --document-version "1" --targets '[{"Key":"InstanceIds","Values":["i-078b1fde3fc75d7fb"]}]' --parameters '{"workingDirectory":[""],"executionTimeout":["3600"],"commands":["cd /home/ec2-user/grpc-sample/", "sudo -u ec2-user git pull"]}' --timeout-seconds 600 --max-concurrency "50" --max-errors "0" --region ap-northeast-1)
          export COMMAND_ID=$(echo $RESPONSE | jq .Command.CommandId)
          echo "::set-output name=commandId::${COMMAND_ID}"

      - name: Check Run Command Result
        run: |
          bash -x ./.github/scripts/check_command_result.sh ${{ steps.exec.outputs.commandId }}

「Execute SSM Run Command」のステップでRun Commandを実行した後に、「Check Run Command Result」のステップで、Run Commandの成否をチェックしています。

シェルスクリプトで実行していて、そのコードは以下の通りです。

#!/bin/bash

function succeeded () {
  status=$(aws ssm get-command-invocation --command-id $1 --instance-id ご自身のEC2インスタンスIDに書き換えてください | jq .Status)
  if [ $status = "Success" ]; then
    echo true
  else
    echo false
  fi
}

commandId=$1
i=1

while [ $i -le 10 ];
do
  succeeded=$(succeeded $commandId)
  if [ $succeeded ]; then
    exit 0
  fi
  i=$($i + 1 )
  sleep 3
done

exit 1

Linux(RHEL8)でロケール設定を行う

動作環境

ロケールとは

ロケールとは、コンピュータにおける言語や地域の設定のことです。

たとえば、日本に設定した場合は、言語は「日本語」、通貨は「円」といった日本地域固有の情報を使ってユーザーとコンピュータがやりとりすることになります。

現在のロケール設定を確認する

ロケールの設定には localectl コマンドを使います。

EC2のRHEL8では、初期設定のロケールはUSになっています。

現在のロケール設定を確認するには、 localectl status を使います。

$ localectl status
   System Locale: LANG=en_US.UTF-8
       VC Keymap: us
      X11 Layout: us

日本のロケール設定が利用可能か調べる

$ localectl list-locales | grep ja

何も返ってきませんでした。 日本のロケールを利用可能にするために追加してやる必要があります。

日本のロケール設定を使えるようにする

yumで日本語関連のパッケージをインストールします。

$ sudo yum install glibc-langpack-ja

すると、日本語のロケールが利用可能になります

$ localectl list-locales | grep ja
ja_JP.eucjp
ja_JP.utf8

ロケールを日本語に設定する

$ sudo localectl set-locale LANG=ja_JP.utf8

確認すると、システムロケールがja_JP.utf8になっています。

$ localectl status
   System Locale: LANG=ja_JP.utf8
       VC Keymap: us
      X11 Layout: us