FireLens(Fluent Bit)からCloudWatch Logsへログ送信に必要な最小権限のタスクロールを作成し動作検証してみた

FireLens(Fluent Bit)からCloudWatch Logsへログを送るさいに必要なIAMポリシーと向き合った。
2021.07.31

ECSのタスク(コンテナ)のログ出力先を変更できるFireLens機能があります。本記事ではFluent Bitを利用したカスタムログルーティングでCloudWatch Logsへアプリケーションコンテナのログを送ります。CloudWatch Logsへ送信するための最小権限と、送信先のCloudWatch Logsのロググループ名を限定したIAMポリシーを作成します。動作検証しましたのでまとめます。

FireLensイメージの準備

Fluent Bitでカスタムログルーティングするための設定ファイルを作成します。設定ファイルを同梱したFireLensイメージを作成しECRにプッシュします。

Icons made by Freepik from www.flaticon.com
  • すべてのログをCloudWatch Logsへ送る
  • ロググループ名は/ecs/logs/sample-test
    • ここで指定したロググループ名は後で必要になります。
  • ログストリーム名はwebapp
  • ロググループが存在していない場合は新規作成する

extra.conf

[SERVICE]
    Flush 1
    Grace 30
    Log_Level info

[OUTPUT]
    Name   cloudwatch_logs
    Match  *
    region ap-northeast-1
    log_group_name /ecs/logs/sample-test
    log_stream_name webapp
    auto_create_group true

完成イメージ

ベースのイメージはAmazonが配布しているAWS用のプラグインが同梱されたFluent Bitのイメージを利用します。カスタムログルーティング用の設定ファイルをイメージ内にコピーします。

Dockerfile

FROM amazon/aws-for-fluent-bit:2.18.0
COPY ./extra.conf /fluent-bit/etc/extra.conf

設定ファイル込みのイメージをECRへプッシュして準備完了です。

docker build -t sample-test-custom-firelens:v1 .
docker tag sample-test-custom-firelens:v1 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/sample-test-custom-firelens:v1
docker push 123456789012.dkr.ecr.ap-northeast-1.amazonaws.com/sample-test-custom-firelens:v1

CloudFormation

テンプレートを実行すると以下のFargateでの動作確認環境を構築できます。VPCは事前に用意してください。

テンプレート

折りたたみ
AWSTemplateFormatVersion: "2010-09-09"
Description: Create Fargate*1, Firelens*1

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Common Settings
        Parameters:
          - ProjectName
          - Environment
      - Label:
          default: ECS VPC Settings
        Parameters:
          - VPCID
          - PublicSubnet1
          - PublicSubnet2
          - PrivateSubnet1
          - PrivateSubnet2

Parameters:
  ProjectName:
    Description: Project Name
    Type: String
    Default: unnamed
  Environment:
    Description: Environment
    Type: String
    Default: dev
    AllowedValues:
      - prod
      - dev
      - stg
      - test
  VPCID:
    Type: AWS::EC2::VPC::Id
  PublicSubnet1:
    Description: "Web App Subnet 1st"
    Type: AWS::EC2::Subnet::Id
  PublicSubnet2:
    Description: "Web App Subnet 2nd"
    Type: AWS::EC2::Subnet::Id
  PrivateSubnet1:
    Description: "ECS Subnet 1st"
    Type: AWS::EC2::Subnet::Id
  PrivateSubnet2:
    Description: "ECS Subnet 2nd"
    Type: AWS::EC2::Subnet::Id
  DesiredCount:
    Type: Number
    Default: 1
  ClusterName:
    Type: String
    Default: cluster
  AppName:
    Type: String
    Default: webapp
  ServiceName:
    Type: String
    Default: service
  TaskDefinitionName:
    Type: String
    Default: taskdefinition
  ImageNameWebApp:
    Description: "Web Application Repository Name also Need to TagName"
    Type: String
    Default: "public.ecr.aws/nginx/nginx:latest"
  ImageNameFirelens:
    Description: "Firelens Repository Name also Need to TagName"
    Type: String
    Default: "public.ecr.aws/aws-observability/aws-for-fluent-bit:latest"

Resources:
  # --------------------------------------------
  # ELB
  # --------------------------------------------
  ELB1:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Type: "application"
      Name: !Sub ${ProjectName}-${Environment}-elb
      Scheme: "internet-facing"
      SecurityGroups:
        - !Ref SecurityGroup2
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      IpAddressType: "ipv4"
      Tags:
        - Key: Name
          Value: !Sub ${ProjectName}-${Environment}-elb

  ELBListener1:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - TargetGroupArn: !Ref TargetGroup1
          Type: "forward"
      LoadBalancerArn: !Ref ELB1
      Port: 80
      Protocol: "HTTP"

  TargetGroup1:
    Type: "AWS::ElasticLoadBalancingV2::TargetGroup"
    Properties:
      VpcId: !Ref VPCID
      Name: !Sub ${ProjectName}-${Environment}-tg
      Protocol: "HTTP"
      HealthCheckPath: "/"
      Port: 80
      TargetType: ip
      HealthCheckIntervalSeconds: 10 # Default is 30.
      HealthyThresholdCount: 2 # Default is 5.
      HealthCheckTimeoutSeconds: 5
      UnhealthyThresholdCount: 2
      TargetGroupAttributes:
        - Key: "stickiness.enabled"
          Value: "false"
        - Key: deregistration_delay.timeout_seconds
          Value: "60" # default is 300.
        - Key: "stickiness.type"
          Value: "lb_cookie"
        - Key: "stickiness.lb_cookie.duration_seconds"
          Value: "86400"
        - Key: "slow_start.duration_seconds"
          Value: "0"
        - Key: "load_balancing.algorithm.type"
          Value: "round_robin"

  # --------------------------------------------
  # CloudWatch Logs Group
  # --------------------------------------------
  # FireLens Stdout
  FireLensLogGroup:
    Type: "AWS::Logs::LogGroup"
    Properties:
      LogGroupName: !Sub "/ecs/logs/${ProjectName}-${Environment}-firelens"

  # --------------------------------------------
  # ECS Fargate
  # --------------------------------------------
  # Cluster
  ECSCluster:
    Type: "AWS::ECS::Cluster"
    Properties:
      ClusterName: !Sub "${ProjectName}-${Environment}-${ClusterName}"
      CapacityProviders:
        - "FARGATE_SPOT"
        - "FARGATE"

  # Service
  ECSService:
    Type: "AWS::ECS::Service"
    Properties:
      ServiceName: !Sub ${ProjectName}-${Environment}-${ServiceName}
      Cluster: !Ref ECSCluster
      LaunchType: "FARGATE"
      PlatformVersion: "1.4.0"
      DesiredCount: !Ref DesiredCount
      LoadBalancers:
        - TargetGroupArn: !Ref TargetGroup1
          ContainerName: !Ref AppName
          ContainerPort: 80
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: "DISABLED"
          SecurityGroups:
            - !Ref SecurityGroup1
          Subnets:
            - !Ref PrivateSubnet1
            - !Ref PrivateSubnet2
      TaskDefinition: !Ref ECSTaskDefinition
    DependsOn: ELBListener1
  # ECS TaskDefinition
  ECSTaskDefinition:
    Type: "AWS::ECS::TaskDefinition"
    Properties:
      Family: !Sub "${ProjectName}-${Environment}-${AppName}-${TaskDefinitionName}"
      TaskRoleArn: !GetAtt ECSTaskRole1.Arn
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole1.Arn
      NetworkMode: "awsvpc"
      RequiresCompatibilities:
        - "FARGATE"
      Cpu: "256"
      Memory: "512"
      ContainerDefinitions:
        - Essential: true
          Name: !Ref AppName
          Image: !Ref ImageNameWebApp
          LogConfiguration:
            LogDriver: "awsfirelens"
          PortMappings:
            - ContainerPort: 80
              HostPort: 80
              Protocol: "tcp"
        - Essential: true
          Name: "log_router"
          Image: !Ref ImageNameFirelens
          LogConfiguration:
            LogDriver: "awslogs"
            Options:
              awslogs-group: !Ref FireLensLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: "firelens"
          FirelensConfiguration:
            Type: "fluentbit"
            Options:
              config-file-type: "file"
              config-file-value: "/fluent-bit/etc/extra.conf"
          User: "0"

  # --------------------------------------------
  # Security Group
  # --------------------------------------------
  # Security Group for WebApp
  SecurityGroup1:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub ${ProjectName}-${Environment}-${AppName}-sg
      GroupDescription: Web App Security Group
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          SourceSecurityGroupId: !Ref SecurityGroup2
          Description: "Access from ELB"
      VpcId: !Ref VPCID
      Tags:
        - Key: Name
          Value: !Sub ${ProjectName}-${Environment}-${AppName}-sg
  # Security Group for ELB
  SecurityGroup2:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub ${ProjectName}-${Environment}-elb-sg
      GroupDescription: ELB Security Group
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: "0.0.0.0/0"
          Description: "Access from Public"
      VpcId: !Ref VPCID
      Tags:
        - Key: Name
          Value: !Sub ${ProjectName}-${Environment}-elb-sg

  # --------------------------------------------
  # IAM Role
  # --------------------------------------------
  # ECS Task Execution Role
  ECSTaskExecutionRole1:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub ${ProjectName}-${Environment}-${AppName}-ECSTaskExecutionRole
      Path: "/"
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
  # ECS Task Role
  ECSTaskRole1:
    Type: "AWS::IAM::Role"
    Properties:
      Path: "/"
      RoleName: !Sub ${ProjectName}-${Environment}-${AppName}-ECSTaskRole
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !Ref ECSExecPolicy
        - !Ref SentCloudWatchLogsPolicy

  # --------------------------------------------
  # IAM Policy
  # --------------------------------------------
  # Allowed ECS Exec for Task Role
  ECSExecPolicy:
    Type: "AWS::IAM::ManagedPolicy"
    Properties:
      ManagedPolicyName: !Sub "${ProjectName}-${Environment}-ECSExecPolicy"
      Path: "/"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - ssmmessages:CreateControlChannel
              - ssmmessages:CreateDataChannel
              - ssmmessages:OpenControlChannel
              - ssmmessages:OpenDataChannel
            Resource: "*"
  # Sent CloudWatch Logs for Task Role
  SentCloudWatchLogsPolicy:
    Type: "AWS::IAM::ManagedPolicy"
    Properties:
      ManagedPolicyName: !Sub "${ProjectName}-${Environment}-SentCloudWatchLogsPolicy"
      Path: "/"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogStream
              - logs:CreateLogGroup
              - logs:DescribeLogStreams
              - logs:PutLogEvents
            Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/ecs/logs/sample-test:*

検証環境

項目 バージョン
aws-for-fluent-bit 2.18.0
Fluent Bit 1.8.2
Fargate platform 1.4.0

ポイント

  • ELB経由でNginxコンテナにアクセスすることで、アクセスログがFireLensコンテナ経由でCloudWatch Logsへ保存
  • CloudWatch Logsへログを送るために必要なタスクロールの権限をできる限り制限
  • FireLensのイメージは事前にカスタムログルーティングの設定ファイルを同梱して準備が必要
  • Nginxコンテナは素のイメージをそのまま使用するので準備不要
  • セキュリティグループが0.0.0.0/0で解放されています、必要に応じてアクセス元を制限してください

FireLensイメージのパラメータ指定

FireLensのイメージ名はECRへプッシュした設定ファイルが同梱したイメージを指定してください。デフォルト値は素のFireLens(Fluent Bit)のイメージになっています。

FilreLensの設定

FireLensのログ設定周りを見ていきます。

アプリコンテナ

  • LogCongurationのLogDriverの指定はawsfirelens
          Name: !Ref AppName
          Image: !Ref ImageNameWebApp
          LogConfiguration:
            LogDriver: "awsfirelens"
          PortMappings:
            - ContainerPort: 80
              HostPort: 80
              Protocol: "tcp"

FireLensコンテナ

  • LogConfigurationはFireLensコンテナの標準出力内容をデバッグ用にCloudWatch Logsへ送ります。
  • FirelensConfigurationにはカスタム設定ファイルをDockerイメージ内にコピー先のパスを指定します。
          Name: "log_router"
          Image: !Ref ImageNameFirelens
          LogConfiguration:
            LogDriver: "awslogs"
            Options:
              awslogs-group: !Ref FireLensLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: "firelens"
          FirelensConfiguration:
            Type: "fluentbit"
            Options:
              config-file-type: "file"
              config-file-value: "/fluent-bit/etc/extra.conf

タスクロール用のIAMポリシー設定

FireLensからCloudWatch Logsへログを送るためにはタスクロールに権限が必要です。Actionは最低限必要な権限に絞り、Resourceで送信先のロググループも限定します。

Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/ecs/logs/sample-test:*/ecs/logs/sample-testが注意するところです。ここはロググループ名を指しており、FireLensからの送信先ロググループ名はFluent Bitの設定ファイル(extra.conf)でロググループ名を決めています。

  # Sent CloudWatch Logs for Task Role
  SentCloudWatchLogsPolicy:
    Type: "AWS::IAM::ManagedPolicy"
    Properties:
      ManagedPolicyName: !Sub "${ProjectName}-${Environment}-SentCloudWatchLogsPolicy"
      Path: "/"
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogStream
              - logs:CreateLogGroup
              - logs:DescribeLogStreams
              - logs:PutLogEvents
            Resource: !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/ecs/logs/sample-test:*

FireLensイメージ内に同梱した設定ファイルを再掲します。IAMポリシーでResourceも制限するときはlog_group_nameで指定した名前を確認しましょう。

extra.conf

[SERVICE]
    Flush 1
    Grace 30
    Log_Level info

[OUTPUT]
    Name   cloudwatch_logs
    Match  *
    region ap-northeast-1
    log_group_name /ecs/logs/sample-test
    log_stream_name webapp
    auto_create_group true

Resourceは制限しないで、Actionだけを純粋にみると以下のIAMポリシーになります。一部Createが必要になるのはFluent Bitの設定ファイルでauto_create_grouptrueだと、指定したロググループと、ログストリームを自動的に新規作成してくれるためです。

CloudWatch Logs用のIAMポリシー

{
	"Version": "2012-10-17",
	"Statement": [{
		"Effect": "Allow",
		"Action": [
			"logs:CreateLogStream",
			"logs:CreateLogGroup",
			"logs:DescribeLogStreams",
			"logs:PutLogEvents"
		],
		"Resource": "*"
	}]
}

完成イメージ

テンプレートを実行をすると以下のIAMポリシーが生成されます。

Resource制限付きCloudWatch LogsのIAMポリシー

{
	"Version": "2012-10-17",
	"Statement": [{
		"Effect": "Allow",
		"Action": [
			"logs:CreateLogStream",
			"logs:CreateLogGroup",
			"logs:DescribeLogStreams",
			"logs:PutLogEvents"
		],
		"Resource": "arn:aws:logs:ap-northeast-1:123456789012:log-group:<loggroup-name>:*"
	}]
}

動作確認

ELBのDNS名にアクセスするとNginxのページが返ってきます。

Nginxのログ確認

アプリケーションコンテナのログはFluent Bitの設定ファイルで指定したロググループにログが保存される予定です。今回の検証環境では/etc/logs/sample-test/webappにログが保存されます。

ELBのヘルスチェックのログを確認できます。検証の目的であったタスクロールのIAMポリシー設定に問題ないことを確認できました。

タスクロールの権限を間違った場合

CloudWatch Logsへ送信用のActionsとResource指定に問題ないことを確認できました。仮にResourceの指定を誤っていた場合、切り分けするにはどこを確認したらよいのか確認しておきます。

Resourceで指定したCloudWatch Logsのロググループ名を適当な名前に変更しました。先ほど送信していたロググループ名へアクセスできない状態にしました。

FireLens経由で送信されるCloudWatch Logsのロググループ(/etc/logs/sample-test/webapp)のログが止まりました。時刻はIAMポリシーを変更して間もない22:00:12で止まっています。ログが止まったということはわかりました。なにが原因で止まったのかはアプリケーションコンテナのログからでは判斷する材料がありません。

FireLensの標準出力結果を送信しているCloudWatch Logsのロググループ(/etc/logs/sample-test-firelens)を確認します。 アプリケーションコンテナのログが止まった直後の22:00:13AccessDeniedExceptionのメッセージ確認できました。アクセス権がないということから、タスクロールのIAMポリシーなど権限周りを疑うことができます。

FireLensコンテナのログ設定はテンプレートの以下の箇所で指定しています。明示的にCloudWatch Logsのロググループをテンプレート内で作成しています。

 # FireLens Stdout
  FireLensLogGroup:
    Type: "AWS::Logs::LogGroup"
    Properties:
      LogGroupName: !Sub "/ecs/logs/${ProjectName}-${Environment}-firelens"

...snip...

          Name: "log_router"
          Image: !Ref ImageNameFirelens
          LogConfiguration:
            LogDriver: "awslogs"
            Options:
              awslogs-group: !Ref FireLensLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: "firelens"
          FirelensConfiguration:
            Type: "fluentbit"
            Options:
              config-file-type: "file"
              config-file-value: "/fluent-bit/etc/extra.conf

アプリケーションコンテナのログ送信先のロググループ名はCloudFormationのテンプレートからは作成していません。Fluent Bitの設定ファイルで対象のロググループ名がない場合は新規作成できるため、Fluent Bitの設定ファイルに寄せています。

おわりに

IAMポリシーのActionはよいとして、Resourceの指定はFluent Bitから作成されるリソース(CloudWatch Logsのロググループ)をCloudFormationのテンプレートで作成するIAMポリシーを指定しています。テンプレートを一発実行して何か問題にならないのか気になったので検証環境を作成しました。どなたかの参考になれば幸いです。

参考