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')

GitリモートリポジトリをGitHubからCodeCommitに引っ越しする



Githubリモートリポジトリをブランチやコミット履歴を含めてまるごとCodeCommitにお引っ越しする手順。
引っ越しにはリポジトリのブランチ・タグ、コミット履歴など全てのリファレンスを含んだバックアップであるbareリポジトリを利用する。


bareリポジトリとは




リポジトリの構成管理


  • ブランチ・タグ、コミット履歴など全てのリファレンスを含む、サーバーサイドの構成管理リポジトリであり完全バックアップである。管理対象のファイル実態は持たない。
  • git clone --bareオプションでbareリポジトリをクローンできる。
  • git clone --mirrorオプションでもbareリポジトリをクローンできる。--bareとの違いは文字通りリモートリポジトリと同期するように設計されていて、.git/configはmirror=trueである。よってデフォルトでは全リファレンスをpushするし、git remote updateでは全リファレンスを追跡する

--mirror Set up a mirror of the source repository. This implies --bare. Compared to --bare, --mirror not only maps local branches of the source to local branches of the target, it maps all refs (including remote-tracking branches, notes etc.) and sets up a refspec configuration such that all these refs are overwritten by a git remote update in the target repository.


ソースリポジトリのミラーを設定します。これは --bare を含意します。--bare と比較して、--mirror はソースのローカルブランチをターゲットのローカルブランチにマッピングするだけでなく、すべてのリファレンス(リモート追跡ブランチ、ノートなども含む)をマッピングし、これらのリファレンスがターゲットリポジトリの git remote update によって上書きされるように refspec 設定を行います。

Git - git-clone Documentation


non-bareリポジトリとは




リポジトリのワークスペース


  • チェックアウト済のブランチ・タグのコミット履歴とファイル実態で構成されるクライアントサイドのワークスペースである。
  • いつものgit clonenon-bareリポジトリ。

引っ越し手順




CodeCommitでリポジトリを作成して、IAMユーザにCodeCommitのプッシュできるポリシーとSSH公開鍵を登録しておく


# GitHubからbareリポジトリをクローンする
git clone --mirror git@github.com:path/to/reponame.git

#  push先をCodeCommitのリポジトリに変更
cd ./reponame.git && git remote set-url --push origin ssh://git-codecommit.ap-northeast-1.amazonaws.com/path/to/reponame

# 全てのリファレンスを移行先にプッシュする(--mirror)
# (clone --mirror により、configのmirror=true状態のため、オプションなしでも全リファレンスはプッシュされる)
git push --mirror

S3に格納されたマネージドログをAthenaで解析する



AthenaでS3バケットにストックしているマネージドのログに対してクエリ検索する。
 

データベースを作成する

Athena でのデータベースの作成 - Amazon Athena コンソール上にファンクションはない。クエリエディタで作成する。  

CREATE DATABASE db-name;

 

テーブルを作成する

 

パーティション化

クエリ対象のスキャン量は料金コストに直結するため、パーティションでデータを絞り込んでコスト節約する。 パーティション化するためにS3のログの格納ルールとテーブルDDLに定義が必要。  

データをパーティションすることで、各クエリによってスキャンされるデータの量を制限できるようになるため、パフォーマンスが向上し、コストが削減されます。任意のキーでデータをパーティションに分割することができます。一般的な方法では、時間に基づいてデータをパーティションします。

Athena でのデータのパーティション化 - Amazon Athena

S3格納ルール

  対象ログが日付等の一定ルールに基づいたパーティションに格納されていること。フォーマットには「Hive形式」と「Hiveではない形式」がある。 Hive形式はパーティションが追加される度にテーブルのリペアが必要なので、ログのエクスポート設定はHiveではない方式にする。

Hiveではない形式

s3://path/to/2021/01/26/

Athena では、Hive 以外のスタイルのパーティション化スキームを使用することも可能です。例えば、 CloudTrail ログと Firehose 配信ストリームは、 などの日付部分に対して個別のパスコンポーネントを使用しますdata/2021/01/26/us/6fc7845e.json。これらの Hive スタイルではないパーティションの場合、ALTER TABLE ADD PARTITION を使用して手動でパーティションを追加します。

Athena でのデータのパーティション化 - Amazon Athena

Hive形式

  • ディレクトリが「key=value」形式
  • パーティションが追加される度にテーブルのリペアが必要
s3://path/to/year=2021/month=01/day=26/

Athena では Apache Hive スタイルのパーティションを使用できます。このパーティションのデータパスには、等号で連結されたキーと値のペア (例えば country=us/... または year=2021/month=01/day=26/...) が含まれています。つまり、それぞれのパスにより、パーティションのキーと値、両方の名前が表されます。新しい Hive パーティションをパーティションされたテーブルにロードするには、(Hive スタイルのパーティションのみで機能する) MSCK REPAIR TABLE コマンドを使用します。

Athena でのデータのパーティション化 - Amazon Athena

テーブル

【初心者向け】Amazon Athenaのパーティションとは  Amazon AthenaのPartition Projectionを使ったALBのアクセスログ解析環境をTerraformで構築する – PSYENCE:MEDIA

テーブル作成のDDLにPARTITIONED BY句とTBLPROPERTIES句を追加して、パーティション射影を有効にする

  • LOCATION句にS3のパーティション構造の上位ディレクトリまで指定する
  • PARTITIONED句にパーティション名を定義する
  • TBLPROPERTIES句にパーティションルールを定義する。projection.{パーティション名}.*にパーティションのルールを定義する。
CREATE EXTERNAL TABLE `table-name`( ... )
PARTITIONED BY (`log_date` string)
LOCATION 's3://{バケット名}/AWSLogs/{アカウントID}/elasticloadbalancing/{リージョン}'
TBLPROPERTIES (
  -- パーティション有効
  'projection.enabled'='true', 
  -- パーティションのデータ型 enum,integer,date,injectedをサポートしている
  'projection.log_date.type'='date',
  -- パーティションのフォーマット=S3のパーティション
  'projection.log_date.format'='yyyy/MM/dd',
  -- パーティション間隔 = 1日おき
  'projection.log_date.interval'='1',
  'projection.log_date.interval.unit'='DAYS',
  -- パーティションのレンジ
  'projection.log_date.range'='NOW-1YEARS,NOW',
  -- パーティションを含むS3バケットのロケーション
  'storage.location.template'='s3://{バケット名}/AWSLogs/{アカウントID}/elasticloadbalancing/{リージョン}/${log_date}'
)

Application Load Balancer ログのクエリ - Amazon Athena

テーブル作成のスニペット

 

ALBアクセスログの場合

 Application Load Balancer ログのクエリ - Amazon Athena

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://{バケット名}/AWSLogs/{アカウントID}/elasticloadbalancing/{リージョン}'
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://{バケット名}/AWSLogs/{アカウントID}/elasticloadbalancing/{リージョン}/${log_date}'
)

 

SELECT
  *
FROM "alb-access-log"
WHERE
  -- パーティションを選択
  log_date BETWEEN '2023/09/01' AND '2023/09/24'
  -- 条件を実装
  AND elb_status_code = '504'

 

VPCフローログの場合

Amazon VPC フローログのクエリ - Amazon Athena  

CREATE EXTERNAL TABLE `vpc-flow-log`(
  `version` int,
  `accountid` string,
  `interfaceid` string,
  `srcaddress` string,
  `dstaddress` string,
  `srcport` int,
  `dstport` int,
  `protocol` int,
  `packets` int,
  `bytes` bigint,
  `timefrom` int,
  `timeto` int,
  `action` string,
  `logstatus` string,
  `instanceid` string,
  `subnetid` string,
  `vpcid` string)
PARTITIONED BY (
  `log_date` string
)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY ' '
STORED AS INPUTFORMAT
  'org.apache.hadoop.mapred.TextInputFormat'
OUTPUTFORMAT
  'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION
  's3://{バケット名}/AWSLogs/{アカウントID}/vpcflowlogs/{リージョン}'
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',
  'skip.header.line.count'='1',
  'storage.location.template'='s3://{バケット名}/AWSLogs/{アカウントID}/vpcflowlogs/{リージョン}/${log_date}'
)

 

SELECT
  *
FROM vpc-flow-log
WHERE 
  -- パーティションを選択
  log_date >= '2023/05/09'
  -- 条件を実装
  AND dstport = 22

 

WAFログの場合

AWS WAF ログのクエリ - Amazon Athena  

CREATE EXTERNAL TABLE `waf_acl-log`(
  `timestamp` bigint COMMENT 'from deserializer',
  `formatversion` int COMMENT 'from deserializer',
  `webaclid` string COMMENT 'from deserializer',
  `terminatingruleid` string COMMENT 'from deserializer',
  `terminatingruletype` string COMMENT 'from deserializer',
  `action` string COMMENT 'from deserializer',
  `terminatingrulematchdetails` array<struct<conditiontype:string,location:string,matcheddata:array<string>>> COMMENT 'from deserializer',
  `httpsourcename` string COMMENT 'from deserializer',
  `httpsourceid` string COMMENT 'from deserializer',
  `rulegrouplist` array<struct<rulegroupid:string,terminatingrule:struct<ruleid:string,action:string>,nonterminatingmatchingrules:array<struct<action:string,ruleid:string>>,excludedrules:array<struct<exclusiontype:string,ruleid:string>>>> COMMENT 'from deserializer',
  `ratebasedrulelist` array<struct<ratebasedruleid:string,limitkey:string,maxrateallowed:int>> COMMENT 'from deserializer',
  `nonterminatingmatchingrules` array<struct<action:string,ruleid:string>> COMMENT 'from deserializer',
  `httprequest` struct<clientip:string,country:string,headers:array<struct<name:string,value:string>>,uri:string,args:string,httpversion:string,httpmethod:string,requestid:string> COMMENT 'from deserializer')
PARTITIONED BY (
  `date` string
)
ROW FORMAT SERDE
  'org.openx.data.jsonserde.JsonSerDe'
STORED AS INPUTFORMAT
  'org.apache.hadoop.mapred.TextInputFormat'
OUTPUTFORMAT
  'org.apache.hadoop.hive.ql.io.IgnoreKeyTextOutputFormat'
LOCATION
  's3://{バケット名}/${date}'
TBLPROPERTIES (
  'classification'='csv',
  'compressionType'='gzip',
  'delimiter'='|',
  'projection.date.format'='yyyy/MM/dd',
  'projection.date.interval'='1',
  'projection.date.interval.unit'='DAYS',
  'projection.date.range'='NOW-1YEARS,NOW',
  'projection.date.type'='date',
  'projection.enabled'='true',
  'storage.location.template'='s3://{バケット名}/${date}',
  'typeOfData'='file'
)

 

SELECT from_unixtime(timestamp/1000, 'Asia/Tokyo') AS JST, *
FROM waf_acl-log
WHERE
  -- パーティションを選択
  date BETWEEN '2023/11/01' AND '2023/11/1'
  AND action = 'BLOCK'
--    AND action = 'ALLOW'
--    AND terminatingruleid = 'AWS-AWSManagedRulesSQLiRuleSet'
--    AND terminatingruleid = 'AWS-AWSManagedRulesPHPRuleSet'
ORDER BY timestamp ASC
LIMIT 100;

MySQLでクエリのパフォーマンスを改善する

クエリ性能を考慮したスキーマ設計&クエリを実装するための覚え書き。

インデックスの効用

顕著に効果が出るケース


データ量=NLJ突合量が多いテーブル同士のJOINでは、内部表の結合キーに思考停止レベルでインデックスを貼る

  1. テーブル内のデータ量が多く、少量のレコードを検索する場合
  2. WHERE句の条件、結合の条件、ORDER BY句の条件として頻繁に利用するただし全表走査が目的のテーブルであれば索引は不要
  3. NULL値が多いデータから、NULL値以外の検索をするIndexはNULLを含まないのでNULL値以外の検索には効果がある

MySQLのIndexをはるコツ

  • ただし表から大部分のレコードを検索する場合、オプティマイザは効率的にフルスキャンを選択する場合はある
  • 追加・更新頻度が高い表は索引の再構築が走るため性能低下に繋がるリスクがある

インデックスが使えない検索条件

SQL実践入門──高速でわかりやすいクエリの書き方 (WEB+DB PRESS plus)


中間一致、後方一致のLIKE術語(前方一致はOK)

  • ... WHERE col_1 LIKE '%検索値'
  • ... WHERE col_1 LIKE '%検索値%'
  • ... WHERE col_1 LIKE '検索値%'

索引列に関数を使っている/演算を行っている

  • ... WHERE LENGTH(col_1) = 10
  • ... WHERE col_1 * 1.1 > 100
  • ... WHERE col_1 > 100 / 1.1

IS NULL術語を使っている

「索引データにNULLは存在しないため」…とあったが、実行計画を見る限りref~constでヒットされた。

否定形を使っている( <> / != / NOT IN)

  • ... WHERE col_1 <> 100

アプリケーション設計


  • ナノ秒早くなるから予約語は大文字に統一する
  • 雰囲気でORDERしない
  • WHEREとORDERはひとつのテーブルに集中する
  • 否定演算は等価演算にする
  • JOINの駆動表は行数の少ないテーブルを選択する

実行計画

スロークエリはEXPLAINで当該クエリの実行計画を確認してボトルネックを分析する。

まとめ

EXPLAINコマンドの各フィールドの詳細を説明したが、実際にEXPLAINコマンドを使ってクエリの実行計画を見る際には次のようなステップを踏むといいだろう。

  1. id/select_type/tableフィールドを見て、どのテーブルがどの順序でアクセスされるのかを知る。これらはクエリの構造を示すフィールドであると言える。サブクエリが含まれている場合にはEXPLAINの表示順とアクセスされる順序が異なる場合があるので気をつける必要がある。
  2. type/key/ref/rowsフィールドを見て、各テーブルから行がどのようにフェッチされるのかを知る。どのテーブルへのアクセスが最も重いか(クエリの性能の足を引っ張っているのか)を、これらのフィールドから判断することが出来る。
  3. Extraフィールドを見て、オプティマイザがどのように判断して、各々のテーブルへのアクセスにおいて何を実行しているのかを知る。Extraフィールドはオプティマイザの挙動を示すものであり、クエリの全体像を把握するのに役立つ。

同じ結果が得られるなら、ひとつのクエリにおいてテーブルからフェッチされる行数が減れば減るほど良い。なぜなら、ストレージエンジンの性能には上限があるからである。例えば、あるストレージエンジンが1秒間に100万行のフェッチが出来る性能を持っているとすると、一つのクエリが平均1000行のフェッチを行うならば1秒間に最大1000クエリ、一つのクエリが平均100行のフェッチを行うならば1秒間に最大1万クエリが可能であるという計算になる。ひとつのクエリがフェッチする行数が減れば、クエリのレスポンスだけでなく全体のスループットも向上するのである。

漢(オトコ)のコンピュータ道: MySQLのEXPLAINを徹底解説!!

select_type 注目


 SIMPLE

そのクエリはJOINも含む単独のクエリ

サブクエリ系

サブクエリの場合、外部クエリとサブクエリでは別々のidが付番される

 PRIMARY

そのクエリは外部クエリを指している tableが<derived>なら実態はDERIVEDサブクエリ

 SUBQUERY

そのクエリは相関関係のないサブクエリを指している。 サブクエリが実行されるのは最初の一回だけで、それ以降はキャッシュされた実行結果が利用されている。

 DERIVED

そのクエリはFROM句で用いられているサブクエリを指している

 ❗DEPENDENT SUBQUERY

そのクエリは相関関係のあるサブクエリ

対策

  • サブクエリ側にインデックスを張って改善する
  • 非相関クエリになるようにクエリを再設計する

table 情報


対象のテーブル名

type 注目


対象のテーブルに対してどのような方法でアクセスするかを示す。

 const👏

プライマリキーまたはユニークキーによって、単一のステートメントに一致した。

 eq_ref👏

JOINにおいてプライマリキーまたはユニークで単一のステートメントに一致した。

 ref👏

インデックスで単一のステートメントに一致した。

 range👏

インデックスによる範囲検索でステートメントに一致した。

 ❗index

フルインデックススキャンした。

対策

 ❗ALL

フルテーブルスキャンした

対策

  • インデックスを作成する

possible_keys


オプティマイザがテーブルのアクセスに利用可能なインデックスの候補として挙げたキーの一覧。

key


実際にオプティマイザによって選択されたキー。

key_len


選択されたキーの長さ。キー長は短い方が早い

ref


検索条件で、keyと比較されている値やカラムを示す。

  • 定数が指定されている場合はconst。
  • JOINの場合は、結合する相手側のテーブルで検索条件として利用されているカラム。

rows


対象tableからフェッチされる行数の見積もりを示す。

  • Extra=Using whereの状況では、フェッチした行に対してさらにWHERE句の検索条件が適用されて行の絞り込みが行われるので、結果は少なくなる可能性がある。
  • JOINの場合、最終的な結果行数の見積もりはJOINする全てのテーブルのrowsフィールドの積である。
  • レコードアクセスタイプがeq_ref(プライマリキー or ユニークキー)の場合、rowsフィールドは1になる。

Extra


オプティマイザがどのような戦略を選択したかということを示す

Using index👏

クエリがインデックスだけを用いて解決された

Using index for group-by👏

MIN()/MAX()がGROUP BY句と併用されているとき、クエリがインデックスだけを用いて解決された

Using where

インデックスだけではWHERE句の条件を全て適用することが出来ていない

対策

  • カバリングインデックスを作成する

Range checked for each record (index map: N)

JOINにおいてrangeまたはindex_mergeが利用された

Not exists

LEFT JOINにおいて、左側のテーブルからフェッチされた行にマッチする行が右側のテーブルに存在しない場合、右側のテーブルはNULLとなるが、右側のテーブルがNOT NULLとして定義されたフィールドでJOINされている場合にはマッチしない行を探せば良い・・・ということを示す。

❗Using filesort

ソート処理にインデックスが使われず、クイックソートされた。

対策

❗Using temporary

ソートのために一時テーブルが作成された。

対策

まとめ。

以上の話をまとめると次のようになる。

  • Filesortとはクイックソートのことである。
  • JOINにおいてORDER BYが指定されているとき、ソートが行われるタイミングは3種類ある。
    • Using filesortがEXPLAINに出ていないときはインデックスを用いてソートが行われている。(最も高速。)
    • Using filesortだけがEXPLAINの最初の行に出ている時は、先にソートしてからJOINが実行されている。
    • Using temporary; Using filesortがEXPLAINの最初の行に出ている時は、先にJOINしてからソートされている。(最も遅い。)
  • WHERE句による検索条件とORDER BYにおけるソート条件は、一つのテーブルに集中させる。
  • JOINにおいてソート処理する場合には、LIMIT句が適用されるのはJOINとソートが完了した後である。
  • LEFT/RIGHT JOINの場合には、LIMIT句の問題はサブクエリである程度対策が可能である。

漢(オトコ)のコンピュータ道: Using filesort

用語

  • NLJ (ネステッド・ループ・ジョイン) テーブル結合のアルゴリズム。結合元テーブル駆動表×被結合テーブル内部表の直積。
  • 駆動表 NLJにおける結合元テーブル
  • 内部表 NLJにおける被結合テーブル
  • 外部クエリ サブクエリからみたPRIMARYのクエリ
  • 相関クエリ サブクエリにおいて、メインクエリの各行に対してサブクエリが実行されるため、オーダー数が大爆発する(dependent subquery)。非相関サブクエリとは、まずサブクエリが実行され、その結果をメインクエリに渡す。実行計画が逆。
  • カバリングインデックス クエリ(WHERE~ORDER=>SELECT)が必要とする情報を網羅した複合インデックスであり、インデックススキャンのみで高速にデータを取得するために最適化設計したインデックス。達成した暁にはExplainのExtraにて、Using Indexの誉が与えられる。

調べ物

駆動表に小さなテーブルを選ぶ理由


結果的に駆動表(n)と内部表(m)の突合数はどちら(nm or mn)にせよを変わらないのでは、、

・駆動表の重要性 Nested Loopsの性能を改善するキーワードとして「駆動表に小さなテーブルを選ぶ」ということを聞いたことのある人もいると思います。これは大方針として間違いではないのですが、実はある前提条件がないと意味がないので、なぜ駆動表が小さいほうが性能的に有利なのか、それが意味を持つ条件は何なのか、その理由をここで理解しておきましょう。 実際、上で解説した Nested Loopsのしくみを前提すると、駆動表がどちらのテーブルになっても、結局のところアクセスされる行数はR(A)✕R(B)で表現されるのだから、駆動表が小さかろうが大きかろうが、結合コストに違いはないように思われます。実は、この「駆動表を小さく」という格言には、次のような暗黙の前提が隠れています。 内部表の結合キーの列にインデックスが存在すること もし内部表の結合キーの列にインデックスが存在する場合、そのインデックスをたどることによって、DBMSは駆動表の1行に対して内部表を馬鹿正直にループする必要がなくなります。いわば内部表のループをある程度スキップできるようになるのです

SQL実践入門──高速でわかりやすいクエリの書き方 (WEB+DB PRESS plus)

内部表はインデックス付き結合キーで高速にルックアップできるため、あとは駆動表が小さいほど結合の効率がよいということだ。

where句またはjoin句にANDで複数条件を与えたときインデックスは使われないのか


  • クエリに用いられるインデックスはテーブルごとに1つで、オプティマイザが最適なインデックスを選択する。
  • クエリ条件&ソートに登場するカラムを網羅的にカバーする複合インデックスが必要。=カバリングインデックス
  • 複合インデックスに対してクエリはインデックス定義順に前方一致でオーダーしなければならない。

実例で学ぶ、JOIN (NLJ) が遅くなる理屈と対処法 #MySQL - Qiita

order by句にインデックスは使われないのか


Using filesortを解消するためにソートしたいカラムにインデックスを張ったが使われない。

-- at_dayカラムにはインデックスを張っているのにUsing filesort
EXPLAIN SELECT * FROM reports ORDER BY at_day DESC;
id	select_type	table	partitions	type	possible_keys	key	key_len	ref	rows	filtered	Extra
1	SIMPLE	reports	NULL	ALL	NULL	NULL	NULL	NULL	240	100.00	Using filesort

なぜなら、'Using index'は(カバリングインデックスを使用している場合など)インデックスの情報のみを使用してデータを取得できる場合に使用できる方法だからです。

MySQLのインデックスがうまく効かない MySQL :: MySQL 8.0 リファレンスマニュアル :: 8.2.1.16 ORDER BY の最適化

  • Using index 

実際の行を読み取るための追加のシークを実行する必要がなく、インデックスツリーの情報のみを使用して、テーブルからカラム情報が取得されます。 この戦略は、クエリーで単一のインデックスの一部であるカラムのみを使用している場合に使用できます。

MySQL :: MySQL 8.0 リファレンスマニュアル :: 8.8.2 EXPLAIN 出力フォーマット

インデックスツリーの情報のみを使用してテーブルからカラムが取得するためには、SELECTでカバリングインデックスに含まれないカラムを指定してはいけない。

-- インデックスに含まれるカラムのみをselectするとUsing index
EXPLAIN SELECT at_day FROM reports ORDER BY at_day DESC;
id	select_type	table	partitions	type	possible_keys	key	key_len	ref	rows	filtered	Extra
1	SIMPLE	reports	NULL	index	NULL	idx_at_day	3	NULL	240	100.00	Using index

select_type.DEPENDENT SUBQUERYとは


explain select * from employees e
where exists ( select * from reports r where r.`employee_id` = e.id );
id	select_type	table	partitions	type	possible_keys	key	key_len	ref	rows	filtered	Extra
1	PRIMARY	e	NULL	ALL	NULL	NULL	NULL	NULL	11	100.00	Using where
2	DEPENDENT SUBQUERY	r	NULL	ref	idx_employee_id	idx_employee_id	4	kkk.e.id	30	100.00	Using where; Using index
  • DEPENDENT SUBQUERYとはいわゆる相関サブクエリに相当するもので、サブクエリにおいて外部クエリのカラムを参照しているサブクエリのことである。
  • 現時点でのMySQL(バージョン5.1)では、サブクエリはまず外部クエリの条件から評価される。そして、外部クエリの条件に合致する行が見つかると、その行がサブクエリの条件に合致するかどうかが評価されるわけである。即ち、サブクエリにおいてフェッチしなければいけない行数が平均N行、外部クエリでフェッチされる行数がM行のとき、サブクエリにおいてM×N行の評価が行われることになる。これは膨大な計算量である。
  • MySQLは内部的にINを直接処理することができないので、EXISTSに変換することでSQL的には相関のないサブクエリも相関サブクエリになってしまうのである。これがまさにMySQLのサブクエリが遅い!と言われている原因だろう。

なぜMySQLのサブクエリは遅いのか。

相関サブクエリに相当する。外部クエリでフェッチしたステートメントごとに、サブクエリを都度フェッチして評価するため計算量が膨らむため遅い。以下対策。

  • 外部クエリのフェッチを絞る
  • サブクエリにインデックスを張る

参考文献


SQL実践入門──高速でわかりやすいクエリの書き方 (WEB+DB PRESS plus)

インデックスの定石系

実行計画系

SSHトンネルで開発環境からリモートのMySQLホストにつないでセキュアにデバッグ

sshホストがフォワード先


ローカルホスト:13306にバインドして、sshリモートホストmysql.host:22にssh接続し、localhost:3306にループバックポートフォワードする


ssh -N -L 13306:localhost:3306 -p 22 -i ./id-rsa.pem user@mysql.host

踏み台sshホスト経由


  • 直にssh接続できないホスト(RDSインスタンス)の場合
  • データベースサーバーがインターネットフェイス非公開の場合

ローカルホスト:13306にバインドして、sshリモートホストbation.host:22にssh接続し、mysql.host:3306にポートフォワードしている


ssh -N -L 13306:mysql.host:3306 -p 22 -i ./id-rsa.pem user@bation.host

前提としてbation.host から mysql.hostへファイアウォールでインバウンド許可していること
フォワード先のホストがインターナルネットワークのみ許可している場合、インターネットフェイスなDNSアクセスだとグローバルIPなのでファイアウォールで弾かれる(挙動としては応答なしで延々と捕まる・固まる)。

SSHコマンド

ssh
	-N # リモートコマンドを実行しない=ターミナルを開かない=トンネル専用
	-L {ローカルホストのバインドポート}:{フォーワード先のホスト}:{フォワード先のポート}
	-p {sshホストのポート} 
	-i {ssh鍵ファイル}
	{ユーザ名}@{sshホスト}

フォーワード先のホストsshホストからみたネットワーク名


BloggerにGoogleフォーム×reCAPTCHAでお問い合わせフォームを作成する


Bloggerで連絡フォームウィジェットがエラーで送信できなかったのでGoogleフォームを使ってカスタムフォームを実装したはなし。


お問い合わせページ



連絡フォームウィジェットのエラー原因


フォーム送信先のエンドポイントhttps://www.blogger.com/contact-form.doがオリジンhttps://blog.bigbridge.work対して、Access-Control-Allow-Origin許可してくれないためCORSエラーが発生していた。

リクエスト詳細


# 概要
リクエスト URL:https://www.blogger.com/contact-form.do
リクエスト メソッド:POST
ステータス コード:200 OK
参照ポリシー:strict-origin-when-cross-origin

# リクエストヘッダ
:authority:www.blogger.com
:method:POST
:path:/contact-form.do
:scheme:https
Accept:*/*
Accept-Encoding:gzip, deflate, br, zstd
Accept-Language:ja,en-US;q=0.9,en;q=0.8
Content-Length:170
Content-Type:application/x-www-form-urlencoded;charset=UTF-8
Origin:https://blog.bigbridge.work
Referer:https://blog.bigbridge.work/
Sec-Ch-Ua:"Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"
Sec-Ch-Ua-Mobile:?0
Sec-Ch-Ua-Platform:"macOS"
Sec-Fetch-Dest:empty
Sec-Fetch-Mode:cors
Sec-Fetch-Site:cross-site
User-Agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36

# ペイロード部
## フォーム
name: テスト
email: test@bigbridge.work
message: テスト
## トークン
blogID: ******************************
token: AOuZoY6iKedo_5ezKrVuDX5Zx7eK_7l4xg:1711659541683

レスポンス詳細


Access-Control-Allow-Originヘッダがない


# レスポンスヘッダ
Alt-Svc:h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Cache-Control:no-cache, no-store, max-age=0, must-revalidate
Content-Encoding:gzip
Content-Length:70
Content-Security-Policy:script-src 'self' *.google.com *.google-analytics.com 'unsafe-inline' 'unsafe-eval' *.gstatic.com *.googlesyndication.com *.blogger.com *.googleapis.com uds.googleusercontent.com https://s.ytimg.com https://i18n-cloud.appspot.com https://www.youtube.com www-onepick-opensocial.googleusercontent.com www-bloggervideo-opensocial.googleusercontent.com www-blogger-opensocial.googleusercontent.com https://www.blogblog.com; report-uri /cspreport
Content-Type:text/javascript; charset=UTF-8
Date:Thu, 28 Mar 2024 20:59:49 GMT
Expires:Mon, 01 Jan 1990 00:00:00 GMT
P3p:CP="This is not a P3P policy! See https://www.google.com/support/accounts/bin/answer.py?hl=en&answer=151657 for more info."
Pragma:no-cache
Server:GSE
X-Content-Type-Options:nosniff
X-Frame-Options:SAMEORIGIN
X-Xss-Protection:1; mode=block

Bloggerフォーラムに送信トークンについて仕様変更を示唆するコメントがあった。Contact us Stopped Working - Blogger Community


一応リクエストのペイロードにはトークンらしきものは添加されているが、そもそも使用テーマWrite Simpleの公開時期が2016年とかなり古いため現在の要件を満たしていないのかもしれない。使用テーマは変えたくなかったのでGoogleフォームを利用してフォームを実装する。


お問い合わせフォームの要件


お問い合わせに求める要件は次の通り


  • お問い合わせを受信出来て、履歴を一元的に管理できること
  • フォームをスタリングできること
  • reCAPTCHAでボット対策できること
  • 入力内容に対してバリデーションできること
  • 送信完了後は完了画面にフォワードできること

お問い合わせを受信出来て、履歴を一元的に管理できること

Google formコンソール
お問い合わせのスプレッドシート


お問い合わせはGoogleフォームで受信し、管理する。すべての受信履歴はスプレッドシートにスタックされる。



回答タブでメール通知はONにしておく



フォームをスタリングできること


Googleフォームをサイトにインラインフレームで組み込むと、入力から完了までGoogleフォームまるだしだ。これはいかん。是が非でもサイトのスタイリングに統一したい。



Googleフォームのaction属性とフォームパーツのname属性を拝借して、自前で組んだフォームにそれぞれ充てる。



こんな感じで、GoogleフォームにPOSTが出来る。
(が、送信後Googleフォームの送信完了画面にリダイレクトするため、自前の完了画面に遷移する実装を後述)


<form action="https://docs.google.com/forms/u/0/d/e/1FAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/formResponse" method="POST">
    <div>
        <div><span>お名前</span></div>
        <div><input type="text" name="entry.2XXXXXXXX"></div>
    </div>
    <div>
        <div><span>メールアドレス</span></div>
        <div><input type="email" name="entry.3XXXXXXXX"></div>
    </div>
    <div>
        <div><span>お問い合わせ内容</span></div>
        <div><textarea name="entry.1XXXXXXXX" rows="10"></textarea></div>
    </div>
    <div>
		<input type="submit" value="送信">
    </div>
</form>

reCAPTCHAでボット対策できること

reCAPTCHAコンソール


reCAPTCHA-V2を設置して、reCAPTCHA検証イベントで送信ボタンを活性状態に切り替える。
フォームの送信タイミングでバリデーションなど追加実装を割り込ませるためにsubmitボタンをbuttonに変更して初期状態はdisabled


<script src="https://www.google.com/recaptcha/api.js"></script>
<form action="https://docs.google.com/forms/u/0/d/e/1FAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/formResponse" method="POST">
	...
	<!-- それらしく送信ボタンの前にreCAPTCHAを組み込む -->
	<div class="g-recaptcha" data-callback="gValidatedCallback" data-sitekey="6XXXXXXXXXXXXXXX_FXXXXXXXXXXXXXXXXXXXXXX" disabled></div>
	<!-- 送信ボタン 初期状態は非活性-->
    <div>
		<button id="id-contact-form-submit" class="submit" disabled>送信</button>
    </div>
</form>

reCAPTCHA検証イベントで送信ボタンを活性状態にスイッチする。disabledフラグだけではhtml改ざんが出来るのでJSで追加検証のフラグも実装する。


/************************************************
 * reCAPTCHA検証のコールバック
 * コードがあれば=判定通過で、ボタンのdisableを解除して通過フラグを立てる
 ************************************************/
let gValidated = false;
function gValidatedCallback(code) {
    if(code !== ''){
  		$('#id-contact-form-submit').removeAttr("disabled");
		gValidated = true;
	}
}

/************************************************
 * 送信ボタンのコールバック
 * - reCAPTCHA検証フラグを追加検証する
 ************************************************/
$(document).on('click', '#id-contact-form-submit', function(){  
	try{
        if( !gValidated ) throw new Error('reCAPTCHA検証を完了してください。');
	}catch(error){
    	alert(error.message);
	}
	return false;
});

送信完了後は完了画面にフォワードできること


GoogleフォームのactionにPOSTするとGoogleフォーム完了画面にリダイレクトしてしまうので、formのtarget属性にダミーの非表示インラインフレームを指定してGoogleフォームのレスポンスを葬りつつ、ロケーションを送信完了ページに移動する。


<form id="id-contact-form" action="https://docs.google.com/forms/u/0/d/e/1FAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/formResponse" method="post" target="hidden_iframe">
	...
</form>
<iframe name="hidden_iframe" style="display:none;"></iframe>

...
/************************************************
* 送信ボタンのコールバック
* - reCAPTCHA検証フラグを追加検証する
* - Googleフォームに送信する
* - ロケーションを完了画面に移動する
 ************************************************/
$(document).on('click', '#id-contact-form-submit', function(e){
	try{
		// バリデーション
        if( !gValidated ) throw new Error('reCAPTCHA検証を完了してください。');

		// フォーム送信 => レスポンスは"target="hidden_iframe"に葬られる
		$('#id-contact-form').submit();
		// ロケーションを完了画面に移動する
		window.location='/p/been-contact.html';      
	}catch(error){
    	alert(error.message);
	}
	return false;
});

入力内容に対してバリデーションできること


Googleフォームの送信結果はインラインフレームに葬るため、送信結果を受け取ることが出来ないのでフロントでフォーム入力値のバリデーションを実装する。
※なおajaxによるフォーム送信ではCORSエラーである


/************************************************
 * メールアドレスのフォーマット検証
 * @return bool true: 合格 / false: 不合格
 ************************************************/
function isValidEmail(email) {
  const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
  return emailRegex.test(email);
}

/************************************************
 * 送信ボタンのコールバック
 * - reCAPTCHA検証フラグを追加検証する
 * - フォーム入力値を検証する
 * - Googleフォームに送信する
 * - ロケーションを完了画面に移動する
 ************************************************/
$(document).on('click', '#id-contact-form-submit', function(e){
    
	try{
		// バリデーション
        if( !gValidated ) throw new Error('reCAPTCHA検証を完了してください。');

		// 必須フォームフィールド
		const nm_name	= 'entry.2XXXXXXXX';
		const nm_email	= 'entry.3XXXXXXXX';
		const nm_body	= 'entry.1XXXXXXXX';

		if( !$(`input[name="${nm_name}"]`).val() ) throw new Error('お名前を入力してください');
		if( !$(`input[name="${nm_email}"]`).val() ) throw new Error('メールアドレスを入力してください');
		if( !isValidEmail( $(`input[name="${nm_email}"]`).val() ) ) throw new Error('メールアドレスのフォーマットが不正です');
		if( !$(`textarea[name="${nm_body}"]`).val() ) throw new Error('お問い合わせ内容を入力してください');
            
		// フォーム送信
		$('#id-contact-form').submit();
		// ロケーション移動
		window.location='/p/been-contact.html';
      
	}catch(error){
    	alert(error.message);
	}
	return false;
});

最終的なお問い合わせページの実装


要件を満たした最終的なお問い合わせページの実装


<script src="https://www.google.com/recaptcha/api.js"></script>
<script>
let gValidated = false;
/************************************************
 * reCAPTCHA検証のコールバック
 * コードがあれば=判定通過で、ボタンのdisableを解除して通過フラグを立てる
 ************************************************/
function gValidatedCallback(code) {
    if(code !== ''){
  		$('#id-contact-form-submit').removeAttr("disabled");
		gValidated = true;
	}
}

/************************************************
 * メールアドレスのフォーマット検証
 * @return bool true: 合格 / false: 不合格
 ************************************************/
function isValidEmail(email) {
  const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
  return emailRegex.test(email);
}

/************************************************
 * 送信ボタンのコールバック
 * - reCAPTCHA検証フラグを追加検証する
 * - フォーム入力値を検証する
 * - Googleフォームに送信する
 * - ロケーションを完了画面に移動する
 ************************************************/
$(document).on('click', '#id-contact-form-submit', function(e){
	try{      
        if( !gValidated ) throw new Error('reCAPTCHA検証を完了してください。');

		const nm_name = 'entry.2XXXXXXXX';
		const nm_email = 'entry.3XXXXXXXX';
		const nm_body = 'entry.1XXXXXXXX';
		if( !$(`input[name="${nm_name}"]`).val() ) throw new Error('お名前を入力してください');
		if( !$(`input[name="${nm_email}"]`).val() ) throw new Error('メールアドレスを入力してください');
		if( !isValidEmail( $(`input[name="${nm_email}"]`).val() ) ) throw new Error('メールアドレスのフォーマットが不正です');
		if( !$(`textarea[name="${nm_body}"]`).val() ) throw new Error('お問い合わせ内容を入力してください');
      
		$('#id-contact-form').submit();
		window.location='/p/been-contact.html';
	}catch(error){
    	alert(error.message);
	}
	return false;
});
</script>

<style>
  
  .formgroup{}
  .formgroup:not(last-child){
    margin-bottom: 2em;
  }
  .formgroup__label{
    color: #000;
  }
  .formgroup__form > * {
    	width: 100%;
  }
  .formgroup .require{
  		font-size: 0.8em;
    	color: #f00;
  }
  .formgroup .require:before{
    	margin-left: 0.8em;
    	content: '*';
  }
  
  button {
  		text-shadow: none;
  }
    
  .submit{
    	color: #fff;
  		background-color: #000;
  }
  .submit:hover{
    	color: #000;
  		background-color: #fff;
  }
  .submit:disabled{
    	color: #000;
  		background-color: #ccc;
  }
  
  .g-recaptcha{
		margin: 1em 0;
  }

</style>

<p>フォームにお問い合わせ内容を入力して送信してください。</p>

<form id="id-contact-form" action="https://docs.google.com/forms/u/0/d/e/1FAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX/formResponse" method="post" target="hidden_iframe">
	<div>
    	<div class="formgroup">
        	<div class="formgroup__label">
          		<span>お名前</span>
          	</div>
        	<div class="formgroup__form">
            	<input type="text" name="entry.2XXXXXXXX" >
          	</div>
    	</div>
    	<div class="formgroup">
        	<div class="formgroup__label">
              	<span>メールアドレス</span>
          	</div>
        	<div class="formgroup__form">
              	<input type="email" name="entry.3XXXXXXXX" >
        	</div>
    	</div>
    	<div class="formgroup">
        	<div class="formgroup__label">
              	<span>お問い合わせ内容</span>
          	</div>
        	<div class="formgroup__form">
              	<textarea name="entry.1XXXXXXXX" rows="10"></textarea>
          	</div>
    	</div>
  	</div>
	<div class="g-recaptcha" data-callback="gValidatedCallback" data-sitekey="6XXXXXXXXXXXXXXX_FXXXXXXXXXXXXXXXXXXXXXX" disabled></div>
	<button id="id-contact-form-submit" class="submit" disabled>送信</button>
</form>
<iframe name="hidden_iframe" style="display:none;"></iframe>