EFS を利用してカスタム FireLens を実装し、アプリケーション ログを CloudWatch Logs に出力する

## 背景
ECS Fargate で稼働するアプリケーションのログファイル（非標準出力）の内容をCloudWatch Logsに出力する要望があります。

しかし、AWS Fargate でホストされるタスクは、file 設定ファイルタイプのみをサポートします。[^1]

メンテナンス性、利用易さ、コスト、またシステムCI/CDを複雑化したくない等の理由で、FireLensとその設定ファイルはカスタムイメージを利用したくないです。

FireLens設定ファイルは EFS に格納し、その EFS を ECS にマウントする案を考えました。

- システムは、Dev環境、STG環境、本番環境で構成されている
- 環境ごとにAWSアカウントが異なって、アカウント間にTransit Gatewayで繋がっている
- EFS は各環境で作成する
- 運用管理サーバ（踏み台）は、Dev環境で構築されている

## EFS構築

1. EFS File system を作成する
    - VPC は、運用サーバ（EC2）また ECS が稼働する VPC を選択する 

2. AccessPoint を作成する
    - File system は、上記 1 で作成した File system を選択する
    - Root directory path は `/` を設定する

3. 各環境で上記１，２の手順を実施する

## EC2にEFSをマウントする
**注意:** クロスアカウントシナリオでは通常の NFS コマンドを使用できないため、botocore と Amazon EFS クライアントが必要になります。

1. セキュリティグループ
　EC2 用のセキュリティグループに、NAS用2049ポートを許可する。
 
     ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3882244/f2a5f8ac-2572-1fc4-1d53-55357184a442.png)

2. Amazon EFS クライアントをインストールする
    ```bash
    $ sudo yum install -y amazon-efs-utils
    ```
    
3. クロスアカウント EFS ファイルシステムにアクセスしてマウントするには、以下のようなポリシーステートメントを IAM ポリシーに追加します。

    - 運用サーバ IAM Profile 用 Policy

        ```json:aws-ec2-profile-policy
        {
            "Sid": "EfsPermissions",
            "Effect": "Allow",
            "Action": [
                "elasticfilesystem:ClientMount",
                "elasticfilesystem:ClientWrite",
                "elasticfilesystem:ClientRootAccess"
            ],
            "Resource": [
                "arn:aws:elasticfilesystem:region:accountA-id:file-system/file-system-id",
                "arn:aws:elasticfilesystem:region:accountB-id:file-system/file-system-id",
                "arn:aws:elasticfilesystem:region:accountB-id:file-system/file-system-id"
            ]
        }
        ```

    - STG環境、本番環境 EFS ポリシーに下記内容を追記

        ```json
        {
            "Sid": "access-point-statement-example03",
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::{dev_account_id}:root"
            },
            "Action": "*",
            "Resource": "arn:aws:elasticfilesystem:ap-northeast-1:{efs_account_id}:file-system/{file_system_id}"
        }
        ```

4. Amazon EFS コンソールにログインして、[File Systems] (ファイルシステム) を選択します。[EFS-File-System-ID] を選択してから、[Network] (ネットワーク) にあるアベイラビリティーゾーンの IP アドレスをメモします。

    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3882244/c4330d4f-129e-e3c2-5372-2617b2694f7a.png)

5. 取得した IP アドレスを使用して、NFS クライアントの /etc/hosts ファイルに hosts エントリを追加します。DNS 名の形式は、`mount-target-IP-Address file-system-ID.efs.region.amazonaws.com` です。

    以下のコマンド例を参照してください。
    
    ```bash
    $ echo "10.0.8.117 fs-087e048b084872a24.efs.ap-northeast-1.amazonaws.com" | sudo tee -a /etc/hosts
    ```
        

6. マウントヘルパーを使用して EFS ファイルシステムをマウントします。

    - ファイルシステムDNS名を使用してマウントするには：

        ```bash
        $ sudo mount -t efs -o iam,tls file-system-dns-name efs-mount-point/
        ```
    
        ```bash
        $ sudo mount -t efs -o iam,tls fs-087e048b084872a24.efs.ap-northeast-1.amazonaws.com efs/
        ```

    \- または -

    - マウントターゲット IP アドレスを使用してマウントするには：

        ```bash
        $ sudo mount -t efs -o iam,tls,mounttargetip=mount-target-ip file-system-id efs-mount-point/
        ```
     
        ```
        $ sudo mount -t efs -o iam,tls,mounttargetip=10.0.8.117 fs-087e048b084872a24 efs/
        ```

7. 再起動しても自動マウントできるため、`/etc/fstab` を修正する。[^2]

    

## ECSにEFSをマウントする

1. ECS 用のセキュリティグループに、NAS用2049ポートを許可する。

2. ECS Task実行ロールに EFS関する権限、例えば`AmazonElasticFileSystemClientFullAccess` ロールをアタッチする

3. 各環境の ECS タスク定義ファイルに、各環境のEFS情報を書き換えた内容を追記する。

    ```json
    "volumes": [
        {
            "name": "firelens-config",
            "efsVolumeConfiguration": {
                "fileSystemId": "{file-system-id}",
                "rootDirectory": "/",
                "transitEncryption": "ENABLED",
                "authorizationConfig": {
                    "accessPointId": "{access-point-id}",
                    "iam": "ENABLED"
                }
            }
        }
    ]
    ```

## タスク定義にFireLensを設定する

1. ECS タスク定義に、FireLens用サイドカーを追加

    ```json:firelens-side-car
    {
        "name": "log_router",
        "image": "public.ecr.aws/aws-observability/aws-for-fluent-bit:stable",
        "cpu": 0,
        "memory": 256,
        "memoryReservation": 128,
        "portMappings": [],
        "essential": true,
        "environment": [],
        "mountPoints": [],
        "volumesFrom": [],
        "user": "0",
        "logConfiguration": {
            "logDriver": "awslogs",
            "options": {
                "awslogs-group": "/ecs/Cluster/Service",
                "awslogs-create-group": "true",
                "awslogs-region": "ap-northeast-1",
                "awslogs-stream-prefix": "ecs"
            }
        },
        "systemControls": [],
    }
    ```
    
2. 各環境 FireLens 用の Container 定義に、下記マウント定義を追記する

    ```json
    "mountPoints": [
        {
            "sourceVolume": "firelens-config",
            "containerPath": "/configs/{env}"
        }
    ],
    ```

    
3. Multiline PARSER 用 `parser_springboot.conf` ファイルを作成

    以下の設定は、ログのタイムスタンプを見て、次のタイムスタンプまでログを合併する。タイムスタンプが付いていないログは、前のログに統合する。

    ```conf
    [MULTILINE_PARSER]
    name          springboot-multiline
    type          regex
    flush_timeout 1000
    #
    # Regex rules for multiline parsing
    # ---------------------------------
    #
    # configuration hints:
    #
    #  - first state always has the name: start_state
    #  - every field in the rule must be inside double quotes
    #
    # rules |   state name  | regex pattern                  | next state
    # ------|---------------|--------------------------------------------
    rule      "start_state"   "/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}/"      "cont"
    rule      "cont"          "/^(?!\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3})/"  "cont"
    ```

4. `extra.conf` ファイルを作成する。

   `Parsers_File` のパスは、上記**ステップ２**の`sourceVolume`パスとなります。
 
   **注意：** [OUTPUT]セクションのプラグインNameについて、`cloudwatch`と最新の`cloudwatch_logs`がありますが、現時点では `cloudwatch_logs` は、`log_group_name` また `log_stream_name` での `$(variable)` テンプレートが利用できないため、今回 `cloudwatch` を利用する。

    ```conf:extra.conf
    # グローバルな設定
    [SERVICE]
        Flush 1
        Grace 30
        Parsers_File /firelens-configs/parser_springboot.conf
    
    [FILTER]
        Name multiline
        Match *
        multiline.key_content log
        multiline.parser springboot-multiline

    [FILTER]
        Name grep
        Match *
        regex log ^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\.\d{3}
        
    [OUTPUT]
        Name cloudwatch
        Match app-log
        region ap-northeast-1
        log_group_name /ecs/Cluster/Service
        log_stream_name app/$(ecs_task_id)
        auto_create_group true
        retry_limit 2
    ```

5. EC2 踏み台から、修正した `extra.conf`ファイルを各環境用のフォルダにコピーする。
    - /configs/dev/extra.conf
    - /configs/stg/extra.conf
    - /configs/prd/extra.conf


6. 各環境 FireLens 用のサイドカー定義に、FireLens 設定ファイルを指定する。

    ```json
    "firelensConfiguration": {
        "type": "fluentbit",
        "options": {
            "config-file-type": "file",
            "config-file-value": "/configs/{env}/extra.conf",
            "enable-ecs-log-metadata": "false"
        }
    }
    ```

7. ECS タスク定義のアプリケーション用のコンテナ定義部分の `logConfiguration` 内容を修正する。

    ```json
    "logConfiguration": {
        "logDriver": "awsfirelens",
        "options": {
            "Name": "cloudwatch",
            "log_group_name": "/ecs/Cluster/Service",
            "log_stream_name": "app/$(ecs_task_id)",
            "auto_create_group": "true",
            "log_key": "log",
            "region": "ap-northeast-1",
            "retry_limit": "2"
        }
    },
    ```

8. 修正後のタスク定義は、下記のようになります。

    <details><summary>ECS タスク定義（taskdef.json）</summary>
   
    ```json:taskdef.json
    {
        "containerDefinitions": [
            {
                "name": "app",
                "image": "<IMAGE_URI>",
                "cpu": 0,
                "memory": 512,
                "memoryReservation": 256,
                "portMappings": [
                    {
                        "name": "app-port",
                        "containerPort": 8080,
                        "hostPort": 8080,
                        "protocol": "tcp",
                        "appProtocol": "http"
                    }
                ],
                "essential": true,
                "environment": [],
                "mountPoints": [],
                "volumesFrom": [],
                "logConfiguration": {
                    "logDriver": "awsfirelens",
                    "options": {
                        "Name": "cloudwatch",
                        "log_group_name": "/ecs/Cluster/Service",
                        "log_stream_name": "app/$(ecs_task_id)",
                        "auto_create_group": "true",
                        "log_key": "log",
                        "region": "ap-northeast-1",
                        "retry_limit": "2"
                    }
                },
                "systemControls": []
            },
            {
                "name": "log_router",
                "image": "public.ecr.aws/aws-observability/aws-for-fluent-bit:stable",
                "cpu": 0,
                "memory": 256,
                "memoryReservation": 128,
                "portMappings": [],
                "essential": true,
                "environment": [],
                "mountPoints": [
                    {
                        "sourceVolume": "firelens-configs",
                        "containerPath": "/firelens-configs"
                    }
                ],
                "volumesFrom": [],
                "user": "0",
                "logConfiguration": {
                    "logDriver": "awslogs",
                    "options": {
                        "awslogs-group": "/ecs/Cluster/Service",
                        "awslogs-create-group": "true",
                        "awslogs-region": "ap-northeast-1",
                        "awslogs-stream-prefix": "ecs"
                    }
                },
                "systemControls": [],
                "firelensConfiguration": {
                    "type": "fluentbit",
                    "options": {
                        "config-file-type": "file",
                        "config-file-value": "/firelens-configs/extra.conf",
                        "enable-ecs-log-metadata": "false"
                    }
                }
            }
        ],
        "family": "ecs-taskdef-app",
        "taskRoleArn": "arn:aws:iam::{account_id}:role/role-ecs-task-exec",
        "executionRoleArn": "arn:aws:iam::{account_id}:role/role-ecs-task-exec",
        "networkMode": "awsvpc",
        "placementConstraints": [],
        "compatibilities": [
            "EC2",
            "FARGATE"
        ],
        "requiresCompatibilities": [
            "FARGATE"
        ],
        "cpu": "256",
        "memory": "512",
        "ephemeralStorage": {
            "sizeInGiB": 100
        },
        "volumes": [
            {
                "name": "firelens-configs",
                "efsVolumeConfiguration": {
                    "fileSystemId": "{file-system-id}",
                    "rootDirectory": "/",
                    "transitEncryption": "ENABLED",
                    "authorizationConfig": {
                        "accessPointId": "{access-point-id}",
                        "iam": "ENABLED"
                    }
                }
            }
        ],
        "runtimePlatform": {
            "cpuArchitecture": "X86_64",
            "operatingSystemFamily": "LINUX"
        }
    }
    ```

    </details>


## 脚注

[^1]: [カスタム設定ファイルを使用する](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/firelens-taskdef.html#firelens-taskdef-customconfig)

[^2]:[/etc/fstab に複数の Amazon EFS ファイルシステムをマウントできない](https://docs.aws.amazon.com/ja_jp/efs/latest/ug/troubleshooting-efs-mounting.html#automount-fix-multiple-fs)