DockerImageにCLI実行モードを実装してECSでバッチ運用する

ECSで稼働するphp:fpm-alpineベースのPHP/Phalconで構築したアプリケーションイメージを流用して、ECSでバッチ処理をスケジュール実行するためのアーキ。

要件

  • 運用中のアプリケーションのイメージを流用できること。
  • スケジュール実行したあと、exit終了すること。
  • ログはCloudWatchLogに出力すること。

PhalconでCLI実行

エントリポイントとバッチ処理を実装する。下記のコードスニペットがそのまま使えました。 【PHP Phalcon】バッチ処理の実装とcronの設定

実行

# cliモードで起動
php /path/to/CLIエントリポイント.php \
&& className(→ ClassNameTaskクラス) \
&& action_name(→ actionNameActionメソッド) \
&& param1(→ メソッドの引数1) \
&& param2(→ メソッドの引数2)

Dockerイメージのフレキシブル運用

ENTRYPOINTCMDに応じて サーバー起動 or CLI実行 を切り替えるシェルスクリプトを用意して、docker run イメージ {コマンド上書き}あるいは、ecsのコマンド上書き設定で起動モードをスイッチ出来るようにする。



Dockerfile

...

# エントリポイントスクリプトをコピー
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# エントリポイントを設定
ENTRYPOINT ["/entrypoint.sh"]

# デフォルトでは FPM を起動
CMD ["fpm"]

entrypoint.sh

#!/bin/sh
set -e  # エラー時に停止

# 引数がない場合、または "fpm" の場合は PHP-FPM を起動
if [ "$1" = "fpm" ] || [ -z "$1" ]; then
    echo "Starting PHP-FPM..."
    exec php-fpm
fi

# "cli" の場合は、引数を `php` に渡して実行(CLIモード)
if [ "$1" = "cli" ]; then
    shift # `cli` を削除
    echo "Running PHP CLI..."
    exec php "$@"
fi

# それ以外のコマンドは直接実行
exec "$@"

実行

# サーバー起動モード
docker run -d イメージ
#=> php-fpm

# CLI実行でワンショット起動
docker run --rm イメージ cli /path/to/cli.php arg1 arg2
#=> php /path/to/cli.php arg1 arg2

# メンテコマンドでワンショット起動
docker run --rm イメージ ls -l
#=> ls -l

ECS

初見はいろいろと問題が出るので、新しいタスクの実行でデバッグしてからスケジュールされたタスクを構築することを推奨。



  • バッチ用のタスク定義を作成して、上記のイメージを指定する。
    • 起動タイプ(Fagate?EC2?)クラスタレイヤーの話なのにここで選ぶのかい?と混乱していたら、フォームを想定環境に最適化してくる親切設計であった。Amazon ECS 起動タイプ - Amazon Elastic Container Service
    • デバッグ、モニタリングためCloudWatchLogは必ずオン
  • クラスタ > スケジュールされたタスクでは主にタスク定義cron式および、コンテナの上書き > コマンドの上書きでCMDをCLI起動用に上書きする。
# だめ ひとつのコマンドとして認識される
# cli /path/to/cli.php className action_name
#=> /entrypoint.sh: exec: line X: cli /path/to/cli.php className action_name: not found

# OK カンマでCMD引数としてプロットしてくれる(説明どおり)
cli,/path/to/cli.php,className,action_name
#=> exec php /path/to/cli.php className action_name


つまずき

ResourceInitializationError

クラスター > タスクの状態より ResourceInitializationError: unable to pull secrets or registry auth: The task cannot pull registry auth from Amazon ECR: There is a connection issue between the task and Amazon ECR. Check your task network configuration. RequestError: send request failed caused by: Post "https://api.ecr.ap-northeast-1.amazonaws.com/": dial tcp 99.77.58.41:443: i/o timeout

イメージのpull → イメージのECRエンドポイントに接続できていない。 → タスクのサブネットがプライベートサブネットだったのでNAT/IGWの準備がなくECRに接続できない。パブリックサブネットに設置して通過。

Essential container in task exited

クラスター > タスクの状態より Essential container in task exited

プロセスが終了してexit状態なのでOK。CloudWatchLogで実行結果を確認する。

Gmailでカスタムドメインのメールアドレスを送受信する

自ドメインをメールサーバー無しで送受信するためのアーキ


登場人物

  1. Gmail カスタムドメイン宛メールの受信メールボックスおよび、SMTPクライアントとして
  2. → GoogleWorkspaceは使わない。一般アカウント。

  3. improvmx カスタムドメイン宛メールをGmail転送するためのMXサーバーとして
  4. SES カスタムドメインでメール送信するためのSMTPサーバーとして(GMailでもよい)
  5. Route53 カスタムドメインのDNSとして

カスタムドメインから受信

Gmail


カスタムドメイン宛メールの受信メールボックスとして

  • 受信専用であれば追加設定は不要。

Improvmx


https://improvmx.com/

カスタムドメインのMXサーバーとして

  • Improvmxは転送専用であり、メールボックス実態は存在しない。転送先メアドのエイリアスとして利用する。
  • SMTP機能は有料オプションなので使わない。

カスタムドメインと転送先メアドを設定して登録開始


転送先メアドで認証プロセスを終えると、ひとまずドメイン未検証の状態でダッシュボードがオープンし、転送ルールと、MX・SPFレコードが払い出される。


上記、SETTINGS > DNS settingsに表示されるるMX・SPFレコードをDNSに登録後、DNS検証が完了するとセッティングがアクティベートされる。 ※なお、SMTPは使わないんだけど、アクティベートにはSPF(TXT)レコードの定義も必須。


  • Email forwarding activeになったらTESTで検証メールを送信させて、無事にGmailメールボックスに届いたら完了。


カスタムドメインから送信

Gmail


SMTPクライアントとして

1. プロフィールとSMTPサーバーを追加する

Gmailアカウントに以下の設定を組んだら、カスタムドメインをFROMアドレスとして利用可能となる。

  1. カスタムドメインのFROM送信者名とメアド
  2. SMTPサーバ

Gmail > 設定 > アカウントとインポート > 名前 | 他のメールアドレスを追加

を開いて、カスタムドメインの送信者名とメアドを指定する。 ここに追加したメールアドレスはGASのMailAppでもメールFROMとして利用可能となる


2. SMTPサーバーの指定

SMTPサーバーとしてGmailとSESを比較

最も手軽なのは検索でよく出てくる自GmailアカウントのSMTPを利用する方法。 カスタムドメインの名前&メアドをFROMにしつつ、SMTPはGmailドメインで送信者認証もサポートしてくれるのでカジュアルな送信基盤としては問題はなさそう。

一方、SESをSMTPにする場合は、カスタムメールFROM機能でカスタムドメイン(サブドメイン)から送信されるため、 GmailではエンベロープFROMヘッダにGmailメアドが露出するのに対して、SESはカスタムドメイン(サブドメイン)となる。 (厳密にはGmailの追加ヘッダX-Gmail-Original-Message-IDなど付記される。)

その他にも、送信クウォータとかレピュテーション要件などそれぞれ要件が異なるため、適宜選択というイメージ。今回はSESで構築する。

SMTPサーバーにGMailを使う場合


FROM(表示名)=mail@domain.to.path エンベロープFROM(実際送信元)={アカウント}@gmail.com

  • カスタムドメインをメールFROMにして、GmailアカウントのSMTPから送信者認証つきで送信できる。
  • もっとも簡単だけど、ヘッダのエンベロープFROMにGmailアドレスが受信者に露出する。

注意事項としては、SMTPサーバーのパスワードはGoogleアカウントのパスワードではなく、アプリパスワードだ。 アプリパスワードの発行のためにはアカウントに二段階認証プロセスの設定が必要。

SMTPサーバーにSESを使う場合

FROM(表示名)=mail@domain.to.path エンベロープFROM(実際送信者)=XXXXXXXXXXXXXXXX-XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX-XXXXXX@mail.domain.to.path

  • カスタムメールFROMで指定したサブドメイン(mail.domain.to.path)から送信者認証つきで送信できる。
  • カスタムメールFROMのセッティングは必要だけど、Gmailアドレスが受信者に露出することはない。

SESでは以下、設定済のこと

  • カスタムドメインのID検証、SPF、DKIM、DMARCレコード
  • サンドボックス解除
  • SMTP設定


送信テスト

カスタムドメインからメールを送信し、SPF、DKIM認証をパスできているか確認する。 テスターmail-tester.comを使うと、問題点をリスト化してくれるのでさらに捗る。

DevContainer@VSCode×Go(Gin)×Air×Delveでデバッグ環境構築




コンテナ開発環境(DevContainers@VSCode) × ホットリロード(air) × ステップインデバッグ(delve)の環境構築メモ

DevContainersならコンテナ内のローカルホストのデバッガポートに直接アタッチ出来るのでシンプルに構成可能できる。

アーキ概要



コンテナ

golang:1.22.11-alpine3.21

delve デバッガ

インストール

go install github.com/go-delve/delve/cmd/dlv@latest

コマンドパレットのGo Install/Update ToolsからインストールでもOK ※VSCodeServer@コンテナにGoエクステンションが入っていること



Air ホットリロード

バイナリインストールと環境初期化

go install github.com/air-verse/air@latest \
&& cd path/to/app_root
&& air init #=> ./.air.tomlが生成される

.air.tomlコンフィグをデバッグ向けに編集

[build]
  # ビルド設定
  cmd = "go build -gcflags=\"all=-N -l\" -o ./tmp/main ."
  # 起動設定
  # --logオプションで標準出力に詳細ログを出力
  full_bin = "dlv --headless=true --listen=:2345 --api-version=2 --accept-multiclient exec --continue ./tmp/main"

# その他はデフォルト

ノーマルビルドと比較してデバッグビルドは時間はかかるようになった

-gcflags="all=-N -l"


デバッガ(delve など)を使う場合、Go の最適化が入っていると変数の値が見えなくなったり、 関数がインライン化されてブレークポイントが期待通り動かなくなることがあるので、Go コンパイラの最適化を無効化するためのオプションを指定する

オプションについて

  • -N: 最適化を無効化してデバッグしやすくする
  • -l: インライン展開を無効化して関数のインライン化を防ぐ

full_bin = "dlv --headless=true --listen=:40000 --api-version=2 --accept-multiclient exec --continue ./tmp/main"


dlv execでビルドバイナリ./tmp/mainをデバッグ実行する

オプションについて

  • —headless=true により、CLI(ターミナル)ではなく、エディタや別のデバッガから接続できる
  • --listen=:2345により、ポート2345にデバッガを待機
  • --accept-multiclient により、複数のクライアント(VSCode, GoLand など)から同時に接続できる
  • --continue により、Delve 起動時に自動的にプログラムを実行(手動で continue を打たなくて済む)

VSCode

デバッグ構成はアタッチ式で

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Attach Server",
            "type": "go",
            "request": "attach",
            "mode": "remote",
            "port": 2345,
            "host": "localhost",
            "showLog": true
        }
    ]
}

デバッグ

  1. アプリケーションディレクトリでairを実行する
cd path/to/app_root && air
  1. VSCodeの実行とデバッグ > Attach Serverでアタッチ開始
  2. ブレークポイントを設定してhttpリクエストを入れたらブレークする

SQLでカレンダーとかインデックスの付番


一定期間のカレンダーを生成したり、結果に論理番号を付番するメモ

カレンダー

起算日(2025-01-01) + オフセット(0..*)でカレンダーを作成する

オフセットの準備

指定レンジでインクリメンタルなオフセット値を持つテーブルを作成する

  • オフセット値は変数に持ち歩かせる
  • information_schema.COLUMNSのクエリ結果でレンジ行を確保しつつ、オフセット値を格納
  • LIMITでレンジを切る
-- 1行目でレンジ変数初期化
SELECT @_index := 0 AS _index -- 0
-- 2行目以降をUNIONで結合
UNION
-- 行確保のため、information_schema.COLUMNSをクエリして、レンジ変数をインクリメント
SELECT @_index := @_index + 1 AS _index FROM information_schema.COLUMNS -- 1..*
-- レンジの上限=7行
LIMIT 7;
+--------+
| _index |
+--------+
|      0 |
|      1 |
|      2 |
|      3 |
|      4 |
|      5 |
|      6 |
+--------+

カレンダーにする


  • 起算日(2025-01-01)にオフセット値を加算する
SELECT '2025-01-01' + INTERVAL _range._index DAY AS DATE FROM (
  -- 1行目でレンジ変数初期化
  SELECT @_index := 0 AS _index
  -- 2行目以降をUNIONで結合
  UNION
  -- 行確保のため、information_schema.COLUMNSをクエリして、レンジ変数をインクリメント
  SELECT @_index := @_index + 1 AS _index FROM information_schema.COLUMNS
  -- レンジの上限=10個
  LIMIT 7
) _range;
+------------+
| DATE       |
+------------+
| 2025-01-01 |
| 2025-01-02 |
| 2025-01-03 |
| 2025-01-04 |
| 2025-01-05 |
| 2025-01-06 |
| 2025-01-07 |
+------------+

論理番号を付番する

クエリ結果に対して、論理番号を付番して意味付けする

こんな科目-点数テーブルがあったとして

+-----------+-------+
| category  | score |
+-----------+-------+
| 国語       |    84 |
| 国語       |    91 |
| 国語       |    77 |
| 数学       |    88 |
| 数学       |    98 |
| 数学       |    81 |
| 理科       |    50 |
| 理科       |    80 |
| 理科       |    68 |
+-----------+-------+

結果に行番号を付番する

  • 行番号を変数に持ち歩かせる
  • FROM句で変数を初期化する

全教科の点数ランキング

SELECT
  -- 結果順に変数を加算
  @_rank := @_rank + 1 AS rank,
  score.category,
  score.score
FROM
  score,
  -- 変数を初期化。テーブルに用はない
  (SELECT @_rank:= 0) AS row_num
-- score降順
ORDER BY score.score DESC;
+------+----------+-------+
| rank | category | score |
+------+----------+-------+
|    1 | 数学      |    98 |
|    2 | 国語      |    91 |
|    3 | 数学      |    88 |
|    4 | 国語      |    84 |
|    5 | 数学      |    81 |
|    6 | 理科      |    80 |
|    7 | 国語      |    77 |
|    8 | 理科      |    68 |
|    9 | 理科      |    50 |
+------+----------+-------+

教科ごの点数ランキング

  • 付番する軸=教科の変わり目で行番号を初期化する
SELECT
  -- 付番する軸=教科の変わり目で行番号を初期化する
  @_rank := IF(@prev_category = score.category, @_rank + 1, 1) AS rank,
  @prev_category := score.category AS category,
  score.score
FROM score
-- カテゴリ×score降順
ORDER BY score.category ASC, score.score DESC
+------+----------+-------+
| rank | category | score |
+------+----------+-------+
|    1 | 国語      |    91 |
|    2 | 国語      |    84 |
|    3 | 国語      |    77 |
|    1 | 数学      |    98 |
|    2 | 数学      |    88 |
|    3 | 数学      |    81 |
|    1 | 理科      |    80 |
|    2 | 理科      |    68 |
|    3 | 理科      |    50 |
+------+----------+-------+

SESのバウンスメールをウェブフック


SESでバウンスレート由来のレビュー・配信停止を抑止するために、SNSのhttpsサブスクリプションでアプリケーションをフックするアーキ

ハードバウンスはSESがアカウントレベルのサプレッションリストに入れたあと、90日間の再送が抑止されるが、アプリケーション層でもレビュー対象として管理し、送信管理、ユーザへの修正依頼に利用する。 Amazon SESアカウントレベルのサプレッションリストの使用 - Amazon Simple Email Service

SES(ID) → ( バウンス/苦情フィードバック) → SNSトピック → (httpsウェブフック) → アプリケーション でフラグを立てる 


サブスクライブ認可

SNSトピックにhttpsプロトコルでサブスクリプションを作成すると、エンドポイントに次のhttpsリクエストが入るのでSubscribeURLを叩いて、承認を完了する。 エンドポイントで応答する実装を組んでもいいし、コピペしてブラウザUAからリクエストしても良い。

{
  "Type" : "SubscriptionConfirmation",
  "MessageId" : "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "Token" :"xxx...",
  "TopicArn" : "arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピック名}",
  "Message" : "You have chosen to subscribe to the topic arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピックID}.¥nTo confirm the subscription, visit the SubscribeURL included in this message.",
  // これ 
  "SubscribeURL" : "https://sns.ap-northeast-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピックID}&Token=xxx...",
  "Timestamp" : "2024-10-16T05:07:58.589Z",
  "SignatureVersion" : "1",
  "Signature" : "xxx...==",
  "SigningCertURL" : "https://sns.ap-northeast-1.amazonaws.com/SimpleNotificationService-xxx.pem"
}

認可完了のレスポンス

<ConfirmSubscriptionResponse>
  <ConfirmSubscriptionResult>
    <SubscriptionArn>
      arn:aws:sns:ap-northeast-1:{アカウントID}:{SNSトピック名}:{サブスクリプションID}
    </SubscriptionArn>
  </ConfirmSubscriptionResult>
  <ResponseMetadata>
    <RequestId>{レスポンスID}/RequestId>
  </ResponseMetadata>
</ConfirmSubscriptionResponse>

フィードバック

SESからSNSトピックにフィードバック通知を組む。


SESバウンストピックの受信

バウンスメールが発生したとき、エンドポイントに次のようなPOSTリクエストが入る。 Messageオブジェクトにバウンスまたは苦情メールのプロパティがjson文字列として入っている。

SESでバウンスなテストメールを発信する

{
  "Type" : "Notification",
  "MessageId" : "79a880a8-d9ea-538d-9f74-df218c1aa5f0",
  "TopicArn" : "arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピックID}",

  "Message" : "{¥"notificationType¥":¥"Bounce¥",¥"bounce¥":{¥"feedbackId¥":¥"0106019293deea0f-19d2444a-7a3a-4e40-a632-8b776bd2202d-000000¥",¥"bounceType¥":¥"Permanent¥",¥"bounceSubType¥":¥"General¥",¥"bouncedRecipients¥":[{¥"emailAddress¥":¥"bounce@simulator.amazonses.com¥",¥"action¥":¥"failed¥",¥"status¥":¥"5.1.1¥",¥"diagnosticCode¥":¥"smtp; 550 5.1.1 user unknown¥"}],¥"timestamp¥":¥"2024-10-16T05:48:32.000Z¥",¥"remoteMtaIp¥":¥"18.139.69.138¥",¥"reportingMTA¥":¥"dns; e234-11.smtp-out.ap-northeast-1.amazonses.com¥"},¥"mail¥":{¥"timestamp¥":¥"2024-10-16T05:48:32.056Z¥",¥"source¥":¥"ses@hoge.jp¥",¥"sourceArn¥":¥"arn:aws:ses:ap-northeast-1:{AWSアカウントID}:identity/hoge.jp¥",¥"sourceIp¥":¥"121.117.160.85¥",¥"callerIdentity¥":¥"ms-ohashi¥",¥"sendingAccountId¥":¥"{AWSアカウントID}¥",¥"messageId¥":¥"0106019293dee7b8-dc011c08-e485-4f71-bf91-e8b7d0542aa1-000000¥",¥"destination¥":[¥"bounce@simulator.amazonses.com¥"],¥"headersTruncated¥":false,¥"headers¥":[{¥"name¥":¥"From¥",¥"value¥":¥"ses@hoge.jp¥"},{¥"name¥":¥"To¥",¥"value¥":¥"bounce@simulator.amazonses.com¥"},{¥"name¥":¥"Subject¥",¥"value¥":¥"test bounce¥"},{¥"name¥":¥"MIME-Version¥",¥"value¥":¥"1.0¥"},{¥"name¥":¥"Content-Type¥",¥"value¥":¥"multipart/alternative;  boundary=¥¥¥"----=_Part_1489259_127934103.1729057712056¥¥¥"¥"}],¥"commonHeaders¥":{¥"from¥":[¥"ses@hoge.jp¥"],¥"to¥":[¥"bounce@simulator.amazonses.com¥"],¥"subject¥":¥"test bounce¥"}}}",

  "Timestamp" : "2024-10-16T05:48:32.836Z",
  "SignatureVersion" : "1",
  "Signature" : "xxx==",
  "SigningCertURL" : "https://sns.ap-northeast-1.amazonaws.com/SimpleNotificationService-xxx.pem",
  "UnsubscribeURL" : "https://sns.ap-northeast-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:ap-northeast-1:{AWSアカウントID}:{SNSトピックID}:xxxxxxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

データボディの仕様 Amazon の Amazon SNS通知コンテンツ SES - Amazon Simple Email Service

ウェブサーバーへのIP直アクセスを制限しつつ、ヘルスチェックを受容する



IPアドレス直のサーバーアクセスを403制限しつつ、ロードバランサのヘルスチェックを通すapacheconfの設定


ポイント


  • 一番初めにローディングされるVirtualHostディレクティブがデフォルトサーバーとなるため、Include順を考慮したconfファイルを命名する
  • ファイル命名規則のInclude順でデフォルトサーバーは自明だが、明示的にServerName anyですべてのホスト名を捕まえている
  • デフォルトサーバーは:80:443ポートごとに設定が必要。 <VirtualHost *:*>だと、うまく動かない
  • デフォルトサーバーはhttpd -Sで確認する

httpd -S
VirtualHost configuration:
*:80                   is a NameVirtualHost
         default server any (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:1)
         port 80 namevhost any (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:1)
         port 80 namevhost webapp.com (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:28)
*:443                  is a NameVirtualHost
         default server any (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:15)
         port 443 namevhost any (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:15)
         port 443 namevhost webapp.com (/usr/local/httpd-2.4/conf/extra/httpd-vhosts.conf:45)

設定


  • :80:443それぞれのポートでデフォルトのバーチャルホストを定義する
  • ドキュメントルートを/var/www/_defaultに設定して、ロードバランサのヘルスチェックファイル/hb.html=>/var/www/_default/hb.htmlを作成
  • ヘルスチェック=hb.htmlはELB=サブネットワークにだけ公開したいので、リモートアドレスをRemoteIPTrustedProxyでX-Forwarded-For偽装をはじく。
  • リモートアドレスがtrustでなければ、X-Forwarded-Forを剥がしてクライアントの本来のリモートアドレスを採用する
  • :443のデフォルトサーバーはアクセス不要なので全拒否



<VirtualHost *:80>
    DocumentRoot "/var/www/_default/" 
    ServerName any
    <Directory "/var/www/_default/">
        Require all granted
    </Directory>
    # リモートアドレスがプロキシーのRemoteIPTrustedProxyアドレス帯ならば、X-Forwarded-Forを採用する。X-Forwarded-Forがなければリモートアドレスを採用する=ロードバランサである
    # リモートアドレスがプロキシーのRemoteIPTrustedProxyアドレス帯でなれけば、X-Forwarded-Forを剥がして、リモートアドレスを採用する=プロキシーを経由しないインターネットアクセス
    RemoteIPHeader X-Forwarded-For
    RemoteIPTrustedProxy 172.21.0.0/16

    # リモートアドレスがプロキシーのRemoteIPTrustedProxyアドレス帯ならば、X-Forwarded-Forを採用する。X-Forwarded-Forがなければリモート
アドレスを採用する=ロードバランサである
    # リモートアドレスがプロキシーのRemoteIPTrustedProxyアドレス帯でなれけば、X-Forwarded-Forを剥がして、リモートアドレスを採用する=プ
ロキシーを経由しないインターネットアクセス
    RemoteIPHeader X-Forwarded-For
    RemoteIPTrustedProxy 172.21.0.0/16

    # サブネットワーク直の侵入のみ許可する
    <Location />
        <RequireAll>
            Require ip 172.21.0.0/16
        </RequireAll>
    </Location>

</VirtualHost>

<VirtualHost *:443>
    ServerName any
    <Location />
        Require all denied
    </Location> 
</VirtualHost>

GuardDutyでセキュリティリスクを可視化する




Amazon GuardDutyは、AWSアカウントのイベントやネットワークトラフィックを監視して、不正アクセス、データ漏洩、マルウェアの兆候など不審な動作や脅威を自動的に検出するセキュリティサービス。検出結果とサマリーをGuardDutyコンソールに可視化してくれる。

具体的には次のような不審な動きを検出してくれる(と、AWS営業さんが熱心に語っていた)

  • ブルートフォース攻撃
  • 通常アクセスされない地域やIPからの接続試行
  • S3から外部への大規模データ転送
  • EC2の削除、IAM権限の変更
  • EC2がボットネットのC2サーバーと通信
  • EC2から外部への異常なトラフィック(例: マルウェアのダウンロード)
  • 高頻度でスキャンを行うポートスキャン
  • VPCから外部IPへの大量のデータ送信
  • ブラックリスト登録済みのIPアドレスとの通信
  • 既知のマルウェア感染ホストへの接続

データソースにはAWS CloudTrail、Amazon VPC フローログ、DNS ログをデータソースとして利用する。

Amazon GuardDuty は、AWS CloudTrail、VPC フローログ、AWS DNS ログから独立したデータストリームを直接取得します。Amazon S3 バケットポリシーを管理したり、ログを収集して保存する方法を変更したりする必要はありません。 Amazon GuardDutyを導入する前に知っておきたいこと | DevelopersIO

GuardDutyの有効化

GuardDutyを有効化するだけで、独立したデータストリームからAWS CloudTrail、Amazon VPC フローログ、DNS ログを継続的にモニタリングしてくれる。

検出の通知

【AWS】GuardDutyでイベント検知したらメール通知する仕組みを作る|hiroyu0510 【AWS】GuardDutyの通知メールをカスタマイズしてみる|hiroyu0510 GuardDutyからのイベント通知をちょっと見やすくして通知する | DevelopersIO


脅威を検出すると、==FindingをGuardDutyのコンソールにリストアップしてくれるが、SNS通知はCloudWatch Eventsで自力で構成する必要がある。

GuardDutyでFindingが発生すると、CloudWatch Eventsルールが発火します。 Amazon GuardDutyを導入する前に知っておきたいこと | DevelopersIO

Amazon EventBridge

EventBridgeでGuardDutyの脅威検出のイベントGuardDuty Findingに対して、SNSに連携するためのルールを作成する

イベントパターン

フックするイベントを定義する which

デフォルトのイベントパターン

イベントパターンについては、デフォルトの記載のままだと、検知したすべてを通知することになります。以下はすべての検知を通知する例です。 【AWS】GuardDutyでイベント検知したらメール通知する仕組みを作る|hiroyu0510

{
  "source": ["aws.guardduty"],
  "detail-type": ["GuardDuty Finding"]
}

脅威にはかなりのパターンがあるので、SNS通知は緊急度の高い脅威レベル(=severity)でフィルタリングして、定常的にGuardDutyコンソールを保守する運用でいく。

もし、通知をフィルタリングしたい場合、危険度Midium以上とか危険度HIGH以上に限定して通知するというような場合は、以下のようにすることもできます。例ですが、危険度4以上(Midium以上)の場合に通知する設定です。severityでフィルタリングをかけることができます。 【AWS】GuardDutyでイベント検知したらメール通知する仕組みを作る|hiroyu0510

{
  "source": ["aws.guardduty"],
  "detail-type": ["GuardDuty Finding"],
  "detail": {
    "severity": [{
      "numeric": [">=", 4]
    }]
  }
}

ターゲット(SNS)への入力

ルール > 編集 > ターゲットを選択 > ターゲットN > 追加設定 > 入力を設定 > ターゲット入力を設定から 入力トランスフォーマーを選択することで、ターゲットへの入力データをカスタマイズできる

入力パス what

使用したいGuardDuty FindingイベントのデータをJSONオブジェクトの任意キーにマッピングする 付録 > GuardDutyのサンプルイベント

{
    "Account_ID": "$.detail.accountId",
    "Finding_ID": "$.detail.id",
    "Finding_Type": "$.detail.type",
    "Finding_description": "$.detail.description",
    "eventFirstSeen": "$.detail.service.eventFirstSeen",
    "region": "$.region",
    "severity": "$.detail.severity"
}

テンプレート how

入力パスに定義したデータキーを使って、ターゲットに渡すテンプレートを作成する テキストでもjsonでも良い

"重要度 <severity> のGuardDutyイベントが発生しました。"
"アカウントID: <Account_ID>"
"発生日時: <eventFirstSeen>" 
"検出タイプ: <Finding_Type>"
"リージョン: <region>"
"タイプの説明: <Finding_description>"
"レポートURL: https://<region>.console.aws.amazon.com/guardduty/home?region=<region>#/findings?macros=current&fId=<Finding_ID>"

出力例 result

重要度 5 のGuardDutyイベントが発生しました。"
"アカウントID: 123456789012"
"発生日時: 2017-10-31T23:16:23Z" 
"検出タイプ: Canary:EC2/Stateless.IntegTest"
"リージョン: us-east-1"
"タイプの説明: Canary:EC2/Stateless.IntegTest"
"詳細はGuardDuty コンソール( https://us-east-1.console.aws.amazon.com/guardduty/home?region=us-east-1#/findings?macros=current&fId=16afba5c5c43e07c9e3e5e2e544e95df )を確認してください。

入力パス、テンプレート、および出力の例


動作確認

GuardDutyに対して、Backdoorテストイベントを発火してSNSトピックのサブスクライバーに届いたら完了

# ディテクタIDはアカウント/リージョンごとに割り当てられたGuardDutyのuuidで、GuardDuty > 設定から確認可能
aws guardduty create-sample-findings \
  --detector-id 96c0a1c32ad10a4bef87a28aad793f46 \
  --finding-types "Backdoor:EC2/DenialOfService.Dns"

脅威に対する公式シューティングガイド

脅威のシューティングはAWS公式ガイドを参考に対策する GuardDuty 検出結果タイプ - Amazon GuardDuty

マルウェアスキャン

[神アップデート]GuardDutyがEC2やECSのマルウェア検知時のスキャンに対応したので実際にスキャンさせてみた #reinforce | DevelopersIO

  • Backdoor:EC2/C&CActivity.BCryptoCurrency:EC2/BitcoinTool.B!DNSなどマルウェアの動作が疑われるFindings検知があると自動でマルウェアスキャンがトリガーされる。
  • オンデマンドのマルウェアスキャンもある

付録

GuardDutyのサンプルイベント

{
  "version": "0",
  "id": "c8c4daa7-a20c-2f03-0070-b7393dd542ad",
  "detail-type": "GuardDuty Finding",
  "source": "aws.guardduty",
  "account": "123456789012",
  "time": "1970-01-01T00:00:00Z",
  "region": "us-east-1",
  "resources": [],
  "detail": {
    "schemaVersion": "2.0",
    "accountId": "123456789012",
    "region": "us-east-1",
    "partition": "aws",
    "id": "16afba5c5c43e07c9e3e5e2e544e95df",
    "arn": "arn:aws:guardduty:us-east-1:123456789012:detector/123456789012/finding/16afba5c5c43e07c9e3e5e2e544e95df",
    "type": "Canary:EC2/Stateless.IntegTest",
    "resource": {
      "resourceType": "Instance",
      "instanceDetails": {
        "instanceId": "i-05746eb48123455e0",
        "instanceType": "t2.micro",
        "launchTime": 1492735675000,
        "productCodes": [],
        "networkInterfaces": [{
          "ipv6Addresses": [],
          "privateDnsName": "ip-0-0-0-0.us-east-1.compute.internal",
          "privateIpAddress": "0.0.0.0",
          "privateIpAddresses": [{
            "privateDnsName": "ip-0-0-0-0.us-east-1.compute.internal",
            "privateIpAddress": "0.0.0.0"
          }],
          "subnetId": "subnet-d58b7123",
          "vpcId": "vpc-34865123",
          "securityGroups": [{
            "groupName": "launch-wizard-1",
            "groupId": "sg-9918a123"
          }],
          "publicDnsName": "ec2-11-111-111-1.us-east-1.compute.amazonaws.com",
          "publicIp": "11.111.111.1"
        }],
        "tags": [{
          "key": "Name",
          "value": "ssh-22-open"
        }],
        "instanceState": "running",
        "availabilityZone": "us-east-1b",
        "imageId": "ami-4836a123",
        "imageDescription": "Amazon Linux AMI 2017.03.0.20170417 x86_64 HVM GP2"
      }
    },
    "service": {
      "serviceName": "guardduty",
      "detectorId": "3caf4e0aaa46ce4ccbcef949a8785353",
      "action": {
        "actionType": "NETWORK_CONNECTION",
        "networkConnectionAction": {
          "connectionDirection": "OUTBOUND",
          "remoteIpDetails": {
            "ipAddressV4": "0.0.0.0",
            "organization": {
              "asn": -1,
              "isp": "GeneratedFindingISP",
              "org": "GeneratedFindingORG"
            },
            "country": {
              "countryName": "United States"
            },
            "city": {
              "cityName": "GeneratedFindingCityName"
            },
            "geoLocation": {
              "lat": 0,
              "lon": 0
            }
          },
          "remotePortDetails": {
            "port": 22,
            "portName": "SSH"
          },
          "localPortDetails": {
            "port": 2000,
            "portName": "Unknown"
          },
          "protocol": "TCP",
          "blocked": false
        }
      },
      "resourceRole": "TARGET",
      "additionalInfo": {
        "unusualProtocol": "UDP",
        "threatListName": "GeneratedFindingCustomerListName",
        "unusual": 22
      },
      "eventFirstSeen": "2017-10-31T23:16:23Z",
      "eventLastSeen": "2017-10-31T23:16:23Z",
      "archived": false,
      "count": 1
    },
    "severity": 5,
    "createdAt": "2017-10-31T23:16:23.824Z",
    "updatedAt": "2017-10-31T23:16:23.824Z",
    "title": "Canary:EC2/Stateless.IntegTest",
    "description": "Canary:EC2/Stateless.IntegTest"
  }
}

MFA認証つきのIAMユーザでaws cliを利用するワンライナーコマンド


IAMユーザのARNとMFAデバイストークンを用意してワンタイムセッションを確立して作業する

eval `aws sts get-session-token --serial-number {IAMユーザのARN} --token-code {MFAトークン} | awk ' $1 == "\"AccessKeyId\":" { gsub(/\"/,""); gsub(/,/,""); print "export AWS_ACCESS_KEY_ID="$2 } $1 == "\"SecretAccessKey\":" { gsub(/\"/,""); gsub(/,/,""); print "export AWS_SECRET_ACCESS_KEY="$2} $1 == "\"SessionToken\":" { gsub(/\"/,""); gsub(/,/,""); print "export AWS_SESSION_TOKEN="$2 } '`

付録

  • AWS Security Token Service(STS)を使用して、MFAデバイスのトークンコードから一時的な認証情報(Temporary Security Credentials)を取得する
  • 有効期限はデフォルト12時間

aws sts get-session-token --serial-number <IAMユーザのARN> --token-code <MFAトークン>

# 結果
{
    "Credentials": {
        "SecretAccessKey": "secret-access-key",
        "SessionToken": "temporary-session-token",
        "Expiration": "expiration-date-time",
        "AccessKeyId": "access-key-id"
    }
}

次の環境変数にセット

export AWS_ACCESS_KEY_ID={AccessKeyId}
export AWS_SECRET_ACCESS_KEY={SecretAccessKey}
export AWS_SESSION_TOKEN={SessionToken}

ClamAVでアップロードファイルをウィルス対策する

clamavdでサーバーサイドのウィルススキャンするためのアーキ

ClamAV



  • マルチプラットフォームでフリーのアンチウィルススイート
  • ClamAVにはコマンド版とデーモン版がある。デーモン版はTCPでファイル単位のウィルス検閲に対応している
  • clamavのdockerイメージを使ってTCPでウィルススキャンする

clamdホスト構築



clamavの公式イメージによると、


The development of this image will be discontinued. Since 0.104 Cisco provides official docker images for clamav. This image here will be on hold and supported as long as possible.

このイメージの開発は終了します。0.104 以降、Cisco は clamav 用の公式 docker イメージを提供しています。こちらのイメージは保留され、可能な限りサポートされます。

mkodockx/docker-clamav


一応ちゃんと動いてくれるが、現在開発停止していてデータベースの更新中に予期しない切断が発生するため、Ciscoの公式版を使ってくれとのこと。
clamav/clamav | dockerhub


version: '3.2'
services:
  app: ...
    depends_on:
      - clamavd

  clamavd:
    container_name: clamavd
    hostname: clamavd
    image: clamav/clamav
    ports:
      - 3310

ウィルスデータベース



ClamAVのウイルステータベースは、/var/lib/clamavディレクトリにmain.cvdおよびdaily.cvdというファイルで保存されている。 これらのデータベースは、freshclamコマンドで更新する。

ClamAVのウイルスデータベースを自動更新するには - @IT


clamav/clamav | dockerhubイメージではfreshclamデーモンが一日一回(--checks=1)ウィルスデータベースを更新するようにチューニングされている。
freshclamコマンド版を使ってcrontabでスケジュールすることもできるが、まあおまかせのデーモン版で。


ps | grep clamav
PID   USER     TIME  COMMAND
   12 clamav    0:00 freshclam --checks=1 --daemon --foreground --stdout --user=clamav
   13 clamav    0:22 clamd --foreground

なお、かつて高頻度でダウンロードをかけるクライアントに対策するためfreshclamによるダウンロードに制限した経緯があるようだ。


Abuse of the download system has forced us to push people towards FreshClam.
Unfortunately a handful have ruined it for everyone. (Looking at you, handful of IPs that download the daily.cvd 3x a second)

ダウンロードシステムの乱用により、私たちは人々をFreshClamに追い込むことを余儀なくされました。
残念ながら、一握りがみんなのためにそれを台無しにしました。(あなたを見て、daily.cvdを1秒間に3回ダウンロードする一握りのIP)

Official docker images of clamav


freshclamの設定ファイルを見るとデフォルトは12回(2時間に一回)のようだ。


# Number of database checks per day.
# Default: 12 (every two hours)                             
#Checks 24   

ClamAVによるリアルタイムスキャンの設定(ClamAV 1.0版) | 稲葉サーバーデザイン


clamavdのクライアント実装



PINGコマンドで疎通を確認した後に、INSTREAMスキャンコマンドを投げる


<?php

class ClamAVClient
{
    // clamdホストのデフォルト
    const CLAMAVD_HOST = 'clamavd';
    const CLAMAVD_PORT = 3310;

    private $_clamavd_host;
    private $_clamavd_port;
    private $_raw_fact;
    private $_socket;

    public function __construct($host=self::CLAMAVD_HOST, $port=self::CLAMAVD_PORT)
    {
        $this->_clamavd_host = $host;
        $this->_clamavd_port = $port;
    }

    public function __destruct()
    {
        $this->closeSocket();
    }

    /**
     * tcpソケットを閉じます
     */
    public function closeSocket(){
        if( is_resource($this->_socket) ) fclose($this->_socket);
    }

    /**
     * tcpソケットを開きます
     * @return resource TCPセッションハンドル
     */
    private function openSocket(){
        $this->_socket = stream_socket_client("tcp://{$this->_clamavd_host}:{$this->_clamavd_port}", $errno, $errstr, 10);
        if (!$this->_socket) {
            throw new RuntimeException("ホストの接続に失敗しました。({$errno}){$errstr}");
        }
        return $this->_socket;
    }

    /**
     * ソケットを取得します
     * @return resource TCPセッションハンドル
     */
    private function getSocket(){
        return $this->_socket ?: $this->openSocket();
    }

    /**
     * clamavdホストにPINGをリクエストします
     * @note PINGに対するclamavdの正常応答はPONG
     * @return bool true: 正常応答 / false: 異常応答
     */
    public function ping(){

        $socket = $this->getSocket();

        stream_socket_sendto($socket, 'PING');
        $this->_raw_fact = stream_socket_recvfrom($socket, 1024);

        return $this->fact() === 'PONG';
    }

    /**
     * clamavdのレスポンステキストからファクトを抽出します
     * @param string $response_text
     * @return string ファクト
     */
    public function fact(){
        $fact = (strrchr($this->_raw_fact, ":")) ?: ": {$this->_raw_fact}";
        return trim(substr($fact, 1));
    }

    /**
     * clamavdにファイルスキャンをリクエストします
     * 
     * @return bool true: ウィルスなし / false: ウイルスあり
     */
    public function scan($filepath){

        // ファイルが存在するか確認
        if ( file_exists($filepath) === false ) {
            throw new RuntimeException("ファイルが存在しません。file({$filepath})");
        }

        // ファイル内容を読み込み
        $fileContent = file_get_contents($filepath);
        if ($fileContent === false) {
            throw new RuntimeException("ファイルの読み込みに失敗しました。file({$filepath})");
        }

        // PINGセッション
        if( ( new ClamAVClient() )->ping() === false ) {
            throw new RuntimeException("PINGに失敗しました。");
        }

        // スキャンセッション
        $socket = $this->getSocket();

        // ストリームスキャンのハンドシェイク開始 => zINSTREAM\0
        $res = stream_socket_sendto($socket, "zINSTREAM\0");

        // ファイルを8KBでチャンク送信する
        $chunkSize = 8192;
        $bytesSent = 0;
        while ( $bytesSent < strlen($fileContent) ) {
            $chunk = substr($fileContent, $bytesSent, $chunkSize);
            $chunkLength = pack('N', strlen($chunk));
            $res = stream_socket_sendto($socket, $chunkLength . $chunk);
            $bytesSent += $chunkSize;
        }

        // スキャンのハンドシェイク終了 => ゼロバイトを送信
        $res = stream_socket_sendto($socket, pack('N', 0));

        // スキャン結果を読み取り => 問題なければ"OK"文字列が入っている
		// ウィルス検出	=> "stream: Eicar-Signature FOUND"
		// ウィルス未検出	=> "stream: OK"
        $this->_raw_fact = stream_socket_recvfrom($socket, 1024);        
        if( empty( $this->_raw_fact ) ){
            throw new RuntimeException("診断結果が空です。");
		}

        echo "clamscan result ({$filepath}) : {$this->_raw_fact}";
        return $this->fact() === 'OK';
    }
}

検閲



テストにはEICAR テストファイルを使用する。
EICAR テストファイルとは?


echo "X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*" > /path/to/virus.txt

try{
	$clamav = new ClamAVClient();
	$result = $clamav->scan('/path/to/virus.txt');
}catch(RuntimeException $e){
	echo "clamavスキャンで例外が発生 {$e->getMessage()}";	
}

if( $result === false ){
  	throw new RuntimeException("clamavスキャンで脅威を検出しました。fact({$clamav->fact()})"); //=> Eicar-Signature FOUND
}

AthenaでALBのアクセスログをクエリすると結果が空


直近のAthenaのALBアクセスログ見ようと思ってクエリしたら結果が空っぽになっていた。S3にログは随時上がってきているのに??


問題発生時期を遡っていくと2024/5/20あたりから、と思ったら2024/5/20にALBのバージョンアップでアクセスログにtraceability_idが追加となりカラム数の不一致が原因であった。
Querying Application Load Balancer logs - Amazon Athena


AWS公式のCREATE TABLE スニペットの通りtraceability_idを追加したテーブルを再構成すると無事にクエリは成功した。

新しいテーブル定義


CREATE EXTERNAL TABLE `alb-access-log-rev20240520`(
  `type` string COMMENT '', 
  `time` string COMMENT '', 
  `elb` string COMMENT '', 
  `client_ip` string COMMENT '', 
  `client_port` int COMMENT '', 
  `target_ip` string COMMENT '', 
  `target_port` int COMMENT '', 
  `request_processing_time` double COMMENT '', 
  `target_processing_time` double COMMENT '', 
  `response_processing_time` double COMMENT '', 
  `elb_status_code` string COMMENT '', 
  `target_status_code` string COMMENT '', 
  `received_bytes` bigint COMMENT '', 
  `sent_bytes` bigint COMMENT '', 
  `request_verb` string COMMENT '', 
  `request_url` string COMMENT '', 
  `request_proto` string COMMENT '', 
  `user_agent` string COMMENT '', 
  `ssl_cipher` string COMMENT '', 
  `ssl_protocol` string COMMENT '', 
  `target_group_arn` string COMMENT '', 
  `trace_id` string COMMENT '', 
  `domain_name` string COMMENT '', 
  `chosen_cert_arn` string COMMENT '', 
  `matched_rule_priority` string COMMENT '', 
  `request_creation_time` string COMMENT '', 
  `actions_executed` string COMMENT '', 
  `redirect_url` string COMMENT '', 
  `lambda_error_reason` string COMMENT '', 
  `target_port_list` string COMMENT '', 
  `target_status_code_list` string COMMENT '', 
  `classification` string COMMENT '', 
  `classification_reason` string COMMENT '', 
  -- 追加↓
  `traceability_id` string COMMENT '')
PARTITIONED BY ( 
  `log_date` string)
ROW FORMAT SERDE 
  'org.apache.hadoop.hive.serde2.RegexSerDe' 
WITH SERDEPROPERTIES ( 
  -- 変更↓
  'input.regex'='([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*):([0-9]*) ([^ ]*)[:-]([0-9]*) ([-.0-9]*) ([-.0-9]*) ([-.0-9]*) (|[-0-9]*) (-|[-0-9]*) ([-0-9]*) ([-0-9]*) \"([^ ]*) (.*) (- |[^ ]*)\" \"([^\"]*)\" ([A-Z0-9-_]+) ([A-Za-z0-9.-]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^\"]*)\" ([-.0-9]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^ ]*)\" \"([^s]+?)\" \"([^s]+)\" \"([^ ]*)\" \"([^ ]*)\" ?([^ ]*)?( .*)?') 
STORED AS INPUTFORMAT 
  'org.apache.hadoop.mapred.TextInputFormat' 
OUTPUTFORMAT 
  'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION
  's3://alb-access-log/AWSLogs/************/elasticloadbalancing/ap-northeast-1'
TBLPROPERTIES (
  'projection.enabled'='true', 
  'projection.log_date.format'='yyyy/MM/dd', 
  'projection.log_date.interval'='1', 
  'projection.log_date.interval.unit'='DAYS', 
  'projection.log_date.range'='NOW-1YEARS,NOW',
  'projection.log_date.type'='date', 
  'storage.location.template'='s3://alb-access-log/AWSLogs/************/elasticloadbalancing/ap-northeast-1/${log_date}', 
  'transient_lastDdlTime'='1717717328')

以前のテーブル定義


CREATE EXTERNAL TABLE `alb-access-log`(
  `type` string COMMENT '', 
  `time` string COMMENT '', 
  `elb` string COMMENT '', 
  `client_ip` string COMMENT '', 
  `client_port` int COMMENT '', 
  `target_ip` string COMMENT '', 
  `target_port` int COMMENT '', 
  `request_processing_time` double COMMENT '', 
  `target_processing_time` double COMMENT '', 
  `response_processing_time` double COMMENT '', 
  `elb_status_code` string COMMENT '', 
  `target_status_code` string COMMENT '', 
  `received_bytes` bigint COMMENT '', 
  `sent_bytes` bigint COMMENT '', 
  `request_verb` string COMMENT '', 
  `request_url` string COMMENT '', 
  `request_proto` string COMMENT '', 
  `user_agent` string COMMENT '', 
  `ssl_cipher` string COMMENT '', 
  `ssl_protocol` string COMMENT '', 
  `target_group_arn` string COMMENT '', 
  `trace_id` string COMMENT '', 
  `domain_name` string COMMENT '', 
  `chosen_cert_arn` string COMMENT '', 
  `matched_rule_priority` string COMMENT '', 
  `request_creation_time` string COMMENT '', 
  `actions_executed` string COMMENT '', 
  `redirect_url` string COMMENT '', 
  `lambda_error_reason` string COMMENT '', 
  `target_port_list` string COMMENT '', 
  `target_status_code_list` string COMMENT '', 
  `classification` string COMMENT '', 
  `classification_reason` string COMMENT '')
PARTITIONED BY ( 
  `log_date` string)
ROW FORMAT SERDE 
  'org.apache.hadoop.hive.serde2.RegexSerDe' 
WITH SERDEPROPERTIES ( 
  'input.regex'='([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*):([0-9]*) ([^ ]*)[:-]([0-9]*) ([-.0-9]*) ([-.0-9]*) ([-.0-9]*) (|[-0-9]*) (-|[-0-9]*) ([-0-9]*) ([-0-9]*) \"([^ ]*) ([^ ]*) (- |[^ ]*)\" \"([^\"]*)\" ([A-Z0-9-]+) ([A-Za-z0-9.-]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^\"]*)\" ([-.0-9]*) ([^ ]*) \"([^\"]*)\" \"([^\"]*)\" \"([^ ]*)\" \"([^s]+?)\" \"([^s]+)\" \"([^ ]*)\" \"([^ ]*)\"') 
STORED AS INPUTFORMAT 
  'org.apache.hadoop.mapred.TextInputFormat' 
OUTPUTFORMAT 
  'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION
  's3://alb-access-log/AWSLogs/************/elasticloadbalancing/ap-northeast-1'
TBLPROPERTIES (
  'projection.enabled'='true', 
  'projection.log_date.format'='yyyy/MM/dd', 
  'projection.log_date.interval'='1', 
  'projection.log_date.interval.unit'='DAYS', 
  'projection.log_date.range'='NOW-1YEARS,NOW',
  'projection.log_date.type'='date', 
  'storage.location.template'='s3://alb-access-log/AWSLogs/************/elasticloadbalancing/ap-northeast-1/${log_date}', 
  'transient_lastDdlTime'='1645361779')