Lambda (golang)でOpenSearchのドメインを定期的に名前解決し、ALBのターゲットを更新（プライベートサブネットのみ）

# 初めに
生産技術部で製品の検査工程を担当しているエンジニアです。工場のデータを見える化するために、工場で使わなくなったサーバを利用して、オンプレミス環境に[The Elastic Stack](https://www.elastic.co/jp/elastic-stack/)(Elasticsearch、Kibana、Logstash、Filebeat、Metricbeat)でサービスを構築しました。

しかし、本業である生産技術業務の傍らで、生産量増加に伴うサーバ負荷対応や故障対応といったサーバの管理を行う事は難しいため、AWSのマネージドサービスの利用を検討しています。AWSを利用する目的は管理面の課題解消だけでなく、AWSで提供される多様なサービスとの連携や、パブリッククラウドを利用する事による他事業部との連携を視野に入れて検討しています。

# AWSで検討している構成
会社のネットワークから[AWS Direct Connect](https://aws.amazon.com/jp/directconnect/)(専用線接続サービス)を利用し、接続しています。セキュリティの要件から、会社からのアクセスに限定し、インターネットゲートウェイは配置せず、プライベートサブネットのみを利用します。

社内に配置したFilebeatからFargateで立ち上げたLogstashにデータを転送し、Logstashから[Amazon OpenSearch Service](https://aws.amazon.com/jp/opensearch-service/)(以下、OpenSearch)に接続します。ALBをOpenSearchに繋ぎ、会社からOpenSearch Dashboardを閲覧出来る様にします。

![](https://storage.googleapis.com/zenn-user-upload/35602d7985a2-20220327.png)

# OpenSearchとALB接続における課題

プライベートサブネットのみでネットワークを構成するため、OpenSearchのエンドポイントはVPCに向けています。この設定では、OpenSearchのENI(Elastic Network Interface)がVPC内に配置され、VPC内のサービスからOpenSearchにアクセスする事が出来ます。OpenSearchのエンドポイントは、以下の様なドメインエンドポイントとしてサービスを公開します。

`https://vpc-domain-name`

https://docs.aws.amazon.com/ja_jp/opensearch-service/latest/developerguide/vpc.html

現在(2022/3/27)時点で、ALBはドメインエンドポイントをターゲットにする事が出来ないため、OpenSearchのENIに割り当てられたプライベートIPアドレスをターゲットとする必要があります。しかし、OpenSearchのIPアドレスを利用する場合に、このような制約があります。

> IP アドレスは変更する可能性があるため、ドメインのエンドポイントを定期的に解決して常に正しいデータノードにアクセスできるようにすることが重要です。DNS 解決間隔を 1 分間に設定することが推奨されます。

# AWS Lambdaを利用した対策と方針
 Lambdaを利用し、1分毎にドメインエンドポイントの名前解決を行い、ALBのターゲットグループの設定と異なる場合は、値を更新する仕組みを実装します。

1分毎の定期実行には、Amazon EventBridge(CloudWatch Events)を利用し、Lambdaに利用する言語は、自分が一番使いやすそうなgolangとします。ライブラリは以下の２つを利用します。

* https://github.com/aws/aws-lambda-go  
Lambdaを操作するために使用
* https://github.com/aws/aws-sdk-go-v2  
ALBを操作するために使用

Lambdaをプライベートサブネットのみで利用するために[AWS PrivateLink](https://aws.amazon.com/jp/privatelink/?privatelink-blogs.sort-by=item.additionalFields.createdDate&privatelink-blogs.sort-order=desc)を設定します。

# Lambda Functionの実装
参考実装：https://github.com/TomoyukiSugiyama/ElasticStack/blob/main/provisioning/build/lambda/populate-alb-tg-with-opensearch/populate-alb-tg-with-opensearch.go

1. 環境変数に設定したOpenSearchのドメインエンドポイント、AWS::ElasticLoadBalancingV2::LoadBalancerのAmazon Resource Name (以下、ARN)、AWS::ElasticLoadBalancingV2::TargetGroupのARNを取得
2. OpenSearchのドメインエンドポイントから名前解決し、IPアドレスを取得
3. aws-lambda-go、aws-sdk-go-v2のライブラリを使用するための初期設定
4. LoadBalancerのARNからLoadbalancerを取得
5. TargetGroupのARNからTargetGroupを取得
6. TargetGroupにOpensearchのIPアドレスが登録されていなければ、新規にTargetを登録
7. ALBのヘルスチェック結果を元に、不要になったTargetの登録を解除

```go:privisioning/lambda/populate-alb-tg-with-opensearch.yaml
func HandleLambdaEvent() {
	// 1. 環境変数取得
	domainEndpoint := os.Getenv("DomainEndpoint")
	albId := os.Getenv("AlbId")
	albTargetGroupId := os.Getenv("AlbTargetGroupId")
	fmt.Printf("GET ENV DomainEndpoint: %s AlbId: %s AlbTargetGroupId: %s\n", domainEndpoint, albId, albTargetGroupId)
	// 2. 名前解決
	opensearchIpAddr := ResolveIpAddress(domainEndpoint)
	fmt.Printf("GET OpensearchIpAddressddress: %s\n", opensearchIpAddr)
	// 3. 初期設定
	svc := Init()
	// 4. 指定したLoadbalancerを取得
	lb := GetSpecifiedLoadbalancer(svc, albId)
	fmt.Printf("GET LoadbalancerName: %s LoadbalancerArn: %s\n", *lb.LoadBalancerName, *lb.LoadBalancerArn)
	// 5. 指定したLoadbalancerのTargetGroupを取得
	tg := GetSpecifiedTargetGroup(svc, lb, albTargetGroupId)
	fmt.Printf("GET TargetGroupName: %s TargetGroupArn: %s\n", *tg.TargetGroupName, *tg.TargetGroupArn)
	// 6. TargetGroupにTargetの登録
	if !HasTarget(svc, tg, opensearchIpAddr) {
		const httpsPort = 443
		RegisterSpecifiedTarget(svc, tg, opensearchIpAddr, httpsPort)
		fmt.Println("REGISTER")
	}
	// 7. UnheltyなTargetの登録解除
	DeregisterUnheltyTargets(svc, tg)
}
```

Cloudformationは、下記のパラメータを設定しています。S3Bucketを指定して実行ファイルを取得します。ドメインエンドポイント、ALBのARN、TargetGroupのARNは環境変数として設定します。IAMロールのポリシーには、VPC内でLambdaを実行するための`AWSLambdaVPCAccessExecutionRole`、S3にアクセスする為の`AmazonS3ReadOnlyAccess`、LoadBalancerに関連するアクセスポリシーを追加しています。


```yaml:privisioning/cfn/lambda.yaml
  Function:
    Type: AWS::Lambda::Function
    Properties:
      Handler: populate-alb-tg-with-opensearch
      Role: !GetAtt LambdaRole.Arn
      Code:
        S3Bucket: lambda-XXXXXXXXXXXXXXXXXXX-artifact
        S3Key: populate-alb-tg-with-opensearch.zip
      Runtime: go1.x
      Timeout: 5
      TracingConfig:
        Mode: Active
      VpcConfig:
        SecurityGroupIds:
          - Ref: SecurityGroupId
        SubnetIds: !Split [",", !Ref SubnetIds]
      Environment:
        Variables:
          DomainEndpoint: !Ref DomainEndpoint
          AlbId: !Ref AlbId
          AlbTargetGroupId: !Ref AlbTargetGroupId

  LambdaRole:
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Action:
              - sts:AssumeRole
            Condition: {}
            Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
        Version: 2012-10-17
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
        - arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
      Policies:
        - PolicyName: FIoTElasticLoadBalancingAccessPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "elasticloadbalancing:DescribeLoadBalancers"
                  - "elasticloadbalancing:DescribeTargetHealth"
                  - "elasticloadbalancing:DescribeTargetGroups"
                  - "elasticloadbalancing:RegisterTargets"
                  - "elasticloadbalancing:DeregisterTargets"
                Resource: "*"
      Tags:
        - Key: com.trim.f-iot
          Value: lambda
    Type: AWS::IAM::Role
```

# EventBridgeの設定
参考実装：https://github.com/TomoyukiSugiyama/ElasticStack/blob/main/provisioning/build/cfn/lambda.yaml

イベントのルールは、cron形式で記述し、1分毎に実行するよう指定します。AWS::Lambda::PermissionにEventBridgeからの関数呼び出しを許可します。

```yaml:privisioning/cfn/lambda.yaml
  EventRule:
    Type: AWS::Events::Rule
    Properties:
      Description: pupulate atb target with opensearch
      Name: populate-alb-tg-with-opensearch-rule
      ScheduleExpression: cron(* * * * ? *)
      State: ENABLED
      Targets:
        - Arn: !GetAtt Function.Arn
          Id: lambda

  EventPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref Function
      Principal: events.amazonaws.com
      SourceAccount: !Ref AWS::AccountId
      SourceArn: !GetAtt EventRule.Arn
```

# PrivateLinkの設定
参考実装：https://github.com/TomoyukiSugiyama/ElasticStack/blob/main/provisioning/build/cfn/vpc-endpoint.yaml

以下のPrivateLinkを設定します。
* LambdaからS3にアクセスするためのエンドポイント(Gateway型のエンドポイントを設定していますが、インターフェース型のエンドポイントも設定可能です。)
* LambdaのLogを取得するためのエンドポイント
* LambdaからALBにアクセスするためのエンドポイント


```yaml:provisioning/cfn/vpc-endpoint.yaml
  S3Endpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
      VpcEndpointType: Gateway
      VpcId: !Ref VpcId
      RouteTableIds:
        - !Ref PrivateRouteTableId

  LogsEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.logs"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      VpcId: !Ref VpcId
      SubnetIds: !Split [",", !Ref SubnetIds]
      SecurityGroupIds:
        - !Ref SecurityGroupId

  ElasticLoadBalancingEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.elasticloadbalancing"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      VpcId: !Ref VpcId
      SubnetIds: !Split [",", !Ref SubnetIds]
      SecurityGroupIds:
        - !Ref SecurityGroupId
```

# 動作確認
OpenSearchのサブネットを編集し、IPアドレスを強制的に変更します。

![](https://storage.googleapis.com/zenn-user-upload/cf3be8162da3-20220327.png)

新しいルートが追加されると、Lambdaで検知してALBのTargetを追加します。

![](https://storage.googleapis.com/zenn-user-upload/225432cf2313-20220327.png)

OpenSearchが古いルートを削除すると、古いIPアドレス宛のヘルスチェックは、Unhealtyとなります。

![](https://storage.googleapis.com/zenn-user-upload/584b1a69c493-20220327.png)

LambdaでUnhealtyなTargetを検知して、Targetの登録を解除します。

![](https://storage.googleapis.com/zenn-user-upload/3824be50b1ef-20220327.png)

TargetGroupのメトリクスを確認すると、想定通りの動きになっていることがわかります。

![](https://storage.googleapis.com/zenn-user-upload/8af64c0bccc6-20220327.png)

ログで確認すると、`REGISTER`、`DEREGISTER`が一回ずつ実行されていることがわかります。

```log
START RequestId: f100a28d-367f-4d89-ad45-ebf6b36867ad Version: $LATEST
GET OpensearchIpAddressddress: 172.31.35.214
GET LoadbalancerName: f-iot-alb LoadbalancerArn: arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXXXXXXX:loadbalancer/app/f-iot-alb/a7e46ad189fb1a72
GET TargetGroupName: f-iot-alb-tg TargetGroupArn: arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXXXXXXX:targetgroup/f-iot-alb-tg/4ab4dba084c2bb5c
REGISTER
END RequestId: f100a28d-367f-4d89-ad45-ebf6b36867ad
:
:
START RequestId: f55b9976-fd87-4e26-acfa-e75ae4ed4d76 Version: $LATEST
GET OpensearchIpAddressddress: 172.31.35.214
GET LoadbalancerName: f-iot-alb LoadbalancerArn: arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXXXXXXX:loadbalancer/app/f-iot-alb/a7e46ad189fb1a72
GET TargetGroupName: f-iot-alb-tg TargetGroupArn: arn:aws:elasticloadbalancing:ap-northeast-1:XXXXXXXXXXXX:targetgroup/f-iot-alb-tg/4ab4dba084c2bb5c
DEREGISTER UnhealtyTarget.Id: 172.31.19.18
END RequestId: f55b9976-fd87-4e26-acfa-e75ae4ed4d76
```

# 最後に
AWSは始めたばかりの初心者で、ハードウェア系の職場のため、一人で試行錯誤しながらAWSを使っている状況です。Lambdaも初めて利用しましたが、なんとか想定通りに動かすことができました。不適切な部分がありましたら、コメントでご指摘お願いします。

インフラの構築自体が素人のため、いろんなところで苦戦しています。今後は、初めて使うAWSサービス(AWS DirectConnect、Fargate)で苦労したことなどを発信していければと思います。今後ともよろしくお願いします。

# ご参考

https://aws.amazon.com/jp/blogs/networking-and-content-delivery/using-aws-lambda-to-enable-static-ip-addresses-for-application-load-balancers/