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 cloneはnon-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>

Elementorのメモリ消費をSwapfileで対策する

 

LightsailのWordPress@BitnamiインスタンスでElementorを入れて固定ページを組んでいるとメモリ消費が著しく、いつの間にかElementor編集画面を開くだけで数百MBとか、編集画面を保存するとGB単位でメモリを消費するようになりインスタンスがハングアップするようになった。


当初、最も小さいインスタンスで十分だったが、メモリだけきつくてだましだましで10USDコースまで引き上げたがまだまだ足りない。この調子ではいくらスケールアップしても安心できない。




料金 - Amazon Lightsail | AWS



Elementorがメモリ食いというのは検索すると情報はあるものの、セーフモードとか再起動とか根本的な対策を探し当てることができず、自力でも何に食っているのか分からずお茶を濁したはなし。


試してみたこと




メモリを食いそうな要因についてダイエットを試したがいずれも芯を食ってない。


  • 画像のサイズ縮小、圧縮、パララックス解除 → 効果小
  • 投稿一覧ウィジェットを削除 → 効果小
  • js実装削除 → 効果なし
  • 未使用のプラグインをお掃除 → 効果なし

結論




メモリがスパイクするのはElementor編集している時だけなので、編集時のスパイクをSwapfileで凌ぐ。
通常のページ閲覧は物理メモリで十分賄えているのでパフォーマンスに影響はない。


# Swap領域がない状態
free
               total        used        free      shared  buff/cache   available
Mem:         1994048     1204604      625100       56884      164344      603616
Swap:              0           0           0

# 2GBのswapfileを作成して0埋め
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048

# パーミッション設定
sudo chmod 600 /swapfile

# スワップ領域として設定
sudo mkswap /swapfile

# スワップファイルを有効化(マウント)する
sudo swapon /swapfile

# 起動時にswapfileを自動的にマウント
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

# swapfileとして認識された
free
               total        used        free      shared  buff/cache   available
Mem:         1994048     1204604      625100       56884      164344      603616
Swap:        2097148      214272     1882876

AmazonLinux1(CentOS6世代)×fluentd GemでCloudWatchにログを転送する

AmazonLinux1(CentOS6世代) のlatesttd-agent3(embedded ruby2.4) において、CloudWatch連携プラグインfluent-plugin-cloudwatch-logsのgem依存関係がruby>=2.5を必要とするようになったため、システムのrubyランタイムを使ってfluentdgem直でCloudWatchにサーバーログを送信するためのロードマップ。


ruby環境

rbenvで2.5.1を入れてglobalをスイッチ


# rbenvバイナリをgithubからクローン
git clone https://github.com/sstephenson/rbenv.git /opt/rbenv

# ログインプロフィールにrbenvの環境設定
echo 'export RBENV_ROOT="/opt/rbenv"' > /etc/profile.d/rbenv.sh
echo 'export PATH="${RBENV_ROOT}/bin:${PATH}"' >> /etc/profile.d/rbenv.sh
echo 'eval "$(rbenv init -)"' >> /etc/profile.d/rbenv.sh

# rbenvのpluginsにruby-buildをクローン
git clone https://github.com/sstephenson/ruby-build.git /opt/rbenv/plugins/ruby-build

# ログインプロフィールをローディング
source /etc/profile.d/rbenv.sh

# ruby 2.5.1をインストールする
rbenv install -v 2.5.1
rbenv rehash

# インストールバイナリ確認
rbenv versions

* system
2.5.1 (set by /opt/rbenv/version)

# システムバージョンを2.5.1に変更
rbenv global 2.5.1

# システムバージョンを確認
rbenv versions
  system
* 2.5.1 (set by /opt/rbenv/version)


fluentdパッケージをインストール


一応rbenv付きで環境を縛る


rbenv exec gem install fluentd

# yajl-rubyがrequire ruby >= 2.6.0
ERROR:  Error installing fluentd:
        The last version of yajl-ruby (~> 1.0) to support your Ruby & RubyGems was 1.4.1. Try installing it with `gem install yajl-ruby -v 1.4.1` and then running the current command again
        yajl-ruby requires Ruby version >= 2.6.0. The current ruby version is 2.5.0.

# yajl-rubyがrequire ruby >= 2.Xなv1.4.1に落としてインストール
rbenv exec gem install yajl-ruby -v 1.4.1

rbenv exec gem install fluentd

# bundlerがrequire ruby >= 3.0.0
ERROR:  Error installing fluentd:
        The last version of bundler (>= 0) to support your Ruby & RubyGems was 2.3.27. Try installing it with `gem install bundler -v 2.3.27` and then running the current command again
        bundler requires Ruby version >= 3.0.0. The current ruby version is 2.5.0.

# bundlerをrequire ruby >= 2.Xなv2.3.27に落としてインストール
rbenv exec gem install bundler -v 2.3.27

# 成功
rbenv exec gem install fluentd

fluentdプラグインをインストール

fluent-plugin-cloudwatch-logsプラグイン


CloudWatchに送信するためにfluent-plugin-cloudwatch-logsプラグインをインストールする


# OK
rbenv exec gem install fluent-plugin-cloudwatch-logs

fluent-plugin-record-reformerプラグイン


ログにホストのメタ情報を添加するためにfluent-plugin-record-reformerプラグインをインストールする


# OK
rbenv exec gem install fluent-plugin-record-reformer

fluentdのセットアップ

fluent.conf


setupコマンドでconfを作成する


fluentd --setup /etc/fluentd
> Installed /etc/fluentd/fluent.conf.

fluentdの制御


ネイティブ制御


fluentdのフロント実行コマンド


fluentd -c /path/to/fluent.conf -vv &

終了は直接プロセスを落とす


pkill fluentd

init.d制御


/etc/init.d/fluentdに下記のinit.dスクリプトを作成する


  • コンフィグファイル: /etc/fluentd/fluent.conf
  • ログファイル: /var/log/fluentd/fluentd.log

#!/bin/sh
# chkconfig: 2345 99 01
# description: Fluentd
### BEGIN INIT INFO
# Provides:          fluentd
# Required-Start:    $local_fs $remote_fs
# Required-Stop:     $local_fs $remote_fs
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: Start daemon at boot time
# Description:       Enable service provided by daemon.
### END INIT INFO
# ログ出力のためroot
USER=root
# rbenv×ruby×fluentdのバイナリ
DAEMON=/opt/rbenv/shims/fluentd

DAEMON_ARGS="-c /etc/fluentd/fluent.conf -o /var/log/fluentd/fluentd.log --daemon /var/run/fluentd.pid"
PID=/var/run/fluentd.pid
case "$1" in
 start)
       echo -n "Starting Fluentd: "
       su - $USER -c "$DAEMON $DAEMON_ARGS"
       echo "done"
       ;;
 stop)
       echo -n "Stopping Fluentd: "
       kill `cat $PID`
       echo "done"
       ;;
 restart)
       $0 stop
       $0 start
       ;;
 *)
       echo "Usage: $0 {start|stop|restart}"
       exit 1
esac
exit 0

制御方法


# init.dスクリプトに実行権限あてる
chmod +x /etc/init.d/fluentd
# 起動
/etc/init.d/fluentd start
# 終了
/etc/init.d/fluentd stop
# 再起動
/etc/init.d/fluentd restart

自動起動の設定


chkconfig --add fluentd
chkconfig fluentd on

chkconfig | grep fluentd
> fluentd         0:off   1:off   2:on    3:on    4:on    5:on    6:off

設定


/etc/fluentd/fluent.conf


apacheのカスタムアクセスログの構成例 )
※formatディレクティブの検証はフォーマットデバッガでテストできる。


<source>
  @type tail
  format /^(?<xforwarded>[^ ]*) (?<host>[^ ]*) [^ ]* (?<user>[^ ]*) \[(?<time>[^\]]*)\] "(?<method>\S+)(?: +(?<path>[^ ]*) +\S*)?" (?<code>[^ ]*) (?<size>[^ ]*)(?: "(?<referer>[^\"]*)" "(?<agent>[^\"]*)")? "(?<sess>[^\"]*)"$/
  time_format %d/%b/%Y:%H:%M:%S %z
  path /usr/local/httpd-2.4/logs/idt:443/access.log
  pos_file /var/log/fluentd/log.httpd.access.idt.pos
  # メタ情報を添加するために.reformタグをつける
  tag this.tagname.reform
</source>

# メタ情報を添加するために.reformタグをスクリーニング
<match **.reform>

  # record-reformerプラグイン
  @type record_reformer
  renew_record false
  enable_ruby true

  # 添加後、スクリーニング再入で本来のmatchアクションディレクティブに補足させるためにreformタグをクレンジングする
  tag ${(tag_parts - ["reform"]).join(".")}

  # ホスト名とタイムスタンプを添加する
  <record>
    hostname ${hostname}
    servertime ${time.iso8601(3)}
  </record>
</match>

# アクションディレクティブ
<match this.tagname.**>
  # cloudwatch-logsプラグイン
  @type cloudwatch_logs

  region ap-northeast-1
  log_group_name path.to.loggroup

  # ロググループ内に{タグ名}で自動的にログストリームを作成する
  auto_create_stream true
  use_tag_as_stream true
</match>


sourceディレクティブに入力されるapacheアクセスログ


# ログ
XXX.XXX.XXX.XXX YYY.YYY.YYY.YYY - - [23/Mar/2024:05:45:00 +0900] "GET /path/to/page HTTP/1.1" 200 138792 "https://host.example.com/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36" "abcdefg"

フォーマット後のjson


{
    "xforwarded": "XXX.XXX.XXX.XXX",
    "host": "YYY.YYY.YYY.YYY",
    "user": "-",
    "method": "GET",
    "path": "/path/to/page",
    "code": "200",
    "size": "687368",
    "referer": "https://host.example.com/",
    "agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36",
    "sess": "abcdefg",
	# record_reformerで追加したメタ情報
    "hostname": "fluent-host",
    "servertime": "2024-03-23T05:45:03.000+09:00"
}


AWS


EC2/IAM


EC2のロールにCloudWatchLogsFullAccessポリシーを追加する


CloudWatch


ロググループpath.to.loggroupを作成しておく
※ログストリームはauto_create_streamオプションで自動的に作成してくれる

paged.jsで作るPagedMediaな帳票文書




帳票、契約書などのビジネス文書をhtmlでプレビューして印刷するまでのロードマップ。

Paged Media


Paged MediaとはCSS3のページ化メディア= 版物の用紙サイズ、ふち、改ページなどのスタイルを指定するためのど真ん中の仕様セット。

ページ化メディアとかページ組版とかPaged Mediaとか、いかにも専門チックな用語が登場するが、ページ概念のあるスタイルセットという理解でOK。


しかしながら、いま現在もメジャーブラウザの対応状況はまちまちだそうで、ブラウザ互換性という意味で安定していないようだ。


ライブプレビュー


それでもPagedMediaで印刷をスタイリング出来るのは結構だが、(印刷ダイアログではなく)スクリーンでも精度の高いライブプレビューを再現したいというもの。
残念ながらPagedMediaはスクリーンのそれとは異なるため、スクリーンのスタイルは別途作り込む必要がある。


単ページの帳票なら印刷ダイアログっぽく用紙サイズのページ要素のコンテナを作ってCSSオンリーでスタイリング出来るが、
可変ページ概念が出てくるとページ要素の増減、ページを横断する段落/テーブル要素の分割&リビルドとか、画像は改ページして、、気の遠くなる調整実装が待っている。


paged.js


そこで、上記の気の遠くなるページ化メディア調整をしてくれるpaged.jsというオープンソースのポリフィルがある。
スクリーンでPagedMediaオリエントにDOMを再構築しれるので、スクリーンを印刷に合わせるのではなくて、印刷にスクリーンを再現させることができる。


使用するPaged Media仕様は@pageディレクティブの用紙サイズsize/マージンmargin/ふち印字@bottom-center/改ページ制御break-insideあたり


実装

スクリプト


次のCDNスクリプトを読み込むと自動的にpaged.jsのポリフィルが走る。
Getting Started with Paged.js —


<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"></script>

2024.3.18現在 CDNで公開中のステーブルバージョンv0.4.3


/**
 * @license Paged.js v0.4.3 | MIT | https://gitlab.coko.foundation/pagedjs/pagedjs
 */

文書を組む


paged.jsは@media printクエリを展開する

paged.jsはPagedMediaを完全擬態しようとするので@media printクエリを展開する。
逆に@media screenクエリはデフォルトで無視するため、スクリーンにのみ適用したいスタイルはlinkタグやstyleタグにmedia=“screen”指定することで適用してくれる。


<!-- linkはmedia=“screen”を入れる -->
<link rel="stylesheet" type="text/css" href="screen.css" media="screen">

<!-- styleタグにmedia="screen" -->
<style media="screen">  
  p {  
    color: red;  
  }  
</style>

じつは上記仕様に至るに破壊的な変更があり、v0.4.0以前はスクリーン向けには本来の@media screenクエリを展開していたが、スクリーンも完全なPagedMediaオリエントへと変遷したようだ。以前v0.2を利用していて、今回現最新版のv0.4.3を試したところ、プレビュー専用のスタイルが言うことを聞かないので頭を悩ませた。

バージョン 0.4.1 以降、Pages.js は @media screenのメディア クエリ内にある CSS の解析を停止します。

Paged Break, the long overdue update —


キャンバス

以下のスタイルを当てると用紙サイズのキャンバスができるので、あとは画面ノリで組むだけ。



<!-- スクリーンにのみあてるスタイル -->
<style>
	@page{
		/* A4・横向きでサイズ指定 */
    	size: A4 landscape;
		/* ふちを20mmとる */
    	margin: 20mm;
		/* 各ページのフッター中央に { 現在ページ } / { 総ページ } を印字 */
    	@bottom-center{
        	content: counter(page) '/' counter(pages);
    	}
	}
</style>

<!-- スクリーンにのみあてるスタイル media="screen" -->
<style media="screen">
body{
	/* 背景色を指定してプレビューぽくする */
    background: #666;
}

/* ポリフィルがあてるページクラスとコンテナクラスをプレビュー用にスタイリングする */

/* コンテナクラス は縦並びでセンタリングする*/
.pagedjs_pages {
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
    width: calc(var(--pagedjs-width));
    margin: 0 auto;
}
/* ポリフィルがあてるページシートクラス */
.pagedjs_page {
    margin: 5mm;
    border: 1px solid #333;
    background: #fff;
}
</style>

スクリーンに帳票を組んだ結果


印刷プレビュー
スクリーンをそのまま再現できている




シートから溢れた要素を適宜改ページしてくれる


表の改ページ精度問題


シートから溢れた文字列とかブロック要素は問題なく改ページしてくれるが、Table、Gridは下図のようにちょん切れたり(widthもありえない数字)、不可視領域に吹っ飛んだり動作不安定でまともに使えず、公式フォーラムでもこの手のイシューがちらほらあるが猛者たちが調整で頑張っている。


Paged.JS and CSS Flex / Grid
Paged.js TABLE breaks · Issue #149 · pagedjs/pagedjs
Chrome: Missing row content on page break within table (v0.4.3 vs v0.5.0-beta.0) (#431) · イシュー · pagedjs / pagedjs · GitLab



表に関しては下記のようなFlexテーブルで代替する。それでもちょん切れる場合は行要素に改ページ禁止break-inside: avoid;を当てるとうまくいく。


<style>
    .flex-column{ display: flex; flex-flow: column;}
    .flex-row{ display: flex; flex-flow: row;}
    .flex-cell {
        flex-grow: 1; padding: 2pt 6pt 2pt 6pt;
        border: 1px solid #000;
    }

    /* 隣接するセル間のボーダーを重複させない */
    .flex-row .flex-cell:not(:first-child) {
        border-left: none;
    }
    .flex-row:not(:first-child) .flex-cell {
        border-top: none;
    }
    .flex-row.head .flex-cell {
        text-align: center;
    }
    .flex-cell:nth-of-type(1){flex-basis: 30%;}
    .flex-cell:nth-of-type(2){flex-basis: 20%;}
    .flex-cell:nth-of-type(3){flex-basis: 10%;}
    .flex-cell:nth-of-type(4){flex-basis: 10%;}
    .flex-cell:nth-of-type(5){flex-basis: 30%;}

    /* 項目と表途中の改ページ禁止 */
    .no-break{
        break-inside: avoid;
    }

</style>

<div class="flex-column">
    <div class="flex-row head">
        <div class="flex-cell">品名</div>
        <div class="flex-cell">規格</div>
        <div class="flex-cell">単価</div>
        <div class="flex-cell">金額</div>
        <div class="flex-cell">摘要</div>
    </div>

    <div class="flex-row">
        <div class="flex-cell">1</div><div class="flex-cell">2</div><div class="flex-cell">3</div><div class="flex-cell">4</div><div class="flex-cell">5</div>
    </div>
    <div class="flex-row">
        <div class="flex-cell">2</div><div class="flex-cell">2</div><div class="flex-cell">3</div><div class="flex-cell">4</div><div class="flex-cell">5</div>
    </div>
    <div class="flex-row">
        <div class="flex-cell">3</div><div class="flex-cell">2</div><div class="flex-cell">3</div><div class="flex-cell">4</div><div class="flex-cell">5</div>
    </div>
    <div class="flex-row">
        <div class="flex-cell">4</div><div class="flex-cell">2</div><div class="flex-cell">3</div><div class="flex-cell">4</div><div class="flex-cell">5</div>
    </div>
    <div class="flex-row">
        <div class="flex-cell">5</div><div class="flex-cell">2</div><div class="flex-cell">3</div><div class="flex-cell">4</div><div class="flex-cell">5</div>
    </div>
    <div class="flex-row no-break">
        <div class="flex-cell" style="flex-basis: 100%; height: 36mm;">
            【備考欄】
        </div>
    </div>
</div>


ALBのOIDCフロールールセットでPOSTリクエストのデータが空になる


ALBのルールセットでOIDCフローを組んでいるWebアプリで起こったはなし。


UAのフォームPOSTデータが稀に空っぽでバックエンドに到達することがある。
クライアント環境/Webサーバー/アプリケーション/セッション層=自分の保守範囲で考えうる仮説と検証を繰り返してもどうしても穴がないため、フロント=>バックエンド間=aws/idp層の調査を開始する。


ALB


問題発生時間帯のALBのアクセスログをAthenaでトレースするとめちゃめちゃど真ん中2に気になるアクセスログ/oauth2/idpresponse?code=...があった。
/oauth2/idpresponseといえば、ALBがOIDCフローのIDプロバイダからユーザの認証結果を受け取るリダイレクトのコールバックエンドポイントだ。



紐解くと、

  • 1では、本来のPOSTのリクエストはバックエンド前段でALB層でUAがIDプロバイダに302リダイレクトされていることがわかる。
  • 2では、IDプロバイダでユーザ検証が完了してALBにリダイレクトしてきたので、ALBが本来リクエストに302リダイレクト復帰させようとしている。
  • 問題は3で、UAから本来POSTメソッドのリクエストがGETメソッドでリクエストされたので、バックエンドが送信元画面に302リダイレクトで差し戻している。
  • 結果、4で、UAが差し戻しで元画面にリクエストしている。ユーザは2と、3が見えずこの間1秒未満なので、何かわからんが元画面に戻った?という状況

なお、1から2の間は、IDプロバイダの認証有効期限とか、ましてALBがUAに食わせる認証結果の収納クッキーの有効期限切れとか起こっているわけでもなく、定期的にIDプロバイダに送ってユーザ検証をしてくれていて、ログ全体でこの検証フローがたびたび発生していて、POSTリクエストに当たったときに問題として顕在化したようだ。


Httpメソッドとデータも維持して復帰してくださいよ、、と思うが、aws公式で、自分で対策してくれとレポートが出ているためしょうがない。


注: Application Load Balancer は 301 と 302 のリダイレクトのみをサポートします。これらのリダイレクトにより、クライアントは後続のリクエストで HTTP メソッドを POST から GET に変更できます。307 リダイレクトが必要な場合は、リダイレクトはターゲットアプリケーションを経由する必要があります。

Application Load Balancer を使用して、ドメインを別のドメインにリダイレクトする


対策


私のユースケースではOIDCフローは追加認証要素でありアプリ層には別途、認証と、一般的な検証&ログインリダイレクト機構を担保しているので、
ホスト全体に適応していたALBのOIDCルールを認証ロケーションに狭めることで、IDプロバイダへの検証割り込みを回避することで対策とした。